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:
Harwinder Sekhon
2026-09-05 22:22:59 -04:00
parent e79b533a60
commit fc55da17db
8 changed files with 1117 additions and 39 deletions
+409
View File
@@ -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
# ══════════════════════════════════════════════════════════════════════