signal_log: weight-research data points, scan_outcomes forward labels, watch provenance
New additive columns on every logged scan: bar_ts (primary closed-bar label, indexed with timeframe), engine_fingerprint (hash of the scoring config incl. the regime weight table in force - SignalAggregator.engine_config_fingerprint, twin of the gate fingerprint), code_version (short git SHA), penalty_tags_json / penalty_total / momentum_fading / exhaustion_count, and post-hoc watch_level / watch_kinds / watch_line written by violent_watch.attach_watch via signal_log.update_watch. Each indicators_json entry now also carries eff_weight (base weight x regime multiplier, the weight the composite actually used) and wscore. engine: IndicatorSignal.effective_weight / weighted_score are stamped inside _compute_composite (record-keeping only); regime_weight_table(config) is the single source of truth for the multiplier table (override honoured). Composite output byte-identical (120-report hash proof). New table scan_outcomes: model-free forward moves per scan (fwd_1h/4h/24h in $ and ATRs, mfe/mae over 24h, burst_1h, bars_used, complete) measured on the stored candle stream with the advisory model's continuity rule; labelled opportunistically at the end of log() (time-boxed, throttled, idempotent) - no daemon; scripts/label_scan_outcomes.py drives the backlog (--dry-run is read-only). analyze_signal_log --scan-outcomes: forward move + hit rate by TF x signal/grade with a NEUTRAL baseline and burst rate by watch level. Readers dedup by the first row per (timeframe, bar_ts). Module 31 (10 tests). Suite: 463 passed. Backtester untouched (test-locked). Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
+409
@@ -10082,6 +10082,415 @@ def _():
|
||||
assert rep.watch_line == "", "analyze_from_dataframes must not stamp it"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 31: signal-log data points (indicator-weight research)
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 31: signal-log data points ═══")
|
||||
|
||||
from engine import _DEFAULT_REGIME_WEIGHTS, regime_weight_table
|
||||
|
||||
|
||||
def _m31_report(ts, tf=Timeframe.M5, price=2450.0, quality="B",
|
||||
signal=SignalStrength.BUY, atr=5.0, setup=False,
|
||||
analysis=None):
|
||||
"""Logged scan with the record-keeping fields populated."""
|
||||
r = _make_log_report()
|
||||
r.timestamp = ts
|
||||
r.final_signal = signal
|
||||
r.signal_quality = quality
|
||||
r.current_price = price
|
||||
r.trade_setup = r.trade_setup if setup else None
|
||||
r.exhaustion_count = 2
|
||||
r.penalty_tags = {"rsi_overbought": 8.0, "extended_from_ema": 4.0}
|
||||
r.penalty_total = 6.5
|
||||
r.momentum_fading = True
|
||||
if analysis is not None:
|
||||
r.primary_analysis = analysis
|
||||
r.primary_analysis.timeframe = tf.value
|
||||
r.primary_analysis.timestamp = ts
|
||||
r.primary_analysis.key_levels = {"support": 2440.0, "atr": atr}
|
||||
return r
|
||||
|
||||
|
||||
def _m31_bars(start, n, step_min=5, up=0.1, base=2450.0, burst_at=None):
|
||||
"""Deterministic ramp: bar i opens base+i*up and closes one `up`
|
||||
higher, with 0.3 wicks — every forward return is exact arithmetic."""
|
||||
out = []
|
||||
t = pd.Timestamp(start)
|
||||
for i in range(n):
|
||||
o = base + i * up
|
||||
c = o + up
|
||||
high = max(o, c) + (20.0 if i == burst_at else 0.3)
|
||||
out.append({
|
||||
"timestamp": (t + pd.Timedelta(minutes=step_min * i)).isoformat(),
|
||||
"open": o, "high": high, "low": min(o, c) - 0.3,
|
||||
"close": c, "volume": 1.0})
|
||||
return out
|
||||
|
||||
|
||||
def _m31_log_stream(lg, bars, chunk=14):
|
||||
"""Log the bar stream the way live scans store it (14 bars per row)."""
|
||||
for i in range(0, len(bars), chunk):
|
||||
window = bars[i:i + chunk]
|
||||
lg.log(_m31_report(str(pd.Timestamp(window[-1]["timestamp"])),
|
||||
quality="D", signal=SignalStrength.NEUTRAL),
|
||||
source="dashboard", timeframe="5min",
|
||||
dataframes={Timeframe.M5: _m27_df(window)})
|
||||
|
||||
|
||||
@test("engine: effective_weight = weight x regime multiplier, composite unchanged")
|
||||
def _():
|
||||
config = TechnicalConfig()
|
||||
result = TechnicalEngine(config).analyze(
|
||||
make_xau_ohlcv(300, seed=11), "XAU/USD", "1h")
|
||||
table = regime_weight_table(config).get(result.market_regime, {})
|
||||
total = weights = 0.0
|
||||
scored = 0
|
||||
for name, ind in result.indicators.items():
|
||||
if name.startswith("_") or ind.weight == 0:
|
||||
assert ind.effective_weight is None, name
|
||||
assert ind.weighted_score is None, name
|
||||
continue
|
||||
scored += 1
|
||||
assert abs(ind.effective_weight
|
||||
- ind.weight * table.get(name, 1.0)) < 1e-12, name
|
||||
assert abs(ind.weighted_score - ind.signal.value
|
||||
* ind.effective_weight * ind.confidence / 100.0) < 1e-12
|
||||
total += ind.weighted_score
|
||||
weights += ind.effective_weight
|
||||
assert scored >= 5, scored
|
||||
recomputed = max(-100.0, min(100.0, (total / weights) * 50))
|
||||
assert abs(recomputed - result.composite_score) < 1e-9, (
|
||||
recomputed, result.composite_score)
|
||||
|
||||
|
||||
@test("engine: regime_weight_table honours regime_weights_override")
|
||||
def _():
|
||||
df = make_xau_ohlcv(300, seed=12)
|
||||
base = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||||
assert regime_weight_table(TechnicalConfig()) == _DEFAULT_REGIME_WEIGHTS
|
||||
# The table is a COPY: mutating it cannot reach the shipped weights.
|
||||
regime_weight_table(TechnicalConfig())["ranging"]["rsi"] = 99.0
|
||||
again = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||||
assert again.composite_score == base.composite_score
|
||||
|
||||
regime = base.market_regime
|
||||
override = {r: dict(w) for r, w in _DEFAULT_REGIME_WEIGHTS.items()}
|
||||
override[regime]["rsi"] = override[regime].get("rsi", 1.0) * 4 + 1
|
||||
config = TechnicalConfig(regime_weights_override=override)
|
||||
assert regime_weight_table(config) == override
|
||||
tuned = TechnicalEngine(config).analyze(df, "XAU/USD", "1h")
|
||||
assert tuned.market_regime == regime
|
||||
assert tuned.composite_score != base.composite_score, "override ignored"
|
||||
total = weights = 0.0
|
||||
for name, ind in tuned.indicators.items():
|
||||
if ind.effective_weight is None:
|
||||
continue
|
||||
assert abs(ind.effective_weight
|
||||
- ind.weight * override[regime].get(name, 1.0)) < 1e-12
|
||||
total += ind.weighted_score
|
||||
weights += ind.effective_weight
|
||||
assert abs(max(-100.0, min(100.0, (total / weights) * 50))
|
||||
- tuned.composite_score) < 1e-9
|
||||
|
||||
|
||||
@test("aggregator: engine_config_fingerprint tracks scoring config only")
|
||||
def _():
|
||||
from pair_signals.xauusd import make_config
|
||||
m5 = SignalAggregator(make_config(Timeframe.M5))
|
||||
m15 = SignalAggregator(make_config(Timeframe.M15))
|
||||
fingerprint = m5.engine_config_fingerprint()
|
||||
assert len(fingerprint) == 12 and fingerprint.isalnum()
|
||||
assert fingerprint == m5.engine_config_fingerprint(), "not stable"
|
||||
assert fingerprint == SignalAggregator(
|
||||
make_config(Timeframe.M5)).engine_config_fingerprint()
|
||||
assert fingerprint != m15.engine_config_fingerprint(), "timeframe blind"
|
||||
|
||||
flipped = make_config(Timeframe.M5)
|
||||
flipped.technical.ema_partial_neutral = (
|
||||
not flipped.technical.ema_partial_neutral)
|
||||
flipped_agg = SignalAggregator(flipped)
|
||||
assert flipped_agg.engine_config_fingerprint() != fingerprint
|
||||
assert flipped_agg.gate_config_fingerprint() == m5.gate_config_fingerprint()
|
||||
|
||||
weighted = make_config(Timeframe.M5)
|
||||
weighted.technical.regime_weights_override = {"ranging": {"rsi": 9.0}}
|
||||
weighted_agg = SignalAggregator(weighted)
|
||||
assert weighted_agg.engine_config_fingerprint() != fingerprint
|
||||
assert weighted_agg.gate_config_fingerprint() == m5.gate_config_fingerprint()
|
||||
|
||||
|
||||
@test("signal_log: migration adds the research columns, scan_outcomes and indexes")
|
||||
def _():
|
||||
import sqlite3
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute(
|
||||
"CREATE TABLE signals (id INTEGER PRIMARY KEY, logged_at TEXT,"
|
||||
" symbol TEXT, timeframe TEXT)")
|
||||
con.commit()
|
||||
con.close()
|
||||
lg = slog.SignalLogger(path)
|
||||
columns = {r[1] for r in lg._conn.execute("PRAGMA table_info(signals)")}
|
||||
assert {"bar_ts", "engine_fingerprint", "code_version",
|
||||
"penalty_tags_json", "penalty_total", "momentum_fading",
|
||||
"exhaustion_count", "watch_level", "watch_kinds",
|
||||
"watch_line"} <= columns, sorted(columns)
|
||||
outcome_cols = [r[1] for r in
|
||||
lg._conn.execute("PRAGMA table_info(scan_outcomes)")]
|
||||
assert outcome_cols[0] == "signal_id"
|
||||
assert {"signal_id", "timeframe", "bar_ts", "atr", "close0",
|
||||
"fwd_1h", "fwd_4h", "fwd_24h", "fwd_1h_atr", "fwd_4h_atr",
|
||||
"fwd_24h_atr", "mfe_24h_atr", "mae_24h_atr", "burst_1h",
|
||||
"bars_used", "complete", "labeled_at"} == set(outcome_cols), \
|
||||
outcome_cols
|
||||
names = {r[0] for r in lg._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index'")}
|
||||
assert "idx_signals_tf_bar" in names
|
||||
assert "idx_scan_outcomes_tf_bar" in names
|
||||
slog.SignalLogger(path).close() # idempotent re-migration
|
||||
lg.close()
|
||||
finally:
|
||||
slog.reset_loggers_for_tests()
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@test("signal_log: a row records bar_ts, fingerprints, penalties and eff weights")
|
||||
def _():
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
analysis = TechnicalEngine(TechnicalConfig()).analyze(
|
||||
make_xau_ohlcv(300, seed=13), "XAU/USD", Timeframe.M5.value)
|
||||
bar = "2026-08-19 12:00:00+00:00"
|
||||
report = _m31_report(bar, analysis=analysis)
|
||||
lg = slog.SignalLogger(path)
|
||||
rowid = lg.log(report, source="telegram", timeframe="5min",
|
||||
extra={"scan_kind": "scan",
|
||||
"config_fingerprint": "gate12345678",
|
||||
"engine_fingerprint": "engn12345678"})
|
||||
row = lg._conn.execute(
|
||||
"SELECT bar_ts, engine_fingerprint, config_fingerprint,"
|
||||
" code_version, penalty_tags_json, penalty_total,"
|
||||
" momentum_fading, exhaustion_count, indicators_json"
|
||||
" FROM signals WHERE id=?", (rowid,)).fetchone()
|
||||
assert row[0] == bar, row[0]
|
||||
assert row[1] == "engn12345678" and row[2] == "gate12345678"
|
||||
assert row[3] == slog._code_version()
|
||||
assert json.loads(row[4]) == {"rsi_overbought": 8.0,
|
||||
"extended_from_ema": 4.0}
|
||||
assert row[5] == 6.5 and row[6] == 1 and row[7] == 2
|
||||
stored = json.loads(row[8])["5min"]["indicators"]
|
||||
scored = [n for n, e in stored.items() if e["eff_weight"] is not None]
|
||||
assert scored, stored
|
||||
for name in scored:
|
||||
entry = stored[name]
|
||||
live = analysis.indicators[name]
|
||||
assert entry["eff_weight"] == live.effective_weight
|
||||
assert entry["wscore"] == live.weighted_score
|
||||
assert entry["weight"] == live.weight # base weight untouched
|
||||
assert stored["atr"]["eff_weight"] is None, "context indicator scored"
|
||||
lg.close()
|
||||
# The aggregator is what hands the engine fingerprint over.
|
||||
captured = {}
|
||||
with patch("signal_log.log_signal",
|
||||
lambda *a, **k: captured.update(k.get("extra") or {})):
|
||||
agg = SignalAggregator(PlatformConfig())
|
||||
agg._log_signal(_make_log_report(), "test", blocked=True)
|
||||
assert captured["engine_fingerprint"] == agg.engine_config_fingerprint()
|
||||
assert captured["config_fingerprint"] == agg.gate_config_fingerprint()
|
||||
finally:
|
||||
slog.reset_loggers_for_tests()
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@test("signal_log: attach_watch fills watch_level/kinds/line post-hoc")
|
||||
def _():
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
os.remove(path)
|
||||
try:
|
||||
os.environ["SIGNAL_LOG_PATH"] = path
|
||||
os.environ["SIGNAL_LOG_ENABLED"] = "1"
|
||||
analysis = _m30_analysis(indicators={
|
||||
"bollinger": _m30_ind("BB", -0.2), "rsi": _m30_ind("RSI", 22.0)})
|
||||
report = _m31_report("2026-08-19 12:00:00+00:00", analysis=analysis)
|
||||
slog.log_signal(report, source="telegram", timeframe="5min")
|
||||
rowid = report._signal_log_rowid
|
||||
with patch("news_engine.NewsEngine.get_week_events", return_value=[]):
|
||||
# alert_line off: the assessment is still recorded.
|
||||
watch = attach_watch(report, Timeframe.M5,
|
||||
{"enabled": True, "alert_line": False},
|
||||
_M30_QUIET)
|
||||
assert watch.level == "watch" and report.watch_line == ""
|
||||
lg = slog._loggers[path]
|
||||
assert lg._conn.execute(
|
||||
"SELECT watch_level, watch_kinds, watch_line FROM signals"
|
||||
" WHERE id=?", (rowid,)).fetchone() == (
|
||||
"watch", ",".join(sorted(watch.kinds)), "")
|
||||
# With the line on, the displayed text is stored too.
|
||||
watch = attach_watch(report, Timeframe.M5, {"enabled": True},
|
||||
_M30_QUIET)
|
||||
assert lg._conn.execute(
|
||||
"SELECT watch_level, watch_line FROM signals WHERE id=?",
|
||||
(rowid,)).fetchone() == (watch.level, report.watch_line)
|
||||
# A report that was never logged is a silent no-op.
|
||||
orphan = _m31_report("2026-08-19 12:05:00+00:00",
|
||||
analysis=analysis)
|
||||
attach_watch(orphan, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||||
assert lg._conn.execute(
|
||||
"SELECT COUNT(*) FROM signals").fetchone()[0] == 1
|
||||
finally:
|
||||
os.environ.pop("SIGNAL_LOG_PATH", None)
|
||||
os.environ.pop("SIGNAL_LOG_ENABLED", None)
|
||||
slog.reset_loggers_for_tests()
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@test("signal_log: label_scan_outcomes computes exact forward moves")
|
||||
def _():
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
bars = _m31_bars("2026-08-19T12:00:00+00:00", 320, up=0.1,
|
||||
burst_at=6) # bar 6 spans 20.3 = 4x the ATR
|
||||
anchor = str(pd.Timestamp(bars[0]["timestamp"]))
|
||||
lg = slog.SignalLogger(path)
|
||||
rowid = lg.log(_m31_report(anchor), source="telegram",
|
||||
timeframe="5min",
|
||||
dataframes={Timeframe.M5: _m27_df(bars[0:1])})
|
||||
# log() labels opportunistically: with no forward bars stored yet
|
||||
# the row exists but nothing is known.
|
||||
assert lg._conn.execute(
|
||||
"SELECT complete, fwd_1h FROM scan_outcomes WHERE signal_id=?",
|
||||
(rowid,)).fetchone() == (0, None)
|
||||
# Only 6 h of stream: the 24 h window is not judgeable yet.
|
||||
_m31_log_stream(lg, bars[:73])
|
||||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||||
cols = ("fwd_1h", "fwd_4h", "fwd_24h", "complete", "bars_used")
|
||||
partial = lg._conn.execute(
|
||||
"SELECT " + ", ".join(cols) + " FROM scan_outcomes"
|
||||
" WHERE signal_id=?", (rowid,)).fetchone()
|
||||
assert partial[3] == 0, partial
|
||||
assert abs(partial[0] - 1.2) < 1e-9 and abs(partial[1] - 4.8) < 1e-9
|
||||
assert partial[2] is None, "unfinished horizon must stay NULL"
|
||||
|
||||
_m31_log_stream(lg, bars[73:]) # the rest arrives later
|
||||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||||
row = lg._conn.execute(
|
||||
"SELECT timeframe, bar_ts, atr, close0, fwd_1h, fwd_4h, fwd_24h,"
|
||||
" fwd_1h_atr, fwd_4h_atr, fwd_24h_atr, mfe_24h_atr, mae_24h_atr,"
|
||||
" burst_1h, bars_used, complete FROM scan_outcomes"
|
||||
" WHERE signal_id=?", (rowid,)).fetchone()
|
||||
assert row[0] == "5min" and row[1] == anchor
|
||||
assert row[2] == 5.0 and abs(row[3] - 2450.1) < 1e-9
|
||||
for value, expected in zip(row[4:10],
|
||||
(1.2, 4.8, 28.8, 0.24, 0.96, 5.76)):
|
||||
assert abs(value - expected) < 1e-9, (row, expected)
|
||||
assert abs(row[10] - 29.1 / 5.0) < 1e-9 # MFE = last high
|
||||
assert abs(row[11] - 0.3 / 5.0) < 1e-9 # MAE = first wick
|
||||
assert row[12] == 1, "the 4x ATR bar in the first hour is a burst"
|
||||
assert row[13] == 288 and row[14] == 1 # 24 h of 5-min bars
|
||||
|
||||
# Idempotent: a settled database writes nothing.
|
||||
assert lg.label_scan_outcomes(budget=500, time_budget=30.0) == 0
|
||||
# Budget caps the rows examined per pass.
|
||||
lg._conn.execute("DELETE FROM scan_outcomes")
|
||||
lg._conn.commit()
|
||||
assert lg.label_scan_outcomes(budget=1, time_budget=30.0) == 1
|
||||
assert lg._conn.execute(
|
||||
"SELECT COUNT(*) FROM scan_outcomes").fetchone()[0] == 1
|
||||
lg.close()
|
||||
finally:
|
||||
slog.reset_loggers_for_tests()
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@test("signal_log: a failing scan-outcome labeller never breaks the insert")
|
||||
def _():
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
lg = slog.SignalLogger(path)
|
||||
logging.disable(logging.CRITICAL)
|
||||
try:
|
||||
with patch.object(slog.SignalLogger, "label_scan_outcomes",
|
||||
side_effect=RuntimeError("label boom")):
|
||||
rowid = lg.log(_m31_report("2026-08-19 12:00:00+00:00"),
|
||||
source="telegram", timeframe="5min")
|
||||
finally:
|
||||
logging.disable(logging.NOTSET)
|
||||
assert lg._conn.execute(
|
||||
"SELECT id FROM signals").fetchone()[0] == rowid
|
||||
lg.close()
|
||||
finally:
|
||||
slog.reset_loggers_for_tests()
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@test("backtest parity: the replay path knows nothing about the new writers")
|
||||
def _():
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(root, "backtest", "gpu_backtest.py"),
|
||||
encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
for banned in ("scan_outcomes", "label_scan_outcomes", "update_watch",
|
||||
"log_watch_update", "import signal_log"):
|
||||
assert banned not in src, f"gpu_backtest must not reference {banned}"
|
||||
# The stamped record-keeping fields are engine-side, so the replay
|
||||
# engine gets them for free without any signal change.
|
||||
from backtest.gpu_backtest import GPUTechnicalEngine
|
||||
assert "_compute_composite" not in GPUTechnicalEngine.__dict__, \
|
||||
"the backtester must keep inheriting the composite maths"
|
||||
|
||||
|
||||
@test("analyze_signal_log: --scan-outcomes reports on the stored labels")
|
||||
def _():
|
||||
import contextlib
|
||||
import io as _io
|
||||
import analyze_signal_log as asl
|
||||
import signal_log as slog
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
bars = _m31_bars("2026-08-19T12:00:00+00:00", 300, up=0.1)
|
||||
lg = slog.SignalLogger(path)
|
||||
anchor = str(pd.Timestamp(bars[0]["timestamp"]))
|
||||
lg.log(_m31_report(anchor, quality="A"), source="telegram",
|
||||
timeframe="5min",
|
||||
dataframes={Timeframe.M5: _m27_df(bars[0:1])})
|
||||
lg.log(_m31_report(anchor, quality="A"), source="dashboard",
|
||||
timeframe="5min") # same bar, second surface
|
||||
_m31_log_stream(lg, bars)
|
||||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||||
lg.update_watch(1, "hot", ["clock", "extreme"], "line")
|
||||
lg.close()
|
||||
rows = asl.scan_outcome_rows(path)
|
||||
assert rows, "no labelled rows"
|
||||
assert len({(r["timeframe"], r["id"]) for r in rows}) == len(rows)
|
||||
assert min(r["id"] for r in rows) == 1, "dedup keeps the lowest id"
|
||||
buf = _io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
asl.print_scan_outcomes(rows)
|
||||
asl.print_scan_outcomes([])
|
||||
out = buf.getvalue()
|
||||
assert "SCAN OUTCOMES" in out and "by timeframe x signal" in out
|
||||
assert "5min BUY" in out, out
|
||||
assert "hot" in out and "No scan_outcomes rows yet" in out
|
||||
finally:
|
||||
slog.reset_loggers_for_tests()
|
||||
os.remove(path)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user