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:
Executable
+115
@@ -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())
|
||||
Reference in New Issue
Block a user