2cc5915b00
Two bodies of work that touch the same files, so one commit. Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule, written before the runs): - SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier) is removed as the last step of the core. One definition (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's host side; D1 is a required frame of the M15 scan; mirrored in cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs 1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs 2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R. - FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1 (−9.7R), buy-only and sell-only on M15, the wide-stop cap (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob). - New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15 pins byte for byte. Twin and reference script MD5-equal on every window; recorder and feature-export parity re-proved; base-rate table rebuilt. - DISPLAY (books byte-identical): exposure.py — the lot size for trade_size.risk_per_trade (new config.json block, off by default, private to the owner) and the count of alerted same-direction plans still open (signals.alerted_at, read-only open_alerted_setups); the daily trend in words on Telegram, the CLI and the dashboard; signals.daily_trend logged. - providers/oanda: a date window with no bars is an empty page, not an error (a D1 pad starting on a Saturday could not be fetched). - Reports only, nothing shipped: seven context screens (no state nominated) and eight outside data sources (none adds a forecast to gold's own daily trend). - Independent verification (report section 5.13): every figure reproduced on both scripts, scoring identical to the previous commit on all four timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022; the neighbour clause is thin (a simple 100-day average misses the R line by 1.2R); "never adds a trade" is an observation, not a guarantee. Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects elsewhere, plus 4 found by an independent review of the fixes; all fixed, each with a test; no trade book changed: - telegram_bot: one _deliver_alert for the candle scan and the level break. The row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and un-stamped when no chat received it or the task is cancelled mid-stamp, so two alerts at one candle close count each other and no SQLite write runs on the event loop. The 5-bar cooldown counts bars of the scan's own frame (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after the settlement pause) and is restored from signals.db at start-up. The lot-size line goes to the owner's own chat only (a group the owner ran /autoscan in used to receive it). /status gains a news-model line and escapes failure details. - signal_log: mark_alerted returns the stamp it wrote; unmark_alerted; last_alerted_bars (read-only). - advisory_outcome: candle continuity is judged bar by bar inside the walk; a hole after a trade's exit no longer voids it. The stored-candle replay resolves 108 of 111 logged setups instead of 99, all equal to the live resolver's R. - providers/oanda: an empty date window is accepted only when it is 5 days or shorter and no malformed candle was skipped; a longer one is retried, then refused, instead of being cached as history with a hole. - news_engine: blank RSS titles skipped; one unreadable headline no longer costs the local model its whole batch (checked on the real bundle); model health recorded; no DeepSeek audit call in a process that does not archive; event-direction cache keyed per release; peek_week_events never fetches. - llm_client: chat_json never raises and never logs the API key (requests quotes the Authorization value in InvalidHeader; error bodies are redacted). - signal_aggregator: the scan's news block is guarded; the replay path (use_news=False) assigns exactly the values it did before. - agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input (event timing over the cached calendar week, headlines newest first). scripts/panel_replay.py replays the logged payload per persona; scripts/label_scan_outcomes.py counts with an ISO cutoff. - test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34); tests that write Parquet print a notice and return without pyarrow. Verification after the last code change: suite 632 passed, 0 failed (also with both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37 green; static Python 3.9 / SQLite 3.34 check clean on every touched file. Evidence: backtest/research/daily_trend_campaign_2026-09-30.md, context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6, 8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
127 lines
5.1 KiB
Python
Executable File
127 lines
5.1 KiB
Python
Executable File
#!/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
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
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, now: Optional[datetime] = None) -> 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. The
|
|
cutoff is built here in logged_at's own format (UTC, 'T' separator),
|
|
the labeller's convention: SQLite's datetime() prints a space, which
|
|
sorts before the 'T', so every row logged on the cutoff's calendar
|
|
day was left out of the count."""
|
|
moment = now or datetime.now(timezone.utc)
|
|
if moment.tzinfo is None:
|
|
moment = moment.replace(tzinfo=timezone.utc)
|
|
cutoff = moment.astimezone(timezone.utc) - timedelta(
|
|
hours=SCAN_LABEL_MIN_AGE_H)
|
|
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 < ?"
|
|
out = {
|
|
"scan rows with indicators": con.execute(scans, (symbol,)).fetchone()[0],
|
|
"of those, window elapsed": con.execute(
|
|
due, (symbol, cutoff.isoformat())).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 the signal_log.path setting in config.json")
|
|
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())
|