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
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
label_scan_outcomes.py — batch driver for the scan_outcomes table.
Labels model-free forward returns (1/4/24 h, $ and ATR-normalized, plus
MFE/MAE, a 3xATR burst flag and a completeness flag) for every logged scan
whose 24 h window has closed, from the closed-candle streams already
stored in the DB. No provider, no network.
The live loggers do the same work opportunistically at the end of every
scan (time-boxed to ~2 s); this script is the catch-up pass for a backlog
— a freshly imported database, or rows whose bars only arrived later.
Idempotent: a row is rewritten only when its numbers change, so a settled
database costs one read pass. Safe beside the live writers (short per-row
transactions with a busy timeout). Read-only preview with --dry-run.
python scripts/label_scan_outcomes.py --dry-run
python scripts/label_scan_outcomes.py
python scripts/label_scan_outcomes.py --db logs/signals.db --budget 5000
"""
import argparse
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app_config # noqa: E402
from signal_log import SCAN_LABEL_MIN_AGE_H, SignalLogger # noqa: E402
CHUNK = 2000
def _counts(con: sqlite3.Connection, symbol: str,
labelled: bool = True) -> dict:
"""Candidate / labelled / complete / pending counts (read-only).
The age filter uses logged_at (always an ISO string) as a proxy for
the bar close — the labeller itself parses the real bar label."""
scans = ("SELECT COUNT(*) FROM signals s WHERE s.symbol = ?"
" AND s.indicators_json IS NOT NULL AND s.price > 0")
due = (scans + " AND s.logged_at <"
f" datetime('now', '-{int(SCAN_LABEL_MIN_AGE_H)} hours')")
out = {
"scan rows with indicators": con.execute(scans, (symbol,)).fetchone()[0],
"of those, window elapsed": con.execute(due, (symbol,)).fetchone()[0],
}
if labelled:
out["labelled"] = con.execute(
"SELECT COUNT(*) FROM scan_outcomes").fetchone()[0]
out["complete"] = con.execute(
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 1"
).fetchone()[0]
out["pending (labelled, incomplete)"] = con.execute(
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 0"
).fetchone()[0]
return out
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--db", default=None, help="defaults to signal_log.path")
ap.add_argument("--dry-run", action="store_true",
help="read-only candidate counts, writes nothing")
ap.add_argument("--budget", type=int, default=100000,
help="maximum rows to write in this run")
ap.add_argument("--symbol", default="XAU/USD")
args = ap.parse_args(argv)
path = args.db or app_config.signal_log_path()
if not os.path.exists(path):
print(f"no database at {path}")
return 1
if args.dry_run:
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
try:
have = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
if "scan_outcomes" not in have:
print("scan_outcomes table not present yet (created on the"
" first non-dry run / next logged scan)")
for key, value in _counts(con, args.symbol,
labelled="scan_outcomes" in have).items():
print(f" {key}: {value}")
finally:
con.close()
return 0
lg = SignalLogger(path)
try:
print("before:", _counts(lg._conn, args.symbol))
written = 0
previous_complete = -1
while written < args.budget:
n = lg.label_scan_outcomes(
budget=min(CHUNK, args.budget - written), time_budget=120.0)
written += n
complete = lg._conn.execute(
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 1"
).fetchone()[0]
# Stop on a pass that wrote nothing, or one that could not
# complete a single further row (only re-labelling stragglers).
if n == 0 or complete == previous_complete:
break
previous_complete = complete
print(f"labelled (rows written): {written}")
print("after:", _counts(lg._conn, args.symbol))
finally:
lg.close()
return 0
if __name__ == "__main__":
sys.exit(main())