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). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user