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
+102
View File
@@ -1152,6 +1152,101 @@ def export_csv(events: Sequence[SignalEvent], path: str) -> None:
print(f"\nWrote {len(recs)} events to {path}")
# ── Scan outcomes (model-free forward returns; no bar data needed) ────
def scan_outcome_rows(db_path: str) -> List[dict]:
"""One labelled scan per (timeframe, bar_ts) — the LOWEST signal id.
Several surfaces log the same bar, so the raw table is duplicated;
SQLite takes the bare columns from the row min() selected."""
con = open_readonly(db_path)
try:
if not con.execute(
"SELECT 1 FROM sqlite_master WHERE type='table'"
" AND name='scan_outcomes'").fetchone():
return []
return [
{"id": r[0], "timeframe": r[1], "signal": r[3] or "",
"quality": r[4] or "-", "watch_level": r[5],
"fwd_4h_atr": r[6], "burst_1h": r[7], "complete": r[8]}
for r in con.execute(
"SELECT MIN(o.signal_id), o.timeframe, o.bar_ts, s.signal,"
" s.quality, s.watch_level, o.fwd_4h_atr, o.burst_1h,"
" o.complete FROM scan_outcomes o"
" JOIN signals s ON s.id = o.signal_id"
" GROUP BY o.timeframe, o.bar_ts")
]
finally:
con.close()
def _scan_stats(rows: Sequence[dict], directional: bool) -> Optional[dict]:
"""n / mean / median forward move and the hit rate for one bucket.
'Hit' = the 4 h move agreed with the signal; for the NEUTRAL baseline
it degrades to the share of UP moves."""
vals = sorted(r["fwd_4h_atr"] for r in rows if r["fwd_4h_atr"] is not None)
if not vals:
return None
hits = 0
for row in rows:
move = row["fwd_4h_atr"]
if move is None:
continue
sign = -1 if (directional and "SELL" in row["signal"]) else 1
hits += int(sign * move > 0)
mid = len(vals) // 2
return {"n": len(vals), "mean": sum(vals) / len(vals),
"median": vals[mid] if len(vals) % 2 else
(vals[mid - 1] + vals[mid]) / 2,
"hit_rate": hits / len(vals)}
def print_scan_outcomes(rows: Sequence[dict]) -> None:
"""Forward-move report: no replay, no model — just what happened."""
if not rows:
print("No scan_outcomes rows yet "
"(python scripts/label_scan_outcomes.py)")
return
complete = [r for r in rows if r["complete"]]
print(f"\n{'═' * 66}\n SCAN OUTCOMES — 4 h forward move in ATRs "
f"({len(complete)} complete of {len(rows)} labelled bars)")
print(f"{'═' * 66}")
directional = [r for r in complete if r["signal"] not in ("", "NEUTRAL")]
neutral = [r for r in complete if r["signal"] == "NEUTRAL"]
for title, groups, keyfn, is_dir in (
("by timeframe x signal", directional,
lambda r: (r["timeframe"], r["signal"]), True),
("by timeframe x grade", directional,
lambda r: (r["timeframe"], r["quality"]), True),
("NEUTRAL baseline", neutral,
lambda r: (r["timeframe"], "NEUTRAL"), False)):
print(f"\n── {title} " + "─" * (52 - len(title)))
buckets: Dict[Tuple[str, str], List[dict]] = {}
for row in groups:
buckets.setdefault(keyfn(row), []).append(row)
for key in sorted(buckets):
stat = _scan_stats(buckets[key], is_dir)
if stat is None:
continue
print(f" {key[0]:6} {key[1]:12} n={stat['n']:5d} "
f"mean {stat['mean']:+.3f} med {stat['median']:+.3f} "
f"{'hit' if is_dir else 'up '} "
f"{100 * stat['hit_rate']:.0f}%")
print("\n── burst (>=3x ATR bar) within 1 h, by watch level " + "─" * 15)
levels: Dict[str, List[int]] = {}
for row in complete:
if row["burst_1h"] is None:
continue
levels.setdefault(row["watch_level"] or "NULL", []).append(
int(row["burst_1h"]))
for level in sorted(levels):
hits = levels[level]
print(f" {level:6} n={len(hits):5d} burst "
f"{100 * sum(hits) / len(hits):.1f}%")
# ── main ──────────────────────────────────────────────────────────────
@@ -1172,8 +1267,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
ap.add_argument("--news", action="store_true",
help="news-sentiment lead/lag + outcome buckets "
"(requires --stored-candles)")
ap.add_argument("--scan-outcomes", action="store_true",
help="labelled forward moves per scan (no bar data "
"needed); see scripts/label_scan_outcomes.py")
args = ap.parse_args(argv)
if args.scan_outcomes:
print_scan_outcomes(scan_outcome_rows(args.db))
return 0
if args.news and not args.stored_candles:
print("--news requires --stored-candles")
return 2