1793d9167a
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). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
529 lines
25 KiB
Python
529 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline replay of the agent panel on logged setups, scored against the
|
|
realised outcome — so a prompt or payload version is judged on the SAME
|
|
trades as its predecessor before it is enabled live.
|
|
|
|
venv/bin/python scripts/panel_replay.py --db logs/signals.db --dry-run
|
|
venv/bin/python scripts/panel_replay.py --db logs/signals.db --max-calls 160 --out replay.csv
|
|
|
|
What it does
|
|
------------
|
|
1. Loads every resolved setup event (analyze_signal_log's dedup: one
|
|
event per identical setup, first occurrence) whose advisory outcome is
|
|
RESOLVED; the outcome is `advisory_r_multiple > 0` (net R at $0.65).
|
|
2. Rebuilds what each persona would see. Rows logged by agent_panel v2
|
|
carry the exact payloads (panel_payloads_json); older rows are
|
|
reconstructed from the stored indicators_json / setup_json / mtf_json /
|
|
penalty_tags_json / reasons_json / warnings_json, the interned
|
|
headlines and events (signals_with_news), and candles from the kept
|
|
Oanda cache windows so the price path is what the scan saw. Calendar
|
|
events are evaluated AS OF the signal bar (released / minutes_until),
|
|
not as of today.
|
|
3. Runs the CURRENT prompts (agent_panel.PANEL_PROMPT_VERSION) through the
|
|
real DeepSeek client on each payload, validates the replies with the
|
|
live code path, aggregates with agent_panel.mean_verdict.
|
|
4. Scores three predictors on the same events: the new run, the logged
|
|
v1 takes (stance x confidence -> pseudo-probability, when present) and
|
|
the base-rate-only predictor (the class win rate for every trade):
|
|
Brier score, log-loss, calibration by probability band, discrimination
|
|
(mean R and win rate by band), constant-voter rate per persona, and the
|
|
counterfactual books (all / skip p <= 40 / only p >= 60).
|
|
|
|
Spend: 4 calls per event against the shared DeepSeek daily cap (500);
|
|
`--max-calls` is a hard stop. `--dry-run` builds and writes the payloads
|
|
without a single call. Read-only on the database.
|
|
|
|
Caveat printed with every summary: a few dozen outcomes from one regime
|
|
can reject a pathological prompt (constant voting, narrative anchoring);
|
|
they cannot prove accuracy. The live scorecard at 100 and 300 outcomes
|
|
(analyze_signal_log.py --stored-candles) is the real test.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import glob
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
import pandas as pd
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
import agent_panel as ap # noqa: E402
|
|
from analyze_signal_log import (cluster_setup_rows, load_rows, # noqa: E402
|
|
open_readonly)
|
|
from config import SignalStrength, Timeframe # noqa: E402
|
|
from news_engine import (EconomicEvent, FundamentalContext, # noqa: E402
|
|
NewsSentiment)
|
|
from signal_aggregator import SignalReport, TradeSetup # noqa: E402
|
|
from signal_log import read_news # noqa: E402
|
|
|
|
PERSONAS = ap.ALL_PERSONAS # research default: score every persona
|
|
CACHE_DIR = os.path.join(ROOT, "backtest", "cache")
|
|
_TF = {tf.value: tf for tf in Timeframe}
|
|
|
|
|
|
# ── Reconstruction ────────────────────────────────────────────────────
|
|
|
|
class _AsOfEvent(EconomicEvent):
|
|
"""EconomicEvent whose `is_upcoming` / `minutes_until` are evaluated
|
|
relative to the signal bar, not the wall clock."""
|
|
def __init__(self, as_of: datetime, **kw):
|
|
super().__init__(**kw)
|
|
self._as_of = as_of
|
|
|
|
@property
|
|
def is_upcoming(self) -> bool: # type: ignore[override]
|
|
return self.time > self._as_of
|
|
|
|
@property
|
|
def minutes_until(self) -> float: # type: ignore[override]
|
|
return (self.time - self._as_of).total_seconds() / 60.0
|
|
|
|
|
|
def _dt(s: Optional[str]) -> Optional[datetime]:
|
|
if not s:
|
|
return None
|
|
try:
|
|
d = datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _loads(s: Any, default: Any) -> Any:
|
|
if s in (None, "", "null"):
|
|
return default
|
|
try:
|
|
return json.loads(s)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _analysis(tf: str, blob: Dict[str, Any]) -> SimpleNamespace:
|
|
inds = {}
|
|
for name, d in (blob.get("indicators") or {}).items():
|
|
try:
|
|
sig = SignalStrength[d.get("signal", "NEUTRAL")]
|
|
except KeyError:
|
|
sig = SignalStrength.NEUTRAL
|
|
inds[name] = SimpleNamespace(
|
|
signal=sig, confidence=float(d.get("confidence") or 0.0),
|
|
weight=float(d.get("weight") or 0.0), value=d.get("value"),
|
|
description=d.get("description") or "")
|
|
return SimpleNamespace(timeframe=tf, timestamp=blob.get("timestamp"),
|
|
key_levels=dict(blob.get("key_levels") or {}),
|
|
indicators=inds)
|
|
|
|
|
|
def _context_from_row(events_json: Optional[str], headlines_json: Optional[str],
|
|
as_of: datetime) -> FundamentalContext:
|
|
ctx = FundamentalContext()
|
|
ev = _loads(events_json, {}) or {}
|
|
for key in ("events_today", "events_upcoming_1h"):
|
|
out = []
|
|
for e in ev.get(key) or []:
|
|
t = _dt(e.get("time"))
|
|
if t is None:
|
|
continue
|
|
out.append(_AsOfEvent(as_of, time=t, currency=e.get("currency") or "",
|
|
impact=e.get("impact") or "", event=e.get("event") or "",
|
|
forecast=e.get("forecast"), previous=e.get("previous"),
|
|
actual=e.get("actual")))
|
|
setattr(ctx, key, out)
|
|
ctx.news_sentiment = dict(ev.get("sentiment_by_currency") or {})
|
|
ctx.calendar_available = bool(ev.get("calendar_available", False))
|
|
for h in _loads(headlines_json, []) or []:
|
|
t = _dt(h.get("timestamp")) or as_of
|
|
ctx.news_headlines.append(NewsSentiment(
|
|
headline=h.get("headline") or "", source=h.get("source") or "",
|
|
timestamp=t, currencies_mentioned=list(h.get("currencies") or []),
|
|
sentiment_score=float(h.get("score") or 0.0),
|
|
keywords=list(h.get("keywords") or []),
|
|
sentiment_by_currency={k: float(v) for k, v in
|
|
(h.get("score_by_currency") or {}).items()
|
|
if v is not None},
|
|
score_source=h.get("score_source") or "keyword"))
|
|
return ctx
|
|
|
|
|
|
_frame_cache: Dict[str, pd.DataFrame] = {}
|
|
_have_cols: set = set()
|
|
|
|
|
|
def _col(name: str) -> str:
|
|
"""Column reference that tolerates a database logged before the
|
|
column existed (a read-only copy is never migrated)."""
|
|
return name if name in _have_cols else f"NULL AS {name}"
|
|
|
|
|
|
def _cached_frame(tf: str) -> Optional[pd.DataFrame]:
|
|
"""Newest kept Oanda cache window for `tf` (raw bars, UTC index)."""
|
|
if tf in _frame_cache:
|
|
return _frame_cache[tf]
|
|
files = sorted(glob.glob(os.path.join(CACHE_DIR, f"oanda_XAU_USD_{tf}_*.parquet")),
|
|
key=lambda p: p.rsplit("_", 1)[-1])
|
|
df = None
|
|
if files:
|
|
try:
|
|
df = pd.read_parquet(files[-1])
|
|
if df.index.tz is None:
|
|
df.index = df.index.tz_localize("UTC")
|
|
except Exception:
|
|
df = None
|
|
_frame_cache[tf] = df
|
|
return df
|
|
|
|
|
|
def _frames_as_of(tfs: Sequence[str], bar_ts: datetime) -> Dict[Timeframe, pd.DataFrame]:
|
|
out: Dict[Timeframe, pd.DataFrame] = {}
|
|
for tf in tfs:
|
|
df = _cached_frame(tf)
|
|
tfe = _TF.get(tf)
|
|
if df is None or tfe is None:
|
|
continue
|
|
sl = df[df.index <= pd.Timestamp(bar_ts)]
|
|
if len(sl) >= 3:
|
|
out[tfe] = sl
|
|
return out
|
|
|
|
|
|
def build_report(con, rowid: int) -> Tuple[Optional[SignalReport], Optional[FundamentalContext],
|
|
Dict[Timeframe, pd.DataFrame], Optional[Dict[str, Any]]]:
|
|
"""(report, context, frames, stored_payloads) for one logged row."""
|
|
row = con.execute(
|
|
"SELECT symbol, timeframe, " + _col("bar_ts") + ", logged_at, signal, score,"
|
|
" confidence, quality, price, market_regime, volatility_regime, mtf_json,"
|
|
" setup_json, reasons_json, warnings_json, event_risk, event_description,"
|
|
" news_sentiment, indicators_json, " + _col("penalty_tags_json") + ", "
|
|
+ _col("exhaustion_count") + ", " + _col("penalty_total") + ", "
|
|
+ _col("momentum_fading") + ", " + _col("panel_payloads_json")
|
|
+ " FROM signals WHERE id=?", (rowid,)).fetchone()
|
|
if row is None:
|
|
return None, None, {}, None
|
|
(symbol, tf, bar_ts, logged_at, signal, score, conf, quality, price, regime, vol,
|
|
mtf_json, setup_json, reasons_json, warnings_json, event_risk, event_desc, news_sent,
|
|
ind_json, pen_json, exh, pen_total, mom, payloads_json) = row
|
|
inds = _loads(ind_json, {}) or {}
|
|
bar_label = bar_ts or (inds.get(tf) or {}).get("timestamp")
|
|
as_of_bar = _dt(bar_label) or _dt(logged_at)
|
|
if as_of_bar is None:
|
|
return None, None, {}, None
|
|
secs = ap._TF_SECONDS.get(tf, 900)
|
|
as_of = as_of_bar + timedelta(seconds=secs) # the bar closed
|
|
|
|
report = SignalReport(symbol=symbol, display_name="XAU/USD",
|
|
timestamp=str(bar_label or logged_at))
|
|
try:
|
|
report.final_signal = SignalStrength[signal]
|
|
except KeyError:
|
|
report.final_signal = SignalStrength.NEUTRAL
|
|
report.final_score = float(score or 0.0)
|
|
report.final_confidence = float(conf or 0.0)
|
|
report.signal_quality = quality or ""
|
|
report.current_price = float(price or 0.0)
|
|
report.market_regime = regime or ""
|
|
report.volatility_regime = vol or ""
|
|
mtf = _loads(mtf_json, {}) or {}
|
|
report.mtf_agreement = bool(mtf.get("agreement", False))
|
|
for k, v in (mtf.get("signals") or {}).items():
|
|
try:
|
|
report.mtf_signals[k] = SignalStrength[v]
|
|
except KeyError:
|
|
report.mtf_signals[k] = SignalStrength.NEUTRAL
|
|
report.mtf_scores = {k: float(v or 0.0) for k, v in (mtf.get("scores") or {}).items()}
|
|
reasons = _loads(reasons_json, {}) or {}
|
|
report.bullish_reasons = list(reasons.get("bullish") or [])
|
|
report.bearish_reasons = list(reasons.get("bearish") or [])
|
|
report.warnings = list(_loads(warnings_json, []) or [])
|
|
report.event_risk = event_risk or "clear"
|
|
report.event_description = event_desc or ""
|
|
report.news_sentiment = float(news_sent or 0.0)
|
|
report.exhaustion_count = int(exh or 0)
|
|
report.penalty_tags = {str(k): float(v or 0.0)
|
|
for k, v in (_loads(pen_json, {}) or {}).items()}
|
|
report.penalty_total = float(pen_total or 0.0)
|
|
report.momentum_fading = bool(mom)
|
|
setup = _loads(setup_json, None)
|
|
if setup:
|
|
report.trade_setup = TradeSetup(
|
|
pair=symbol, display_name="XAU/USD", direction=setup.get("direction"),
|
|
entry_price=float(setup.get("entry") or 0.0),
|
|
stop_loss=float(setup.get("stop_loss") or 0.0),
|
|
take_profit_1=float(setup.get("take_profit_1") or 0.0),
|
|
take_profit_2=float(setup.get("take_profit_2") or 0.0),
|
|
risk_reward=float(setup.get("risk_reward") or 0.0),
|
|
risk_pips=float(setup.get("risk_pips") or 0.0),
|
|
reward_pips=float(setup.get("reward_pips") or 0.0),
|
|
pip_value=0.10, strategy=setup.get("strategy") or "GENERIC",
|
|
setup_pattern=setup.get("setup_pattern"),
|
|
round_trip_cost=setup.get("round_trip_cost"),
|
|
trail_arm_r=setup.get("trail_arm_r"), trail_dist_r=setup.get("trail_dist_r"))
|
|
if tf in inds:
|
|
report.primary_analysis = _analysis(tf, inds[tf])
|
|
report.confirmation_analyses = [_analysis(k, v) for k, v in inds.items() if k != tf]
|
|
events_json, headlines_json = read_news(con, rowid)
|
|
context = _context_from_row(events_json, headlines_json, as_of)
|
|
frames = _frames_as_of([tf] + [k for k in inds if k != tf], as_of_bar)
|
|
return report, context, frames, _loads(payloads_json, None)
|
|
|
|
|
|
def stored_personas(stored: Any) -> List[str]:
|
|
"""The requested personas whose exact payload the row logged."""
|
|
if not isinstance(stored, dict):
|
|
return []
|
|
return [p for p in PERSONAS if isinstance(stored.get(p), dict)]
|
|
|
|
|
|
def build_jobs(report: SignalReport, context: FundamentalContext,
|
|
frames: Dict[Timeframe, pd.DataFrame], stored: Optional[Dict[str, Any]],
|
|
settings: Dict[str, Any]) -> Tuple[Tuple[str, str, Dict[str, Any]], ...]:
|
|
"""Persona jobs on the CURRENT prompts. A persona whose exact payload
|
|
the row logged replays that payload; every other persona is rebuilt
|
|
through the live payload builders. Per persona, not all-or-nothing:
|
|
the live panel stores only the personas it ran (macro alone since
|
|
2026-09-17), so "all four or none" never used a stored payload."""
|
|
systems = {"macro": ap._MACRO_SYSTEM, "technical": ap._TECH_SYSTEM,
|
|
"risk": ap._RISK_SYSTEM, "contrarian": ap._CONTRARIAN_SYSTEM}
|
|
kept = {p: stored[p] for p in stored_personas(stored)}
|
|
if len(kept) == len(PERSONAS):
|
|
return tuple((p, systems[p], kept[p]) for p in PERSONAS)
|
|
candles: Dict[str, Any] = {}
|
|
analyses = [report.primary_analysis] + list(report.confirmation_analyses)
|
|
counts = [int(settings.get("candle_bars_primary", ap.DEFAULT_CANDLE_BARS_PRIMARY))] + \
|
|
[int(settings.get("candle_bars_confirm", ap.DEFAULT_CANDLE_BARS_CONFIRM))] * \
|
|
len(report.confirmation_analyses)
|
|
for analysis, n in zip(analyses, counts):
|
|
if analysis is None:
|
|
continue
|
|
atr = ap._finite(getattr((analysis.indicators or {}).get("atr"), "value", None))
|
|
df = frames.get(_TF.get(analysis.timeframe))
|
|
if atr and atr > 0 and df is not None and len(df) >= 2:
|
|
candles[analysis.timeframe] = ap._normalize_candles(df, atr, n)
|
|
ctx = ap._price_context(report, frames, context)
|
|
rebuilt = ap._panel_jobs(report, context, candles or None, ctx, PERSONAS)
|
|
return tuple((p, system, kept.get(p, payload)) for p, system, payload in rebuilt)
|
|
|
|
|
|
# ── Scoring ───────────────────────────────────────────────────────────
|
|
|
|
def _brier(ps: Sequence[float], ys: Sequence[int]) -> float:
|
|
return sum((p / 100.0 - y) ** 2 for p, y in zip(ps, ys)) / len(ps)
|
|
|
|
|
|
def _logloss(ps: Sequence[float], ys: Sequence[int]) -> float:
|
|
eps = 1e-3
|
|
tot = 0.0
|
|
for p, y in zip(ps, ys):
|
|
q = min(1 - eps, max(eps, p / 100.0))
|
|
tot += -(y * math.log(q) + (1 - y) * math.log(1 - q))
|
|
return tot / len(ps)
|
|
|
|
|
|
def score_predictor(name: str, ps: Sequence[Optional[float]], rs: Sequence[float]) -> Dict[str, Any]:
|
|
pairs = [(p, r) for p, r in zip(ps, rs) if p is not None]
|
|
if not pairs:
|
|
return {"predictor": name, "n": 0}
|
|
P = [p for p, _ in pairs]; R = [r for _, r in pairs]; Y = [1 if r > 0 else 0 for r in R]
|
|
bands = {}
|
|
for label, lo, hi in (("<=40", -1, ap.P_OPPOSE), ("40-60", ap.P_OPPOSE, ap.P_SUPPORT),
|
|
(">=60", ap.P_SUPPORT, 101)):
|
|
sel = [(p, r) for p, r in pairs if (lo < p <= hi if label == "<=40" else
|
|
lo < p < hi if label == "40-60" else p >= lo)]
|
|
if sel:
|
|
bands[label] = {"n": len(sel), "mean_p": round(sum(p for p, _ in sel) / len(sel), 1),
|
|
"win_rate": round(100 * sum(1 for _, r in sel if r > 0) / len(sel), 1),
|
|
"mean_r": round(sum(r for _, r in sel) / len(sel), 3),
|
|
"sum_r": round(sum(r for _, r in sel), 2)}
|
|
else:
|
|
bands[label] = {"n": 0}
|
|
stances = [ap.derive_stance(p) for p in P]
|
|
modal = max(set(stances), key=stances.count)
|
|
return {
|
|
"predictor": name, "n": len(P),
|
|
"brier": round(_brier(P, Y), 4), "logloss": round(_logloss(P, Y), 4),
|
|
"mean_p": round(sum(P) / len(P), 1), "win_rate": round(100 * sum(Y) / len(Y), 1),
|
|
"bands": bands, "modal_stance": modal,
|
|
"constant_voter_rate": round(100 * stances.count(modal) / len(stances), 1),
|
|
"sum_r_all": round(sum(R), 2),
|
|
"sum_r_skip_oppose": round(sum(r for p, r in pairs if p > ap.P_OPPOSE), 2),
|
|
"sum_r_support_only": round(sum(r for p, r in pairs if p >= ap.P_SUPPORT), 2),
|
|
}
|
|
|
|
|
|
def print_summary(results: List[Dict[str, Any]]) -> None:
|
|
print("\n== Predictor scorecard (outcome = advisory R > 0 at $0.65) ==")
|
|
print(f"{'predictor':22s} {'n':>3s} {'Brier':>6s} {'logloss':>7s} {'mean p':>6s} {'win%':>5s} "
|
|
f"{'modal':>8s} {'const%':>6s} {'R all':>7s} {'R skip<=40':>10s} {'R >=60 only':>11s}")
|
|
for r in results:
|
|
if r.get("n", 0) == 0:
|
|
print(f"{r['predictor']:22s} 0"); continue
|
|
print(f"{r['predictor']:22s} {r['n']:3d} {r['brier']:6.3f} {r['logloss']:7.3f} {r['mean_p']:6.1f} "
|
|
f"{r['win_rate']:5.1f} {r['modal_stance']:>8s} {r['constant_voter_rate']:6.1f} "
|
|
f"{r['sum_r_all']:+7.2f} {r['sum_r_skip_oppose']:+10.2f} {r['sum_r_support_only']:+11.2f}")
|
|
print("\n calibration bands (n / mean p / observed win% / mean R):")
|
|
for r in results:
|
|
if r.get("n", 0) == 0:
|
|
continue
|
|
cells = " ".join(f"{k}: {v['n']}/{v.get('mean_p', '-')}/{v.get('win_rate', '-')}/{v.get('mean_r', '-')}"
|
|
if v["n"] else f"{k}: 0" for k, v in r["bands"].items())
|
|
print(f" {r['predictor']:22s} {cells}")
|
|
print("\nCaveat: a few dozen outcomes from one regime can reject a pathological prompt "
|
|
"(constant voting, narrative anchoring); they cannot prove accuracy.")
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────
|
|
|
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--db", default=os.path.join(ROOT, "logs", "signals.db"))
|
|
parser.add_argument("--limit", type=int, default=0, help="max events (0 = all)")
|
|
parser.add_argument("--max-calls", type=int, default=200,
|
|
help="hard stop on LLM requests (4 per event)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="build payloads only; no LLM call")
|
|
parser.add_argument("--out", default=None, help="per-event CSV (and .payloads.json on --dry-run)")
|
|
parser.add_argument("--include-pending", action="store_true",
|
|
help="also build payloads for unresolved setups (dry-run only)")
|
|
parser.add_argument("--personas", default=",".join(ap.ALL_PERSONAS),
|
|
help="comma-separated personas to run (default: all four, so every "
|
|
"voice is scored; the live default is agent_panel.personas = macro)")
|
|
args = parser.parse_args(argv)
|
|
global PERSONAS
|
|
PERSONAS = ap.normalize_personas([p for p in args.personas.split(",") if p.strip()])
|
|
|
|
rows = load_rows(args.db)
|
|
events = cluster_setup_rows(rows)
|
|
con = open_readonly(args.db)
|
|
global _have_cols
|
|
_have_cols = {r[1] for r in con.execute("PRAGMA table_info(signals)")}
|
|
settings = {"candle_bars_primary": ap.DEFAULT_CANDLE_BARS_PRIMARY,
|
|
"candle_bars_confirm": ap.DEFAULT_CANDLE_BARS_CONFIRM}
|
|
picked = []
|
|
for ev in events:
|
|
res = next((r for r in ev.rows if r.advisory_status == "RESOLVED" and r.advisory_r is not None), None)
|
|
if res is None and not (args.dry_run and args.include_pending):
|
|
continue
|
|
picked.append((ev, res))
|
|
if args.limit:
|
|
picked = picked[:args.limit]
|
|
print(f"events: {len(events)} total, {len(picked)} selected "
|
|
f"({'dry run' if args.dry_run else 'live calls, max ' + str(args.max_calls)}), "
|
|
f"prompt version {ap.PANEL_PROMPT_VERSION}, personas {','.join(PERSONAS)}")
|
|
|
|
client = None if args.dry_run else ap.get_llm_client()
|
|
calls = 0
|
|
per_event: List[Dict[str, Any]] = []
|
|
payload_dump: Dict[str, Any] = {}
|
|
for ev, res in picked:
|
|
rep = ev.representative
|
|
# Prefer a member row that already carries the v2 payloads.
|
|
rowid = rep.id
|
|
for r in ev.rows:
|
|
has = (con.execute("SELECT panel_payloads_json IS NOT NULL FROM signals"
|
|
" WHERE id=?", (r.id,)).fetchone()
|
|
if "panel_payloads_json" in _have_cols else None)
|
|
if has and has[0]:
|
|
rowid = r.id
|
|
break
|
|
report, context, frames, stored = build_report(con, rowid)
|
|
if report is None or report.trade_setup is None:
|
|
continue
|
|
jobs = build_jobs(report, context, frames, stored, settings)
|
|
ctx = next((payload.get("context") for _, _, payload in jobs if payload.get("context")), {}) or {}
|
|
base_p = (ctx.get("base_rate") or {}).get("win_rate")
|
|
# logged v1 takes (any member row) -> pseudo-probabilities
|
|
v1 = {}
|
|
for r in ev.rows:
|
|
takes = con.execute("SELECT panel_takes_json, " + _col("panel_prompt_version")
|
|
+ " FROM signals WHERE id=?", (r.id,)).fetchone()
|
|
if takes and takes[0]:
|
|
for t in _loads(takes[0], []) or []:
|
|
p = t.get("p_win")
|
|
if p is None and t.get("stance") in ap._VALID_STANCES:
|
|
p = ap.pseudo_probability(t["stance"], float(t.get("confidence") or 0))
|
|
if p is not None:
|
|
v1[t.get("persona")] = float(p)
|
|
v1["_version"] = takes[1] or "v1-logged"
|
|
break
|
|
entry: Dict[str, Any] = {
|
|
"event_row": rep.id, "bar": report.timestamp, "tf": rep.timeframe,
|
|
"direction": report.trade_setup.direction, "grade": rep.quality,
|
|
"strategy": report.trade_setup.strategy,
|
|
# which personas replayed the exact logged payload ("" = all rebuilt)
|
|
"R": res.advisory_r if res else None,
|
|
"stored_payloads": ",".join(stored_personas(stored)),
|
|
"base_p": base_p, "prior_move_4h_atr": ctx.get("prior_move_4h_atr"),
|
|
"move_from_start_atr": ctx.get("move_from_24h_start_atr"),
|
|
"big_candle_last3": ctx.get("big_candle_last3"),
|
|
}
|
|
for p in PERSONAS:
|
|
entry[f"v1_{p}"] = v1.get(p)
|
|
v1_ps = [v1[p] for p in PERSONAS if p in v1]
|
|
entry["v1_mean"] = round(sum(v1_ps) / len(v1_ps), 1) if len(v1_ps) >= min(ap.MEAN_QUORUM, len(v1_ps) or 1) and v1_ps else None
|
|
if args.dry_run:
|
|
payload_dump[str(rep.id)] = {name: payload for name, _, payload in jobs}
|
|
else:
|
|
takes = []
|
|
for name, system, payload in jobs:
|
|
if calls >= args.max_calls:
|
|
break
|
|
calls += 1
|
|
reply = client.chat_json(system, payload)
|
|
take = ap._validate_take(name, reply)
|
|
if take is not None:
|
|
takes.append(take)
|
|
entry[f"new_{name}"] = take.p_win
|
|
entry[f"new_{name}_for"] = take.reason_for
|
|
entry[f"new_{name}_against"] = take.reason_against
|
|
v = ap.mean_verdict(takes, quorum=min(ap.MEAN_QUORUM, len(jobs)))
|
|
entry["new_mean"] = v.probability if v else None
|
|
entry["new_verdict"] = v.verdict if v else None
|
|
print(f" #{rep.id:<5d} {report.timestamp[:16]} {rep.timeframe:5s} "
|
|
f"{report.trade_setup.direction:4s} {rep.quality:2s} R={entry['R']:+.2f} "
|
|
f"base={base_p} new_mean={entry['new_mean']} "
|
|
f"({', '.join(f'{t.persona[:4]} {t.p_win:.0f}' for t in takes)}) "
|
|
f"v1_mean={entry['v1_mean']}")
|
|
per_event.append(entry)
|
|
if not args.dry_run and calls >= args.max_calls:
|
|
print(f" stopped at --max-calls {args.max_calls}")
|
|
break
|
|
con.close()
|
|
|
|
if args.dry_run:
|
|
print(f"built payloads for {len(per_event)} events")
|
|
if args.out:
|
|
with open(args.out.rsplit('.', 1)[0] + ".payloads.json", "w") as fh:
|
|
json.dump(payload_dump, fh, indent=1, sort_keys=True)
|
|
print(f"wrote {args.out.rsplit('.', 1)[0]}.payloads.json")
|
|
|
|
scored = [e for e in per_event if e["R"] is not None]
|
|
if scored:
|
|
rs = [e["R"] for e in scored]
|
|
results = [score_predictor("base-rate only",
|
|
[e["base_p"] if e["base_p"] is not None else None for e in scored], rs),
|
|
score_predictor("v1 logged (mean)", [e["v1_mean"] for e in scored], rs)]
|
|
for p in PERSONAS:
|
|
results.append(score_predictor(f"v1 logged {p}", [e.get(f"v1_{p}") for e in scored], rs))
|
|
if not args.dry_run:
|
|
results.append(score_predictor(f"NEW {ap.PANEL_PROMPT_VERSION} (mean)",
|
|
[e.get("new_mean") for e in scored], rs))
|
|
for p in PERSONAS:
|
|
results.append(score_predictor(f"NEW {p}", [e.get(f"new_{p}") for e in scored], rs))
|
|
print_summary(results)
|
|
if args.out and per_event:
|
|
pd.DataFrame(per_event).to_csv(args.out, index=False)
|
|
print(f"wrote {args.out} ({len(per_event)} events)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|