Files
ForexBot2/scripts/signal_parity_study.py
T
Harwinder Sekhon a979dcba4b signal_log: persist agent panel verdict + per-persona takes to SQLite
Add panel_verdict, panel_confidence, panel_rationale, panel_takes_json
columns to the signals table (additive, idempotent ALTER TABLE migration
for existing DBs). _report_row now reads the panel fields attach_panel
writes onto the SignalReport, so LLM panel verdicts stop evaporating on
process exit. Also documents the Oanda re-pin (round_trip_cost stays
$0.30 for cross-book comparability and the live TP gate; $0.65 is the
measured headline) and adds the feed-divergence / parity / shadow-scan
study tooling referenced by CLAUDE.md.
2026-08-14 20:41:54 -04:00

247 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Phase 2 — signal-level replay parity (data source change test)
===============================================================
Replays the REAL decision core (`SignalAggregator.analyze_from_dataframes`,
news off, use_news=False) over the same window for two providers and
compares the *decisions* bar-by-bar: direction, composite/final score,
quality grade, gate/setup presence, strategy tag, and entry/SL/TP levels.
This turns the Phase-1 price-diff stats into flip rates. Feeds are read
from the Phase-1 parquet cache (scripts/feed_divergence_study.py writes
them); pass --no-cache-fetch only if you want to fetch fresh via
fetch_range first.
Slicing matches gpu_backtest.py exactly: decision at close of bar `pos`,
each TF sliced to bars fully closed by that decision time, primary at the
shared grid, so the two feeds are compared on identical timestamps.
Usage:
venv/bin/python scripts/signal_parity_study.py --a oanda --b tiingo \
--tf M15 --stride 8 --days 365
"""
import argparse
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import Timeframe, XAUUSD
from pair_signals.xauusd import make_config
from signal_aggregator import SignalAggregator
REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = REPO / "backtest" / "cache"
RESEARCH_DIR = REPO / "backtest" / "research"
BAR_SECONDS = {
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400, Timeframe.W1: 604800,
}
_TF_LOOKUP = {t.value: t for t in Timeframe}
_TF_LOOKUP.update({"M1": Timeframe.M1, "M5": Timeframe.M5,
"M15": Timeframe.M15, "M30": Timeframe.M30,
"H1": Timeframe.H1, "H4": Timeframe.H4,
"D1": Timeframe.D1, "W1": Timeframe.W1})
_CONFIRMS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
def load_cached(name: str, tf: Timeframe, start: pd.Timestamp,
end: pd.Timestamp) -> pd.DataFrame:
cp = CACHE_DIR / f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet"
if not cp.exists():
sys.exit(f"missing cache {cp}; run scripts/feed_divergence_study.py "
f"--a oanda --b {name} --tfs {tf.value} --days ... first")
return pd.read_parquet(cp)
def tag_of(report) -> str:
first_bull = report.bullish_reasons[0] if report.bullish_reasons else ""
first_bear = report.bearish_reasons[0] if report.bearish_reasons else ""
if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear:
return "XAU_PB"
if "XAU CHART PATTERN" in first_bull or "XAU CHART PATTERN" in first_bear:
return "XAU_CP"
if "XAU STRATEGY" in first_bull:
return "XAU"
return "GENERIC"
def decide(agg, dfs, tss, stride, warmup) -> list:
"""Replay decisions over the shared grid, returning one row per bar."""
primary = dfs[agg.config.primary_timeframe]
dur = pd.Timedelta(seconds=BAR_SECONDS[agg.config.primary_timeframe])
# positions of sampled bars in the primary frame
primary_pos = primary.index.get_indexer(tss)
rows = []
for i in range(0, len(tss), stride):
ts = tss[i]
pos = int(primary_pos[i])
if pos < warmup:
continue
slices = {}
for tf, full in dfs.items():
cutoff = ts # decision_time = ts + dur; bars closed by ts
cut = int(full.index.searchsorted(cutoff, side="right"))
if cut <= 0:
continue
slices[tf] = full.iloc[:cut]
if agg.config.primary_timeframe not in slices:
continue
r = agg.analyze_from_dataframes(XAUUSD, slices, use_news=False)
ts_setup = r.trade_setup
rows.append({
"ts": ts,
"direction": r.final_signal.value,
"final_score": r.final_score,
"primary_score": r.primary_score,
"grade": r.signal_quality,
"has_setup": ts_setup is not None,
"tag": tag_of(r) if ts_setup else "",
"entry": ts_setup.entry_price if ts_setup else np.nan,
"sl": ts_setup.stop_loss if ts_setup else np.nan,
"tp1": ts_setup.take_profit_1 if ts_setup else np.nan,
"tp2": ts_setup.take_profit_2 if ts_setup else np.nan,
})
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--a", required=True)
ap.add_argument("--b", required=True)
ap.add_argument("--tf", default="M15")
ap.add_argument("--stride", type=int, default=1,
help="sample every Nth shared bar (default 1)")
ap.add_argument("--days", type=int, default=365)
ap.add_argument("--end", default=None)
ap.add_argument("--warmup", type=int, default=300)
args = ap.parse_args()
tf = _TF_LOOKUP.get(args.tf)
if tf is None:
sys.exit(f"unknown tf {args.tf}")
confirms = _CONFIRMS[tf]
tfs = [tf] + confirms
end = pd.Timestamp(args.end, tz="UTC") if args.end \
else pd.Timestamp.now(tz="UTC")
start = end - pd.Timedelta(days=args.days)
cfg = make_config(tf, confirms)
agg = SignalAggregator(cfg)
a_dfs, b_dfs = {}, {}
for t in tfs:
a_dfs[t] = load_cached(args.a, t, start, end)
b_dfs[t] = load_cached(args.b, t, start, end)
# shared grid = primary timestamps present in both feeds
shared = a_dfs[tf].index.intersection(b_dfs[tf].index)
t0 = time.time()
print(f"replaying {len(shared)} shared {tf.value} bars "
f"(stride {args.stride} → ~{len(shared)//args.stride}) on {args.a}...")
a_rows = decide(agg, a_dfs, shared, args.stride, args.warmup)
print(f" {args.a}: {len(a_rows)} decisions in {time.time()-t0:.1f}s")
t0 = time.time()
b_rows = decide(agg, b_dfs, shared, args.stride, args.warmup)
print(f" {args.b}: {len(b_rows)} decisions in {time.time()-t0:.1f}s")
A = pd.DataFrame(a_rows).set_index("ts")
B = pd.DataFrame(b_rows).set_index("ts")
common = A.index.intersection(B.index)
A, B = A.loc[common], B.loc[common]
n = len(common)
print(f"\n{'=' * 78}")
print(f"SIGNAL-LEVEL PARITY — {tf.value} — {args.a} vs {args.b} "
f"({n:,} bars)")
print("=" * 78)
def pct(x): return f"{100 * x:.2f}%"
same_dir = (A["direction"] == B["direction"])
same_grade = (A["grade"] == B["grade"])
same_setup = (A["has_setup"] == B["has_setup"])
same_all = same_dir & same_grade & same_setup
print(f"\nIDENTITY")
print(f" same direction : {pct(same_dir.mean())}")
print(f" same grade : {pct(same_grade.mean())}")
print(f" same setup presence : {pct(same_setup.mean())}")
print(f" same dir+grade+setup : {pct(same_all.mean())}")
both_sig = (A["direction"] != 0) & (B["direction"] != 0)
flips = both_sig & (A["direction"] != B["direction"])
opp = flips & (A["direction"] == -B["direction"])
print(f"\nDIRECTION")
print(f" bars both actionable : {both_sig.sum():,} "
f"({pct(both_sig.mean())})")
print(f" direction flips : {flips.sum():,} "
f"({pct(flips.sum()/max(both_sig.sum(),1))} of actionable)")
print(f" opposite-sign flips : {opp.sum():,}")
grade_a = set(A.loc[A["has_setup"], "grade"])
print(f"\nGRADE (of {args.a} setups, grades seen: {sorted(grade_a)})")
changed = A.loc[both_sig, "grade"] != B.loc[both_sig, "grade"]
print(f" grade changed on actionable bars: "
f"{changed.sum():,} ({pct(changed.mean())})")
# score deltas on shared bars
ds = (A["final_score"] - B["final_score"]).abs()
dp = (A["primary_score"] - B["primary_score"]).abs()
print(f"\nSCORE Δ ({args.a} − {args.b})")
print(f" final_score mean {ds.mean():6.2f} p95 {ds.quantile(.95):6.2f} "
f"max {ds.max():6.2f}")
print(f" primary_score mean {dp.mean():6.2f} p95 {dp.quantile(.95):6.2f} "
f"max {dp.max():6.2f}")
# setup price diffs on bars where BOTH generated a setup
both_setup = A["has_setup"] & B["has_setup"]
if both_setup.any():
print(f"\nSETUP LEVELS (both-setup bars: {both_setup.sum():,})")
for col, label in (("entry", "entry"), ("sl", "SL"), ("tp1", "TP1"),
("tp2", "TP2")):
d = (A.loc[both_setup, col] - B.loc[both_setup, col]).abs()
print(f" {label:<6} mean ${d.mean():6.3f} p95 ${d.quantile(.95):7.3f} "
f"max ${d.max():7.2f}")
# tag disagreement on both-setup bars
if both_setup.any():
same_tag = (A.loc[both_setup, "tag"] == B.loc[both_setup, "tag"])
print(f" same strategy tag : {pct(same_tag.mean())}")
# where do flips cluster (hour)?
flip_hours = A.loc[flips].index.hour.value_counts().sort_index()
if len(flip_hours):
print(f"\nFLIPS BY UTC HOUR")
for h, c in flip_hours.items():
print(f" {h:02d}: {c}")
out = {
"tf": tf.value, "a": args.a, "b": args.b, "bars": n,
"same_dir": same_dir.mean(), "same_grade": same_grade.mean(),
"same_setup": same_setup.mean(), "same_all": same_all.mean(),
"flip_rate_actionable": flips.sum() / max(both_sig.sum(), 1),
"mean_abs_final_score_delta": ds.mean(),
"mean_abs_primary_score_delta": dp.mean(),
}
print(f"\n{out}")
if __name__ == "__main__":
main()