chore: keep only what the system needs — drop research tooling, study scripts, diagram, plan

Owner directive 2026-09-06: Dukascopy and similar research material is irrelevant to
the running system; keep only code and files with a role in system functioning.

Deleted (all in git history before this commit):
- backtest/research/ tooling and data: book_report, book_diff, burst_map,
  burst_precursors, burst_indicator_map, open_trade_bursts, calendar_impact,
  watch_rule_eval, sizing_model, commit_ladder, llm_news_eval + llm_news_golden.csv,
  ff_calendar_scraper, duka_spread + duka_spread_by_hour.csv (the Dukascopy spread
  study), feed_divergence_oanda_vs_tiingo.csv, the panel dry-test JSONL, __init__.py
- scripts/feed_divergence_study.py, scripts/signal_parity_study.py (completed studies;
  their reports keep the results)
- docs/architecture.png + architecture_diagram.py (developer illustration)
- docs/data_source_change_test_plan.md (Phase 4 shadow scan stays noted in CLAUDE.md)

Kept: the campaign .md reports in backtest/research/ as the decision ledger CLAUDE.md
cites (rule 1 evidence); scripts/shadow_scan.py (pending operational step);
backtest/diagnose_filters.py (test-locked backtester diagnostic).

Local, untracked material removed on this box: .superpowers/, walkforward/, baseline/,
the commit-ladder and LLM-eval result folders, the Dukascopy boundary CSV; the
sizing_2026-09 folder is pruned to the four 3-year baseline books CLAUDE.md names.

.gitignore no longer lists the removed research outputs; the backtest instructions
and CLAUDE.md no longer point at deleted tools.

Suite: 461 passed, 0 failed (nothing imported the removed files).

Claude-Session: https://claude.ai/code/session_0156YhogLytzj9P2iH1skGbL
This commit is contained in:
Harwinder Sekhon
2026-09-06 17:35:41 -04:00
parent de3f8a6ab8
commit c004e3f30b
28 changed files with 15 additions and 4800 deletions
-5
View File
@@ -26,12 +26,7 @@ Thumbs.db
# Backtesting data (keep scripts, ignore generated files)
backtest/cache/
backtest/*.csv
backtest/research/*.parquet
backtest/research/duka_m5_boundary_spreads.csv
backtest/__pycache__/
.env.*
logs/
backtest/research/commit_ladder_2026-08/
backtest/research/llm_news_eval_2026-08/
backtest/research/sizing_2026-09/
+5 -5
View File
@@ -37,7 +37,7 @@ python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-0
**`test_all.py` is NOT pytest** — it uses a custom `@test("name")` decorator that runs each test immediately at import; don't grep for `def test_` or try `pytest test_all.py`. Run the whole file; it prints per-module results and exits non-zero on failure. There is no way to run a single test short of commenting modules out. Tests must stay green; every new gate/knob needs tests. Signal-log fixtures: use a WEEKDAY signal bar when a test exercises candle continuity — the outcome model treats weekend and 21:00 UTC gaps as scheduled, so Saturday-dated bars never produce a gap. `_candles_json` stores `df.tail(14)`, so a test snapshot longer than 14 bars silently drops its head. When patching weekend logic in tests, patch `providers.base._is_forex_weekend` — but know there are THREE independent signal-path weekend implementations (`providers/base.py`, `signal_aggregator.py`, and an inline copy in `backtest/gpu_backtest.py:_is_xau_blocked_by_calendar`) plus the advisory-only continuity copy in `advisory_outcome.py`; patching one does not reach the others.
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets). scikit-learn and lightgbm are installed in the dev `venv/` for `backtest/research/` only — never add them to `requirements.txt` or import them from live code; anything shipped must be numpy-only.
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets). scikit-learn and lightgbm are installed in the dev `venv/` for ad-hoc research only — never add them to `requirements.txt` or import them from live code; anything shipped must be numpy-only.
## Configuration & credentials
@@ -96,11 +96,11 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
## Current state notes (dated per entry)
- Oanda v20 is the live feed (`data_providers.active: "oanda"`). The 2026-08-04 re-pin made the 27 `backtest/oanda_*.csv` books the baseline; all TD-era books are superseded. (2026-08-14) The re-pin M5 book `oanda_m5_12mo_065_nopb_20260814.csv` (+106.82R PF 1.51) is itself SUPERSEDED by the 2026-09-03 book in the entry below; the M15 reference is unchanged since 2026-08-04: `oanda_m15_12mo_065.csv` (397 / +73.93R / PF 1.53). See `backtest/research/repin_verification_2026-08-14.md`.
- Cost reporting is dual: $0.65 is the measured-Oanda realistic headline ($0.65-$0.72 round trip), while $0.30 remains for cross-book comparability and as the live TP spread-gate input. Raising `round_trip_cost` from $0.30 would move the TP floor from about $9 to $19.50 and requires full rule-1 revalidation. The $0.65 figure comes from the 2026-07-24 Dukascopy tick study (`backtest/research/duka_spread.py` + `duka_spread_by_hour.csv`, kept as the evidence driver although nothing imports it).
- Cost reporting is dual: $0.65 is the measured-Oanda realistic headline ($0.65-$0.72 round trip), while $0.30 remains for cross-book comparability and as the live TP spread-gate input. Raising `round_trip_cost` from $0.30 would move the TP floor from about $9 to $19.50 and requires full rule-1 revalidation. The $0.65 figure comes from the 2026-07-24 Dukascopy tick study (9M ticks over 12 months; its tooling was removed 2026-09-06 — the conclusion lives here and in memory).
- Twelve Data is signal-incompatible with Oanda (Phase 1: about 19% M5 bar-direction disagreement and a systematic ~$1 high bias; see `backtest/research/feed_divergence_2026-08-14.md`). On an Oanda candle outage, stop scanning rather than switching to TD candles.
- Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan remains a manual operational step.
- Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan (`scripts/shadow_scan.py`) remains a manual operational step.
- Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes. As of 2026-09-06 the PDF builds are BEHIND the HTML (violent-move watch entry added without a PDF rebuild).
- 2026-09-03 signal-log campaign shipped two new per-TF gates (`backtest/research/signal_log_campaign_2026-09.md`): M5 `sell_blocked_regimes=["transitioning"]` and H1 `sell_min_quality=[]` (all H1 shorts blocked). Current-HEAD M5 canonical book is now `backtest/oanda_m5_12mo_065_nopb_20260903.csv` (557 / +112.21R / PF 1.56, halves +55.9/+56.4); H1 reference `backtest/oanda_h1_12mo_065_20260903.csv` (91 / +22.2R / PF 1.79; 5yr +49.1R PF 1.46; OOS +16.7R PF 1.74).
- 2026-09-06 stake-sizing campaign (`backtest/research/sizing_campaign_2026-09.md`): a walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time features, 3-year Oanda books at $0.65) FAILED the pre-registered rule on M5 (clean null) and M15 (lift inside the label-shuffle null, explained by BUY-vs-SELL). Nothing that changes numbers shipped. Behaviour-neutral tooling remains: `position_sizing.build_features` + `--export-features` (default export stays 63 columns), `SignalReport.penalty_tags/penalty_total/momentum_fading`, `TradeSetup.stake_multiplier` (None; reserved), `backtest/research/{book_report,burst_map,sizing_model}.py`. Stage-B facts: M5 stretch measures are POSITIVE in both halves (do not re-propose exhaustion sizing on M5); M15 exhaustion tag is weakly negative (≈ +3R/yr, noise); bursts cluster at 12–15 UTC and 01 UTC bar-close and do NOT hurt entries.
- 2026-09-06 stake-sizing campaign (`backtest/research/sizing_campaign_2026-09.md`): a walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time features, 3-year Oanda books at $0.65) FAILED the pre-registered rule on M5 (clean null) and M15 (lift inside the label-shuffle null, explained by BUY-vs-SELL). Nothing that changes numbers shipped. Behaviour-neutral tooling remains: `position_sizing.build_features` + `--export-features` (default export stays 63 columns), `SignalReport.penalty_tags/penalty_total/momentum_fading`, `TradeSetup.stake_multiplier` (None; reserved) (the campaign's research scripts were removed 2026-09-06; git history has them). Stage-B facts: M5 stretch measures are POSITIVE in both halves (do not re-propose exhaustion sizing on M5); M15 exhaustion tag is weakly negative (≈ +3R/yr, noise); bursts cluster at 12–15 UTC and 01 UTC bar-close and do NOT hurt entries.
- Auto-execution (oanda_daemon.py / oanda_executor.py) was removed 2026-09-03 — the owner is not auto-trading; last commit containing it is 31002cb. Book-parity rules that lived in its docstring (no broker TP, 5-bar cooldown, once-per-bar trail) remain the backtester's model.
- 2026-09-06 stale-code cleanup (behaviour-neutral; suite green, M5/H1 12-month books byte-identical after every signal-side commit): `main.py --timeframe` now accepts M5/M15/H1/H4 and takes its confirmation TFs from the pair factory (the CLI's private table had paired M5 with M1+M15); `PlatformConfig.pip_value` is gone (`PAIR_INFO` is the source); removed as dead or superseded: `_get_affected_pairs`/`event_impact_on_pairs`, `SignalLogger.advance_with_bars`/`path`/`news_for`, `reset_llm_client`, the Telegram scan-summary/keyboard helpers and duplicate confirmation map, the 2026-07 Oanda-vs-TD research pager+study, `burst_profile.py`, `hour_analysis.py`, `scripts/compare_oanda_tiingo.py`, the never-implemented LLM-provider plan and the merged config.json spec. `docs/architecture_diagram.py` only writes the PNG when run as a script.
- 2026-09-06 stale-code cleanup (behaviour-neutral; suite green, M5/H1 12-month books byte-identical after every signal-side commit): `main.py --timeframe` now accepts M5/M15/H1/H4 and takes its confirmation TFs from the pair factory (the CLI's private table had paired M5 with M1+M15); `PlatformConfig.pip_value` is gone (`PAIR_INFO` is the source); removed as dead or superseded: `_get_affected_pairs`/`event_impact_on_pairs`, `SignalLogger.advance_with_bars`/`path`/`news_for`, `reset_llm_client`, the Telegram scan-summary/keyboard helpers and duplicate confirmation map, the 2026-07 Oanda-vs-TD research pager+study, `burst_profile.py`, `hour_analysis.py`, `scripts/compare_oanda_tiingo.py`, the never-implemented LLM-provider plan and the merged config.json spec. Second pass the same day (owner directive: keep only what the system needs): every research script and data file under `backtest/research/` (book_report, book_diff, the burst tools, sizing_model, commit_ladder, llm_news_eval + golden CSV, ff_calendar_scraper, calendar_impact, watch_rule_eval, the Dukascopy spread study + CSVs, the panel dry-test JSONL), the two completed study scripts in `scripts/`, the architecture diagram + generator and the data-source test plan were deleted; the campaign `.md` reports stay as the decision ledger. Git history before `953c5ad` has all of it.
+7 -10
View File
@@ -9,21 +9,18 @@ Every backtest report must record:
- Per trade (the exported CSV already carries these): entry/exit time and
price, exit reason, R multiple, MFE/MAE, entry reasons, and every
indicator's signal / value / agreement at decision time.
- Breakdowns by session/hour, weekday, regime and volatility regime
(backtest/research/book_report.py) and, for any candidate change, the
removed/added/shifted trade diff against the control book
(backtest/research/book_diff.py).
- Breakdowns by session/hour, weekday, regime and volatility regime, and,
for any candidate change, the removed/added/shifted trade diff against the
control book (the 5-bar cooldown displaces entries, so judge run-level
numbers, never the added-trade bucket alone).
- The md5 of the exported book whenever the run is a parity or regression check.
Replays go through the real signal code (analyze_from_dataframes) — never a
re-implementation.
--- 2026-09-06: decision-time feature export ---
- `--export-features` appends stake_multiplier, r_sized and the 65 `f_*` decision-time
features (position_sizing.FEATURE_ORDER) AFTER the 63 pinned columns; default export is
byte-identical to the pinned books. Use backtest/research/book_report.py for weekday /
hour x weekday / session / streak / rolling-WR / feature-quintile (both halves) tables,
backtest/research/burst_map.py for the burst-by-hour map, and
backtest/research/sizing_model.py for the walk-forward + shuffle-control harness.
byte-identical to the pinned books.
- Any sizing report must show the FLAT book and the SIZED book side by side and the
label-shuffle null; the 2026-09-06 campaign verdict (nothing ships) is the reference.
label-shuffle null; the 2026-09-06 campaign verdict (nothing ships,
backtest/research/sizing_campaign_2026-09.md) is the reference.
-5
View File
@@ -1,5 +0,0 @@
"""Research tooling: calendar sourcing/store and edge-screening utilities.
Nothing here is imported by the live bot; these modules support
backtesting and edge validation only.
"""
-86
View File
@@ -1,86 +0,0 @@
"""Trade-book diff: relaxed/gated run vs its control.
Usage:
python backtest/research/book_diff.py CONTROL.csv CANDIDATE.csv BAR_MINUTES
Decision numbers are RUN-LEVEL (ΔtotalR, ΔPF, ΔmaxDD, Δtrades) — never
the added-trade bucket alone: the 5-bar entry cooldown means new
entries displace existing ones. The added/removed decomposition and the
per-decision-hour histogram of added trades are diagnostics.
Shifted trades = removed+added pairs in the same direction within
±5 bars (cooldown displacement, not a genuinely new/lost signal).
"""
import sys
import pandas as pd
def _metrics(df):
r = df["r_multiple"]
gains, losses = r[r > 0].sum(), -r[r <= 0].sum()
cum = r.cumsum()
return dict(trades=len(df), total_r=r.sum(),
pf=gains / max(losses, 1e-9),
wr=(r > 0).mean() * 100 if len(df) else 0.0,
max_dd=(cum.cummax() - cum).max() if len(cum) else 0.0)
def diff_books(control_csv, candidate_csv, bar_minutes, split="2026-01-29"):
ctrl, cand = pd.read_csv(control_csv), pd.read_csv(candidate_csv)
for df in (ctrl, cand):
df["_t"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
df["_key"] = df["entry_time"].astype(str) + "|" + df["direction"]
added = cand[~cand["_key"].isin(ctrl["_key"])].copy()
removed = ctrl[~ctrl["_key"].isin(cand["_key"])].copy()
# cooldown displacement: same-direction removed/added within ±5 bars
tol = pd.Timedelta(minutes=5 * bar_minutes)
shifted = 0
rem_pool = removed.copy()
for _, a in added.iterrows():
m = rem_pool[(rem_pool["direction"] == a["direction"])
& ((rem_pool["_t"] - a["_t"]).abs() <= tol)]
if len(m):
shifted += 1
rem_pool = rem_pool.drop(m.index[0])
mc, mk = _metrics(ctrl), _metrics(cand)
split_ts = pd.Timestamp(split, tz="UTC")
halves = (cand[cand["_t"] < split_ts]["r_multiple"].sum(),
cand[cand["_t"] >= split_ts]["r_multiple"].sum())
hour_hist = None
if len(added):
added["dec_hour"] = (added["_t"]
- pd.Timedelta(minutes=bar_minutes)).dt.hour
hour_hist = added.groupby("dec_hour")["r_multiple"] \
.agg(["count", "sum"]).round(2)
return dict(
control=mc, candidate=mk,
delta_r=mk["total_r"] - mc["total_r"],
delta_pf=mk["pf"] - mc["pf"],
delta_dd=mk["max_dd"] - mc["max_dd"],
delta_trades=mk["trades"] - mc["trades"],
added_n=len(added), added_r=added["r_multiple"].sum(),
removed_n=len(removed), removed_r=removed["r_multiple"].sum(),
shifted=shifted, halves=halves, added_by_hour=hour_hist,
)
if __name__ == "__main__":
d = diff_books(sys.argv[1], sys.argv[2], int(sys.argv[3]))
c, k = d["control"], d["candidate"]
print(f"control : {c['trades']:4d} trades {c['total_r']:+7.1f}R "
f"PF {c['pf']:.2f} WR {c['wr']:.1f}% DD {c['max_dd']:.1f}")
print(f"candidate: {k['trades']:4d} trades {k['total_r']:+7.1f}R "
f"PF {k['pf']:.2f} WR {k['wr']:.1f}% DD {k['max_dd']:.1f}")
print(f"ΔR {d['delta_r']:+.1f} ΔPF {d['delta_pf']:+.2f} "
f"ΔDD {d['delta_dd']:+.1f} Δtrades {d['delta_trades']:+d}")
print(f"added {d['added_n']} ({d['added_r']:+.1f}R) "
f"removed {d['removed_n']} ({d['removed_r']:+.1f}R) "
f"shifted≈{d['shifted']}")
print(f"candidate halves: H1 {d['halves'][0]:+.1f}R H2 {d['halves'][1]:+.1f}R")
if d["added_by_hour"] is not None:
print("\nadded trades by decision hour:")
print(d["added_by_hour"].to_string())
-581
View File
@@ -1,581 +0,0 @@
"""Full descriptive report of a backtest trade book (research only).
Usage:
python backtest/research/book_report.py --book BOOK.csv --bar-minutes N \
[--split YYYY-MM-DD|median] [--parquet-4h PATH] [--parquet-1d PATH] \
[--out DIR]
Prints (and writes to DIR/report.txt) headline metrics for the whole book
and for each half, group tables (weekday / decision hour / session / quality
/ regime / vol_regime / direction / strategy / exit_reason / indicators_agree),
win-loss streaks, a rolling win-rate summary, and a quantile-bucket screen of
every numeric decision-time feature the book carries (`f_*` / `x_*` columns
plus score, confidence, rr_planned, indicators_agree/oppose and `ind_*_value`).
The screen is deliberately half-split: a feature only "passes" when the
top-bucket-minus-bottom-bucket mean-R gap has the SAME SIGN in both halves of
the book and both extreme buckets are populated in both halves. Bin edges are
fit on the WHOLE book (so the halves share edges and stay comparable) — that
leaks a little cross-half information into the edges themselves, which is why
the sign test, not the size of the gap, is the thing to trust.
Everything here is measurement, not simulation: it never re-runs the signal
engine, it only describes a book that gpu_backtest.py already produced.
Analysis tooling only — never imported by the live code.
"""
from __future__ import annotations
import argparse
import math
import os
from typing import Iterable
import numpy as np
import pandas as pd
SESSIONS = (
("asian", 3, 6),
("london", 7, 12),
("overlap", 13, 16),
("ny", 17, 22),
)
# Numeric non-`f_`/`x_` columns that are known at DECISION time and are
# therefore legal screen candidates. bars_held / r_multiple / mfe_r / mae_r
# are post-entry outcomes and must never appear here.
BASE_SCREEN_COLS = ("score", "confidence", "rr_planned",
"indicators_agree", "indicators_oppose")
def session_of(hour: int) -> str:
for name, lo, hi in SESSIONS:
if lo <= hour <= hi:
return name
return "off"
def feature_columns(df: pd.DataFrame) -> list[str]:
"""Decision-time feature columns carried by this book, generically."""
extra = [c for c in df.columns if c.startswith(("f_", "x_"))]
base = [c for c in BASE_SCREEN_COLS if c in df.columns]
ind = [c for c in df.columns
if c.startswith("ind_") and c.endswith("_value")]
out, seen = [], set()
for c in extra + base + ind:
if c not in seen:
seen.add(c)
out.append(c)
return out
# ───────────────────────────── loading ──────────────────────────────
def load_book(csv: str, bar_minutes: int = 5,
split: str = "median") -> pd.DataFrame:
"""Read a gpu_backtest trade book and add the decision-time columns.
`decision_time` = entry_time − one bar: entries fill at the NEXT bar's
open, so the bar the signal actually decided on closed one bar earlier.
That is the clock blocked_hours_utc and every session filter evaluate.
`half` is 1 before the split instant and 2 at/after it.
"""
df = pd.read_csv(csv)
for col in ("entry_time", "exit_time"):
if col in df.columns:
df[col] = pd.to_datetime(df[col], utc=True, format="mixed")
df["decision_time"] = df["entry_time"] - pd.Timedelta(minutes=bar_minutes)
df["decision_hour"] = df["decision_time"].dt.hour
df["weekday"] = df["decision_time"].dt.weekday
df["session"] = df["decision_hour"].map(session_of)
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
df["half"] = _halves(df["entry_time"], split)
return df
def split_instant(entry_time: pd.Series, split: str) -> pd.Timestamp:
if split == "median":
return entry_time.median()
return pd.Timestamp(split, tz="UTC")
def _halves(entry_time: pd.Series, split: str) -> pd.Series:
cut = split_instant(entry_time, split)
return np.where(entry_time < cut, 1, 2)
# ───────────────────────────── metrics ──────────────────────────────
def base_metrics(r: pd.Series, df: pd.DataFrame | None = None) -> dict:
"""Headline stats for a series of R multiples.
max_dd uses the gpu_backtest.compute_metrics convention: a 0.0 is
prepended to the cumulative-R curve so an opening losing streak counts
as drawdown from flat, not from an already-negative first trade.
pf/expectancy split on r > 0 / r < 0 (exact zeros are neither).
"""
r = pd.Series(r, dtype="float64").dropna()
n = int(len(r))
if n == 0:
return {"n": 0, "total_r": 0.0, "mean_r": 0.0, "median_r": 0.0,
"wr": 0.0, "pf": 0.0, "max_dd": 0.0, "sharpe": 0.0,
"expectancy": 0.0, "expectancy_alt": 0.0, "recovery": 0.0,
"avg_bars": float("nan"), "avg_mfe": float("nan"),
"avg_mae": float("nan")}
wins, losses = r[r > 0], r[r < 0]
gross_win = float(wins.sum())
gross_loss = float(-losses.sum())
cum = np.concatenate(([0.0], np.cumsum(r.to_numpy())))
max_dd = float((np.maximum.accumulate(cum) - cum).max())
std = float(r.std(ddof=0))
mean_r = float(r.mean())
wr = len(wins) / n
lr = len(losses) / n
avg_win = float(wins.mean()) if len(wins) else 0.0
avg_loss = float(-losses.mean()) if len(losses) else 0.0
expectancy_alt = wr * avg_win - lr * avg_loss
# Identity check: the textbook expectancy form must equal the plain mean
# whenever no trade is exactly flat (and it does even then, since a 0 R
# trade contributes 0 to both sides).
assert math.isclose(expectancy_alt, mean_r, rel_tol=1e-9, abs_tol=1e-9), (
f"expectancy mismatch: {expectancy_alt} vs mean {mean_r}")
out = {
"n": n,
"total_r": float(r.sum()),
"mean_r": mean_r,
"median_r": float(r.median()),
"wr": wr * 100.0,
"pf": (gross_win / gross_loss) if gross_loss > 0 else float("inf"),
"max_dd": max_dd,
"sharpe": (mean_r / std) if std > 0 else 0.0,
"expectancy": mean_r,
"expectancy_alt": expectancy_alt,
"recovery": (float(r.sum()) / max_dd) if max_dd > 0 else float("inf"),
"avg_bars": float("nan"),
"avg_mfe": float("nan"),
"avg_mae": float("nan"),
}
if df is not None:
for key, col in (("avg_bars", "bars_held"), ("avg_mfe", "mfe_r"),
("avg_mae", "mae_r")):
if col in df.columns:
out[key] = float(pd.to_numeric(df[col],
errors="coerce").mean())
return out
def _pf(r: pd.Series) -> float:
"""Profit factor (loss side is r <= 0)."""
pos = r[r > 0].sum()
neg = -r[r <= 0].sum()
return float(pos / max(neg, 1e-9))
def group_table(df: pd.DataFrame, by, min_n: int = 10) -> pd.DataFrame:
"""Per-group P&L with a half-1 / half-2 sign-stability flag."""
keys = [by] if isinstance(by, str) else list(by)
rows = []
for key, sub in df.groupby(keys, dropna=False, sort=True):
r = sub["r_multiple"]
h1, h2 = sub[sub["half"] == 1]["r_multiple"], sub[sub["half"] == 2]["r_multiple"]
m1 = float(h1.mean()) if len(h1) else float("nan")
m2 = float(h2.mean()) if len(h2) else float("nan")
stable = bool(len(h1) >= min_n and len(h2) >= min_n
and np.sign(m1) != 0 and np.sign(m1) == np.sign(m2))
rec = {}
# pandas >= 2.2 hands back a 1-tuple even for a single group key.
vals = key if isinstance(key, tuple) else (key,)
for k, v in zip(keys, vals):
rec[k] = v
rec.update({
"n": int(len(sub)),
"total_r": round(float(r.sum()), 2),
"mean_r": round(float(r.mean()), 3),
"wr": round(float((r > 0).mean() * 100), 2),
"pf": round(_pf(r), 2),
"n_h1": int(len(h1)),
"total_r_h1": round(float(h1.sum()), 2),
"mean_r_h1": round(m1, 3) if len(h1) else float("nan"),
"n_h2": int(len(h2)),
"total_r_h2": round(float(h2.sum()), 2),
"mean_r_h2": round(m2, 3) if len(h2) else float("nan"),
"sign_stable": stable,
})
rows.append(rec)
out = pd.DataFrame(rows)
if not out.empty:
out = out.set_index(keys)
return out
def streaks(df: pd.DataFrame) -> dict:
"""Longest win/loss runs and the longest stretch under water (in trades)."""
d = df.sort_values("entry_time", kind="mergesort")
r = d["r_multiple"].to_numpy(dtype="float64")
best_w = best_l = cur_w = cur_l = 0
for x in r:
if x > 0:
cur_w, cur_l = cur_w + 1, 0
elif x < 0:
cur_l, cur_w = cur_l + 1, 0
else:
cur_w = cur_l = 0
best_w, best_l = max(best_w, cur_w), max(best_l, cur_l)
cum = np.concatenate(([0.0], np.cumsum(r)))
peak = np.maximum.accumulate(cum)
flat, longest = 0, 0
for i in range(1, len(cum)):
flat = 0 if cum[i] >= peak[i] else flat + 1
longest = max(longest, flat)
return {"max_win_streak": best_w, "max_loss_streak": best_l,
"longest_flat_trades": longest}
def rolling_wr(df: pd.DataFrame, window: int = 20) -> pd.Series:
d = df.sort_values("entry_time", kind="mergesort")
s = (d["r_multiple"] > 0).astype(float).rolling(window).mean() * 100
s.index = d["entry_time"].to_numpy()
return s
def equity_curve(df: pd.DataFrame) -> pd.DataFrame:
d = df.sort_values("entry_time", kind="mergesort")
cum = d["r_multiple"].cumsum()
peak = pd.concat([pd.Series([0.0]), cum]).cummax().iloc[1:]
peak.index = cum.index
return pd.DataFrame({"entry_time": d["entry_time"].to_numpy(),
"cum_r": cum.to_numpy(),
"drawdown": (peak - cum).to_numpy()})
# ─────────────────────── quantile bucket screen ─────────────────────
def quantile_buckets(df: pd.DataFrame, col: str, q: int = 5,
min_n: int = 10) -> pd.DataFrame:
"""Equal-frequency buckets of `col` with per-half mean R.
Bin edges are fit on the WHOLE book (both halves share edges).
Result carries gap_h1 / gap_h2 / sign_stable in `.attrs` and as
repeated columns so the CSV is self-describing.
"""
v = pd.to_numeric(df.get(col), errors="coerce")
d = df.loc[v.notna()].copy()
d["_v"] = v.loc[v.notna()]
empty = pd.DataFrame(columns=["bin", "lo", "hi", "n", "mean_r", "pf", "wr",
"n_h1", "mean_r_h1", "n_h2", "mean_r_h2",
"gap_h1", "gap_h2", "sign_stable"])
empty.attrs = {"feature": col, "gap_h1": float("nan"),
"gap_h2": float("nan"), "sign_stable": False, "n": 0,
"mode": "empty"}
if d.empty:
return empty
nuniq = int(d["_v"].nunique())
if nuniq < 3:
mode = "values"
d["_bin"] = d["_v"]
order = sorted(d["_v"].unique())
edges = {b: (float(b), float(b)) for b in order}
else:
mode = "qcut"
binned = pd.qcut(d["_v"], q, duplicates="drop")
d["_bin"] = binned
order = list(binned.cat.categories)
edges = {b: (float(b.left), float(b.right)) for b in order}
rows = []
for b in order:
sub = d[d["_bin"] == b]
if sub.empty:
continue
r = sub["r_multiple"]
h1 = sub[sub["half"] == 1]["r_multiple"]
h2 = sub[sub["half"] == 2]["r_multiple"]
lo, hi = edges[b]
rows.append({
"bin": str(b), "lo": round(lo, 4), "hi": round(hi, 4),
"n": int(len(sub)), "mean_r": round(float(r.mean()), 3),
"pf": round(_pf(r), 2),
"wr": round(float((r > 0).mean() * 100), 2),
"n_h1": int(len(h1)),
"mean_r_h1": round(float(h1.mean()), 3) if len(h1) else float("nan"),
"n_h2": int(len(h2)),
"mean_r_h2": round(float(h2.mean()), 3) if len(h2) else float("nan"),
})
out = pd.DataFrame(rows)
if out.empty:
return empty
bot, top = out.iloc[0], out.iloc[-1]
gap_h1 = float(top["mean_r_h1"] - bot["mean_r_h1"])
gap_h2 = float(top["mean_r_h2"] - bot["mean_r_h2"])
populated = min(bot["n_h1"], bot["n_h2"], top["n_h1"], top["n_h2"]) >= min_n
signs_ok = (not math.isnan(gap_h1) and not math.isnan(gap_h2)
and np.sign(gap_h1) != 0
and np.sign(gap_h1) == np.sign(gap_h2))
sign_stable = bool(populated and signs_ok and len(out) >= 2)
out["gap_h1"] = round(gap_h1, 3) if not math.isnan(gap_h1) else float("nan")
out["gap_h2"] = round(gap_h2, 3) if not math.isnan(gap_h2) else float("nan")
out["sign_stable"] = sign_stable
out.attrs = {"feature": col, "gap_h1": gap_h1, "gap_h2": gap_h2,
"sign_stable": sign_stable, "n": int(len(d)), "mode": mode}
return out
def feature_screen(df: pd.DataFrame, q: int = 5, min_n: int = 10,
columns: Iterable[str] | None = None) -> pd.DataFrame:
"""Bucket screen over every decision-time numeric feature in the book."""
cols = list(columns) if columns is not None else feature_columns(df)
rows = []
for c in cols:
bk = quantile_buckets(df, c, q=q, min_n=min_n)
a = bk.attrs
stable = bool(a.get("sign_stable"))
g1, g2 = a.get("gap_h1", float("nan")), a.get("gap_h2", float("nan"))
min_abs = (min(abs(g1), abs(g2)) if stable else 0.0)
rows.append({"feature": c,
"gap_h1": round(g1, 3) if g1 == g1 else float("nan"),
"gap_h2": round(g2, 3) if g2 == g2 else float("nan"),
"min_abs_gap": round(float(min_abs), 3),
"sign_stable": stable,
"n": int(a.get("n", 0)),
"bins": int(len(bk))})
out = pd.DataFrame(rows)
if out.empty:
return out
return out.sort_values(["min_abs_gap", "n"],
ascending=[False, False]).reset_index(drop=True)
# ────────────────────── higher-timeframe extension ──────────────────
def wilder_atr(bars: pd.DataFrame, period: int = 14) -> pd.Series:
h, l, c = bars["High"], bars["Low"], bars["Close"]
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def _ext_series(parquet: str, minutes: int) -> pd.DataFrame:
bars = pd.read_parquet(parquet).sort_index()
bars = bars[~bars.index.duplicated(keep="last")]
ext = ((bars["Close"] - bars["Close"].rolling(20).mean())
/ wilder_atr(bars, 14))
out = pd.DataFrame({
"close_time": bars.index + pd.Timedelta(minutes=minutes),
"ext": ext.to_numpy(),
}).dropna().reset_index(drop=True)
# Books parse to datetime64[us, UTC] (format="mixed"), parquet indexes are
# [ns, UTC]; merge_asof refuses mismatched resolutions.
out["close_time"] = out["close_time"].astype("datetime64[ns, UTC]")
return out
def add_htf_extension(df: pd.DataFrame, parquet_4h: str | None,
parquet_1d: str | None) -> pd.DataFrame:
"""Add direction-signed H4 / D1 stretch-from-mean in ATR units.
ext = (close − 20-bar SMA of close) / Wilder ATR(14) on that timeframe,
taken from the LAST higher-TF bar whose CLOSE time (bar label + one bar)
is <= decision_time — i.e. only information the live scan could have had.
Signed +1 for BUY, −1 for SELL, so positive always means "extended in the
direction of the trade".
"""
out = df.copy()
sign = np.where(out["direction"].astype(str).str.upper().str.startswith("B"),
1.0, -1.0)
for col, parquet, minutes in (("x_h4_ext_atr", parquet_4h, 240),
("x_d1_ext_atr", parquet_1d, 1440)):
if not parquet:
continue
right = _ext_series(parquet, minutes)
left = out[["decision_time"]].reset_index()
left["decision_time"] = left["decision_time"].astype(
"datetime64[ns, UTC]")
left = left.sort_values("decision_time", kind="mergesort")
merged = pd.merge_asof(left, right, left_on="decision_time",
right_on="close_time", direction="backward")
merged["_stale_h"] = ((merged["decision_time"] - merged["close_time"])
.dt.total_seconds() / 3600.0)
merged = merged.set_index("index").reindex(out.index)
out[col] = merged["ext"].to_numpy() * sign
out.attrs[f"{col}_max_stale_h"] = float(
merged["_stale_h"].max()) if len(merged) else float("nan")
return out
# ──────────────────────────── reporting ─────────────────────────────
def _fmt(v, nd=3):
if isinstance(v, float):
if math.isinf(v):
return "inf"
if math.isnan(v):
return "n/a"
return f"{v:.{nd}f}"
return str(v)
def _metrics_block(name: str, m: dict) -> list[str]:
return [
f" {name:<10} n={m['n']:<5} totalR={_fmt(m['total_r'], 2):>8} "
f"meanR={_fmt(m['mean_r'])} medR={_fmt(m['median_r'])} "
f"WR={_fmt(m['wr'], 2)}% PF={_fmt(m['pf'], 2)}",
f" {'':<10} maxDD={_fmt(m['max_dd'], 2)} recovery={_fmt(m['recovery'], 2)}"
f" sharpe={_fmt(m['sharpe'])} expectancy={_fmt(m['expectancy'])}"
f" (wr·avgW − lr·avgL = {_fmt(m['expectancy_alt'])})",
f" {'':<10} avg bars={_fmt(m['avg_bars'], 1)} "
f"avg MFE={_fmt(m['avg_mfe'])}R avg MAE={_fmt(m['avg_mae'])}R",
]
def build_report(df: pd.DataFrame, book: str, bar_minutes: int, split: str,
out_dir: str, q: int = 5, min_n: int = 10) -> str:
lines: list[str] = []
add = lines.append
cut = split_instant(df["entry_time"], split)
add("=" * 78)
add(f"BOOK REPORT {os.path.basename(book)}")
add("=" * 78)
add(f"trades : {len(df)}")
add(f"entry window : {df['entry_time'].min()} -> {df['entry_time'].max()}")
add(f"bar minutes : {bar_minutes} (decision_time = entry_time − 1 bar)")
add(f"split rule : {split} -> cut at {cut} "
f"(half 1 = before, half 2 = at/after)")
add(f"half sizes : h1={int((df['half'] == 1).sum())} "
f"h2={int((df['half'] == 2).sum())}")
add("session map : asian 3-6, london 7-12, overlap 13-16, ny 17-22, "
"else off (on DECISION hour, UTC)")
add("bucket edges : quantile bins are fit on the WHOLE book; the two "
"halves share the same edges.")
feats = feature_columns(df)
add(f"screen features : {len(feats)} "
f"({sum(1 for c in feats if c.startswith(('f_', 'x_')))} f_/x_ columns)")
for k, v in df.attrs.items():
add(f"htf staleness : {k} = {_fmt(v, 1)} h (max age of the matched bar)")
add("")
add("── HEADLINE METRICS ──")
lines.extend(_metrics_block("full", base_metrics(df["r_multiple"], df)))
for h in (1, 2):
sub = df[df["half"] == h]
lines.extend(_metrics_block(f"half {h}",
base_metrics(sub["r_multiple"], sub)))
group_specs = [
("weekday", "weekday"), ("decision_hour", "decision_hour"),
("session", "session"), ("quality", "quality"), ("regime", "regime"),
("vol_regime", "vol_regime"), ("direction", "direction"),
("strategy", "strategy"), ("exit_reason", "exit_reason"),
("indicators_agree", "indicators_agree"),
]
tables = {}
for name, col in group_specs:
if col not in df.columns:
continue
t = group_table(df, col, min_n=min_n)
tables[name] = t
add("")
add(f"── BY {name.upper()} ──")
add(t.to_string() if not t.empty else " (empty)")
# (weekday, decision_hour) pivots
add("")
add("── weekday x decision_hour : TOTAL R ──")
wh = group_table(df, ["weekday", "decision_hour"], min_n=min_n)
if not wh.empty:
piv_r = wh["total_r"].unstack("decision_hour")
piv_n = wh["n"].unstack("decision_hour")
add(piv_r.fillna(0).round(2).to_string())
add("")
add("── weekday x decision_hour : N ──")
add(piv_n.fillna(0).astype(int).to_string())
tables["weekday_hour"] = wh
piv_r.to_csv(os.path.join(out_dir, "groups_weekday_hour_total_r.csv"))
piv_n.to_csv(os.path.join(out_dir, "groups_weekday_hour_n.csv"))
st = streaks(df)
add("")
add("── STREAKS ──")
add(f" max consecutive wins : {st['max_win_streak']}")
add(f" max consecutive losses : {st['max_loss_streak']}")
add(f" longest flat stretch : {st['longest_flat_trades']} trades "
f"between equity highs")
rw = rolling_wr(df, 20).dropna()
add("")
add("── ROLLING WIN RATE (20 trades) ──")
if len(rw):
add(f" min={_fmt(rw.min(), 2)}% median={_fmt(rw.median(), 2)}% "
f"max={_fmt(rw.max(), 2)}% n={len(rw)}")
add(" last 5: " + ", ".join(
f"{ts:%Y-%m-%d %H:%M}={v:.2f}%" for ts, v in rw.tail(5).items()))
else:
add(" (fewer than 20 trades)")
scr = feature_screen(df, q=q, min_n=min_n)
add("")
add("── FEATURE SCREEN (top 25 by min |gap| across halves) ──")
add(" gap = mean R(top bucket) − mean R(bottom bucket), per half; "
"min_abs_gap is 0 unless the sign is stable.")
add(scr.head(25).to_string(index=False) if not scr.empty else " (none)")
stable = scr[scr["sign_stable"]].head(10) if not scr.empty else scr
add("")
add(f"── BUCKET TABLES FOR THE TOP {len(stable)} SIGN-STABLE FEATURES ──")
if stable.empty:
add(" (no feature has a sign-stable top-vs-bottom gap)")
for feat in stable["feature"]:
bk = quantile_buckets(df, feat, q=q, min_n=min_n)
add("")
add(f" · {feat} (mode={bk.attrs['mode']}, n={bk.attrs['n']}, "
f"gap_h1={_fmt(bk.attrs['gap_h1'])}, "
f"gap_h2={_fmt(bk.attrs['gap_h2'])})")
add(bk.to_string(index=False))
bk.to_csv(os.path.join(out_dir, f"buckets_{feat}.csv"), index=False)
# CSV artefacts
for name, t in tables.items():
t.to_csv(os.path.join(out_dir, f"groups_{name}.csv"))
scr.to_csv(os.path.join(out_dir, "feature_screen.csv"), index=False)
equity_curve(df).to_csv(os.path.join(out_dir, "equity.csv"), index=False)
all_buckets = []
for c in feats:
bk = quantile_buckets(df, c, q=q, min_n=min_n)
if bk.empty:
continue
bk = bk.copy()
bk.insert(0, "feature", c)
all_buckets.append(bk)
if all_buckets:
pd.concat(all_buckets, ignore_index=True).to_csv(
os.path.join(out_dir, "buckets.csv"), index=False)
return "\n".join(lines)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--book", required=True)
ap.add_argument("--bar-minutes", type=int, required=True)
ap.add_argument("--split", default="median",
help="'median' (default) or YYYY-MM-DD")
ap.add_argument("--parquet-4h", default=None)
ap.add_argument("--parquet-1d", default=None)
ap.add_argument("--q", type=int, default=5)
ap.add_argument("--min-n", type=int, default=10)
ap.add_argument("--out", default="backtest/research/book_report_out")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
df = load_book(args.book, args.bar_minutes, args.split)
if args.parquet_4h or args.parquet_1d:
df = add_htf_extension(df, args.parquet_4h, args.parquet_1d)
text = build_report(df, args.book, args.bar_minutes, args.split,
args.out, q=args.q, min_n=args.min_n)
print(text)
with open(os.path.join(args.out, "report.txt"), "w") as fh:
fh.write(text + "\n")
print(f"\n[written] {os.path.join(args.out, 'report.txt')}")
if __name__ == "__main__":
main()
-121
View File
@@ -1,121 +0,0 @@
"""What do the system's OWN indicators read right before a violent bar? Analysis tooling only.
Uses backtest.gpu_backtest.precompute_indicators (the same series the backtester feeds the
engine) on 3 years of bars; every state is taken at t-1 (known before the bar). Reports burst
rate (range >= 3x prior ATR14) for the NEXT bar and within the NEXT HOUR by indicator state,
by regime, by volatility regime, by weekday x hour, day of month, calendar proximity — each in
both halves — and whether the state predicts the burst's DIRECTION.
python backtest/research/burst_indicator_map.py PARQUET BAR_MINUTES [--start --end]
"""
import argparse, sys
from pathlib import Path
import numpy as np, pandas as pd
ROOT = Path(__file__).resolve().parents[2]; sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "backtest"))
from gpu_backtest import precompute_indicators
from config import TechnicalConfig
CAL = ROOT / "backtest" / "cache" / "calendar.parquet"
def wilder_atr(df, period=14):
pc = df["Close"].shift(1)
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def rate_table(d, col, target, half, order=None):
base1 = d.loc[half == 1, target].mean(); base2 = d.loc[half == 2, target].mean()
g = d.groupby(col, observed=True)
out = pd.DataFrame({"n": g.size(),
"h1_lift": d[half == 1].groupby(col, observed=True)[target].mean() / base1,
"h2_lift": d[half == 2].groupby(col, observed=True)[target].mean() / base2,
"rate%": 100 * g[target].mean()})
if order is not None: out = out.reindex([o for o in order if o in out.index])
out["stable"] = np.where((out.h1_lift > 1.15) & (out.h2_lift > 1.15), "HIGH", np.where((out.h1_lift < 0.85) & (out.h2_lift < 0.85), "low", ""))
return out.round(2)
def main():
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
ap.add_argument("--start", default="2023-09-05"); ap.add_argument("--end", default="2026-09-05")
a = ap.parse_args(); bm = a.bar_minutes
px = pd.read_parquet(a.parquet)
px = px[(px.index >= pd.Timestamp(a.start, tz="UTC") - pd.Timedelta(days=30)) & (px.index < pd.Timestamp(a.end, tz="UTC"))]
ic = precompute_indicators(px, TechnicalConfig())
d = pd.DataFrame(index=px.index)
atr_prev = wilder_atr(px).shift(1)
rng = px["High"] - px["Low"]
d["burst"] = (rng >= 3.0 * atr_prev).astype(int)
H = max(1, 60 // bm)
d["next_hour"] = d["burst"][::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
d["next_bar"] = d["burst"]
d["bdir"] = np.sign(px["Close"] - px["Open"])
# states at t-1
sh = lambda arr: pd.Series(np.asarray(arr, dtype=float), index=px.index).shift(1)
rsi = sh(ic.rsi); adx = sh(ic.adx); adxs = sh(ic.adx_slope); hist = sh(ic.macd_hist); bbp = sh(ic.bb_position)
bbw = sh(ic.bb_band_width); bbaw = sh(ic.bb_avg_width); kcsq = sh(ic.kc_squeeze); roc = sh(ic.roc)
ef, em, es = sh(ic.ema_fast), sh(ic.ema_medium), sh(ic.ema_slow); et = sh(ic.ema_trend) if ic.ema_trend is not None else None
close1 = px["Close"].shift(1); atr1 = sh(ic.atr)
kcu, kcl = sh(ic.kc_upper), sh(ic.kc_lower)
d["rsi"] = pd.cut(rsi, [0, 30, 40, 50, 60, 70, 100], labels=["<30", "30-40", "40-50", "50-60", "60-70", ">=70"])
d["adx"] = pd.cut(adx, [0, 20, 25, 35, 50, 200], labels=["<20", "20-25", "25-35", "35-50", ">=50"])
d["adx_slope"] = pd.cut(adxs, [-1e9, -3, 0, 3, 1e9], labels=["falling<-3", "-3..0", "0..3", "rising>3"])
hist_prev = hist.shift(1)
d["macd"] = np.select([(hist > 0) & (hist > hist_prev), (hist > 0), (hist < 0) & (hist < hist_prev), (hist < 0)],
["pos&rising", "pos&fading", "neg&falling", "neg&fading"], default="flat")
d["bb_pos"] = pd.cut(bbp, [-1e9, 0, 0.2, 0.8, 1.0, 1e9], labels=["<0 (below)", "0-0.2", "0.2-0.8", "0.8-1", ">1 (above)"])
d["bb_squeeze"] = np.where(bbw < 0.75 * bbaw, "squeeze", "normal")
d["kc_squeeze"] = np.where(kcsq > 0, "TTM squeeze", "no")
d["kc_pos"] = np.select([close1 > kcu, close1 < kcl], ["above KC", "below KC"], default="inside")
align = np.sign(close1 - ef) + np.sign(ef - em) + np.sign(em - es) + (np.sign(es - et) if et is not None else 0)
d["ema_align"] = pd.cut(align, [-5, -3.5, -0.5, 0.5, 3.5, 5], labels=["fully bear(-4)", "mostly bear", "mixed", "mostly bull", "fully bull(+4)"])
ribbon = (pd.concat([ef, em, es], axis=1).max(axis=1) - pd.concat([ef, em, es], axis=1).min(axis=1)) / atr1
d["ribbon"] = np.where(ribbon < 1.0, "compressed(<1 ATR)", "expanded")
ema_dir = np.where(align > 0, 1, np.where(align < 0, -1, 0))
d["regime"] = np.select([(adx >= 25) & (ema_dir > 0), (adx >= 25) & (ema_dir < 0), adx >= 25, adxs > 3.0],
["trending_up", "trending_down", "trending(mixed)", "transitioning"], default="ranging")
atr_pct = atr1.rolling(50).apply(lambda w: (w[-1] > w).mean() * 100, raw=True)
d["vol_regime"] = pd.cut(atr_pct, [-1, 30, 60, 80, 101], labels=["low", "normal", "high", "extreme"])
d["roc"] = pd.cut(roc, [-1e9, -1, -0.3, 0.3, 1, 1e9], labels=["<-1%", "-1..-0.3", "-0.3..0.3", "0.3..1", ">1%"])
d["ema200_dist"] = pd.cut((close1 - et) / et * 100 if et is not None else pd.Series(np.nan, index=px.index),
[-1e9, -2.5, -0.5, 0.5, 2.5, 1e9], labels=["<-2.5%", "-2.5..-0.5", "near(±0.5%)", "0.5..2.5", ">2.5%"])
# time
ct = px.index + pd.Timedelta(minutes=bm)
d["hour"] = ct.hour; d["weekday"] = ct.weekday; d["dom"] = pd.cut(ct.day, [0, 5, 10, 15, 20, 25, 31], labels=["1-5", "6-10", "11-15", "16-20", "21-25", "26-31"])
d["first_friday"] = np.where((ct.weekday == 4) & (ct.day <= 7), "first Friday", "other")
# calendar proximity (high-impact USD)
cal = pd.read_parquet(CAL); cal["ts"] = pd.to_datetime(cal.dateline, unit="s", utc=True)
hi = np.sort(cal[(cal.currency == "USD") & (cal.impactName.str.lower() == "high")].ts.values.astype("datetime64[ns]"))
t = ct.values.astype("datetime64[ns]")
nxt = np.searchsorted(hi, t) # next event at/after bar close
mins_to = np.where(nxt < len(hi), (hi[np.minimum(nxt, len(hi) - 1)] - t) / np.timedelta64(1, "m"), 1e9)
prv = nxt - 1
mins_since = np.where(prv >= 0, (t - hi[np.maximum(prv, 0)]) / np.timedelta64(1, "m"), 1e9)
d["calendar"] = np.select([mins_since <= 30, mins_to <= 15, mins_to <= 60, mins_to <= 180], ["0-30m AFTER hi-impact", "next 15m", "15-60m ahead", "1-3h ahead"], default="none within 3h")
d = d[d.index >= pd.Timestamp(a.start, tz="UTC")].dropna(subset=["rsi", "adx", "bb_pos", "vol_regime"])
half = pd.Series(np.where(np.arange(len(d)) < len(d) // 2, 1, 2), index=d.index)
print(f"bars {len(d)} base next_bar {100*d.next_bar.mean():.3f}% next_hour {100*d.next_hour.mean():.2f}% (lift = rate / base; 'HIGH' = >1.15x in BOTH halves, 'low' = <0.85x in both)")
groups = ["calendar", "regime", "vol_regime", "rsi", "adx", "adx_slope", "macd", "bb_pos", "bb_squeeze", "kc_squeeze", "kc_pos", "ema_align", "ribbon", "roc", "ema200_dist", "dom", "first_friday"]
for target in ("next_bar", "next_hour"):
print(f"\n################ TARGET: {target} ################")
for g in groups:
print(f"-- {g}"); print(rate_table(d, g, target, half).to_string())
# weekday x hour top cells (next_bar)
piv = d.pivot_table(index="weekday", columns="hour", values="next_bar", aggfunc="mean") * 100
base = 100 * d.next_bar.mean()
cells = piv.stack().sort_values(ascending=False)
print(f"\n################ TOP 15 weekday x close-hour cells (next-bar burst pct, base {base:.2f}) ################")
names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 6: "Sun"}
for (wd, h), v in cells.head(15).items():
n = int(((d.weekday == wd) & (d.hour == h)).sum())
print(f" {names.get(wd, wd)} {h:02d}:xx {v:.2f}% ({v/base:.1f}x, n={n})")
# direction: does the state predict the burst direction?
b = d[d.burst == 1]
print("\n################ BURST DIRECTION vs prior state (share of bursts going UP) ################")
for g, up_states in (("ema_align", None), ("rsi", None), ("macd", None), ("regime", None)):
print(f"-- {g}: " + ", ".join(f"{k}: {100*(v>0).mean():.0f}% up (n={len(v)})" for k, v in b.groupby(g, observed=True).bdir if len(v) >= 20))
if __name__ == "__main__":
main()
-444
View File
@@ -1,444 +0,0 @@
"""Where and when gold moves violently — burst map of the raw bars.
Usage:
python backtest/research/burst_map.py --parquet PATH --bar-minutes N \
[--k 3,4,5] [--book BOOK.csv] [--within-bars 12] \
[--start YYYY-MM-DD --end YYYY-MM-DD] [--out DIR]
A "burst" is one bar whose range is >= K x the PRIOR bar's Wilder ATR(14) —
the same definition every burst study in this folder has used (spelled
out here bar-for-bar: TR = max(H-L, |H-Cprev|, |L-Cprev|), ATR =
TR.ewm(alpha=1/14, min_periods=14, adjust=False).mean(), ratio =
(H-L) / ATR.shift(1)).
Reports:
* burst frequency by (weekday, UTC bar-CLOSE hour), plus mean |1 h| and
|4 h| absolute moves in $ and in ATR units on the same grid;
* post-burst drift in the burst's own direction over 6/12/24/48 bars,
overall and by session bucket — does a violent bar continue or snap back;
* the largest trailing-60-minute close-to-close moves, de-overlapped;
* if a trade book is given: how trades that were decided just after a burst,
or that were OPEN when one printed, performed against the rest.
Every ATR-unit number here is normalised by the PRIOR bar's ATR — the same
denominator as the burst test, so a "4x burst that ran another 4 ATR" reads
directly. All hours are UTC and are the bar's CLOSE hour (label + one bar),
matching the clock the live gates evaluate.
Analysis tooling only — never imported by the live code.
"""
from __future__ import annotations
import argparse
import math
import os
import numpy as np
import pandas as pd
try: # package import (backtest.research.burst_map)
from backtest.research.book_report import base_metrics, load_book, session_of
except ImportError: # run directly as a script from anywhere
from book_report import base_metrics, load_book, session_of
SLOT_UNKNOWN = ""
# ─────────────────────────── bar primitives ─────────────────────────
def load_bars(parquet: str, start: str | None = None,
end: str | None = None) -> pd.DataFrame:
"""Read a cached OHLCV parquet, de-duplicated and oldest-first."""
df = pd.read_parquet(parquet).sort_index()
df = df[~df.index.duplicated(keep="last")]
if start:
df = df[df.index >= pd.Timestamp(start, tz="UTC")]
if end:
df = df[df.index <= pd.Timestamp(end, tz="UTC")]
return df
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Wilder ATR (the definition every burst study in this folder uses)."""
h, l, c = df["High"], df["Low"], df["Close"]
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def burst_ratio(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Bar range expressed in PRIOR-bar ATRs."""
return (df["High"] - df["Low"]) / wilder_atr(df, period).shift(1)
def burst_flags(df: pd.DataFrame, k: float, period: int = 14) -> pd.Series:
"""True where the bar's range is >= k x the prior bar's Wilder ATR(14)."""
return (burst_ratio(df, period) >= k).fillna(False)
def burst_dir(df: pd.DataFrame) -> pd.Series:
"""+1 up bar, −1 down bar, 0 doji (sign of Close − Open)."""
return np.sign(df["Close"] - df["Open"]).astype(float)
def close_times(df: pd.DataFrame, bar_minutes: int) -> pd.DatetimeIndex:
"""Bar CLOSE timestamps (the parquet index labels bar OPEN time)."""
return df.index + pd.Timedelta(minutes=bar_minutes)
# ───────────────────── when does gold move? tables ──────────────────
def hour_weekday_table(df: pd.DataFrame, k: float, bar_minutes: int = 5,
ks: tuple[float, ...] | None = None) -> dict:
"""Burst frequency and absolute-move size on a (weekday, close-hour) grid.
Returns a dict of DataFrames (several tables are wanted, one frame each):
burst_pct : weekday rows x close-hour cols, % of bars that are >= k x
bursts (one frame; per-k frames live in `burst_pct_by_k`)
absmove : (weekday, close-hour) rows; bars, mean |1 h move| and
|4 h move| in $ and in prior-bar ATR units
by_hour : the same stats collapsed to close-hour
by_weekday : the same stats collapsed to weekday
"""
ks = tuple(ks) if ks else (k,)
ct = close_times(df, bar_minutes)
hour = pd.Series(ct.hour, index=df.index)
wd = pd.Series(ct.weekday, index=df.index)
atr_prev = wilder_atr(df).shift(1)
per_hour = max(1, int(round(60 / bar_minutes)))
c = df["Close"]
mv1 = (c - c.shift(per_hour)).abs()
mv4 = (c - c.shift(4 * per_hour)).abs()
stats = pd.DataFrame({
"weekday": wd, "hour": hour,
"abs1h_usd": mv1, "abs4h_usd": mv4,
"abs1h_atr": mv1 / atr_prev, "abs4h_atr": mv4 / atr_prev,
})
for kk in ks:
stats[f"burst_{kk:g}x"] = burst_flags(df, kk).astype(float) * 100.0
agg = {"abs1h_usd": "mean", "abs4h_usd": "mean",
"abs1h_atr": "mean", "abs4h_atr": "mean"}
agg.update({f"burst_{kk:g}x": "mean" for kk in ks})
grid = stats.groupby(["weekday", "hour"]).agg(agg)
grid.insert(0, "bars", stats.groupby(["weekday", "hour"]).size())
by_hour = stats.groupby("hour").agg(agg)
by_hour.insert(0, "bars", stats.groupby("hour").size())
by_wd = stats.groupby("weekday").agg(agg)
by_wd.insert(0, "bars", stats.groupby("weekday").size())
pct_by_k = {kk: grid[f"burst_{kk:g}x"].unstack("hour") for kk in ks}
return {
"burst_pct": pct_by_k[k if k in pct_by_k else ks[0]].round(3),
"burst_pct_by_k": {kk: v.round(3) for kk, v in pct_by_k.items()},
"absmove": grid.round(3),
"by_hour": by_hour.round(3),
"by_weekday": by_wd.round(3),
}
def post_burst_drift(df: pd.DataFrame, k: float,
horizons: tuple[int, ...] = (6, 12, 24, 48),
bar_minutes: int = 5) -> pd.DataFrame:
"""Signed drift IN the burst's direction over the next h bars, in ATRs.
A positive mean means violent bars continue; negative means they snap
back. Split by the session bucket of the burst bar's close hour.
"""
flags = burst_flags(df, k)
d = burst_dir(df)
atr_prev = wilder_atr(df).shift(1)
ct = close_times(df, bar_minutes)
bucket = pd.Series([session_of(h) for h in ct.hour], index=df.index)
c = df["Close"]
rows = []
for h in horizons:
fwd = (c.shift(-h) - c) * d / atr_prev
sub = fwd[flags].dropna()
buckets = bucket[flags].reindex(sub.index)
rows.append({"horizon_bars": h, "bucket": "ALL", "n": int(len(sub)),
"mean_atr": round(float(sub.mean()), 4) if len(sub) else float("nan"),
"median_atr": round(float(sub.median()), 4) if len(sub) else float("nan"),
"pct_positive": round(float((sub > 0).mean() * 100), 2) if len(sub) else float("nan")})
for b, s in sub.groupby(buckets):
rows.append({"horizon_bars": h, "bucket": b, "n": int(len(s)),
"mean_atr": round(float(s.mean()), 4),
"median_atr": round(float(s.median()), 4),
"pct_positive": round(float((s > 0).mean() * 100), 2)})
return pd.DataFrame(rows)
def slot_label(weekday: int, hour: int, day: int) -> str:
"""Time-slot label for a big move.
NOTE: there is no economic-calendar file in this repo, so these are
TIME SLOTS, not confirmed events — "payroll window" means "the clock
slot US payrolls normally print in", nothing more.
"""
if weekday == 4 and day <= 7 and hour in (12, 13):
return "first-Friday 12:30 slot (payroll-report window)"
if weekday == 2 and hour in (18, 19):
return "Wednesday 18:00 slot (central-bank window)"
if hour in (12, 13):
return "12:30 slot (US data window)"
if hour == 14:
return "14:00 slot"
if hour in (21, 22, 23):
return "settlement/after-hours"
if hour in (0, 1, 2):
return "Asia open"
return SLOT_UNKNOWN
def largest_moves(df: pd.DataFrame, n: int = 40, window_minutes: int = 60,
bar_minutes: int = 5) -> pd.DataFrame:
"""The n biggest trailing-`window_minutes` close-to-close moves.
Overlapping windows are de-duplicated greedily: the largest move wins and
anything whose close time is within +/- `window_minutes` of an already
kept move is dropped, so one violent hour contributes one row.
"""
bars = max(1, int(round(window_minutes / bar_minutes)))
c = df["Close"]
move = c - c.shift(bars)
atr_prev = wilder_atr(df).shift(1)
ct = close_times(df, bar_minutes)
tbl = pd.DataFrame({
"close_time": ct, "move_usd": move.to_numpy(),
"move_atr": (move / atr_prev).to_numpy(),
}).dropna()
tbl["abs_usd"] = tbl["move_usd"].abs()
tbl = tbl.sort_values("abs_usd", ascending=False)
keep, taken = [], []
gap = pd.Timedelta(minutes=window_minutes)
for _, row in tbl.iterrows():
t = row["close_time"]
if any(abs(t - u) <= gap for u in taken):
continue
taken.append(t)
keep.append(row)
if len(keep) >= n:
break
out = pd.DataFrame(keep).reset_index(drop=True)
if out.empty:
return out
out["weekday"] = out["close_time"].dt.weekday
out["hour"] = out["close_time"].dt.hour
out["direction"] = np.where(out["move_usd"] > 0, "UP", "DOWN")
out["slot"] = [slot_label(w, h, t.day) for w, h, t
in zip(out["weekday"], out["hour"], out["close_time"])]
out["move_usd"] = out["move_usd"].round(2)
out["move_atr"] = out["move_atr"].round(2)
return out[["close_time", "weekday", "hour", "direction", "move_usd",
"move_atr", "slot"]]
# ───────────────────────── book <-> burst join ──────────────────────
def _stats(r: pd.Series) -> dict:
m = base_metrics(r)
return {"n": m["n"], "total_r": round(m["total_r"], 2),
"mean_r": round(m["mean_r"], 3), "wr": round(m["wr"], 2),
"pf": round(m["pf"], 2) if math.isfinite(m["pf"]) else float("inf")}
def join_book_to_bursts(book_df: pd.DataFrame, bars: pd.DataFrame, k: float,
within_bars: int = 12,
bar_minutes: int = 5) -> dict:
"""Split a trade book by its relationship to burst bars.
(a) `after_burst` : decision_time falls within `within_bars` bars after
the most recent burst's close (bar distance is counted POSITIONALLY on
the bar index, so weekend gaps cannot inflate it).
(b) `burst_during` : at least one burst closed inside [entry, exit).
(c) `burst_agrees` / `burst_opposes`: of (b), whether the FIRST burst that
printed during the trade pushed with or against the trade direction.
"""
ct = close_times(bars, bar_minutes)
ct_arr = ct.to_numpy()
flags = burst_flags(bars, k).to_numpy()
bdir = burst_dir(bars).to_numpy()
burst_pos = np.flatnonzero(flags)
# Last burst position at or before each bar (−1 where none yet).
last_burst = np.where(flags, np.arange(len(flags)), -1)
last_burst = np.maximum.accumulate(last_burst)
dec = book_df["decision_time"].astype("datetime64[ns, UTC]").to_numpy()
ent = book_df["entry_time"].astype("datetime64[ns, UTC]").to_numpy()
exi = book_df["exit_time"].astype("datetime64[ns, UTC]").to_numpy()
# Position of the last bar that had CLOSED by decision_time.
idx = np.searchsorted(ct_arr, dec, side="right") - 1
valid = idx >= 0
lb = np.where(valid, last_burst[np.clip(idx, 0, len(flags) - 1)], -1)
bars_since = np.where((lb >= 0) & valid, idx - lb, np.iinfo(np.int32).max)
after = pd.Series((bars_since >= 0) & (bars_since <= within_bars),
index=book_df.index)
# Bursts printing inside the open trade.
bct = ct_arr[burst_pos] if len(burst_pos) else np.array([], dtype=ct_arr.dtype)
lo = np.searchsorted(bct, ent, side="left")
hi = np.searchsorted(bct, exi, side="left")
during = pd.Series(hi > lo, index=book_df.index)
trade_sign = np.where(
book_df["direction"].astype(str).str.upper().str.startswith("B"),
1.0, -1.0)
has = hi > lo
first_dir = np.zeros(len(book_df), dtype=float)
if len(burst_pos) and has.any():
# hi > lo guarantees lo[has] indexes a real burst.
first_dir[has] = bdir[burst_pos[lo[has]]]
agrees = pd.Series(has & (first_dir * trade_sign > 0),
index=book_df.index)
opposes = pd.Series(has & (first_dir * trade_sign < 0),
index=book_df.index)
r = book_df["r_multiple"]
out = {
"k": k, "within_bars": within_bars,
"bursts_in_window": int(len(burst_pos)),
"after_burst": _stats(r[after]),
"not_after_burst": _stats(r[~after]),
"burst_during": _stats(r[during]),
"no_burst_during": _stats(r[~during]),
"burst_agrees": _stats(r[agrees]),
"burst_opposes": _stats(r[opposes]),
"median_bars_since_burst": float(np.median(
bars_since[bars_since < np.iinfo(np.int32).max])) if (
bars_since < np.iinfo(np.int32).max).any() else float("nan"),
}
return out
# ──────────────────────────── reporting ─────────────────────────────
def _fmt_stats(name: str, s: dict) -> str:
pf = "inf" if math.isinf(s["pf"]) else f"{s['pf']:.2f}"
return (f" {name:<18} n={s['n']:<5} totalR={s['total_r']:>8.2f} "
f"meanR={s['mean_r']:>7.3f} WR={s['wr']:>6.2f}% PF={pf}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--parquet", required=True)
ap.add_argument("--bar-minutes", type=int, required=True)
ap.add_argument("--k", default="3,4,5",
help="burst thresholds in prior-bar ATRs (default 3,4,5)")
ap.add_argument("--book", default=None)
ap.add_argument("--book-bar-minutes", type=int, default=None,
help="bar minutes of the BOOK (default: --bar-minutes)")
ap.add_argument("--within-bars", type=int, default=12)
ap.add_argument("--start", default=None)
ap.add_argument("--end", default=None)
ap.add_argument("--n-moves", type=int, default=40)
ap.add_argument("--out", default="backtest/research/burst_map_out")
args = ap.parse_args()
ks = [float(x) for x in args.k.split(",") if x.strip()]
os.makedirs(args.out, exist_ok=True)
bars = load_bars(args.parquet, args.start, args.end)
bm = args.bar_minutes
primary = ks[0]
lines: list[str] = []
add = lines.append
add("=" * 78)
add(f"BURST MAP {os.path.basename(args.parquet)}")
add("=" * 78)
add(f"bars : {len(bars)} ({bars.index[0]} -> {bars.index[-1]}"
f", labels = bar OPEN time)")
add(f"bar minutes : {bm} hours below are bar CLOSE hours (UTC)")
add(f"burst definition : range >= K x PRIOR bar Wilder ATR(14); "
f"K = {', '.join(f'{k:g}' for k in ks)}")
add("ATR units : always normalised by the PRIOR bar's ATR")
for k in ks:
f = burst_flags(bars, k)
add(f" K={k:g}: {int(f.sum())} burst bars "
f"({float(f.mean()) * 100:.3f}% of bars)")
tabs = hour_weekday_table(bars, primary, bm, ks=tuple(ks))
add("")
add(f"── BURST % BY (weekday, close-hour), K={primary:g} ──")
add(tabs["burst_pct"].fillna(0).round(2).to_string())
add("")
add("── MEAN ABSOLUTE MOVE BY CLOSE-HOUR (all weekdays) ──")
add(tabs["by_hour"].to_string())
add("")
add("── MEAN ABSOLUTE MOVE BY WEEKDAY ──")
add(tabs["by_weekday"].to_string())
add("")
add(f"── POST-BURST DRIFT (in the burst's own direction), K={primary:g} ──")
drift = post_burst_drift(bars, primary, bar_minutes=bm)
add(drift.to_string(index=False))
add("")
add(f"── LARGEST {args.n_moves} TRAILING-60-MIN MOVES (de-overlapped) ──")
add(" slot labels are CLOCK SLOTS — no economic calendar exists in "
"this repo, so no event is confirmed.")
moves = largest_moves(bars, n=args.n_moves, bar_minutes=bm)
add(moves.to_string(index=False))
join_rows = []
if args.book:
bbm = args.book_bar_minutes or bm
book = load_book(args.book, bbm)
add("")
add(f"── BOOK JOIN {os.path.basename(args.book)} "
f"({len(book)} trades, book bar = {bbm} min) ──")
first, last = close_times(bars, bm)[0], close_times(bars, bm)[-1]
outside = int(((book["decision_time"] < first)
| (book["exit_time"] > last)).sum())
if outside:
add(f" !! {outside} trade(s) start before / end after the bar "
f"window ({first} .. {last}) — their burst context is "
f"partial. Widen --start/--end or use a longer parquet.")
add(" CAUTION: group (b) is survivorship-shaped — a trade only sees "
"a burst if it survived long enough to still be open, so its high "
"PF is mostly 'winners last longer', not a burst edge.")
for k in ks:
j = join_book_to_bursts(book, bars, k, args.within_bars, bm)
add(f" K={k:g} ({j['bursts_in_window']} bursts in window; "
f"median bars since last burst = "
f"{j['median_bars_since_burst']:.0f})")
add(f" (a) decided within {args.within_bars} bars of a burst")
add(_fmt_stats("after_burst", j["after_burst"]))
add(_fmt_stats("not_after_burst", j["not_after_burst"]))
add(" (b) a burst printed while the trade was open")
add(_fmt_stats("burst_during", j["burst_during"]))
add(_fmt_stats("no_burst_during", j["no_burst_during"]))
add(" (c) of (b), first burst vs the trade's direction")
add(_fmt_stats("burst_agrees", j["burst_agrees"]))
add(_fmt_stats("burst_opposes", j["burst_opposes"]))
for grp in ("after_burst", "not_after_burst", "burst_during",
"no_burst_during", "burst_agrees", "burst_opposes"):
join_rows.append({"k": k, "group": grp,
"within_bars": args.within_bars,
**j[grp]})
text = "\n".join(lines)
print(text)
with open(os.path.join(args.out, "burst_map.txt"), "w") as fh:
fh.write(text + "\n")
pd.concat([v.assign(k=kk) for kk, v in tabs["burst_pct_by_k"].items()]
).to_csv(os.path.join(args.out, "hour_weekday_burst_pct.csv"))
tabs["absmove"].to_csv(os.path.join(args.out, "hour_weekday_absmove.csv"))
tabs["by_hour"].to_csv(os.path.join(args.out, "by_hour.csv"))
tabs["by_weekday"].to_csv(os.path.join(args.out, "by_weekday.csv"))
pd.concat([post_burst_drift(bars, k, bar_minutes=bm).assign(k=k)
for k in ks]).to_csv(
os.path.join(args.out, "post_burst_drift.csv"), index=False)
moves.to_csv(os.path.join(args.out, "largest_moves.csv"), index=False)
if join_rows:
pd.DataFrame(join_rows).to_csv(
os.path.join(args.out, "book_join.csv"), index=False)
print(f"\n[written] {os.path.join(args.out, 'burst_map.txt')}")
if __name__ == "__main__":
main()
-127
View File
@@ -1,127 +0,0 @@
"""Are there signs BEFORE a violent bar? Analysis tooling only — never imported by the live code.
For every bar t, features are computed from bars <= t-1 only. Targets:
next_bar : bar t itself is a burst (range >= K x prior Wilder ATR14)
next_hour : any burst in bars t .. t+H-1
Reports per-quintile burst rates (lift vs base rate) in both halves, the same
within/outside the scheduled-data hours, and an out-of-sample logistic model
(fit on half 1, scored on half 2): AUC + lift in the top 5% of scores.
python backtest/research/burst_precursors.py PARQUET BAR_MINUTES [--k 3] [--start ... --end ...]
"""
import argparse
import numpy as np, pandas as pd
def wilder_atr(df, period=14):
pc = df["Close"].shift(1)
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def build(df, bar_minutes, k):
d = df.copy()
rng = d["High"] - d["Low"]
atr_prev = wilder_atr(d).shift(1) # ATR known at t-1
d["burst"] = (rng >= k * atr_prev).astype(int)
H = max(1, 60 // bar_minutes)
# burst within next H bars (t..t+H-1)
d["next_hour"] = d["burst"][::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
d["next_bar"] = d["burst"]
close_t = d.index + pd.Timedelta(minutes=bar_minutes)
d["hour"] = close_t.hour
r1 = rng.shift(1)
d["squeeze6"] = r1.rolling(6).mean() / atr_prev
d["squeeze12"] = r1.rolling(12).mean() / atr_prev
d["last_bar_range"] = r1 / atr_prev
sma = d["Close"].shift(1).rolling(20).mean(); sd = d["Close"].shift(1).rolling(20).std(ddof=0)
bbw = (4 * sd / sma)
d["bbw_pct500"] = bbw.rolling(500).rank(pct=True)
v1 = d["Volume"].shift(1)
d["vol_ratio"] = v1.rolling(3).mean() / v1.rolling(50).mean()
d["drift12"] = (d["Close"].shift(1) - d["Close"].shift(13)).abs() / atr_prev
d["atr_pct500"] = atr_prev.rolling(500).rank(pct=True)
b = d["burst"].shift(1).fillna(0).astype(int)
since = np.zeros(len(d)); cnt = 200
bv = b.values
for i in range(len(d)):
cnt = 0 if bv[i] == 1 else min(200, cnt + 1)
since[i] = cnt
d["bars_since_burst"] = since
return d.dropna(subset=["squeeze6", "squeeze12", "bbw_pct500", "vol_ratio", "drift12", "atr_pct500"])
FEATS = ["squeeze6", "squeeze12", "last_bar_range", "bbw_pct500", "vol_ratio", "drift12", "atr_pct500", "bars_since_burst"]
def quintile_table(d, feat, target, half):
q = pd.qcut(d[feat], 5, labels=False, duplicates="drop")
base1 = d.loc[half == 1, target].mean(); base2 = d.loc[half == 2, target].mean()
rows = []
for b in sorted(q.dropna().unique()):
m = q == b
lo, hi = d.loc[m, feat].min(), d.loc[m, feat].max()
r1 = d.loc[m & (half == 1), target].mean(); r2 = d.loc[m & (half == 2), target].mean()
rows.append((int(b), lo, hi, int(m.sum()), 100 * r1, r1 / base1, 100 * r2, r2 / base2))
t = pd.DataFrame(rows, columns=["bin", "lo", "hi", "n", "h1_rate%", "h1_lift", "h2_rate%", "h2_lift"])
return t.round(3)
def main():
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
ap.add_argument("--k", type=float, default=3.0); ap.add_argument("--start"); ap.add_argument("--end")
a = ap.parse_args()
df = pd.read_parquet(a.parquet)
if a.start: df = df[df.index >= pd.Timestamp(a.start, tz="UTC")]
if a.end: df = df[df.index < pd.Timestamp(a.end, tz="UTC")]
d = build(df, a.bar_minutes, a.k)
half = pd.Series(np.where(np.arange(len(d)) < len(d) // 2, 1, 2), index=d.index)
print(f"bars {len(d)} K={a.k} base rate next_bar {100*d.next_bar.mean():.3f}% next_hour {100*d.next_hour.mean():.2f}%")
print(f"half 1 ends {d.index[len(d)//2 - 1]}")
# 1. the clock
hr = d.groupby("hour")[["next_bar", "next_hour"]].mean() * 100
hr["lift_next_bar"] = hr["next_bar"] / (100 * d.next_bar.mean())
print("\n== THE CLOCK (bar-close hour UTC): burst rate % and lift ==")
print(hr.round(2).T.to_string())
sched = d["hour"].isin([1, 12, 13])
print(f"\nscheduled hours (01,12,13) hold {100*d.loc[sched,'next_bar'].sum()/d.next_bar.sum():.1f}% of bursts in {100*sched.mean():.1f}% of bars")
# 2. per-feature quintiles, both halves
for target in ("next_bar", "next_hour"):
print(f"\n== TARGET {target}: quintile burst rate (lift vs base) — ALL hours ==")
for f in FEATS:
t = quintile_table(d, f, target, half)
print(f"-- {f}: lifts h1 {list(t.h1_lift)} h2 {list(t.h2_lift)} (bins lo..hi {t.lo.iloc[0]:.2f}..{t.hi.iloc[-1]:.2f})")
# 3. same, OUTSIDE the scheduled hours (does anything predict the unscheduled ones?)
u = d[~sched]; hu = half[~sched]
print(f"\n== next_bar lifts OUTSIDE scheduled hours (base {100*u.next_bar.mean():.3f}%) ==")
for f in FEATS:
t = quintile_table(u, f, "next_bar", hu)
print(f"-- {f}: h1 {list(t.h1_lift)} h2 {list(t.h2_lift)}")
# 4. OOS logistic model: fit half 1, score half 2
try:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
X = d[FEATS].copy()
for h in range(24): X[f"h{h}"] = (d["hour"] == h).astype(float)
Xc = d[FEATS].copy() # clock-free variant
for name, XX in (("features+clock", X), ("features only", Xc), ("clock only", X[[f"h{h}" for h in range(24)]])):
mu, sd = XX[half == 1].mean(), XX[half == 1].std().replace(0, 1)
Z = (XX - mu) / sd
for target in ("next_bar", "next_hour"):
m = LogisticRegression(C=0.1, max_iter=2000, class_weight="balanced").fit(Z[half == 1], d.loc[half == 1, target])
p = m.predict_proba(Z[half == 2])[:, 1]; y = d.loc[half == 2, target]
auc = roc_auc_score(y, p)
top = p >= np.quantile(p, 0.95)
print(f"OOS {name:15s} {target:9s}: AUC {auc:.3f} | top-5% of scores: burst rate {100*y[top].mean():.2f}% vs base {100*y.mean():.2f}% (lift {y[top].mean()/y.mean():.2f}x), captures {100*y[top].sum()/y.sum():.1f}% of bursts")
except ImportError:
print("sklearn missing — OOS model skipped")
# 5. direction: does the burst continue the prior 12-bar drift?
bt = d[d.burst == 1]
prior = np.sign(bt["Close"].shift(1) - bt["Close"].shift(13)) if False else np.sign(d["Close"].shift(1) - d["Close"].shift(13))[d.burst == 1]
bdir = np.sign(bt["Close"] - bt["Open"])
agree = (prior == bdir)[(prior != 0) & (bdir != 0)]
print(f"\nburst direction == prior-hour drift direction: {100*agree.mean():.1f}% of {len(agree)} bursts (50% = no information)")
if __name__ == "__main__":
main()
-111
View File
@@ -1,111 +0,0 @@
"""Do scheduled economic events move gold violently, and do they hurt OPEN trades? Analysis tooling only.
Needs backtest/cache/calendar.parquet (ff_calendar_scraper.py). Uses USD events by impact.
python backtest/research/calendar_impact.py PARQUET_BARS BAR_MINUTES BOOK.csv ARM DIST [--start --end]
"""
import argparse, sys
from pathlib import Path
import numpy as np, pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from open_trade_bursts import wilder_atr, simulate, r_of, stats, K
CAL = Path(__file__).resolve().parent.parent / "cache" / "calendar.parquet"
def main():
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
ap.add_argument("book"); ap.add_argument("arm", type=float); ap.add_argument("dist", type=float)
ap.add_argument("--start", default="2023-09-05"); ap.add_argument("--end", default="2026-09-05")
a = ap.parse_args(); bm = a.bar_minutes
cal = pd.read_parquet(CAL)
cal["ts"] = pd.to_datetime(cal["dateline"], unit="s", utc=True)
cal = cal[(cal.ts >= pd.Timestamp(a.start, tz="UTC")) & (cal.ts < pd.Timestamp(a.end, tz="UTC"))]
usd = cal[cal.currency == "USD"].copy()
usd["impact"] = usd["impactName"].str.lower()
print(f"calendar: {len(cal)} events in window, USD {len(usd)}: " + ", ".join(f"{k}={v}" for k, v in usd.impact.value_counts().items()))
px = pd.read_parquet(a.parquet); px = px[(px.index >= pd.Timestamp(a.start, tz="UTC")) & (px.index < pd.Timestamp(a.end, tz="UTC"))]
atr_prev = wilder_atr(px).shift(1)
rng = px["High"] - px["Low"]; burst = (rng >= K * atr_prev).fillna(False)
close = px["Close"]; idx = px.index
H1 = 60 // bm
move60 = (close.shift(-H1) - close).abs() / atr_prev # |move| over the next hour from bar OPEN-ish (label)
move15 = (close.shift(-max(1, 15 // bm)) - close).abs() / atr_prev
burst60 = burst[::-1].rolling(H1, min_periods=1).max()[::-1].astype(bool)
# event -> bar containing it (label <= ts < label + bm)
def bar_pos(ts):
p = idx.searchsorted(ts, side="right") - 1
return p if 0 <= p < len(idx) and (ts - idx[p]) < pd.Timedelta(minutes=bm) else -1
usd["pos"] = [bar_pos(t) for t in usd.ts]
usd = usd[usd.pos >= 0]
usd["bar_range_atr"] = rng.values[usd.pos] / atr_prev.values[usd.pos]
usd["burst_in_60m"] = burst60.values[usd.pos]
usd["move60_atr"] = move60.values[usd.pos]; usd["move60_usd"] = (close.shift(-H1) - close).abs().values[usd.pos]
usd["move15_atr"] = move15.values[usd.pos]
# baseline: all bars, and bars in the same close-hours without any USD high/medium event within the hour
chour = (idx + pd.Timedelta(minutes=bm)).hour
ev_bars = set()
for p in usd[usd.impact.isin(["high", "medium"])].pos: ev_bars.update(range(max(0, p - H1), min(len(idx), p + H1)))
quiet = ~np.isin(np.arange(len(idx)), list(ev_bars))
def base_row(mask, label):
return dict(group=label, n=int(mask.sum()), bar_range_atr=float((rng / atr_prev)[mask].mean()), burst_in_60m_pct=100 * float(burst60[mask].mean()),
move15_atr=float(move15[mask].mean()), move60_atr=float(move60[mask].mean()), move60_usd=float((close.shift(-H1) - close).abs()[mask].mean()))
rows = [base_row(np.ones(len(idx), bool), "ALL bars"), base_row(quiet & np.isin(chour, [12, 13, 14]), "12-14 UTC bars with NO USD event")]
for imp in ("high", "medium", "low"):
g = usd[usd.impact == imp]
rows.append(dict(group=f"USD {imp}-impact events", n=len(g), bar_range_atr=g.bar_range_atr.mean(), burst_in_60m_pct=100 * g.burst_in_60m.mean(),
move15_atr=g.move15_atr.mean(), move60_atr=g.move60_atr.mean(), move60_usd=g.move60_usd.mean()))
print("\n== HOW MUCH GOLD MOVES AROUND USD EVENTS (event bar = the bar containing the release) ==")
print(pd.DataFrame(rows).round(2).to_string(index=False))
hi = usd[usd.impact == "high"]
top = hi.groupby("name").agg(n=("ts", "size"), bar_range_atr=("bar_range_atr", "mean"), burst60_pct=("burst_in_60m", lambda s: 100 * s.mean()),
move60_usd=("move60_usd", "mean"), move60_atr=("move60_atr", "mean")).sort_values("n", ascending=False)
print("\n== HIGH-IMPACT USD EVENTS BY NAME (n >= 6) ==")
print(top[top.n >= 6].round(2).to_string())
# what share of ALL bursts sit within ±30 min of a high/medium USD event?
near = np.zeros(len(idx), bool)
for p in usd[usd.impact.isin(["high", "medium"])].pos: near[max(0, p - 30 // bm):min(len(idx), p + 30 // bm + 1)] = True
nearh = np.zeros(len(idx), bool)
for p in hi.pos: nearh[max(0, p - 30 // bm):min(len(idx), p + 30 // bm + 1)] = True
print(f"\nshare of ALL violent bars within ±30 min of a USD high-impact event: {100*burst[nearh].sum()/burst.sum():.1f}% (those bars are {100*nearh.mean():.1f}% of time)")
print(f"share within ±30 min of a USD high- or medium-impact event: {100*burst[near].sum()/burst.sum():.1f}% (bars {100*near.mean():.1f}% of time)")
# open trades through high-impact events
book = pd.read_csv(a.book); book["entry_time"] = pd.to_datetime(book.entry_time, utc=True, format="mixed")
bars = dict(O=px.Open.values, H=px.High.values, L=px.Low.values, C=px.Close.values, burst=burst.values, chour=chour.values)
pos = idx.get_indexer(book.entry_time); ok = pos >= 0; book = book[ok].reset_index(drop=True); pos = pos[ok]
ev_pos = np.sort(hi.pos.values)
# flat mask: exit at the open of the bar that CONTAINS a high-impact event... use the bar before (exit ~5-15 min before)
flat_mask = np.zeros(len(idx), bool); tight_mask = np.zeros(len(idx), bool)
for p in ev_pos:
if p - 1 >= 0: flat_mask[p - 1] = True
tight_mask[max(0, p - 1):min(len(idx), p + H1)] = True
res = {}
for v, kw in (("base", {}), ("mask_flat", dict(flat_mask=flat_mask)), ("mask_tight", dict(tight_mask=tight_mask))):
rs, fl, thru = [], [], []
for i, t in book.iterrows():
xp, held, reason, flag = simulate(bars, pos[i], t.direction, t.entry, t.sl, a.arm, a.dist, v, **kw)
rs.append(r_of(t.direction, t.entry, t.sl, xp)); fl.append(flag)
if v == "base":
j0, j1 = pos[i], pos[i] + held
k = ev_pos[(ev_pos > j0) & (ev_pos <= j1)]
thru.append(k[0] if len(k) else -1)
res[v] = (np.array(rs), np.array(fl))
if v == "base": book["ev_pos"] = thru
base = res["base"][0]; half = np.where(np.arange(len(book)) < len(book) // 2, 1, 2)
hit = book.ev_pos.values >= 0
print(f"\n== OPEN TRADES THAT HELD THROUGH A HIGH-IMPACT USD EVENT ({a.book.split('/')[-1]}) ==")
print(f"trades through an event: {hit.sum()} of {len(book)} ({100*hit.mean():.1f}%) | mean R {base[hit].mean():+.3f} vs {base[~hit].mean():+.3f} for the rest | WR {100*(base[hit]>0).mean():.0f}% vs {100*(base[~hit]>0).mean():.0f}%")
if hit.sum():
d = np.where(book.direction == "BUY", 1.0, -1.0)
m15 = np.array([ (close.shift(-max(1,15//bm)).values[p] - close.values[p]) if p >= 0 else np.nan for p in book.ev_pos])
with_ = hit & (d * m15 > 0); against = hit & (d * m15 < 0)
print(f" event's first 15-min move WITH the trade: n={with_.sum()} mean R {base[with_].mean():+.3f} | AGAINST: n={against.sum()} mean R {base[against].mean():+.3f} (WR {100*(base[against]>0).mean():.0f}%)")
print("\n== PROTECTION AROUND HIGH-IMPACT USD EVENTS ==")
b = stats(base)
for v, (r, f) in res.items():
s = stats(r); aff = f == 1
d1 = r[half == 1].sum() - base[half == 1].sum(); d2 = r[half == 2].sum() - base[half == 2].sum()
print(f"{v:11s} total {s['total']:+8.2f} Δ {s['total']-b['total']:+7.2f} PF {s['pf']:.2f} DD {s['dd']:.2f} affected {aff.sum():4d} (affected: base {base[aff].sum():+.2f} -> {r[aff].sum():+.2f}) | Δ h1 {d1:+.2f} / h2 {d2:+.2f}")
if __name__ == "__main__":
main()
-178
View File
@@ -1,178 +0,0 @@
"""Commit-ladder campaign driver (2026-08).
Question: of all signal-code states shipped in the last two months, which
configuration is the most accurate? Historical in-era backtest numbers are
not comparable (the backtester's realism model changed on 07-08/07-16 and
the data feed switched to Oanda on 07-28), so every historical
configuration is recreated on TODAY's harness via --set overrides and
replayed on the same pinned Oanda bars. Bug-fix commits stay on; the
ladder compares shipped tuning decisions only.
Usage:
python backtest/research/commit_ladder.py # full matrix
python backtest/research/commit_ladder.py --tf 5min # one timeframe
python backtest/research/commit_ladder.py --windows 12mo,oos
Resumable: a run whose CSV already exists in the output dir is skipped.
Outputs: backtest/research/commit_ladder_2026-08/<variant>_<window>.csv
+ .log per run, summary.csv, and a printed ranking table.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
BT = ROOT / "backtest" / "gpu_backtest.py"
OUT = ROOT / "backtest" / "research" / "commit_ladder_2026-08"
WINDOWS = {
"12mo": ("2025-07-30", "2026-07-30"),
"oos": ("2024-06-01", "2025-03-30"),
"5yr": ("2021-07-30", "2026-07-30"),
}
SPLIT_12MO = pd.Timestamp("2026-01-29", tz="UTC") # book_diff.py's split
# (variant, "commit date what-it-represents", [--set overrides])
VARIANTS: dict[str, list[tuple[str, str, list[str]]]] = {
"5min": [
("M5-A", "dad2432 07-09 gates+trailing+pullback",
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21]",
"xau_strategy.cp_enabled=false",
"signals.trail_dist_r=0.75",
"xau_strategy.m5_min_abs_score=30",
"xau_strategy.m5_min_confirmation_score=10"]),
("M5-B", "f3ba4d5 07-11 +hour-9 block",
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
"xau_strategy.cp_enabled=false",
"signals.trail_dist_r=0.75",
"xau_strategy.m5_min_abs_score=30",
"xau_strategy.m5_min_confirmation_score=10"]),
("M5-C", "de6424e 07-16 +chart patterns",
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
"signals.trail_dist_r=0.75",
"xau_strategy.m5_min_abs_score=30",
"xau_strategy.m5_min_confirmation_score=10"]),
("M5-D", "3ee79b5 07-21 +trail dist 0.85",
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
"xau_strategy.m5_min_abs_score=30",
"xau_strategy.m5_min_confirmation_score=10"]),
("M5-E", "0396970 07-30 relaxation (HEAD)", []),
],
"15min": [
("M15-A", "987ec5b 07-09 pre-campaigns",
["signals.tp_floor_rr=0.5",
"xau_strategy.blocked_regimes=[]",
"xau_strategy.sell_blocked_hours_utc=[]",
"signals.trail_arm_r=0.60"]),
("M15-B", "f3ba4d5/f88e39f 07-11 1R floor + regime block",
["xau_strategy.sell_blocked_hours_utc=[]",
"signals.trail_arm_r=0.60"]),
("M15-C", "7d3fd95 07-16 +SELL hour 15/17 block",
["signals.trail_arm_r=0.60"]),
("M15-D", "bf8e5fb 07-21 trail arm 0.70 (HEAD)", []),
],
"1h": [
("H1-A", "pre-d95d31a 07-08 TP-tightening model",
["signals.min_rr_ratio=0.4",
"signals.tp_tightening_factor=0.8"]),
("H1-B", "HEAD", []),
],
}
def run_one(tf: str, name: str, overrides: list[str], wkey: str,
spread: str | None = None) -> Path:
suffix = f"_s{spread}" if spread else ""
csv_path = OUT / f"{name}_{wkey}{suffix}.csv"
log_path = OUT / f"{name}_{wkey}{suffix}.log"
if csv_path.exists():
print(f" [skip] {csv_path.name} exists")
return csv_path
start, end = WINDOWS[wkey]
cmd = [sys.executable, str(BT), "--pair", "XAUUSD",
"--primary-tf", tf, "--start", start, "--end", end,
"--export-csv", str(csv_path)]
if spread:
cmd += ["--spread", spread]
for ov in overrides:
cmd += ["--set", ov]
print(f" [run ] {csv_path.name} ...", flush=True)
with open(log_path, "w") as lf:
rc = subprocess.run(cmd, stdout=lf, stderr=subprocess.STDOUT,
cwd=ROOT).returncode
if rc != 0:
raise SystemExit(f"backtest failed (rc={rc}) — see {log_path}")
log = log_path.read_text()
n_ov = log.count("--set override")
if n_ov != len(overrides):
raise SystemExit(
f"{name}_{wkey}: expected {len(overrides)} --set overrides, "
f"log shows {n_ov} — see {log_path}")
if "Loading cached" not in log and wkey != "sanity":
print(f" [warn] {log_path.name}: no 'Loading cached' line — "
f"data was fetched live for at least one timeframe")
return csv_path
def metrics(csv_path: Path, wkey: str) -> dict:
df = pd.read_csv(csv_path, parse_dates=["entry_time"])
if df.empty:
return {"trades": 0, "total_r": 0.0, "wr": 0.0, "pf": 0.0,
"max_dd": 0.0, "h1_r": 0.0, "h2_r": 0.0, "years_pos": ""}
if df["entry_time"].dt.tz is None:
df["entry_time"] = df["entry_time"].dt.tz_localize("UTC")
r = df["r_multiple"]
pos, neg = r[r > 0].sum(), r[r < 0].sum()
cum = r.cumsum()
dd = (pd.concat([pd.Series([0.0]), cum]).cummax()[1:] - cum).max()
out = {
"trades": len(df),
"total_r": round(r.sum(), 1),
"wr": round((r > 0).mean() * 100, 1),
"pf": round(pos / abs(neg), 2) if neg else float("inf"),
"max_dd": round(dd, 1),
"h1_r": "", "h2_r": "", "years_pos": "",
}
if wkey == "12mo":
h1 = r[df["entry_time"] < SPLIT_12MO].sum()
out["h1_r"] = round(h1, 1)
out["h2_r"] = round(r.sum() - h1, 1)
if wkey == "5yr":
by_year = r.groupby(df["entry_time"].dt.year).sum()
out["years_pos"] = f"{(by_year > 0).sum()}/{len(by_year)}"
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--tf", choices=list(VARIANTS), default=None)
ap.add_argument("--windows", default="12mo,oos,5yr")
args = ap.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
wkeys = [w.strip() for w in args.windows.split(",")]
tfs = [args.tf] if args.tf else list(VARIANTS)
rows = []
for tf in tfs:
for name, label, overrides in VARIANTS[tf]:
for wkey in wkeys:
csv_path = run_one(tf, name, overrides, wkey)
m = metrics(csv_path, wkey)
rows.append({"tf": tf, "variant": name, "represents": label,
"window": wkey, **m})
print(f" {name} {wkey}: {m['trades']} trades, "
f"{m['total_r']}R, PF {m['pf']}, WR {m['wr']}%")
summary = pd.DataFrame(rows)
summary.to_csv(OUT / "summary.csv", index=False)
print("\n=== SUMMARY (also in summary.csv) ===")
print(summary.to_string(index=False))
if __name__ == "__main__":
main()
-193
View File
@@ -1,193 +0,0 @@
"""Dukascopy XAU/USD tick download + bid/ask spread study.
Validates ForexBot2's $0.30 round-trip cost assumption (config:
SignalConfig.round_trip_cost / PAIR_INFO default_spread). Round-trip
cost of a market entry+exit = one ask-bid spread width (buy at ask,
sell at bid), so the assumption holds iff typical spread ~ $0.30.
bi5 format: LZMA 'alone' stream of 20-byte big-endian records
(ms_in_hour uint32, ask uint32, bid uint32, ask_vol f32, bid_vol f32);
XAUUSD prices scaled x1000. URL months are 0-indexed.
"""
import lzma
import sys
from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import requests
SCALE = 1000.0
CACHE = Path(__file__).parent / "tick_cache"
CACHE.mkdir(exist_ok=True)
URL = "https://datafeed.dukascopy.com/datafeed/XAUUSD/{y}/{m:02d}/{d:02d}/{h:02d}h_ticks.bi5"
# Bot's tradable bar-close hours: session 03:00-23:00 minus XAU blocked
# hours [0,1,2,10,11,13,16,21] (M5 additionally blocks 9 — reported
# separately below).
BLOCKED = {0, 1, 2, 10, 11, 13, 16, 21}
TRADABLE = sorted(set(range(3, 23)) - BLOCKED)
# ── Day sample ────────────────────────────────────────────────────────
recent = [date(2026, 7, 6) + timedelta(days=i) for i in range(17)]
recent = [d for d in recent if d.weekday() < 5] # Mon-Fri Jul 6-22
monthly = [date(2025, 8, 13), date(2025, 9, 10), date(2025, 10, 15),
date(2025, 11, 12), date(2025, 12, 10), date(2026, 1, 14),
date(2026, 2, 11), date(2026, 3, 11), date(2026, 4, 15),
date(2026, 5, 13), date(2026, 6, 10)] # mid-month Wednesdays
nfp = [date(2026, 6, 5), date(2026, 7, 3)] # NFP Fridays
sundays = [date(2026, 7, 12), date(2026, 7, 19)] # Sun 22-24 open
DAYS = sorted(set(recent + monthly + nfp + sundays))
assert all(d.weekday() < 5 for d in recent + monthly + nfp)
_tls = __import__("threading").local()
def _session() -> requests.Session:
if not hasattr(_tls, "s"):
_tls.s = requests.Session()
_tls.s.headers["User-Agent"] = "Mozilla/5.0 (research; spread-study)"
return _tls.s
def fetch(day: date, hour: int) -> Path:
p = CACHE / f"{day.isoformat()}_{hour:02d}.bi5"
if p.exists():
return p
u = URL.format(y=day.year, m=day.month - 1, d=day.day, h=hour)
last = None
for attempt in range(4):
try:
r = _session().get(u, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"{u} -> HTTP {r.status_code}")
p.write_bytes(r.content)
return p
except (requests.ConnectionError, requests.Timeout, RuntimeError) as exc:
last = exc
__import__("time").sleep(1.5 * (attempt + 1))
if hasattr(_tls, "s"):
_tls.s.close()
del _tls.s
raise RuntimeError(f"giving up on {u}: {last}")
def decode(p: Path, day: date, hour: int) -> pd.DataFrame:
blob = p.read_bytes()
if not blob:
return pd.DataFrame()
raw = lzma.decompress(blob)
n = len(raw) // 20
a = np.frombuffer(raw[: n * 20], dtype=">u4").reshape(n, 5)
f = np.frombuffer(raw[: n * 20], dtype=">f4").reshape(n, 5)
df = pd.DataFrame({
"ms": a[:, 0].astype(np.int64),
"ask": a[:, 1] / SCALE,
"bid": a[:, 2] / SCALE,
})
df["spread"] = df["ask"] - df["bid"]
df["date"], df["hour"] = day.isoformat(), hour
df["weekday"] = day.weekday()
return df
def load_all() -> pd.DataFrame:
jobs = [(d, h) for d in DAYS for h in range(24)]
with ThreadPoolExecutor(max_workers=3) as ex:
paths = list(ex.map(lambda j: fetch(*j), jobs))
frames = [decode(p, d, h) for p, (d, h) in zip(paths, jobs)]
df = pd.concat([f for f in frames if not f.empty], ignore_index=True)
return df
def wstats(g: pd.DataFrame) -> pd.Series:
"""Tick stats + time-weighted mean (each tick's spread held until the
next tick; last tick held to hour end)."""
tw_num = tw_den = 0.0
keys = [k for k in ("date", "hour") if k in g.columns]
for _, grp in g.groupby(keys, sort=False):
ms = grp["ms"].to_numpy()
sp = grp["spread"].to_numpy()
w = np.diff(np.append(ms, 3_600_000)).clip(min=0)
tw_num += float((sp * w).sum())
tw_den += float(w.sum())
s = g["spread"]
return pd.Series({
"ticks": len(s),
"tw_mean": tw_num / tw_den if tw_den else np.nan,
"mean": s.mean(), "median": s.median(),
"p75": s.quantile(0.75), "p90": s.quantile(0.90),
"p99": s.quantile(0.99), "max": s.max(),
"pct_le_030": (s <= 0.30).mean() * 100,
"pct_ge_060": (s >= 0.60).mean() * 100,
})
def m5_boundary_spreads(df: pd.DataFrame) -> pd.DataFrame:
"""Spread of the FIRST tick at/after each M5 boundary — what a
next-open fill actually pays."""
rows = []
for (day, hour), grp in df.groupby(["date", "hour"], sort=False):
ms = grp["ms"].to_numpy()
sp = grp["spread"].to_numpy()
wd = grp["weekday"].iat[0]
for b in range(0, 3_600_000, 300_000):
i = np.searchsorted(ms, b)
if i < len(ms) and ms[i] - b <= 30_000: # tick within 30 s
rows.append((day, wd, hour, b // 60_000, sp[i]))
return pd.DataFrame(rows, columns=["date", "weekday", "hour", "minute", "spread"])
def main() -> None:
df = load_all()
print(f"days={df['date'].nunique()} ticks={len(df):,} "
f"price range {df['bid'].min():.0f}-{df['ask'].max():.0f}")
print("\n── Overall (all sampled hours) ──")
print(wstats(df).round(3).to_string())
trad = df[df["hour"].isin(TRADABLE) & (df["weekday"] < 5)]
print("\n── Bot-tradable hours (session 03-23 minus blocked, Mon-Fri) ──")
print(wstats(trad).round(3).to_string())
roll = df[df["hour"].isin([21, 22, 23])]
print("\n── Rollover window 21-24 UTC ──")
print(wstats(roll).round(3).to_string())
print("\n── Per UTC hour ──")
per_hour = df[df["weekday"] < 5].groupby("hour").apply(wstats, include_groups=False).round(3)
per_hour["tradable"] = ["Y" if h in TRADABLE else "-" for h in per_hour.index]
print(per_hour.to_string())
per_hour.to_csv(Path(__file__).parent / "duka_spread_by_hour.csv")
print("\n── M5 next-open boundary fills ──")
mb = m5_boundary_spreads(df)
mb_trad = mb[mb["hour"].isin(TRADABLE) & (mb["weekday"] < 5)]
for name, d in [("all hours", mb), ("tradable hours", mb_trad)]:
s = d["spread"]
print(f"{name}: n={len(s):,} mean={s.mean():.3f} median={s.median():.3f} "
f"p90={s.quantile(0.9):.3f} p99={s.quantile(0.99):.3f} "
f"%<=0.30={(s <= 0.30).mean()*100:.1f} %>=0.60={(s >= 0.60).mean()*100:.1f}")
mb.to_csv(Path(__file__).parent / "duka_m5_boundary_spreads.csv", index=False)
print("\n── By month (tradable hours, tick median / tw_mean) ──")
trad2 = trad.copy()
trad2["month"] = trad2["date"].str[:7]
bym = trad2.groupby("month").apply(wstats, include_groups=False).round(3)
print(bym[["ticks", "tw_mean", "median", "p90", "pct_le_030", "pct_ge_060"]].to_string())
print("\n── NFP Fridays 12:00-15:00 UTC vs same hours other days ──")
nfp_days = {"2026-06-05", "2026-07-03"}
win = df[df["hour"].isin([12, 13, 14])]
for name, d in [("NFP", win[win["date"].isin(nfp_days)]),
("non-NFP", win[~win["date"].isin(nfp_days) & (win["weekday"] < 5)])]:
s = d["spread"]
print(f"{name}: n={len(s):,} tick-median={s.median():.3f} p90={s.quantile(0.9):.3f} "
f"p99={s.quantile(0.99):.3f} max={s.max():.2f}")
if __name__ == "__main__":
sys.exit(main())
-25
View File
@@ -1,25 +0,0 @@
hour,ticks,tw_mean,mean,median,p75,p90,p99,max,pct_le_030,pct_ge_060,tradable
0,362893.0,0.749,0.768,0.79,0.82,0.86,1.1,7.69,0.053,94.561,-
1,540182.0,0.735,0.742,0.74,0.81,0.87,1.0,5.59,0.073,90.449,-
2,431589.0,0.716,0.722,0.73,0.8,0.86,0.95,2.96,0.1,85.27,-
3,343952.0,0.708,0.711,0.71,0.79,0.84,0.95,1.67,0.081,82.939,Y
4,246262.0,0.704,0.702,0.71,0.78,0.82,0.94,1.86,0.098,82.627,Y
5,388365.0,0.712,0.716,0.72,0.79,0.82,0.95,2.17,0.076,86.745,Y
6,368999.0,0.65,0.651,0.65,0.69,0.74,0.94,2.32,0.079,71.668,Y
7,313149.0,0.601,0.598,0.59,0.65,0.69,0.994,3.08,0.239,42.904,Y
8,347276.0,0.612,0.616,0.6,0.66,0.74,1.0,3.04,0.308,48.349,Y
9,321619.0,0.612,0.615,0.6,0.66,0.72,1.0,5.95,0.288,50.644,Y
10,295271.0,0.611,0.618,0.6,0.66,0.71,1.0,9.36,0.164,49.253,-
11,336963.0,0.619,0.624,0.6,0.67,0.79,1.0,4.97,0.203,50.178,-
12,476773.0,0.66,0.697,0.63,0.72,0.877,1.367,14.98,0.257,59.633,Y
13,651425.0,0.641,0.648,0.63,0.69,0.787,1.487,11.407,0.296,60.297,-
14,673391.0,0.625,0.625,0.63,0.68,0.77,0.9,11.98,0.458,59.085,Y
15,598398.0,0.636,0.638,0.64,0.7,0.78,0.91,4.49,0.433,66.296,Y
16,485023.0,0.648,0.644,0.64,0.7,0.757,0.917,4.09,0.118,68.496,-
17,450119.0,0.626,0.628,0.627,0.7,0.726,0.84,4.08,0.154,63.337,Y
18,411538.0,0.626,0.63,0.637,0.7,0.71,0.79,4.16,0.107,67.734,Y
19,435396.0,0.638,0.643,0.65,0.7,0.72,0.896,5.804,0.11,72.007,Y
20,212654.0,0.684,0.665,0.66,0.7,0.76,1.217,7.15,0.226,70.215,Y
21,31319.0,0.627,0.622,0.61,0.68,0.75,1.02,2.08,0.089,53.153,-
22,108879.0,0.829,0.834,0.797,0.84,0.98,2.05,10.11,0.056,95.434,Y
23,161079.0,1.032,0.913,0.8,0.89,1.307,2.77,13.174,0.092,95.41,-
1 hour ticks tw_mean mean median p75 p90 p99 max pct_le_030 pct_ge_060 tradable
2 0 362893.0 0.749 0.768 0.79 0.82 0.86 1.1 7.69 0.053 94.561 -
3 1 540182.0 0.735 0.742 0.74 0.81 0.87 1.0 5.59 0.073 90.449 -
4 2 431589.0 0.716 0.722 0.73 0.8 0.86 0.95 2.96 0.1 85.27 -
5 3 343952.0 0.708 0.711 0.71 0.79 0.84 0.95 1.67 0.081 82.939 Y
6 4 246262.0 0.704 0.702 0.71 0.78 0.82 0.94 1.86 0.098 82.627 Y
7 5 388365.0 0.712 0.716 0.72 0.79 0.82 0.95 2.17 0.076 86.745 Y
8 6 368999.0 0.65 0.651 0.65 0.69 0.74 0.94 2.32 0.079 71.668 Y
9 7 313149.0 0.601 0.598 0.59 0.65 0.69 0.994 3.08 0.239 42.904 Y
10 8 347276.0 0.612 0.616 0.6 0.66 0.74 1.0 3.04 0.308 48.349 Y
11 9 321619.0 0.612 0.615 0.6 0.66 0.72 1.0 5.95 0.288 50.644 Y
12 10 295271.0 0.611 0.618 0.6 0.66 0.71 1.0 9.36 0.164 49.253 -
13 11 336963.0 0.619 0.624 0.6 0.67 0.79 1.0 4.97 0.203 50.178 -
14 12 476773.0 0.66 0.697 0.63 0.72 0.877 1.367 14.98 0.257 59.633 Y
15 13 651425.0 0.641 0.648 0.63 0.69 0.787 1.487 11.407 0.296 60.297 -
16 14 673391.0 0.625 0.625 0.63 0.68 0.77 0.9 11.98 0.458 59.085 Y
17 15 598398.0 0.636 0.638 0.64 0.7 0.78 0.91 4.49 0.433 66.296 Y
18 16 485023.0 0.648 0.644 0.64 0.7 0.757 0.917 4.09 0.118 68.496 -
19 17 450119.0 0.626 0.628 0.627 0.7 0.726 0.84 4.08 0.154 63.337 Y
20 18 411538.0 0.626 0.63 0.637 0.7 0.71 0.79 4.16 0.107 67.734 Y
21 19 435396.0 0.638 0.643 0.65 0.7 0.72 0.896 5.804 0.11 72.007 Y
22 20 212654.0 0.684 0.665 0.66 0.7 0.76 1.217 7.15 0.226 70.215 Y
23 21 31319.0 0.627 0.622 0.61 0.68 0.75 1.02 2.08 0.089 53.153 -
24 22 108879.0 0.829 0.834 0.797 0.84 0.98 2.05 10.11 0.056 95.434 Y
25 23 161079.0 1.032 0.913 0.8 0.89 1.307 2.77 13.174 0.092 95.41 -
@@ -1,8 +1,8 @@
# Feed Divergence Study — Oanda vs Tiingo vs Twelve Data (Phase 1)
**Date:** 2026-08-14
**Test plan:** `docs/data_source_change_test_plan.md`
**Tool:** `scripts/feed_divergence_study.py` (fetch_range over 365d, parquet-cached)
**Test plan:** `docs/data_source_change_test_plan.md` (removed 2026-09-06, in git history; the pending Phase 4 shadow scan is noted in CLAUDE.md)
**Tool:** `scripts/feed_divergence_study.py` (script removed 2026-09-06, in git history) (fetch_range over 365d, parquet-cached)
**Window:** 2025-08-14 → 2026-08-14 (12 months); Oanda-vs-TD truncated at TD cache head (2026-07-23)
## Headline
@@ -1,2 +0,0 @@
tf,a,b,a_bars,b_bars,common,a_only,b_only,dir_agree_pct,ret_corr,mean_abs_dclose,p95_abs_dclose,max_abs_dclose,dclose_pct_atr_mean
5min,oanda,tiingo,70901,73241,70807,94,2434,97.674000,0.994970,0.123070,0.285000,26.105000,2.147942
1 tf a b a_bars b_bars common a_only b_only dir_agree_pct ret_corr mean_abs_dclose p95_abs_dclose max_abs_dclose dclose_pct_atr_mean
2 5min oanda tiingo 70901 73241 70807 94 2434 97.674000 0.994970 0.123070 0.285000 26.105000 2.147942
-144
View File
@@ -1,144 +0,0 @@
"""Forex Factory calendar scraper -> backtest/cache/calendar.parquet.
Fetches weekly calendar pages and parses the event JSON embedded in
`window.calendarComponentStates` (the `days:` array). Event `dateline`
is a unix epoch, so timestamps carry no timezone ambiguity — verified
2026-07-11 against known release times (NFP 12:30/13:30 UTC across DST,
ECB 12:15/13:15) and against EUR/USD M15 volatility: the bar containing
a high-impact event averages 3.5x normal range, and shifting event
times by ±1h destroys that alignment.
Usage:
python backtest/research/ff_calendar_scraper.py --start 2024-01-01 --end 2026-07-13
python backtest/research/ff_calendar_scraper.py # refresh: last stored week -> today
Merges into the existing parquet on event (id, dateline), so refreshing
is idempotent and also picks up revised `actual` values for recent weeks.
Be polite: ~1.6s between requests (a full year is ~52 requests).
"""
import argparse
import json
import time
from datetime import date, timedelta
from pathlib import Path
from typing import List, Optional
import pandas as pd
import requests
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
PARQUET = CACHE_DIR / "calendar.parquet"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
FIELDS = ["id", "dateline", "name", "country", "currency", "impactName",
"timeLabel", "timeMasked", "actual", "forecast", "previous",
"revision", "actualBetterWorse"]
def week_url(d: date) -> str:
return (f"https://www.forexfactory.com/calendar?week="
f"{d.strftime('%b').lower()}{d.day}.{d.year}")
def extract_days(html: str) -> Optional[list]:
"""Balanced-bracket extraction of the `days:` JSON array."""
i = html.find("days:")
if i < 0:
return None
j = html.find("[", i)
depth, in_str, esc = 0, False, False
for k in range(j, len(html)):
ch = html[k]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
return json.loads(html[j:k + 1])
return None
def fetch_week(sess: requests.Session, d: date, retries: int = 4) -> Optional[List[dict]]:
for attempt in range(retries):
try:
r = sess.get(week_url(d), timeout=30)
if r.status_code == 200:
days = extract_days(r.text)
if days is not None:
return [{k: e.get(k) for k in FIELDS}
for day in days for e in day.get("events", [])]
except requests.RequestException:
pass
time.sleep(5 * (attempt + 1))
return None
def scrape(start: date, end: date) -> pd.DataFrame:
sess = requests.Session()
sess.headers["User-Agent"] = UA
rows: List[dict] = []
failed: List[date] = []
d = start
while d <= end:
week_rows = fetch_week(sess, d)
if week_rows is None:
failed.append(d)
print(f"FAILED week {d}")
else:
rows.extend(week_rows)
d += timedelta(days=7)
time.sleep(1.6)
if failed:
print(f"WARNING: {len(failed)} weeks failed: {failed}")
return pd.DataFrame(rows)
def merge_into_store(new: pd.DataFrame, path: Path = PARQUET) -> pd.DataFrame:
if path.exists():
old = pd.read_parquet(path)
combined = pd.concat([old, new])
else:
combined = new
# keep="last": a refresh overwrites stale rows (actuals fill in
# after release, revisions land weeks later).
combined = (combined.drop_duplicates(subset=["id", "dateline"], keep="last")
.sort_values("dateline").reset_index(drop=True))
combined.to_parquet(path, index=False)
return combined
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--start", type=date.fromisoformat, default=None)
ap.add_argument("--end", type=date.fromisoformat, default=date.today())
args = ap.parse_args()
start = args.start
if start is None:
if PARQUET.exists():
last = pd.read_parquet(PARQUET).dateline.max()
start = pd.Timestamp(last, unit="s").date() - timedelta(days=7)
else:
start = date(2024, 1, 1)
print(f"Scraping {start} -> {args.end}")
new = scrape(start, args.end)
combined = merge_into_store(new)
print(f"Store: {len(combined)} events "
f"({pd.Timestamp(combined.dateline.min(), unit='s')} -> "
f"{pd.Timestamp(combined.dateline.max(), unit='s')}) -> {PARQUET}")
if __name__ == "__main__":
main()
-697
View File
@@ -1,697 +0,0 @@
"""
DeepSeek news-analysis validation harness — XAU/USD.
====================================================
Research-only (no production code changes). Answers: is the DeepSeek
layer's news analysis *correct*?
Part A (events): replays every high-impact USD release with a genuine
surprise (2024-01 → 2026-07, from backtest/cache/calendar.parquet)
through the REAL `NewsEngine._llm_event_direction` — real prompt, real
payload, real validation — and scores its "gold bullish/bearish" calls
against gold's actual move (Oanda M15 mids) at +15m/30m/1h/4h/24h.
Baselines: mechanical surprise-sign doctrine, FF actualBetterWorse,
always-bullish/bearish (2024-26 is a gold bull regime), coin flip.
Part B (headlines): a curated golden set (llm_news_golden.csv) through
the REAL `NewsEngine._llm_headline_scores` in production-sized batches,
vs the keyword scorer it overrides. Includes repeat-batch (determinism)
and shuffled-order (cross-contamination) probes.
Implementation notes that keep the replay faithful to production:
* `_llm_event_direction`/`_llm_headline_scores` resolve the client via
`get_llm_client()`, so we seed the module singleton with a research
client (raised daily cap: 586 events > production cap 500).
* The event cache key omits the forecast, so a repeat `actual` would
silently re-serve a direction computed for a different forecast —
the cache is cleared before every call.
* A "neutral" reply and a hard failure both return None; they are
disambiguated by reading the event cache back after the call
(absent = failure, "" = neutral, "bullish:/bearish: …" = call).
* Raw request/response bytes are captured with a requests response
hook — the request-building code path is untouched.
Usage (from repo root):
venv/bin/python backtest/research/llm_news_eval.py events --limit 5
venv/bin/python backtest/research/llm_news_eval.py events --determinism 20
venv/bin/python backtest/research/llm_news_eval.py headlines
venv/bin/python backtest/research/llm_news_eval.py report
Artifacts land in backtest/research/llm_news_eval_2026-08/ (JSONL replay
logs are resume-safe: completed events are never re-paid). `report` is a
pure offline function of the artifacts.
"""
import argparse
import json
import math
import random
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
import llm_client
from app_config import deepseek_api_key, get_app_config
from config import NewsConfig
from llm_client import DEFAULT_MODEL, DeepSeekClient
from news_engine import EconomicEvent, NewsEngine
RESEARCH_DIR = Path(__file__).parent
ART_DIR = RESEARCH_DIR / "llm_news_eval_2026-08"
CALENDAR = REPO_ROOT / "backtest" / "cache" / "calendar.parquet"
BARS = (REPO_ROOT / "backtest" / "cache" /
"oanda_XAU_USD_15min_20210531_20260804.parquet")
GOLDEN = RESEARCH_DIR / "llm_news_golden.csv"
EVENTS_JSONL = ART_DIR / "events_replay.jsonl"
HEADLINES_JSONL = ART_DIR / "headlines_replay.jsonl"
SCORED_CSV = ART_DIR / "events_scored.csv"
HORIZONS = [("15m", 15), ("30m", 30), ("1h", 60), ("4h", 240),
("24h", 1440)]
NEUTRAL_BAND = 0.15 # |xau| below this = neutral prediction (Part B)
BATCH_CAP = 25 # production batch cap (news_engine.py:783)
EXPECTED_CORPUS = 586
# ── shared plumbing ──────────────────────────────────────────────────
class _Recorder:
"""requests response hook capturing the exact wire traffic."""
def __init__(self):
self.last = None
def hook(self, resp, **kwargs):
body = resp.request.body
if isinstance(body, bytes):
body = body.decode("utf-8", "replace")
self.last = {
"request_body": body,
"http_status": resp.status_code,
"response_body": resp.text,
"elapsed_s": round(resp.elapsed.total_seconds(), 3),
}
return resp
def pop(self):
cap, self.last = self.last, None
return cap or {}
def install_research_client(timeout: float):
"""Build a research DeepSeekClient (raised cap) and seed the module
singleton so the production code paths use it. Returns (client,
recorder)."""
app = get_app_config()
key = deepseek_api_key(app)
ds = app.deepseek_settings
if not key:
sys.exit("No DeepSeek API key (env DEEPSEEK_API_KEY or "
"config.json deepseek.api_key) — aborting before spend.")
if ds.get("enabled") is False:
sys.exit("deepseek.enabled is false in config.json — aborting.")
client = DeepSeekClient(
api_key=key,
model=str(ds.get("model", DEFAULT_MODEL)),
timeout=timeout,
daily_cap=10 ** 6, # research: corpus 586 > production cap 500
)
rec = _Recorder()
client._session.hooks["response"].append(rec.hook)
with llm_client._client_lock:
llm_client._client = client
return client, rec
def _s(x):
"""Parquet str columns carry float NaN — EconomicEvent needs None."""
return None if pd.isna(x) else str(x)
def load_corpus():
"""High-impact USD releases with a genuine surprise, through the
production `EconomicEvent.surprise` gate. Asserts run before any
API spend."""
df = pd.read_parquet(CALENDAR)
df = df[(df["impactName"] == "high") & (df["currency"] == "USD")]
rows = []
for r in df.itertuples():
ev = EconomicEvent(
time=datetime.fromtimestamp(int(r.dateline), tz=timezone.utc),
currency="USD", impact="high", event=_s(r.name) or "",
forecast=_s(r.forecast), previous=_s(r.previous),
actual=_s(r.actual),
)
if ev.surprise not in ("positive", "negative"):
continue
rows.append({
"ffid": int(r.id), "dateline": int(r.dateline),
"utc_time": ev.time.isoformat(), "name": ev.event,
"actual": ev.actual, "forecast": ev.forecast,
"previous": ev.previous, "surprise": ev.surprise,
"better_worse": int(r.actualBetterWorse), "ev": ev,
})
ids = [d["ffid"] for d in rows]
if len(rows) != EXPECTED_CORPUS:
sys.exit(f"corpus size {len(rows)} != {EXPECTED_CORPUS} — "
"calendar.parquet changed; re-verify before spending.")
if len(set(ids)) != len(ids):
sys.exit("FF ids not unique — resume keying would break.")
# Pin the actualBetterWorse mapping on two unambiguous NFP prints.
nfp = {d["utc_time"][:10]: d["better_worse"] for d in rows
if d["name"] == "Non-Farm Employment Change"}
if nfp.get("2024-01-05") != 1 or nfp.get("2024-05-03") != 2:
sys.exit("actualBetterWorse mapping pins failed (expected "
"1=better-for-USD on 2024-01-05 beat, 2=worse on "
"2024-05-03 miss).")
rows.sort(key=lambda d: (d["dateline"], d["ffid"]))
return rows
def _load_jsonl(path: Path):
recs = []
if path.exists():
with open(path) as f:
for line in f:
line = line.strip()
if line:
recs.append(json.loads(line))
return recs
# ── Part A: event replay ─────────────────────────────────────────────
def _extract_reason(cap):
try:
content = json.loads(cap["response_body"])[
"choices"][0]["message"]["content"]
return json.loads(content).get("reason")
except Exception:
return None
def cmd_events(args):
ART_DIR.mkdir(exist_ok=True)
corpus = load_corpus()
if args.limit:
corpus = corpus[:args.limit]
engine = NewsEngine(NewsConfig())
existing = _load_jsonl(EVENTS_JSONL)
done1 = {r["ffid"] for r in existing
if r.get("run_pass") == 1 and r.get("llm_direction")}
done2 = {r["ffid"] for r in existing
if r.get("run_pass") == 2 and r.get("llm_direction")}
pending = [d for d in corpus if d["ffid"] not in done1]
print(f"corpus {len(corpus)} | already recorded {len(done1)} | "
f"pending {len(pending)} | determinism re-calls "
f"{args.determinism} | model calls are ~100 tokens each "
f"(deepseek-v4-flash, static-prefix cached) → cost ≈ cents")
if not pending and not args.determinism:
print("nothing to do")
return
_, rec = install_research_client(args.timeout)
out = open(EVENTS_JSONL, "a")
def call_one(d, run_pass):
# Cache key omits forecast → cleared per call so 196 colliding
# corpus rows can't be served a stale direction.
with NewsEngine._cache_lock:
NewsEngine._llm_event_cache.clear()
ev = d["ev"]
engine._llm_event_direction(ev)
key = (ev.event, ev.currency, str(ev.actual or ""))
with NewsEngine._cache_lock:
cached = NewsEngine._llm_event_cache.get(key)
cap = rec.pop()
if cached is None:
direction, text = None, None # hard failure → retry
elif cached == "":
direction, text = "neutral", ""
else:
direction, text = cached.split(":", 1)[0], cached
recd = {k: d[k] for k in (
"ffid", "dateline", "utc_time", "name", "actual", "forecast",
"previous", "surprise", "better_worse")}
recd.update({
"llm_direction": direction, "llm_text": text,
"reason_raw": _extract_reason(cap), "run_pass": run_pass,
"ts": datetime.now(timezone.utc).isoformat(), **cap,
})
out.write(json.dumps(recd) + "\n")
out.flush()
return direction
remaining = pending
for npass in range(1, args.retry_passes + 1):
if not remaining:
break
failed = []
for i, d in enumerate(remaining):
if call_one(d, 1) is None:
failed.append(d)
if args.sleep:
time.sleep(args.sleep)
if (i + 1) % 25 == 0:
print(f" pass {npass}: {i + 1}/{len(remaining)} "
f"({len(failed)} failed)")
print(f"pass {npass} done: {len(remaining) - len(failed)} ok, "
f"{len(failed)} failed")
remaining = failed
if remaining:
print(f"WARNING: {len(remaining)} events still unrecorded after "
f"{args.retry_passes} passes — re-run `events` to resume.")
if args.determinism:
ok_ids = {x["ffid"] for x in remaining}
done_now = [d for d in corpus if d["ffid"] not in ok_ids]
done_now.sort(key=lambda d: (d["name"], d["dateline"]))
stride = max(1, len(done_now) // args.determinism)
sample = done_now[::stride][:args.determinism]
todo = [d for d in sample if d["ffid"] not in done2]
print(f"determinism probe: {len(todo)} re-calls "
f"({len(sample) - len(todo)} already recorded)")
for d in todo:
call_one(d, 2)
if args.sleep:
time.sleep(args.sleep)
out.close()
print("events replay complete →", EVENTS_JSONL)
# ── Part B: golden headlines ─────────────────────────────────────────
def _load_golden():
g = pd.read_csv(GOLDEN)
need = {"headline", "category", "expected_xau_sign", "rationale"}
if not need <= set(g.columns):
sys.exit(f"golden CSV missing columns {need - set(g.columns)}")
if not g["expected_xau_sign"].isin([-1, 0, 1]).all():
sys.exit("expected_xau_sign must be -1/0/+1")
if not g["headline"].is_unique:
sys.exit("duplicate headlines in golden CSV")
return g
def cmd_headlines(args):
ART_DIR.mkdir(exist_ok=True)
g = _load_golden()
heads = list(g["headline"])
_, rec = install_research_client(args.timeout)
engine = NewsEngine(NewsConfig())
out = open(HEADLINES_JSONL, "w") # tiny (~9 calls): fresh each run
def run_batch(texts, tag, batch_idx):
scores = None
for attempt in range(3):
scores = engine._llm_headline_scores(texts)
cap = rec.pop()
if scores is not None:
break
print(f" {tag} batch {batch_idx} attempt {attempt + 1} "
f"failed (http {cap.get('http_status')})")
time.sleep(2.0)
out.write(json.dumps({
"run_tag": tag, "batch": batch_idx, "headlines": texts,
"scores": scores,
"ts": datetime.now(timezone.utc).isoformat(), **cap,
}) + "\n")
out.flush()
return scores
batches = [heads[i:i + BATCH_CAP] for i in range(0, len(heads),
BATCH_CAP)]
for bi, b in enumerate(batches):
ok = run_batch(b, "main", bi) is not None
print(f"main batch {bi} ({len(b)} headlines): "
f"{'ok' if ok else 'FAILED'}")
time.sleep(args.sleep)
run_batch(batches[0], "repeat", 0)
print("repeat probe done")
shuffled = heads[:]
random.Random(20260806).shuffle(shuffled)
for bi in range(0, len(shuffled), BATCH_CAP):
run_batch(shuffled[bi:bi + BATCH_CAP], "shuffled",
bi // BATCH_CAP)
print("shuffled probe done →", HEADLINES_JSONL)
out.close()
# ── report: pure offline function of the artifacts ───────────────────
def _binom_p(k, n):
"""Exact two-sided binomial p-value vs p=0.5 (no scipy)."""
if n == 0:
return float("nan")
dist = [math.comb(n, i) for i in range(n + 1)]
tot = float(2 ** n)
pk = dist[k]
return min(1.0, sum(c for c in dist if c <= pk) / tot)
def _mcnemar_p(hit_a, hit_b):
"""Exact McNemar via binomial on discordant pairs. Inputs: aligned
boolean lists."""
b = sum(1 for x, y in zip(hit_a, hit_b) if x and not y)
c = sum(1 for x, y in zip(hit_a, hit_b) if not x and y)
return b, c, _binom_p(min(b, c), b + c)
def _latest_events(recs):
"""Last pass-1 record per ffid (retries append; last wins)."""
out = {}
for r in recs:
if r.get("run_pass") == 1:
out[r["ffid"]] = r
return list(out.values())
def _fmt_rate(hits, n, label=""):
if n == 0:
return f"{label:<24} n=0"
p = _binom_p(hits, n)
return (f"{label:<24} {hits:>4}/{n:<4} = {hits / n * 100:5.1f}% "
f"(binom p={p:.3f})")
def report_events():
recs = _latest_events(_load_jsonl(EVENTS_JSONL))
if not recs:
print("no event replay records — run `events` first")
return
bars = pd.read_parquet(BARS)
closes = bars["Close"].to_numpy()
close_ns = (bars.index + pd.Timedelta(minutes=15)).asi8
def anchor(t_ns, h_min):
i = np.searchsorted(close_ns, t_ns, side="right") - 1
if i < 0:
return None
stale_ns = max(45.0, h_min / 4.0) * 60e9
if t_ns - close_ns[i] > stale_ns:
return None
return closes[i]
rows, skip = [], {h: 0 for h, _ in HORIZONS}
base_skipped = 0
for r in recs:
t_ns = int(r["dateline"]) * 10 ** 9
base = anchor(t_ns, 0)
if base is None:
base_skipped += 1
continue
row = dict(r)
row.pop("request_body", None)
row.pop("response_body", None)
row["year"] = r["utc_time"][:4]
row["base"] = base
for hname, hmin in HORIZONS:
p = anchor(t_ns + hmin * 60 * 10 ** 9, hmin)
if p is None:
skip[hname] += 1
row[f"d_{hname}"] = np.nan
else:
row[f"d_{hname}"] = p - base
rows.append(row)
df = pd.DataFrame(rows)
df.to_csv(SCORED_CSV, index=False)
n_fail = int((df["llm_direction"].isna()).sum())
print("═" * 70)
print("PART A — event gold-direction vs actual XAU move")
print("═" * 70)
print(f"events replayed: {len(df)} (baseline-price skipped: "
f"{base_skipped}) | LLM hard failures: {n_fail}")
print("horizon skip counts (stale/closed):",
{k: v for k, v in skip.items() if v})
dist = df["llm_direction"].value_counts(dropna=False).to_dict()
print("LLM call distribution:", dist)
# Unconditional per-horizon noise scale (null): median |Δ| over all
# 2024+ bar times, position-shifted.
bars24 = bars[bars.index >= "2024-01-01"]
null_med = {}
for hname, hmin in HORIZONS:
k = hmin // 15
d = (bars24["Close"].shift(-k) - bars24["Close"]).abs().dropna()
null_med[hname] = float(d.median())
print("unconditional null median |Δ| ($):",
{k: round(v, 2) for k, v in null_med.items()})
# Strategy call columns: +1 gold up, −1 gold down, 0/NaN excluded.
df["c_llm"] = df["llm_direction"].map(
{"bullish": 1, "bearish": -1}).fillna(0)
df["c_doctrine"] = df["surprise"].map({"positive": -1, "negative": 1})
df["c_ffbw"] = df["better_worse"].map({1: -1, 2: 1}).fillna(0)
df["c_bull"] = 1
df["c_bear"] = -1
strategies = [("LLM", "c_llm"), ("doctrine(surprise)", "c_doctrine"),
("FF betterWorse", "c_ffbw"), ("always-bullish", "c_bull"),
("always-bearish", "c_bear")]
for hname, _ in HORIZONS:
dcol = f"d_{hname}"
sub = df[df[dcol].notna()]
print(f"\n── horizon {hname} (n scored bars={len(sub)}) ──")
zeros = int((sub[dcol] == 0).sum())
if zeros:
print(f" Δ==0 count (scored as miss): {zeros}")
for label, col in strategies:
s = sub[sub[col] != 0]
hits = int((np.sign(s[dcol]) == s[col]).sum())
print(" " + _fmt_rate(hits, len(s), label))
# noise-filtered LLM hit rate
s = sub[(sub["c_llm"] != 0) &
(sub[dcol].abs() >= null_med[hname])]
hits = int((np.sign(s[dcol]) == s["c_llm"]).sum())
print(" " + _fmt_rate(hits, len(s),
f"LLM |Δ|≥null-med"))
# event-window magnitude vs null
mag = sub[dcol].abs().median()
print(f" event-window median |Δ| = ${mag:.2f} "
f"(null ${null_med[hname]:.2f})")
# paired McNemar: LLM vs each baseline on common directional set
for label, col in [("doctrine", "c_doctrine"),
("FF betterWorse", "c_ffbw"),
("always-bullish", "c_bull")]:
s = sub[(sub["c_llm"] != 0) & (sub[col] != 0)]
ha = list(np.sign(s[dcol]) == s["c_llm"])
hb = list(np.sign(s[dcol]) == s[col])
b, c, p = _mcnemar_p(ha, hb)
print(f" McNemar LLM vs {label:<15} b={b:>3} c={c:>3} "
f"p={p:.3f}")
# neutral-call flatness (15m)
neu = df[(df["llm_direction"] == "neutral") & df["d_15m"].notna()]
dire = df[(df["c_llm"] != 0) & df["d_15m"].notna()]
if len(neu):
print(f"\nneutral calls (n={len(neu)}): median |Δ_15m| "
f"${neu['d_15m'].abs().median():.2f} vs directional "
f"(n={len(dire)}) ${dire['d_15m'].abs().median():.2f} "
f"(null ${null_med['15m']:.2f})")
# breakdowns at 1h
dcol = "d_1h"
sub = df[df[dcol].notna() & (df["c_llm"] != 0)]
print("\n── LLM hit rate by event name (1h, top 10 by count) ──")
for name, grp in sorted(sub.groupby("name"), key=lambda kv: -len(kv[1]))[:10]:
hits = int((np.sign(grp[dcol]) == grp["c_llm"]).sum())
print(" " + _fmt_rate(hits, len(grp), name[:24]))
print("── LLM hit rate by year (1h) ──")
for yr, grp in sub.groupby("year"):
hits = int((np.sign(grp[dcol]) == grp["c_llm"]).sum())
print(" " + _fmt_rate(hits, len(grp), yr))
# cluster view: shared datelines
print("\n── same-timestamp clusters ──")
clus = df.groupby("dateline").filter(lambda g: len(g) > 1)
groups = list(clus.groupby("dateline"))
consistent = 0
net_hits, net_n = 0, 0
for _, g in groups:
signs = set(g.loc[g["c_llm"] != 0, "c_llm"])
if len(signs) <= 1:
consistent += 1
net = int(g["c_llm"].sum())
d = g["d_1h"].iloc[0]
if net != 0 and pd.notna(d) and d != 0:
net_n += 1
if np.sign(d) == np.sign(net):
net_hits += 1
print(f"clusters: {len(groups)} datelines "
f"({len(clus)} events, {len(clus) / max(len(df), 1) * 100:.0f}% of "
f"corpus) | internally consistent: {consistent}/{len(groups)}")
print(" " + _fmt_rate(net_hits, net_n, "cluster net-call (1h)"))
# determinism probe
p2 = {r["ffid"]: r for r in _load_jsonl(EVENTS_JSONL)
if r.get("run_pass") == 2 and r.get("llm_direction")}
if p2:
p1 = {r["ffid"]: r for r in recs}
agree = same_reason = n = 0
for fid, r2 in p2.items():
r1 = p1.get(fid)
if not r1 or not r1.get("llm_direction"):
continue
n += 1
if r1["llm_direction"] == r2["llm_direction"]:
agree += 1
if r1.get("reason_raw") == r2.get("reason_raw"):
same_reason += 1
print(f"\ndeterminism probe: direction agreement {agree}/{n}, "
f"identical reason text {same_reason}/{n}")
# stratified reason examples
print("\n── reason samples (1h hit/miss) ──")
sub = df[df[dcol].notna() & (df["c_llm"] != 0)].copy()
sub["hit"] = np.sign(sub[dcol]) == sub["c_llm"]
for verdict in (True, False):
picked, seen = [], set()
for _, r in sub[sub["hit"] == verdict].iterrows():
if r["name"] in seen:
continue
seen.add(r["name"])
picked.append(r)
if len(picked) == 10:
break
tag = "HIT " if verdict else "MISS"
for r in picked:
print(f" [{tag}] {r['utc_time'][:16]} {r['name'][:30]:<30} "
f"a={r['actual']} f={r['forecast']} → "
f"{r['llm_direction']:<7} Δ1h={r[dcol]:+7.2f} "
f"“{(r['reason_raw'] or '')[:60]}”")
print(f"\nscored table → {SCORED_CSV}")
def report_headlines():
recs = _load_jsonl(HEADLINES_JSONL)
if not recs:
print("\n(no headline replay records — run `headlines` first)")
return
g = _load_golden()
engine = NewsEngine(NewsConfig())
def collect(tag):
m = {}
for r in recs:
if r["run_tag"] == tag and r["scores"]:
for h, sc in zip(r["headlines"], r["scores"]):
m[h] = tuple(sc)
return m
main, shuf = collect("main"), collect("shuffled")
print("\n" + "═" * 70)
print("PART B — golden headline set: LLM vs keyword baseline")
print("═" * 70)
missing = [h for h in g["headline"] if h not in main]
if missing:
print(f"WARNING: {len(missing)} headlines missing from main run")
def cls(x):
if x >= NEUTRAL_BAND:
return 1
if x <= -NEUTRAL_BAND:
return -1
return 0
rows = []
for _, r in g.iterrows():
h = r["headline"]
if h not in main:
continue
llm_xau = main[h][1]
kw = engine._analyze_headline_sentiment(h, for_currency="XAU")
rows.append({
"headline": h, "category": r["category"],
"exp": int(r["expected_xau_sign"]),
"llm_xau": llm_xau, "llm_cls": cls(llm_xau),
"kw": kw, "kw_cls": cls(kw),
})
t = pd.DataFrame(rows)
for who in ("llm", "kw"):
acc = (t[f"{who}_cls"] == t["exp"]).mean()
print(f"{who.upper():<4} 3-class accuracy (band ±{NEUTRAL_BAND}): "
f"{(t[f'{who}_cls'] == t['exp']).sum()}/{len(t)} "
f"= {acc * 100:.1f}%")
ha = list(t["llm_cls"] == t["exp"])
hb = list(t["kw_cls"] == t["exp"])
b, c, p = _mcnemar_p(ha, hb)
print(f"paired McNemar LLM vs keyword: b={b} c={c} p={p:.3f}")
print("\nper-category accuracy (LLM | keyword):")
for cat, grp in t.groupby("category"):
a1 = (grp["llm_cls"] == grp["exp"]).sum()
a2 = (grp["kw_cls"] == grp["exp"]).sum()
print(f" {cat:<18} {a1:>2}/{len(grp):<2} | {a2:>2}/{len(grp)}")
print("\nLLM confusion (rows=expected, cols=predicted -1/0/+1):")
cm = pd.crosstab(t["exp"], t["llm_cls"]).reindex(
index=[-1, 0, 1], columns=[-1, 0, 1], fill_value=0)
print(cm.to_string())
print("\nLLM misclassified headlines:")
for _, r in t[t["llm_cls"] != t["exp"]].iterrows():
print(f" exp {r['exp']:+d} got {r['llm_xau']:+.2f} "
f"[{r['category']}] {r['headline'][:70]}")
# repeat-batch determinism
m_b0 = [r for r in recs if r["run_tag"] == "main" and r["batch"] == 0]
r_b0 = [r for r in recs if r["run_tag"] == "repeat"]
if m_b0 and r_b0 and m_b0[0]["scores"] and r_b0[0]["scores"]:
a = np.array(m_b0[0]["scores"], dtype=float)
b_ = np.array(r_b0[0]["scores"], dtype=float)
exact = int((a == b_).all(axis=1).sum())
print(f"\nrepeat-batch: {exact}/{len(a)} score pairs identical, "
f"max |diff| = {np.abs(a - b_).max():.3f}")
# shuffled-order drift
if shuf:
both = [h for h in t["headline"] if h in shuf]
drift = [abs(main[h][1] - shuf[h][1]) for h in both]
if drift:
print(f"shuffled-order xau drift over {len(both)} headlines: "
f"mean {np.mean(drift):.3f}, max {np.max(drift):.3f}")
def cmd_report(args):
report_events()
report_headlines()
# ── CLI ──────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[1])
sub = ap.add_subparsers(dest="cmd", required=True)
e = sub.add_parser("events", help="replay release surprises")
e.add_argument("--limit", type=int, default=0,
help="smoke-test on first N events")
e.add_argument("--determinism", type=int, default=0,
help="re-call N stratified events a second time")
e.add_argument("--sleep", type=float, default=0.3)
e.add_argument("--retry-passes", type=int, default=3)
e.add_argument("--timeout", type=float, default=30.0)
e.set_defaults(fn=cmd_events)
h = sub.add_parser("headlines", help="golden-set probes")
h.add_argument("--sleep", type=float, default=0.3)
h.add_argument("--timeout", type=float, default=30.0)
h.set_defaults(fn=cmd_headlines)
r = sub.add_parser("report", help="offline scoring report")
r.set_defaults(fn=cmd_report)
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
-79
View File
@@ -1,79 +0,0 @@
headline,category,expected_xau_sign,rationale
"Powell says restrictive policy needed for longer as inflation proves sticky",hawkish_fed,-1,"higher-for-longer rates lift real yields, gold-bearish"
"Fed minutes show officials leaning toward two more rate hikes this year",hawkish_fed,-1,"more hikes = stronger dollar/yields"
"Hot CPI print fuels bets on additional Fed tightening",hawkish_fed,-1,"tightening expectations weigh on gold"
"Fed's Waller: higher-for-longer rates warranted by resilient economy",hawkish_fed,-1,"hawkish guidance"
"FOMC raises rates 50bp, flags further increases ahead",hawkish_fed,-1,"outsized hike plus hawkish guidance"
"Treasury yields spike as Fed pushes back on rate cut hopes",hawkish_fed,-1,"cut hopes dashed, yields up"
"Core inflation accelerates, cementing case for hawkish Fed stance",hawkish_fed,-1,"hawkish repricing"
"Fed dot plot shifts higher; markets price out easing for the year",hawkish_fed,-1,"easing priced out"
"Fed cuts rates 25bp and signals more easing to come",dovish_fed,1,"easing cycle is gold-bullish"
"Powell hints at rate cuts as inflation cools toward target",dovish_fed,1,"cut guidance"
"FOMC minutes reveal growing support for policy easing",dovish_fed,1,"dovish minutes"
"Fed's Williams says rate cuts appropriate later this year",dovish_fed,1,"cut guidance"
"Soft inflation data boosts odds of a dovish Fed pivot",dovish_fed,1,"pivot odds up"
"Fed pauses hikes, markets price aggressive easing cycle",dovish_fed,1,"easing priced in"
"Central banks turn dovish as global growth falters",dovish_fed,1,"global easing backdrop"
"Fed to restart bond purchases to support flagging economy",dovish_fed,1,"QE is gold-bullish"
"Dollar index hits 12-month high on robust US economic data",strong_usd,-1,"strong dollar pressures gold"
"US GDP grows 4.1% annualized, blowing past expectations",strong_usd,-1,"strong economy, hawkish implication"
"Blockbuster jobs report sends dollar surging across the board",strong_usd,-1,"strong jobs lift dollar and yields"
"US retail sales smash forecasts, underscoring consumer strength",strong_usd,-1,"strong data"
"Greenback rallies as US data outshines Europe and Asia",strong_usd,-1,"dollar strength"
"ISM services index jumps to two-year high on strong demand",strong_usd,-1,"strong data"
"Consumer confidence surges to post-pandemic high in the US",strong_usd,-1,"strong data"
"Dollar tumbles to eight-month low after dismal manufacturing data",weak_usd,1,"weak dollar lifts gold"
"US economy adds far fewer jobs than expected; dollar slides",weak_usd,1,"weak jobs, dovish implication"
"Greenback weakens as US retail sales unexpectedly contract",weak_usd,1,"weak data"
"US GDP growth revised down sharply, stoking slowdown fears",weak_usd,1,"slowdown is dovish"
"Dollar slumps as trade deficit balloons to record",weak_usd,1,"dollar weakness"
"Soft services PMI adds to evidence of cooling US economy",weak_usd,1,"cooling economy"
"US consumer sentiment plunges to two-year low",weak_usd,1,"weak data"
"Real yields climb to multi-year highs as TIPS sell off",real_yields,-1,"rising real yields raise gold's opportunity cost"
"10-year Treasury yield surges past 5% on supply glut",real_yields,-1,"yield spike"
"Rising real interest rates dim appeal of non-yielding assets",real_yields,-1,"explicit opportunity-cost story"
"Treasury yields sink as investors flock to bonds",real_yields,1,"falling yields support gold"
"Real yields turn negative as inflation outpaces rates",real_yields,1,"negative real yields are gold's best backdrop"
"Bond rally drives 10-year yield to lowest level in a year",real_yields,1,"falling yields"
"Geopolitical tensions escalate in Middle East, investors seek safety",safe_haven,1,"safe-haven bid"
"Stocks crater worldwide as banking crisis fears spread",safe_haven,1,"crisis flight to safety"
"Escalating conflict drives flight to safe-haven assets",safe_haven,1,"explicit haven flow"
"Investors dump risk assets after surprise sovereign default",safe_haven,1,"default shock"
"Military escalation on NATO border rattles global markets",safe_haven,1,"geopolitical stress"
"Wall Street plunges 4% in worst session since pandemic",safe_haven,1,"risk-off"
"Currency crisis in emerging markets sparks contagion fears",safe_haven,1,"contagion, haven bid"
"Nuclear rhetoric intensifies, sending investors to havens",safe_haven,1,"extreme geopolitical stress"
"Ceasefire agreement lifts global markets, haven demand fades",risk_on,-1,"haven unwind"
"Stocks roar to record highs as trade deal signed",risk_on,-1,"risk-on drains haven demand"
"Risk appetite returns as banking fears subside",risk_on,-1,"risk-on"
"Global equities rally on strong earnings and easing tensions",risk_on,-1,"risk-on"
"Gold hits record high as central banks keep buying bullion",gold_direct,1,"direct demand"
"Gold ETFs post biggest weekly inflows in three years",gold_direct,1,"investment inflows"
"China's central bank extends gold buying spree to 18th month",gold_direct,1,"official-sector demand"
"Miners struggle to meet gold demand as supply deficit widens",gold_direct,1,"supply deficit"
"Gold slides 2% as investors book profits after record run",gold_direct,-1,"profit-taking pressure"
"Gold tumbles below key support as ETF outflows accelerate",gold_direct,-1,"outflows"
"Bullion demand weakens as Indian jewellery buying stalls",gold_direct,-1,"physical demand soft"
"Speculators slash bullish gold bets as dollar strengthens",gold_direct,-1,"positioning unwind"
"Fed holds rates steady, signals no rate hike on the horizon",negation_traps,1,"dovish hold; keyword layer sees the phrase rate hike and flips it gold-bearish"
"Powell says no rate cuts until inflation shows sustained cooling",negation_traps,-1,"hawkish hold; keyword layer sees rate cut and calls it gold-bullish"
"Recession fears fade as US data streak beats estimates",negation_traps,-1,"fading fear is risk-on; keywords see recession plus beats and cancel out"
"Inflation fails to accelerate, easing pressure on the Fed",negation_traps,1,"disinflation opens door to cuts"
"Fed's Bowman: not yet confident inflation is beaten, cuts premature",negation_traps,-1,"hawkish pushback phrased around negations"
"Central bank stops short of hiking, but warns tightening may resume",negation_traps,-1,"hawkish hold with tightening bias"
"No safe-haven bid for gold despite equity wobble, analysts say",negation_traps,-1,"explicitly negated haven demand"
"Jobs report not as weak as feared, dollar recovers",negation_traps,-1,"less-bad data is dollar-positive"
"US manufacturing output plunges as new orders dry up",inverted_metrics,1,"weak economy is dovish/gold-bullish; keyword layer reads plunges as bearish"
"US factory activity slumps to three-year low",inverted_metrics,1,"weak data; keyword reads slumps as bearish"
"Consumer spending falls for third straight month",inverted_metrics,1,"weak data; keyword reads falls as bearish"
"Private payrolls growth slides to weakest pace since 2020",inverted_metrics,1,"weak jobs; keyword reads slides as bearish"
"Jobless claims hit two-year high as layoffs spread",inverted_metrics,1,"higher claims = weaker economy = gold-bullish"
"US unemployment rate hits 4.5%, highest since 2021",inverted_metrics,1,"higher unemployment = dovish = gold-bullish"
"Lakers clinch playoff berth with overtime win over Suns",distractors,0,"irrelevant"
"New Marvel film smashes box office records on opening weekend",distractors,0,"irrelevant"
"Apple unveils thinner MacBook with faster chip lineup",distractors,0,"irrelevant"
"Scientists discover new deep-sea species off Pacific coast",distractors,0,"irrelevant"
"Heavy rain forecast for the Midwest through the weekend",distractors,0,"irrelevant"
"Streaming service raises subscription prices in Europe",distractors,0,"irrelevant"
"Electric vehicle startup recalls 12,000 cars over software glitch",distractors,0,"irrelevant"
"Local elections see record turnout in several US states",distractors,0,"irrelevant"
1 headline category expected_xau_sign rationale
2 Powell says restrictive policy needed for longer as inflation proves sticky hawkish_fed -1 higher-for-longer rates lift real yields, gold-bearish
3 Fed minutes show officials leaning toward two more rate hikes this year hawkish_fed -1 more hikes = stronger dollar/yields
4 Hot CPI print fuels bets on additional Fed tightening hawkish_fed -1 tightening expectations weigh on gold
5 Fed's Waller: higher-for-longer rates warranted by resilient economy hawkish_fed -1 hawkish guidance
6 FOMC raises rates 50bp, flags further increases ahead hawkish_fed -1 outsized hike plus hawkish guidance
7 Treasury yields spike as Fed pushes back on rate cut hopes hawkish_fed -1 cut hopes dashed, yields up
8 Core inflation accelerates, cementing case for hawkish Fed stance hawkish_fed -1 hawkish repricing
9 Fed dot plot shifts higher; markets price out easing for the year hawkish_fed -1 easing priced out
10 Fed cuts rates 25bp and signals more easing to come dovish_fed 1 easing cycle is gold-bullish
11 Powell hints at rate cuts as inflation cools toward target dovish_fed 1 cut guidance
12 FOMC minutes reveal growing support for policy easing dovish_fed 1 dovish minutes
13 Fed's Williams says rate cuts appropriate later this year dovish_fed 1 cut guidance
14 Soft inflation data boosts odds of a dovish Fed pivot dovish_fed 1 pivot odds up
15 Fed pauses hikes, markets price aggressive easing cycle dovish_fed 1 easing priced in
16 Central banks turn dovish as global growth falters dovish_fed 1 global easing backdrop
17 Fed to restart bond purchases to support flagging economy dovish_fed 1 QE is gold-bullish
18 Dollar index hits 12-month high on robust US economic data strong_usd -1 strong dollar pressures gold
19 US GDP grows 4.1% annualized, blowing past expectations strong_usd -1 strong economy, hawkish implication
20 Blockbuster jobs report sends dollar surging across the board strong_usd -1 strong jobs lift dollar and yields
21 US retail sales smash forecasts, underscoring consumer strength strong_usd -1 strong data
22 Greenback rallies as US data outshines Europe and Asia strong_usd -1 dollar strength
23 ISM services index jumps to two-year high on strong demand strong_usd -1 strong data
24 Consumer confidence surges to post-pandemic high in the US strong_usd -1 strong data
25 Dollar tumbles to eight-month low after dismal manufacturing data weak_usd 1 weak dollar lifts gold
26 US economy adds far fewer jobs than expected; dollar slides weak_usd 1 weak jobs, dovish implication
27 Greenback weakens as US retail sales unexpectedly contract weak_usd 1 weak data
28 US GDP growth revised down sharply, stoking slowdown fears weak_usd 1 slowdown is dovish
29 Dollar slumps as trade deficit balloons to record weak_usd 1 dollar weakness
30 Soft services PMI adds to evidence of cooling US economy weak_usd 1 cooling economy
31 US consumer sentiment plunges to two-year low weak_usd 1 weak data
32 Real yields climb to multi-year highs as TIPS sell off real_yields -1 rising real yields raise gold's opportunity cost
33 10-year Treasury yield surges past 5% on supply glut real_yields -1 yield spike
34 Rising real interest rates dim appeal of non-yielding assets real_yields -1 explicit opportunity-cost story
35 Treasury yields sink as investors flock to bonds real_yields 1 falling yields support gold
36 Real yields turn negative as inflation outpaces rates real_yields 1 negative real yields are gold's best backdrop
37 Bond rally drives 10-year yield to lowest level in a year real_yields 1 falling yields
38 Geopolitical tensions escalate in Middle East, investors seek safety safe_haven 1 safe-haven bid
39 Stocks crater worldwide as banking crisis fears spread safe_haven 1 crisis flight to safety
40 Escalating conflict drives flight to safe-haven assets safe_haven 1 explicit haven flow
41 Investors dump risk assets after surprise sovereign default safe_haven 1 default shock
42 Military escalation on NATO border rattles global markets safe_haven 1 geopolitical stress
43 Wall Street plunges 4% in worst session since pandemic safe_haven 1 risk-off
44 Currency crisis in emerging markets sparks contagion fears safe_haven 1 contagion, haven bid
45 Nuclear rhetoric intensifies, sending investors to havens safe_haven 1 extreme geopolitical stress
46 Ceasefire agreement lifts global markets, haven demand fades risk_on -1 haven unwind
47 Stocks roar to record highs as trade deal signed risk_on -1 risk-on drains haven demand
48 Risk appetite returns as banking fears subside risk_on -1 risk-on
49 Global equities rally on strong earnings and easing tensions risk_on -1 risk-on
50 Gold hits record high as central banks keep buying bullion gold_direct 1 direct demand
51 Gold ETFs post biggest weekly inflows in three years gold_direct 1 investment inflows
52 China's central bank extends gold buying spree to 18th month gold_direct 1 official-sector demand
53 Miners struggle to meet gold demand as supply deficit widens gold_direct 1 supply deficit
54 Gold slides 2% as investors book profits after record run gold_direct -1 profit-taking pressure
55 Gold tumbles below key support as ETF outflows accelerate gold_direct -1 outflows
56 Bullion demand weakens as Indian jewellery buying stalls gold_direct -1 physical demand soft
57 Speculators slash bullish gold bets as dollar strengthens gold_direct -1 positioning unwind
58 Fed holds rates steady, signals no rate hike on the horizon negation_traps 1 dovish hold; keyword layer sees the phrase rate hike and flips it gold-bearish
59 Powell says no rate cuts until inflation shows sustained cooling negation_traps -1 hawkish hold; keyword layer sees rate cut and calls it gold-bullish
60 Recession fears fade as US data streak beats estimates negation_traps -1 fading fear is risk-on; keywords see recession plus beats and cancel out
61 Inflation fails to accelerate, easing pressure on the Fed negation_traps 1 disinflation opens door to cuts
62 Fed's Bowman: not yet confident inflation is beaten, cuts premature negation_traps -1 hawkish pushback phrased around negations
63 Central bank stops short of hiking, but warns tightening may resume negation_traps -1 hawkish hold with tightening bias
64 No safe-haven bid for gold despite equity wobble, analysts say negation_traps -1 explicitly negated haven demand
65 Jobs report not as weak as feared, dollar recovers negation_traps -1 less-bad data is dollar-positive
66 US manufacturing output plunges as new orders dry up inverted_metrics 1 weak economy is dovish/gold-bullish; keyword layer reads plunges as bearish
67 US factory activity slumps to three-year low inverted_metrics 1 weak data; keyword reads slumps as bearish
68 Consumer spending falls for third straight month inverted_metrics 1 weak data; keyword reads falls as bearish
69 Private payrolls growth slides to weakest pace since 2020 inverted_metrics 1 weak jobs; keyword reads slides as bearish
70 Jobless claims hit two-year high as layoffs spread inverted_metrics 1 higher claims = weaker economy = gold-bullish
71 US unemployment rate hits 4.5%, highest since 2021 inverted_metrics 1 higher unemployment = dovish = gold-bullish
72 Lakers clinch playoff berth with overtime win over Suns distractors 0 irrelevant
73 New Marvel film smashes box office records on opening weekend distractors 0 irrelevant
74 Apple unveils thinner MacBook with faster chip lineup distractors 0 irrelevant
75 Scientists discover new deep-sea species off Pacific coast distractors 0 irrelevant
76 Heavy rain forecast for the Midwest through the weekend distractors 0 irrelevant
77 Streaming service raises subscription prices in Europe distractors 0 irrelevant
78 Electric vehicle startup recalls 12,000 cars over software glitch distractors 0 irrelevant
79 Local elections see record turnout in several US states distractors 0 irrelevant
-150
View File
@@ -1,150 +0,0 @@
"""What do violent bars do to OPEN trades, and can we protect them? Analysis tooling only.
Replays every trade of a book on the raw bars with the backtester's exit model (SL-first,
gap-through at open, close-only trail ratchet, 300-bar timeout, $0.65 cost) and compares:
base : reproduce the book (validation: must match r_multiple for almost every trade)
flat_data : exit at the OPEN of the first bar whose close-hour enters {12,13} UTC (US data)
flat_asia : same for close-hour {0,1} UTC (Asian open)
tight_data : entering {12,13}, hold a protective stop 0.5R below the best price for the window
tight_asia : same for {0,1}
react_exit : a burst bar (range >= 3x prior ATR14) closes AGAINST the trade -> exit next open
react_tight : after an opposing burst, protective stop 0.5R below the best price for the rest
Plus a damage table: what happens to a trade when the first burst hits it (with vs against).
python backtest/research/open_trade_bursts.py BOOK.csv PARQUET BAR_MINUTES ARM DIST
"""
import sys
import numpy as np, pandas as pd
COST = 0.65; MAX_BARS = 300; K = 3.0; PROT = 0.5
def wilder_atr(df, period=14):
pc = df["Close"].shift(1)
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def simulate(bars, i0, direction, entry, sl, arm, dist, variant, hours_flat=(), hours_tight=(), flat_mask=None, tight_mask=None):
"""bars: dict of numpy arrays; i0 = index of the entry bar. Returns (exit_price, bars_held, reason, flag)."""
O, H, L, C, burst, chour = bars["O"], bars["H"], bars["L"], bars["C"], bars["burst"], bars["chour"]
d = 1.0 if direction == "BUY" else -1.0
risk = abs(entry - sl); stop = sl; peak = entry; flag = 0
n = len(O); tight = False; tight_until = -1; exit_next = False
for j in range(i0, min(n, i0 + MAX_BARS)):
if exit_next:
return O[j], j - i0, "REACT", flag
h = chour[j]
in_flat = h in hours_flat; in_tight = h in hours_tight
if variant.startswith("flat") and in_flat:
return O[j], j - i0, "FLAT", 1
if variant == "mask_flat" and flat_mask is not None and flat_mask[j]:
return O[j], j - i0, "FLAT", 1
if variant.startswith("tight") and in_tight:
tight = True; tight_until = j; flag = 1
if variant == "mask_tight" and tight_mask is not None and tight_mask[j]:
tight = True; tight_until = j; flag = 1
# stop check (SL first, gap-through at open)
if d > 0:
if O[j] <= stop: return O[j], j - i0, "SL", flag
if L[j] <= stop: return stop, j - i0, "SL", flag
else:
if O[j] >= stop: return O[j], j - i0, "SL", flag
if H[j] >= stop: return stop, j - i0, "SL", flag
# update peak / trail at bar close
peak = max(peak, H[j]) if d > 0 else min(peak, L[j])
fav = d * (peak - entry)
if fav >= arm * risk:
stop = max(stop, peak - dist * risk) if d > 0 else min(stop, peak + dist * risk)
if tight and (variant == "react_tight" or j <= tight_until or in_tight):
stop = max(stop, peak - PROT * risk) if d > 0 else min(stop, peak + PROT * risk)
if variant.startswith("react") and burst[j] and d * (C[j] - O[j]) < 0:
flag = 1
if variant == "react_exit": exit_next = True
else: tight = True
j = min(n - 1, i0 + MAX_BARS - 1)
return C[j], j - i0, "TIMEOUT", flag
def r_of(direction, entry, sl, exit_price):
d = 1.0 if direction == "BUY" else -1.0
return (d * (exit_price - entry) - COST) / abs(entry - sl)
def stats(r):
r = np.asarray(r); eq = np.concatenate([[0.0], np.cumsum(r)]); dd = (np.maximum.accumulate(eq) - eq).max()
pf = r[r > 0].sum() / max(-r[r < 0].sum(), 1e-9)
return dict(n=len(r), total=r.sum(), mean=r.mean(), wr=100 * (r > 0).mean(), pf=pf, dd=dd)
def main():
book_csv, pq, bm, arm, dist = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
book = pd.read_csv(book_csv); book["entry_time"] = pd.to_datetime(book["entry_time"], utc=True, format="mixed")
px = pd.read_parquet(pq)
atr_prev = wilder_atr(px).shift(1)
bars = dict(O=px["Open"].values, H=px["High"].values, L=px["Low"].values, C=px["Close"].values,
burst=((px["High"] - px["Low"]) >= K * atr_prev).fillna(False).values,
chour=(px.index + pd.Timedelta(minutes=bm)).hour.values)
idx = pd.Index(px.index)
pos = idx.get_indexer(book["entry_time"])
ok = pos >= 0
print(f"{book_csv}: {len(book)} trades, {ok.sum()} matched to bars; arm {arm} dist {dist}")
book = book[ok].reset_index(drop=True); pos = pos[ok]
half = np.where(np.arange(len(book)) < len(book) // 2, 1, 2)
variants = {"base": {}, "flat_data": dict(hours_flat=(12, 13)), "flat_asia": dict(hours_flat=(0, 1)),
"tight_data": dict(hours_tight=(12, 13)), "tight_asia": dict(hours_tight=(0, 1)),
"react_exit": {}, "react_tight": {}}
results = {}
for v, kw in variants.items():
rs, flags = [], []
for i, t in book.iterrows():
xp, held, reason, flag = simulate(bars, pos[i], t.direction, t.entry, t.sl, arm, dist, v, **kw)
rs.append(r_of(t.direction, t.entry, t.sl, xp)); flags.append(flag)
results[v] = (np.array(rs), np.array(flags))
base = results["base"][0]
match = np.isclose(base, book["r_multiple"].values, atol=0.02).mean()
print(f"validation: replay reproduces the book's r_multiple (±0.02) on {100*match:.1f}% of trades; "
f"sum replay {base.sum():+.2f} vs book {book.r_multiple.sum():+.2f}")
print("\n== PROTECTION VARIANTS (whole book; 'affected' = trades where the rule fired) ==")
print(f"{'variant':12s} {'n':>5s} {'affected':>8s} {'total R':>9s} {'Δ vs base':>9s} {'PF':>5s} {'maxDD':>6s} {'WR%':>5s} | affected only: base R -> variant R | Δ h1 / Δ h2")
b = stats(base)
for v, (r, f) in results.items():
s = stats(r); aff = f == 1
d1 = r[half == 1].sum() - base[half == 1].sum(); d2 = r[half == 2].sum() - base[half == 2].sum()
print(f"{v:12s} {s['n']:5d} {aff.sum():8d} {s['total']:+9.2f} {s['total']-b['total']:+9.2f} {s['pf']:5.2f} {s['dd']:6.2f} {s['wr']:5.1f} | "
f"{base[aff].sum():+8.2f} -> {r[aff].sum():+8.2f} (n={aff.sum()}) | {d1:+.2f} / {d2:+.2f}")
# damage table: first burst while open (base path), with vs against
print("\n== DAMAGE: first violent bar that hits an OPEN trade (base exits) ==")
rows = []
for i, t in book.iterrows():
d = 1.0 if t.direction == "BUY" else -1.0; risk = abs(t.entry - t.sl)
xp, held, reason, _ = simulate(bars, pos[i], t.direction, t.entry, t.sl, arm, dist, "base")
j0 = pos[i]; j1 = j0 + held
for j in range(j0 + 1, j1 + 1): # bursts after the entry bar, up to and including the exit bar
if bars["burst"][j]:
against = d * (bars["C"][j] - bars["O"][j]) < 0
mtm_before = (d * (bars["C"][j - 1] - t.entry) - COST) / risk
mtm_after = (d * (bars["C"][j] - t.entry) - COST) / risk
rows.append(dict(against=against, bars_open=j - j0, mtm_before=mtm_before, mtm_after=mtm_after,
final=r_of(t.direction, t.entry, t.sl, xp), ended_here=(j == j1), hour=bars["chour"][j],
sched=bars["chour"][j] in (0, 1, 12, 13)))
break
dmg = pd.DataFrame(rows)
if len(dmg):
g = dmg.groupby("against").agg(n=("final", "size"), bars_open_med=("bars_open", "median"),
mtm_before=("mtm_before", "mean"), mtm_after=("mtm_after", "mean"),
final=("final", "mean"), ended_on_that_bar_pct=("ended_here", lambda s: 100 * s.mean()),
in_sched_hours_pct=("sched", lambda s: 100 * s.mean()))
print(g.round(3).to_string())
ag = dmg[dmg.against]
print(f"\nopposing bursts: {len(ag)} trades ({100*len(ag)/len(book):.1f}% of the book); "
f"mean R just before {ag.mtm_before.mean():+.3f} -> just after {ag.mtm_after.mean():+.3f} -> final {ag.final.mean():+.3f}; "
f"{100*(ag.final<0).mean():.0f}% end as losers; {100*ag.ended_here.mean():.0f}% are stopped ON that bar")
print(f" those with an open PROFIT before the burst (mtm>0): n={int((ag.mtm_before>0).sum())}, final mean {ag[ag.mtm_before>0].final.mean():+.3f}, "
f"{100*(ag[ag.mtm_before>0].final<0).mean():.0f}% end as losers")
print(f" by hour of the opposing burst (n, mean final R): " + ", ".join(f"{h:02d}:{n}/{m:+.2f}" for h, n, m in
ag.groupby("hour").final.agg(["size", "mean"]).reset_index().itertuples(index=False)))
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
{"probe": "P1 coherent-bullish", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[-2.4, -2.2, -2.4, -2.3], [-2.3, -2.1, -2.3, -2.2], [-2.2, -2.0, -2.2, -2.0], [-2.0, -1.9, -2.1, -1.9], [-1.9, -1.8, -2.0, -1.8], [-1.8, -1.6, -1.8, -1.7], [-1.7, -1.5, -1.7, -1.6], [-1.6, -1.4, -1.6, -1.4], [-1.4, -1.3, -1.5, -1.3], [-1.3, -1.2, -1.4, -1.2], [-1.2, -1.0, -1.2, -1.1], [-1.1, -0.9, -1.1, -1.0], [-1.0, -0.8, -1.0, -0.8], [-0.8, -0.7, -0.9, -0.7], [-0.7, -0.6, -0.8, -0.6], [-0.6, -0.4, -0.6, -0.5], [-0.5, -0.3, -0.5, -0.4], [-0.4, -0.2, -0.4, -0.2], [-0.2, -0.1, -0.3, -0.1], [-0.1, 0.0, -0.2, 0.0]]}}}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
{"probe": "P2 bearish-vs-BUY-brief", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_down", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1}, "rsi": {"signal": "SELL", "confidence": 70, "value": 74.0}, "macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4}, "bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95}, "adx": {"signal": "SELL", "confidence": 70, "value": 35.0}, "roc": {"signal": "SELL", "confidence": 60, "value": -0.5}, "keltner": {"signal": "SELL", "confidence": 65, "value": -2.8}, "candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0}}, "mtf": {"5min": {"signal": "SELL", "score": -28.0}, "1h": {"signal": "STRONG_SELL", "score": -41.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 3, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[2.4, 2.4, 2.2, 2.3], [2.3, 2.3, 2.1, 2.2], [2.2, 2.2, 2.0, 2.0], [2.0, 2.1, 1.9, 1.9], [1.9, 2.0, 1.8, 1.8], [1.8, 1.8, 1.6, 1.7], [1.7, 1.7, 1.5, 1.6], [1.6, 1.6, 1.4, 1.4], [1.4, 1.5, 1.3, 1.3], [1.3, 1.4, 1.2, 1.2], [1.2, 1.2, 1.0, 1.1], [1.1, 1.1, 0.9, 1.0], [1.0, 1.0, 0.8, 0.8], [0.8, 0.9, 0.7, 0.7], [0.7, 0.8, 0.6, 0.6], [0.6, 0.6, 0.4, 0.5], [0.5, 0.5, 0.3, 0.4], [0.4, 0.4, 0.2, 0.2], [0.2, 0.3, 0.1, 0.1], [0.1, 0.2, -0.0, 0.0]]}}}, "raw_reply": {"stance": "oppose", "confidence": 85, "note": "The price is falling and every indicator says sell, so buying fights the trend."}}
{"probe": "P3 conflicting", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 12.0, "confidence": 35, "price": 2000.0, "market_regime": "ranging", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6}, "rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0}, "macd": {"signal": "SELL", "confidence": 55, "value": -0.3}, "bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5}, "adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0}, "roc": {"signal": "SELL", "confidence": 50, "value": -0.1}, "keltner": {"signal": "BUY", "confidence": 50, "value": 0.8}, "candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0}}, "mtf": {"5min": {"signal": "NEUTRAL", "score": 4.0}, "1h": {"signal": "NEUTRAL", "score": -3.0}}, "key_levels": {}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0]]}}}, "raw_reply": {"stance": "neutral", "confidence": 30, "note": "Mixed signals with no clear direction, so cannot strongly support or oppose."}}
{"probe": "P4 empty-evidence", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {}, "mtf": {}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "neutral", "confidence": 20, "note": "Not enough data to judge the trade direction."}}
{"probe": "P5 hallucination-trap", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
{"probe": "P6 output-discipline", "payload": {}, "raw_reply": null}
{"probe": "P7 synthesizer", "payload": {"direction": "BUY", "takes": [{"persona": "macro", "stance": "support", "confidence": 70, "note": "evidence aligned"}, {"persona": "technical", "stance": "support", "confidence": 80, "note": "evidence aligned"}, {"persona": "risk", "stance": "support", "confidence": 65, "note": "evidence aligned"}, {"persona": "contrarian", "stance": "support", "confidence": 60, "note": "evidence aligned"}]}, "raw_reply": {"verdict": "AGREE", "confidence": 85, "rationale": "All four analysts support buying, with high confidence and consistent evidence. No one opposes, so the panel clearly backs the trade."}}
@@ -1,7 +1,7 @@
# Signal-Level Replay Parity — Oanda vs Tiingo (Phase 2)
**Date:** 2026-08-14
**Tool:** `scripts/signal_parity_study.py` (replays real `analyze_from_dataframes`, news off)
**Tool:** `scripts/signal_parity_study.py` (script removed 2026-09-06, in git history) (replays real `analyze_from_dataframes`, news off)
**Window:** 2025-08-14 → 2026-08-14 (12 months); M15 stride 8, M5 stride 24 → ~2,900 sampled decisions each
**Preceding:** Phase 1 price-diff study (`feed_divergence_2026-08-14.md`)
-961
View File
@@ -1,961 +0,0 @@
"""Stage-D stake-sizing model driver (2026-09 campaign).
Walk-forward fit + evaluation of a per-signal stake multiplier on a trade
book exported by ``gpu_backtest.py --export-features`` (63 base columns +
``stake_multiplier``/``r_sized`` + 65 ``f_*`` decision-time features).
The protocol implemented here is the one pre-registered in
``backtest/research/sizing_campaign_2026-09.md`` and is NOT to be varied to
rescue a result:
* expanding walk-forward, 12-month burn-in, calendar-quarter refits, a
``purge_bars × bar_minutes`` gap between the end of train and the start of
the test quarter, standardisation fit on train only;
* models: ridge on winsorised R (primary), L2 logistic on ``R > 0``
(secondary), a one-feature ``f_is_buy`` simplicity baseline, an
exhaustion-block-only ablation, and an optional lightgbm upper bound;
* size map ``m = round_0.05(clip(normaliser × (0.5 + clip((z−lo)/(hi−lo),0,1)),
0.5, 1.5))`` with ``lo``/``hi``/``normaliser`` calibrated on TRAIN
predictions only;
* a label-shuffle null (training labels permuted, test labels untouched);
* six acceptance clauses, printed PASS/FAIL individually.
Usage:
python backtest/research/sizing_model.py --book BOOK.csv --bar-minutes 5 \
--features f_a,f_b,... [--model ridge|logistic] [--out DIR] \
[--n-shuffle 50] [--skip-lgbm] \
[--write-artifact PATH --tf 5min --confirm-tfs 15min,1h]
Analysis tooling only — never imported by the live code.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import subprocess
import sys
import warnings
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from position_sizing import FEATURE_ORDER, FEATURE_VERSION # noqa: E402 (read-only import)
# ── Pre-registered constants (flag any change loudly) ──────────────────
WINSOR_LO, WINSOR_HI = -1.5, 4.0
LAMBDA_GRID: Tuple[float, ...] = (1.0, 10.0, 100.0, 1000.0)
CV_FOLDS = 5
MIN_TRAIN = 150
MAP_Q_LO, MAP_Q_HI = 0.10, 0.90
MAP_OUT_LO, MAP_OUT_HI = 0.5, 1.5
MAP_CLIP_LO, MAP_CLIP_HI = 0.5, 1.5
MAP_ROUND_TO = 0.05
MEAN_MULT_TOL = 0.05
DD_TOL = 1.10
PF_FLOOR = 1.3
NEWTON_MAX_ITER = 50
NEWTON_TOL = 1e-8
#: The exhaustion-block ablation's fixed feature list.
EXHAUSTION_FEATURES: Tuple[str, ...] = (
"f_swing_dist_atr",
"f_ema21_dist_atr",
"f_ema200_dist_atr",
"f_exhaustion_count",
"f_penalty_total",
"f_tag_bb_overextended",
"f_tag_rsi_extended",
"f_tag_no_pullback",
"f_tag_exhaustion",
)
ISBUY_FEATURES: Tuple[str, ...] = ("f_is_buy",)
#: lightgbm never ships; running the full shuffle null through 200-tree fits
#: costs minutes for a number nobody acts on, so its null is capped here.
#: DEVIATION FROM THE PROTOCOL CONSTANT — printed in the summary when used.
LGBM_MAX_SHUFFLE = 10
LGBM_PARAMS = dict(
n_estimators=200,
learning_rate=0.03,
num_leaves=7,
min_child_samples=30,
subsample=0.8,
colsample_bytree=0.8,
reg_lambda=10,
)
_LINES: List[str] = []
def say(msg: str = "") -> None:
"""Print and tee into the summary buffer."""
print(msg)
_LINES.append(msg)
# ══════════════════════════════════════════════════════════════════════
# Book loading
# ══════════════════════════════════════════════════════════════════════
def load_book(path: Path) -> pd.DataFrame:
"""Read a feature book, parse ``entry_time`` UTC, drop NaN-R rows."""
df = pd.read_csv(path)
if "entry_time" not in df.columns or "r_multiple" not in df.columns:
raise SystemExit(f"{path}: not a trade book (no entry_time/r_multiple)")
df["entry_time"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
n0 = len(df)
bad = ~np.isfinite(pd.to_numeric(df["r_multiple"], errors="coerce").to_numpy(float))
if bad.any():
say(f"WARNING: dropping {int(bad.sum())}/{n0} rows with non-finite r_multiple")
df = df.loc[~bad].copy()
df["r_multiple"] = df["r_multiple"].astype(float)
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
return df
def resolve_features(df: pd.DataFrame, names: Sequence[str], label: str) -> List[str]:
"""Validate a requested feature list against the book and FEATURE_ORDER."""
missing = [n for n in names if n not in df.columns]
if missing:
raise SystemExit(f"{label}: columns absent from book: {', '.join(missing)}")
unknown = [n for n in names if n.startswith("f_") and n not in FEATURE_ORDER]
if unknown:
say(f"NOTE: {label}: {', '.join(unknown)} not in position_sizing.FEATURE_ORDER")
return list(names)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def git_commit() -> str:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT, text=True).strip()
except Exception: # pragma: no cover - research tool
return "unknown"
# ══════════════════════════════════════════════════════════════════════
# Walk-forward geometry
# ══════════════════════════════════════════════════════════════════════
def quarter_starts(first: pd.Timestamp, last: pd.Timestamp) -> List[pd.Timestamp]:
"""Every Jan/Apr/Jul/Oct 1 00:00 UTC in ``[first, last]`` inclusive."""
out: List[pd.Timestamp] = []
year = first.year
while True:
for month in (1, 4, 7, 10):
ts = pd.Timestamp(year=year, month=month, day=1, tz="UTC")
if ts < first:
continue
if ts > last:
return out
out.append(ts)
year += 1
def quarter_label(ts: pd.Timestamp) -> str:
return f"{ts.year}Q{(ts.month - 1) // 3 + 1}"
def build_folds(times: np.ndarray, n_folds: int) -> List[np.ndarray]:
"""Contiguous-by-time index blocks (rows are already time-sorted)."""
n = len(times)
edges = np.linspace(0, n, n_folds + 1).astype(int)
return [np.arange(edges[i], edges[i + 1]) for i in range(n_folds)
if edges[i + 1] > edges[i]]
# ══════════════════════════════════════════════════════════════════════
# Standardisation
# ══════════════════════════════════════════════════════════════════════
def fit_standardiser(X: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (mean, scale, keep) from a training matrix.
NaNs are ignored when computing the mean (they are later filled with it).
``scale`` is the population std with zeros replaced by 1.0; ``keep`` is
False for zero-variance-in-train columns, which are dropped from the fit
and carry a coefficient of 0.
"""
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
mean = np.nanmean(X, axis=0)
std = np.nanstd(X, axis=0)
mean = np.where(np.isfinite(mean), mean, 0.0)
std = np.where(np.isfinite(std), std, 0.0)
keep = std > 0
scale = np.where(keep, std, 1.0)
return mean, scale, keep
def apply_standardiser(X: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
"""Fill NaN with the TRAIN mean, then standardise."""
Z = np.array(X, dtype=float, copy=True)
bad = ~np.isfinite(Z)
if bad.any():
Z[bad] = np.take(mean, np.where(bad)[1])
return (Z - mean) / scale
# ══════════════════════════════════════════════════════════════════════
# Models (numpy only)
# ══════════════════════════════════════════════════════════════════════
def _solve(A: np.ndarray, b: np.ndarray) -> np.ndarray:
try:
return np.linalg.solve(A, b)
except np.linalg.LinAlgError: # pragma: no cover - degenerate design
return np.linalg.lstsq(A, b, rcond=None)[0]
def fit_ridge(X: np.ndarray, y: np.ndarray, lam: float) -> Tuple[np.ndarray, float]:
"""Closed-form ridge with an UNPENALISED intercept.
Centring X and y is exactly equivalent to appending a 1-column carrying
zero penalty. Returns ``(coef, intercept)`` with ``pred = intercept + X @ coef``.
"""
if X.shape[1] == 0:
return np.zeros(0), float(y.mean()) if len(y) else 0.0
xm = X.mean(axis=0)
ym = float(y.mean())
Xc = X - xm
w = _solve(Xc.T @ Xc + lam * np.eye(X.shape[1]), Xc.T @ (y - ym))
return w, ym - float(xm @ w)
def fit_logistic(X: np.ndarray, y: np.ndarray, lam: float,
max_iter: int = NEWTON_MAX_ITER,
tol: float = NEWTON_TOL) -> Tuple[np.ndarray, float, float]:
"""L2 logistic via Newton-Raphson, intercept unpenalised.
Returns ``(coef, intercept, grad_norm)`` where ``grad_norm`` is the
infinity-norm of the penalised gradient at the returned point.
"""
n, p = X.shape
A = np.hstack([np.ones((n, 1)), X])
pen = np.zeros(p + 1)
pen[1:] = lam
beta = np.zeros(p + 1)
# Warm-start the intercept at the empirical log-odds.
frac = float(np.clip(y.mean(), 1e-6, 1 - 1e-6))
beta[0] = math.log(frac / (1 - frac))
grad = np.zeros(p + 1)
for _ in range(max_iter):
eta = A @ beta
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
grad = A.T @ (y - prob) - pen * beta
W = np.clip(prob * (1 - prob), 1e-10, None)
H = A.T @ (A * W[:, None]) + np.diag(pen)
step = _solve(H, grad)
beta = beta + step
if np.max(np.abs(step)) < tol:
break
eta = A @ beta
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
grad = A.T @ (y - prob) - pen * beta
return beta[1:], float(beta[0]), float(np.max(np.abs(grad)))
def predict_linear(X: np.ndarray, coef: np.ndarray, intercept: float) -> np.ndarray:
if X.shape[1] == 0:
return np.full(len(X), intercept, dtype=float)
return intercept + X @ coef
def sigmoid(z: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(z, -35, 35)))
def log_loss(y: np.ndarray, p: np.ndarray) -> float:
p = np.clip(p, 1e-12, 1 - 1e-12)
return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
def choose_lambda(X: np.ndarray, y: np.ndarray, kind: str,
grid: Sequence[float] = LAMBDA_GRID,
n_folds: int = CV_FOLDS) -> Tuple[float, Dict[float, float]]:
"""5-fold time-contiguous CV inside the training window."""
folds = build_folds(np.arange(len(y)), n_folds)
if X.shape[1] == 0 or len(folds) < 2:
return float(grid[0]), {}
scores: Dict[float, float] = {}
for lam in grid:
vals: List[float] = []
for hold in folds:
mask = np.ones(len(y), dtype=bool)
mask[hold] = False
if mask.sum() < 2:
continue
if kind == "logistic":
ytr = y[mask]
if ytr.min() == ytr.max(): # single-class fold
p = np.full(len(hold), float(np.clip(ytr.mean(), 1e-6, 1 - 1e-6)))
else:
c, b, _ = fit_logistic(X[mask], ytr, lam)
p = sigmoid(predict_linear(X[hold], c, b))
vals.append(log_loss(y[hold], p))
else:
c, b = fit_ridge(X[mask], y[mask], lam)
resid = y[hold] - predict_linear(X[hold], c, b)
vals.append(float(np.mean(resid ** 2)))
scores[float(lam)] = float(np.mean(vals)) if vals else float("inf")
best = min(scores, key=lambda k: scores[k])
return best, scores
def lgbm_available() -> bool:
try:
import lightgbm # noqa: F401
return True
except Exception:
return False
def fit_lgbm(X: np.ndarray, y: np.ndarray, seed: int):
import lightgbm as lgb
model = lgb.LGBMRegressor(random_state=seed, verbose=-1, **LGBM_PARAMS)
model.fit(X, y)
return model
# ══════════════════════════════════════════════════════════════════════
# Size map
# ══════════════════════════════════════════════════════════════════════
def round_to(x, step: float = MAP_ROUND_TO) -> np.ndarray:
"""Round to the nearest ``step`` and kill float dust (0.05 → 2 dp)."""
decimals = max(0, int(round(-math.log10(step)))) + 1
return np.round(np.round(np.asarray(x, dtype=float) / step) * step, decimals)
def calibrate_map(z_train: np.ndarray) -> Dict[str, float]:
"""lo/hi/normaliser from TRAIN predictions only."""
lo = float(np.quantile(z_train, MAP_Q_LO))
hi = float(np.quantile(z_train, MAP_Q_HI))
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
return {"lo": lo, "hi": hi, "normalizer": 1.0, "degenerate": True}
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z_train - lo) / (hi - lo), 0, 1)
mean_raw = float(np.mean(raw))
norm = 1.0 / mean_raw if mean_raw > 0 else 1.0
return {"lo": lo, "hi": hi, "normalizer": norm, "degenerate": False}
def apply_map(z: np.ndarray, cal: Dict[str, float]) -> np.ndarray:
z = np.asarray(z, dtype=float)
if cal.get("degenerate"):
return np.ones(len(z))
lo, hi, norm = cal["lo"], cal["hi"], cal["normalizer"]
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z - lo) / (hi - lo), 0, 1)
return round_to(np.clip(raw * norm, MAP_CLIP_LO, MAP_CLIP_HI))
# ══════════════════════════════════════════════════════════════════════
# Metrics
# ══════════════════════════════════════════════════════════════════════
def max_drawdown(r: np.ndarray) -> float:
"""0-prepended cumulative-R drawdown (gpu_backtest.compute_metrics)."""
r = np.asarray(r, dtype=float)
if len(r) == 0:
return 0.0
cum = np.concatenate(([0.0], np.cumsum(r)))
return float((np.maximum.accumulate(cum) - cum).max())
def profit_factor(r: np.ndarray) -> float:
r = np.asarray(r, dtype=float)
gp = float(r[r > 0].sum()) if (r > 0).any() else 0.0
gl = float(-r[r < 0].sum()) if (r < 0).any() else 0.0
return gp / gl if gl > 0 else float("inf")
def multiplier_hist(m: np.ndarray) -> pd.DataFrame:
"""Counts in 0.1-wide bins from 0.5 to 1.5.
``np.histogram`` already closes the final bin on the right, so an
exactly-1.5 multiplier lands in [1.4, 1.5] once and the counts sum to
``len(m)``.
"""
edges = np.round(np.arange(MAP_OUT_LO, MAP_OUT_HI + 1e-9, 0.1), 2)
counts, _ = np.histogram(np.asarray(m, dtype=float), bins=edges)
counts = counts.astype(int)
return pd.DataFrame({
"bin_lo": edges[:-1], "bin_hi": edges[1:], "count": counts,
"pct": np.where(len(m), counts / max(len(m), 1) * 100, 0.0),
})
# ══════════════════════════════════════════════════════════════════════
# Walk-forward driver
# ══════════════════════════════════════════════════════════════════════
def walk_forward(df: pd.DataFrame, features: Sequence[str], model: str,
bar_minutes: int, burn_in_months: int, purge_bars: int,
seed: int, y_train_override: Optional[np.ndarray] = None,
verbose: bool = True) -> Dict:
"""One full expanding walk-forward pass.
``y_train_override`` (full-book length, aligned to ``df``) replaces the
TRAINING labels only — this is how the shuffle null is run. Test labels
always come from the real ``r_multiple``.
"""
times = df["entry_time"].to_numpy()
r_all = df["r_multiple"].to_numpy(float)
r_train_src = r_all if y_train_override is None else np.asarray(y_train_override, float)
X_all = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float) \
if features else np.zeros((len(df), 0))
book_start = df["entry_time"].iloc[0]
book_end = df["entry_time"].iloc[-1]
burn_end = book_start + pd.DateOffset(months=burn_in_months)
purge = pd.Timedelta(minutes=purge_bars * bar_minutes)
qs = [q for q in quarter_starts(book_start, book_end) if q >= burn_end]
rows: List[Dict] = []
per_q: List[Dict] = []
coef_rows: List[Dict] = []
skipped: List[str] = []
dropped_seen: set = set()
for q_start in qs:
q_end = q_start + pd.DateOffset(months=3)
train_mask = df["entry_time"] < (q_start - purge)
test_mask = (df["entry_time"] >= q_start) & (df["entry_time"] < q_end)
n_tr, n_te = int(train_mask.sum()), int(test_mask.sum())
if n_tr < MIN_TRAIN or n_te == 0:
skipped.append(f"{quarter_label(q_start)} (train={n_tr}, test={n_te})")
continue
tr = np.flatnonzero(train_mask.to_numpy())
te = np.flatnonzero(test_mask.to_numpy())
Xtr_raw, Xte_raw = X_all[tr], X_all[te]
mean, scale, keep = fit_standardiser(Xtr_raw)
dropped = [features[i] for i in np.flatnonzero(~keep)]
if verbose and dropped:
tag = (quarter_label(q_start), tuple(dropped))
if tag not in dropped_seen:
dropped_seen.add(tag)
say(f" WARNING {quarter_label(q_start)}: zero variance in train, "
f"dropped from fit (coef 0): {', '.join(dropped)}")
Xtr = apply_standardiser(Xtr_raw, mean, scale)[:, keep]
Xte = apply_standardiser(Xte_raw, mean, scale)[:, keep]
r_tr = r_train_src[tr]
lam: Optional[float] = None
if model == "logistic":
y_tr = (r_tr > 0).astype(float)
lam, _ = choose_lambda(Xtr, y_tr, "logistic")
coef, b, _ = fit_logistic(Xtr, y_tr, lam)
z_tr = sigmoid(predict_linear(Xtr, coef, b))
z_te = sigmoid(predict_linear(Xte, coef, b))
elif model == "lgbm":
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
gbm = fit_lgbm(Xtr, y_tr, seed)
z_tr = np.asarray(gbm.predict(Xtr), dtype=float)
z_te = np.asarray(gbm.predict(Xte), dtype=float)
coef, b = np.zeros(int(keep.sum())), 0.0
else: # ridge / isbuy / exhaustion
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
lam, _ = choose_lambda(Xtr, y_tr, "ridge")
coef, b = fit_ridge(Xtr, y_tr, lam)
z_tr = predict_linear(Xtr, coef, b)
z_te = predict_linear(Xte, coef, b)
cal = calibrate_map(z_tr)
m_te = apply_map(z_te, cal)
if model != "lgbm":
full_coef = np.zeros(len(features))
full_coef[np.flatnonzero(keep)] = coef
for name, c in zip(features, full_coef):
coef_rows.append({"quarter": quarter_label(q_start), "feature": name,
"coef_std": float(c), "lambda": lam,
"dropped": name in dropped})
coef_rows.append({"quarter": quarter_label(q_start), "feature": "_intercept",
"coef_std": float(b), "lambda": lam, "dropped": False})
r_te = r_all[te]
for i, idx in enumerate(te):
rows.append({
"entry_time": df["entry_time"].iloc[idx],
"r_multiple": float(r_te[i]),
"mult": float(m_te[i]),
"r_sized": float(r_te[i] * m_te[i]),
"quarter": quarter_label(q_start),
"z": float(z_te[i]),
})
per_q.append({
"quarter": quarter_label(q_start),
"q_start": q_start, "n_train": n_tr, "n_test": n_te,
"flat_r": float(r_te.sum()), "sized_r": float((r_te * m_te).sum()),
"mean_m": float(m_te.mean()), "lambda": lam,
"map_lo": cal["lo"], "map_hi": cal["hi"], "normalizer": cal["normalizer"],
})
oos = pd.DataFrame(rows)
return {"oos": oos, "quarters": pd.DataFrame(per_q),
"coefs": pd.DataFrame(coef_rows), "skipped": skipped,
"quarter_starts": qs}
def evaluate(oos: pd.DataFrame) -> Dict:
"""Headline OOS numbers for one model's walk-forward output."""
if oos.empty:
return {"n": 0, "flat_r": 0.0, "sized_r": 0.0, "lift": 0.0}
r = oos["r_multiple"].to_numpy(float)
rs = oos["r_sized"].to_numpy(float)
m = oos["mult"].to_numpy(float)
med = oos["entry_time"].median()
first = (oos["entry_time"] < med).to_numpy()
second = ~first
return {
"n": int(len(oos)),
"flat_r": float(r.sum()), "sized_r": float(rs.sum()),
"lift": float(rs.sum() - r.sum()),
"flat_pf": profit_factor(r), "sized_pf": profit_factor(rs),
"flat_dd": max_drawdown(r), "sized_dd": max_drawdown(rs),
"mean_m": float(m.mean()), "min_m": float(m.min()), "max_m": float(m.max()),
"median_time": med,
"n_h1": int(first.sum()), "n_h2": int(second.sum()),
"flat_h1": float(r[first].sum()), "flat_h2": float(r[second].sum()),
"sized_h1": float(rs[first].sum()), "sized_h2": float(rs[second].sum()),
}
def acceptance(ev: Dict, real_lift: float, shuffle_p95: float) -> Dict:
"""The six pre-registered clauses."""
c1 = ev["sized_h1"] > ev["flat_h1"] and ev["sized_h2"] > ev["flat_h2"]
c2 = ev["sized_r"] > ev["flat_r"]
c3 = ev["sized_dd"] <= DD_TOL * ev["flat_dd"]
c4 = (ev["sized_pf"] >= PF_FLOOR) and ev["sized_h1"] > 0 and ev["sized_h2"] > 0
c5 = real_lift > shuffle_p95 if np.isfinite(shuffle_p95) else False
c6 = (abs(ev["mean_m"] - 1.0) <= MEAN_MULT_TOL
and ev["min_m"] >= MAP_CLIP_LO - 1e-9 and ev["max_m"] <= MAP_CLIP_HI + 1e-9)
clauses = {"1_both_halves": bool(c1), "2_overall": bool(c2), "3_drawdown": bool(c3),
"4_pf_and_halves": bool(c4), "5_beats_shuffle": bool(c5),
"6_multiplier_bounds": bool(c6)}
clauses["overall"] = bool(all(clauses.values()))
return clauses
def shuffle_null(df: pd.DataFrame, features: Sequence[str], model: str,
bar_minutes: int, burn_in_months: int, purge_bars: int,
n_shuffle: int, seed: int) -> np.ndarray:
"""Lift distribution under permuted TRAINING labels (test labels real)."""
rng = np.random.default_rng(seed)
r_all = df["r_multiple"].to_numpy(float)
lifts: List[float] = []
for _ in range(n_shuffle):
r_perm = r_all[rng.permutation(len(r_all))]
res = walk_forward(df, features, model, bar_minutes, burn_in_months,
purge_bars, seed, y_train_override=r_perm, verbose=False)
ev = evaluate(res["oos"])
lifts.append(ev["lift"])
return np.asarray(lifts, dtype=float)
# ══════════════════════════════════════════════════════════════════════
# Final fit + artifact
# ══════════════════════════════════════════════════════════════════════
def final_fit(df: pd.DataFrame, features: Sequence[str], model: str,
seed: int) -> Dict:
"""Fit on ALL trades; calibrate the map on in-sample predictions."""
X_raw = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float)
r = df["r_multiple"].to_numpy(float)
mean, scale, keep = fit_standardiser(X_raw)
X = apply_standardiser(X_raw, mean, scale)[:, keep]
if model == "logistic":
y = (r > 0).astype(float)
lam, _ = choose_lambda(X, y, "logistic")
coef, b, gnorm = fit_logistic(X, y, lam)
z = sigmoid(predict_linear(X, coef, b))
target = "r_positive"
else:
y = np.clip(r, WINSOR_LO, WINSOR_HI)
lam, _ = choose_lambda(X, y, "ridge")
coef, b = fit_ridge(X, y, lam)
gnorm = float("nan")
z = predict_linear(X, coef, b)
target = "r_winsorised"
full_coef = np.zeros(len(features))
full_coef[np.flatnonzero(keep)] = coef
cal = calibrate_map(z)
return {"mean": mean, "scale": scale, "keep": keep, "coef": full_coef,
"intercept": float(b), "lambda": float(lam), "z": z,
"mult": apply_map(z, cal), "map": cal, "target": target,
"grad_norm": gnorm}
def predict_multiplier(feature_dict: Dict[str, float], artifact: Dict) -> float:
"""Runtime formula: standardise → linear score → size map → 0.05 grid."""
names = artifact["features"]
mean = artifact["mean"]
scale = artifact["scale"]
coef = artifact["coef"]
z = float(artifact["intercept"])
for i, name in enumerate(names):
raw = feature_dict.get(name, mean[i])
if raw is None or not np.isfinite(float(raw)):
raw = mean[i]
z += float(coef[i]) * (float(raw) - float(mean[i])) / float(scale[i])
if artifact["model"] == "logistic":
z = 1.0 / (1.0 + math.exp(-max(-35.0, min(35.0, z))))
mp = artifact["map"]
if mp["hi"] <= mp["lo"]:
return 1.0
frac = min(1.0, max(0.0, (z - mp["lo"]) / (mp["hi"] - mp["lo"])))
raw_m = mp["out_lo"] + (mp["out_hi"] - mp["out_lo"]) * frac
val = min(mp["clip_hi"], max(mp["clip_lo"], raw_m * mp["normalizer"]))
step = mp["round_to"]
return float(round(round(val / step) * step, 2))
def build_artifact(fit: Dict, features: Sequence[str], model: str, df: pd.DataFrame,
ev: Dict, clauses: Dict, real_lift: float, shuffle: np.ndarray,
n_shuffle: int, book: Path, tf: str, confirm_tfs: List[str],
burn_in_months: int, purge_bars: int, spread: float) -> Dict:
return {
"schema_version": 1,
"feature_version": FEATURE_VERSION,
"pair": "XAU/USD",
"primary_tf": tf,
"confirmation_tfs": confirm_tfs,
"model": model,
"target": fit["target"],
"features": list(features),
"mean": [float(v) for v in fit["mean"]],
"scale": [float(v) for v in fit["scale"]],
"coef": [float(v) for v in fit["coef"]],
"intercept": float(fit["intercept"]),
"map": {
"kind": "clipped_linear_on_quantiles",
"lo": float(fit["map"]["lo"]), "hi": float(fit["map"]["hi"]),
"out_lo": MAP_OUT_LO, "out_hi": MAP_OUT_HI,
"normalizer": float(fit["map"]["normalizer"]),
"clip_lo": MAP_CLIP_LO, "clip_hi": MAP_CLIP_HI,
"round_to": MAP_ROUND_TO,
},
"training": {
"window": [df["entry_time"].iloc[0].isoformat(),
df["entry_time"].iloc[-1].isoformat()],
"spread": spread,
"n_train_trades": int(len(df)),
"n_oos_trades": int(ev.get("n", 0)),
"burn_in_months": burn_in_months,
"purge_bars": purge_bars,
"refit": "calendar_quarter",
"final_fit": "full_window",
"lambda": float(fit["lambda"]),
"git_commit": git_commit(),
"book_sha256": sha256_file(book),
},
"acceptance": {
**{k: v for k, v in clauses.items()},
"flat_r": ev.get("flat_r"), "sized_r": ev.get("sized_r"),
"sized_pf": ev.get("sized_pf"), "flat_dd": ev.get("flat_dd"),
"sized_dd": ev.get("sized_dd"),
"sized_h1": ev.get("sized_h1"), "sized_h2": ev.get("sized_h2"),
"flat_h1": ev.get("flat_h1"), "flat_h2": ev.get("flat_h2"),
"real_lift": float(real_lift),
"shuffle_p95_lift": float(np.percentile(shuffle, 95)) if len(shuffle) else None,
"n_shuffle": int(n_shuffle),
"mean_multiplier": ev.get("mean_m"),
},
}
# ══════════════════════════════════════════════════════════════════════
# Reporting
# ══════════════════════════════════════════════════════════════════════
def fmt(x, nd=2, width=0) -> str:
if x is None:
return "n/a".rjust(width)
if isinstance(x, float) and not np.isfinite(x):
return ("inf" if x > 0 else "-inf").rjust(width)
s = f"{x:.{nd}f}" if isinstance(x, (int, float, np.floating)) else str(x)
return s.rjust(width) if width else s
def print_quarters(q: pd.DataFrame) -> None:
say(f"{'quarter':<9}{'n_tr':>6}{'n_te':>6}{'flat R':>10}{'sized R':>10}"
f"{'mean m':>9}{'lambda':>9}")
for _, row in q.iterrows():
say(f"{row['quarter']:<9}{int(row['n_train']):>6}{int(row['n_test']):>6}"
f"{fmt(row['flat_r'], 2, 10)}{fmt(row['sized_r'], 2, 10)}"
f"{fmt(row['mean_m'], 3, 9)}"
f"{('n/a' if row['lambda'] is None else fmt(row['lambda'], 0)):>9}")
def print_hist(m: np.ndarray) -> None:
h = multiplier_hist(m)
say(f"{'bin':<12}{'count':>8}{'pct':>8}")
for _, row in h.iterrows():
bar = "#" * int(round(row["pct"] / 2))
say(f"{row['bin_lo']:.1f}–{row['bin_hi']:.1f}{'':<4}{int(row['count']):>8}"
f"{row['pct']:>7.1f}% {bar}")
def print_coef_signs(coefs: pd.DataFrame, label: str) -> None:
"""Mean standardised coefficient + sign stability across quarters."""
if coefs.empty:
return
d = coefs[coefs["feature"] != "_intercept"]
say(f" standardised coefficients across quarters ({label}):")
say(f" {'feature':<24}{'mean':>10}{'min':>10}{'max':>10}{'+q':>5}{'-q':>5} sign-stable")
for name, grp in d.groupby("feature", sort=False):
c = grp["coef_std"].to_numpy(float)
pos, neg = int((c > 0).sum()), int((c < 0).sum())
stable = "yes" if (pos == 0 or neg == 0) and (pos + neg) > 0 else "no"
say(f" {name:<24}{c.mean():>10.4f}{c.min():>10.4f}{c.max():>10.4f}"
f"{pos:>5}{neg:>5} {stable}")
def print_comparison(table: List[Dict]) -> None:
say(f"{'model':<12}{'n':>6}{'flat R':>10}{'sized R':>10}{'lift':>9}"
f"{'PF':>7}{'DD':>8}{'mean m':>8}{'p95 null':>10}{'rank%':>7} pass")
for row in table:
say(f"{row['model']:<12}{row['n']:>6}{fmt(row['flat_r'], 2, 10)}"
f"{fmt(row['sized_r'], 2, 10)}{fmt(row['lift'], 2, 9)}"
f"{fmt(row['sized_pf'], 2, 7)}{fmt(row['sized_dd'], 2, 8)}"
f"{fmt(row['mean_m'], 3, 8)}{fmt(row['shuffle_p95'], 2, 10)}"
f"{fmt(row['rank_pct'], 0, 7)} {'PASS' if row['pass'] else 'FAIL'}")
# ══════════════════════════════════════════════════════════════════════
# main
# ══════════════════════════════════════════════════════════════════════
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--book", required=True, type=Path)
ap.add_argument("--bar-minutes", required=True, type=int)
ap.add_argument("--features", required=True,
help="comma-separated f_* column names for the main model")
ap.add_argument("--model", default="ridge", choices=("ridge", "logistic"))
ap.add_argument("--burn-in-months", type=int, default=12)
ap.add_argument("--purge-bars", type=int, default=300)
ap.add_argument("--n-shuffle", type=int, default=50)
ap.add_argument("--seed", type=int, default=20260905)
ap.add_argument("--skip-lgbm", action="store_true")
ap.add_argument("--out", type=Path, default=None)
ap.add_argument("--write-artifact", type=Path, default=None)
ap.add_argument("--tf", default=None, help="primary TF label for the artifact")
ap.add_argument("--confirm-tfs", default="15min,1h")
ap.add_argument("--spread", type=float, default=0.65,
help="cost the book was produced at (recorded in the artifact)")
args = ap.parse_args()
if args.write_artifact and not args.tf:
raise SystemExit("--write-artifact requires --tf")
say("=" * 96)
say("Stage-D stake-sizing walk-forward — RESEARCH ONLY")
say("=" * 96)
say(f"book : {args.book}")
df = load_book(args.book)
say(f"trades : {len(df)} "
f"({df['entry_time'].iloc[0]} → {df['entry_time'].iloc[-1]})")
say(f"bar minutes : {args.bar_minutes} purge = {args.purge_bars} bars = "
f"{args.purge_bars * args.bar_minutes} min "
f"({args.purge_bars * args.bar_minutes / 1440:.2f} d)")
say(f"burn-in : {args.burn_in_months} months "
f"(train-only until {df['entry_time'].iloc[0] + pd.DateOffset(months=args.burn_in_months)})")
say(f"model / seed : {args.model} / {args.seed} shuffles: {args.n_shuffle}")
say(f"winsor / lambda : [{WINSOR_LO}, {WINSOR_HI}] / {list(LAMBDA_GRID)} "
f"CV folds {CV_FOLDS} (time-contiguous)")
say(f"map : m = round_{MAP_ROUND_TO}(clip(norm × (0.5 + clip((z−lo)/(hi−lo),0,1)"
f"), {MAP_CLIP_LO}, {MAP_CLIP_HI})) lo/hi = train q{MAP_Q_LO}/q{MAP_Q_HI}")
say("")
main_feats = resolve_features(df, [f.strip() for f in args.features.split(",") if f.strip()],
"--features")
say(f"main features ({len(main_feats)}): {', '.join(main_feats)}")
specs: List[Tuple[str, List[str], str]] = [(args.model, main_feats, args.model)]
for label, feats in (("isbuy", ISBUY_FEATURES), ("exhaustion", EXHAUSTION_FEATURES)):
have = [f for f in feats if f in df.columns]
if len(have) != len(feats):
say(f"NOTE: {label} baseline missing "
f"{', '.join(f for f in feats if f not in df.columns)} — using {len(have)}")
if have:
specs.append((label, have, "ridge"))
use_lgbm = (not args.skip_lgbm) and lgbm_available()
if not args.skip_lgbm and not use_lgbm:
say("lightgbm not installed — upper bound skipped")
if use_lgbm:
all_f = [c for c in df.columns if c.startswith("f_")]
specs.append(("lgbm", all_f, "lgbm"))
say(f"lightgbm upper bound: {len(all_f)} f_* columns")
say("")
results: Dict[str, Dict] = {}
table: List[Dict] = []
for label, feats, kind in specs:
say("-" * 96)
say(f"MODEL: {label} ({kind}, {len(feats)} feature(s))")
say("-" * 96)
res = walk_forward(df, feats, kind, args.bar_minutes, args.burn_in_months,
args.purge_bars, args.seed, verbose=True)
if res["skipped"]:
say(f" skipped quarters: {'; '.join(res['skipped'])}")
if res["oos"].empty:
say(" no OOS quarters — nothing to evaluate")
continue
ev = evaluate(res["oos"])
n_sh = args.n_shuffle if kind != "lgbm" else min(args.n_shuffle, LGBM_MAX_SHUFFLE)
if kind == "lgbm" and n_sh != args.n_shuffle:
say(f" NOTE (protocol deviation, flagged): lgbm shuffle null capped at "
f"{n_sh} repeats (never ships)")
sh = shuffle_null(df, feats, kind, args.bar_minutes, args.burn_in_months,
args.purge_bars, n_sh, args.seed) if n_sh > 0 else np.array([])
p95 = float(np.percentile(sh, 95)) if len(sh) else float("nan")
rank = float((sh < ev["lift"]).mean() * 100) if len(sh) else float("nan")
cl = acceptance(ev, ev["lift"], p95)
say(f" OOS quarters : {len(res['quarters'])} "
f"({res['quarters']['quarter'].iloc[0]} → {res['quarters']['quarter'].iloc[-1]}), "
f"{ev['n']} trades")
say(f" flat R : {ev['flat_r']:+.2f} PF {fmt(ev['flat_pf'])} "
f"maxDD {ev['flat_dd']:.2f}")
say(f" sized R : {ev['sized_r']:+.2f} PF {fmt(ev['sized_pf'])} "
f"maxDD {ev['sized_dd']:.2f}")
say(f" lift : {ev['lift']:+.2f} R")
say(f" halves (split {ev['median_time']}): "
f"h1 flat {ev['flat_h1']:+.2f} / sized {ev['sized_h1']:+.2f} (n={ev['n_h1']}) | "
f"h2 flat {ev['flat_h2']:+.2f} / sized {ev['sized_h2']:+.2f} (n={ev['n_h2']})")
say(f" multiplier : mean {ev['mean_m']:.3f} min {ev['min_m']:.2f} "
f"max {ev['max_m']:.2f}")
say(f" shuffle null : n={len(sh)} mean {fmt(float(sh.mean()) if len(sh) else None)} "
f"p95 {fmt(p95)} real-lift percentile rank {fmt(rank, 0)}%")
say(" acceptance:")
for k, v in cl.items():
if k == "overall":
continue
say(f" {k:<22} {'PASS' if v else 'FAIL'}")
say(f" {'OVERALL':<22} {'PASS' if cl['overall'] else 'FAIL'}")
say(" per-quarter:")
print_quarters(res["quarters"])
print_coef_signs(res["coefs"], label)
say("")
results[label] = {"res": res, "ev": ev, "shuffle": sh, "p95": p95,
"rank": rank, "clauses": cl, "features": feats, "kind": kind}
table.append({"model": label, "n": ev["n"], "flat_r": ev["flat_r"],
"sized_r": ev["sized_r"], "lift": ev["lift"],
"sized_pf": ev["sized_pf"], "sized_dd": ev["sized_dd"],
"mean_m": ev["mean_m"], "shuffle_p95": p95, "rank_pct": rank,
"pass": cl["overall"]})
if args.model not in results:
raise SystemExit("the main model produced no OOS quarters — check burn-in / "
"book window / MIN_TRAIN")
say("=" * 96)
say("COMPARISON")
say("=" * 96)
print_comparison(table)
say("")
say(f"OOS multiplier histogram ({args.model}):")
print_hist(results[args.model]["res"]["oos"]["mult"].to_numpy())
say("")
main_label = args.model
beats = None
if main_label in results and "isbuy" in results:
beats = results[main_label]["ev"]["lift"] > results["isbuy"]["ev"]["lift"]
say(f"beats isbuy baseline: {'yes' if beats else 'no'} "
f"({results[main_label]['ev']['lift']:+.2f} R vs "
f"{results['isbuy']['ev']['lift']:+.2f} R)")
say(f"OVERALL ACCEPTANCE ({main_label}): "
f"{'PASS' if results[main_label]['clauses']['overall'] else 'FAIL'}")
say("")
# ── artifact ──────────────────────────────────────────────────────
artifact = None
if args.write_artifact:
say("-" * 96)
say("FINAL FIT (all trades) + ARTIFACT")
say("-" * 96)
fit = final_fit(df, main_feats, args.model, args.seed)
say(f" lambda (CV on full window): {fit['lambda']:g}")
if np.isfinite(fit["grad_norm"]):
say(f" logistic |grad|_inf at convergence: {fit['grad_norm']:.3e}")
say(f" map lo/hi/normaliser: {fit['map']['lo']:.5f} / {fit['map']['hi']:.5f} / "
f"{fit['map']['normalizer']:.5f}")
artifact = build_artifact(
fit, main_feats, args.model, df, results[main_label]["ev"],
results[main_label]["clauses"], results[main_label]["ev"]["lift"],
results[main_label]["shuffle"], args.n_shuffle, args.book, args.tf,
[t.strip() for t in args.confirm_tfs.split(",") if t.strip()],
args.burn_in_months, args.purge_bars, args.spread)
# Self-check: the runtime formula must reproduce the in-sample map exactly.
rows = df[main_feats].to_dict("records")
repro = np.array([predict_multiplier(r, artifact) for r in rows])
exact = bool(np.array_equal(repro, fit["mult"]))
say(f" predict_multiplier reproduces in-sample multipliers exactly: "
f"{'YES' if exact else 'NO'} (max |diff| "
f"{float(np.max(np.abs(repro - fit['mult']))):.3e})")
if not exact:
raise SystemExit("predict_multiplier does not reproduce the final fit — abort")
args.write_artifact.parent.mkdir(parents=True, exist_ok=True)
args.write_artifact.write_text(json.dumps(artifact, indent=2, default=str))
say(f" wrote {args.write_artifact}")
say("")
# ── outputs ───────────────────────────────────────────────────────
if args.out:
out = args.out
out.mkdir(parents=True, exist_ok=True)
main_res = results[main_label]
main_res["res"]["oos"].to_csv(out / "oos_trades.csv", index=False)
main_res["res"]["quarters"].to_csv(out / "quarters.csv", index=False)
coefs = pd.concat(
[r["res"]["coefs"].assign(model=lbl) for lbl, r in results.items()
if not r["res"]["coefs"].empty],
ignore_index=True) if results else pd.DataFrame()
coefs.to_csv(out / "coefs_by_quarter.csv", index=False)
pd.DataFrame({
"model": np.repeat([lbl for lbl in results], [len(r["shuffle"]) for r in results.values()]),
"lift": np.concatenate([r["shuffle"] for r in results.values()])
if results else np.array([]),
}).to_csv(out / "shuffle_lifts.csv", index=False)
multiplier_hist(main_res["res"]["oos"]["mult"].to_numpy()).to_csv(
out / "multiplier_hist.csv", index=False)
payload = {
"book": str(args.book), "book_sha256": sha256_file(args.book),
"git_commit": git_commit(), "bar_minutes": args.bar_minutes,
"model": args.model, "features": main_feats, "seed": args.seed,
"n_shuffle": args.n_shuffle, "burn_in_months": args.burn_in_months,
"purge_bars": args.purge_bars,
"beats_isbuy": beats,
"models": {
lbl: {
"kind": r["kind"], "features": r["features"],
"evaluation": {k: (None if isinstance(v, float) and not np.isfinite(v)
else (str(v) if isinstance(v, pd.Timestamp) else v))
for k, v in r["ev"].items()},
"shuffle": {"n": int(len(r["shuffle"])),
"mean": float(r["shuffle"].mean()) if len(r["shuffle"]) else None,
"p95": None if not np.isfinite(r["p95"]) else r["p95"],
"real_lift_percentile": None if not np.isfinite(r["rank"])
else r["rank"]},
"acceptance": r["clauses"],
} for lbl, r in results.items()
},
"artifact": artifact,
}
(out / "results.json").write_text(json.dumps(payload, indent=2, default=str))
(out / "summary.txt").write_text("\n".join(_LINES) + "\n")
print(f"\nwrote outputs to {out}")
if __name__ == "__main__":
main()
-78
View File
@@ -1,78 +0,0 @@
"""Evaluate the violent-move WATCH rule (as shipped in violent_watch.py) bar by bar on history. Analysis tooling only.
Level per bar from: calendar (high-impact USD within 30 min ahead / 30 min after), clock slots, indicator readings.
Reports share of bars per level, burst rate next bar / next hour per level (both halves), and hot-hours per day.
python backtest/research/watch_rule_eval.py PARQUET BAR_MINUTES
"""
import sys
from pathlib import Path
import numpy as np, pandas as pd
ROOT = Path(__file__).resolve().parents[2]; sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "backtest"))
from gpu_backtest import precompute_indicators
from config import TechnicalConfig
def wilder_atr(df, period=14):
pc = df["Close"].shift(1)
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
def main():
pq, bm = sys.argv[1], int(sys.argv[2])
px = pd.read_parquet(pq); px = px[(px.index >= pd.Timestamp("2023-08-05", tz="UTC")) & (px.index < pd.Timestamp("2026-09-05", tz="UTC"))]
ic = precompute_indicators(px, TechnicalConfig())
sh = lambda a: pd.Series(np.asarray(a, dtype=float), index=px.index).shift(1)
rsi, adxs, hist, bbp, kcu, kcl = sh(ic.rsi), sh(ic.adx_slope), sh(ic.macd_hist), sh(ic.bb_position), sh(ic.kc_upper), sh(ic.kc_lower)
ef, em, es, et, adx, atr1 = sh(ic.ema_fast), sh(ic.ema_medium), sh(ic.ema_slow), sh(ic.ema_trend), sh(ic.adx), sh(ic.atr)
c1 = px["Close"].shift(1); hp = hist.shift(1)
atr_prev = wilder_atr(px).shift(1); rng = px["High"] - px["Low"]
burst = (rng >= 3.0 * atr_prev).astype(int); H = max(1, 60 // bm)
next_hour = burst[::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
align = np.sign(c1 - ef) + np.sign(ef - em) + np.sign(em - es) + np.sign(es - et)
regime = np.select([(adx >= 25) & (align > 0), (adx >= 25) & (align < 0), adx >= 25, adxs > 3.0], ["trending_up", "trending_down", "trending", "transitioning"], default="ranging")
atr_pct = atr1.rolling(50).apply(lambda w: (w[-1] > w).mean() * 100, raw=True); extreme = atr_pct > 80
# readings
bear = ((bbp < 0).astype(int) + (rsi < 30).astype(int) + (c1 < kcl).astype(int) + ((hist < 0) & (hist < hp) & (hp < 0)).astype(int))
bull = ((bbp > 1).astype(int) + (rsi >= 70).astype(int) + (c1 > kcu).astype(int) + ((hist > 0) & (hist > hp) & (hp > 0)).astype(int))
neutral = (adxs > 3).astype(int) + extreme.astype(int) + (regime == "transitioning").astype(int)
readings = bear + bull + neutral
# clock (now = bar close time)
ct = px.index + pd.Timedelta(minutes=bm); wd = ct.weekday; hr = ct.hour
clock = ((wd <= 4) & np.isin(hr, [12, 13, 14])) | (hr == 1) | ((wd == 6) & np.isin(hr, [22, 23]))
# calendar
cal = pd.read_parquet(ROOT / "backtest/cache/calendar.parquet"); cal["ts"] = pd.to_datetime(cal.dateline, unit="s", utc=True)
hi = np.sort(cal[(cal.currency == "USD") & (cal.impactName.str.lower() == "high")].ts.values.astype("datetime64[ns]"))
t = ct.values.astype("datetime64[ns]"); nx = np.searchsorted(hi, t)
to_next = np.where(nx < len(hi), (hi[np.minimum(nx, len(hi) - 1)] - t) / np.timedelta64(1, "m"), 1e9)
since = np.where(nx - 1 >= 0, (t - hi[np.maximum(nx - 1, 0)]) / np.timedelta64(1, "m"), 1e9)
calendar = ((to_next > 0) & (to_next <= 30)) | (since <= 30)
# FINAL RULE (2026-09-06, chosen from the variant sweep recorded in sizing_campaign_2026-09.md):
# extreme = price-at-extreme readings only (outside Bollinger, RSI beyond 30/70, outside Keltner)
# hot = calendar | (clock & extreme >= 1) -> ~8.6% of bars, next-bar odds 3-5x normal, ~4 hot hours/day
# watch = clock | extreme >= 2 -> ~2x normal
extreme = ((bbp < 0) | (bbp > 1)).astype(int) + ((rsi < 30) | (rsi >= 70)).astype(int) + ((c1 < kcl) | (c1 > kcu)).astype(int)
level = np.where(calendar | (clock & (extreme >= 1)), "hot", np.where(clock | (extreme >= 2), "watch", "none"))
d = pd.DataFrame({"level": level, "burst": burst.values, "next_hour": next_hour.values, "readings": readings.values, "clock": clock, "calendar": calendar}, index=px.index)
d = d[d.index >= pd.Timestamp("2023-09-05", tz="UTC")]
half = np.where(np.arange(len(d)) < len(d) // 2, 1, 2)
b1, b2 = d.burst[half == 1].mean(), d.burst[half == 2].mean(); h1, h2 = d.next_hour[half == 1].mean(), d.next_hour[half == 2].mean()
print(f"{Path(pq).name}: {len(d)} bars; base next-bar {100*d.burst.mean():.2f}%, next-hour {100*d.next_hour.mean():.1f}%")
rows = []
for lv in ("none", "watch", "hot"):
m = d.level == lv
rows.append(dict(level=lv, share_of_bars=100 * m.mean(), next_bar_pct=100 * d.burst[m].mean(), lift_h1=d.burst[m & (half == 1)].mean() / b1, lift_h2=d.burst[m & (half == 2)].mean() / b2,
next_hour_pct=100 * d.next_hour[m].mean(), hour_lift_h1=d.next_hour[m & (half == 1)].mean() / h1, hour_lift_h2=d.next_hour[m & (half == 2)].mean() / h2,
share_of_all_bursts=100 * d.burst[m].sum() / d.burst.sum()))
print(pd.DataFrame(rows).round(2).to_string(index=False))
hot = d[d.level == "hot"]
kinds = pd.Series(np.where(hot.calendar, "calendar", "clock+extreme reading"), index=hot.index)
print("hot bars by cause:", (100 * kinds.value_counts(normalize=True)).round(1).to_dict())
hot_hours = hot.index.floor("h").unique()
days = pd.Series(hot_hours.date).value_counts()
print(f"distinct HOT hours per calendar day: mean {days.mean():.1f}, median {days.median():.0f}, max {days.max()} (≈ heads-up messages/day before the daily cap)")
hb = d.burst.groupby(d.index.floor("h")).max(); hh = pd.Series(1, index=hot_hours)
print(f"of all hours with a violent bar, the hour was flagged HOT at its start or during: {100*hb[hb==1].index.isin(hot_hours).mean():.1f}%")
if __name__ == "__main__":
main()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

-191
View File
@@ -1,191 +0,0 @@
"""ForexBot2 architecture diagram → docs/architecture.png.
Regenerate after architecture changes: venv/bin/python docs/architecture_diagram.py
(matplotlib only — no graphviz on the dev box)."""
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
W, H = 100.0, 100.0
fig, ax = plt.subplots(figsize=(16, 11.5))
ax.set_xlim(0, W); ax.set_ylim(0, H); ax.axis("off")
INK = "#263238"; MUT = "#607D8B"; ARR = "#455A64"; HAUL = "#7B8FA1"
def box(x, y, w, h, title, lines, fc, ec, title_fs=8.2, body_fs=6.6, lw=1.3):
ax.add_patch(FancyBboxPatch((x, y), w, h,
boxstyle="round,pad=0.35,rounding_size=0.9",
facecolor=fc, edgecolor=ec, linewidth=lw, zorder=2))
cy = y + h - 1.9
ax.text(x + w / 2, cy, title, ha="center", va="center", fontsize=title_fs,
fontweight="bold", color=INK, zorder=3)
cy -= 2.3
for ln in lines:
ax.text(x + w / 2, cy, ln, ha="center", va="center", fontsize=body_fs,
color=INK, zorder=3)
cy -= 1.85
def band(x, y, w, h, label, color):
ax.add_patch(FancyBboxPatch((x, y), w, h,
boxstyle="round,pad=0.3,rounding_size=1.2",
facecolor="none", edgecolor=color, linewidth=1.0,
linestyle=(0, (4, 3)), zorder=1))
ax.text(x + 0.9, y + h - 1.4, label, ha="left", va="center",
fontsize=7.2, fontweight="bold", color=color, zorder=3)
def arrow(p1, p2, color=ARR, lw=1.5, style="-|>", conn="arc3,rad=0.0",
label=None, lx=0, ly=0, ls="solid", fs=6.4, rot=0):
ax.add_patch(FancyArrowPatch(p1, p2, arrowstyle=style, mutation_scale=11,
color=color, linewidth=lw, linestyle=ls,
connectionstyle=conn, zorder=4))
if label:
mx, my = (p1[0] + p2[0]) / 2 + lx, (p1[1] + p2[1]) / 2 + ly
ax.text(mx, my, label, fontsize=fs, color=MUT, ha="center",
va="center", zorder=5, rotation=rot,
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.9))
# ── title ────────────────────────────────────────────────────────────
ax.text(2, 98.4, "ForexBot2 — XAU/USD Signal-Intelligence Platform",
fontsize=15, fontweight="bold", color=INK, ha="left")
ax.text(2, 96.0, "one pipeline, four delivery surfaces · closed bars only · live == backtest parity",
fontsize=8.5, color=MUT, ha="left")
EXT_FC, EXT_EC = "#ECEFF1", "#90A4AE"
DAT_FC, DAT_EC = "#E3F2FD", "#5C9BD1"
NWS_FC, NWS_EC = "#E0F2F1", "#3D9C8F"
COR_FC, COR_EC = "#FFFDF5", "#C9A227"
REP_FC, REP_EC = "#FFE9A8", "#B8860B"
DEL_FC, DEL_EC = "#E8F5E9", "#5FAF63"
BTS_FC, BTS_EC = "#EDE7F6", "#8E6BC6"
# ── external services (top band) ─────────────────────────────────────
band(1, 85.0, 98, 9.4, "EXTERNAL SERVICES", "#78909C")
box(2.5, 86.2, 15.5, 6.4, "Oanda v20 API",
["candles (live feed)", "practice orders"], EXT_FC, EXT_EC)
box(19.5, 86.2, 15.5, 6.4, "Twelve Data · Tiingo",
["registered fallbacks", "(Tiingo: tick stream)"], EXT_FC, EXT_EC)
box(36.5, 86.2, 15.5, 6.4, "Forex Factory",
["economic calendar", "MyFXBook RSS fallback"], EXT_FC, EXT_EC)
box(53.5, 86.2, 12.5, 6.4, "News RSS ×4",
["gold / macro", "headlines"], EXT_FC, EXT_EC)
box(67.5, 86.2, 13.5, 6.4, "DeepSeek API",
["V4-Flash LLM", "thinking: disabled"], EXT_FC, EXT_EC)
box(82.5, 86.2, 15, 6.4, "Telegram API",
["owner bot +", "subscriber channel"], EXT_FC, EXT_EC)
# ── data & context layer ─────────────────────────────────────────────
box(2.5, 69.8, 33, 11.0, "providers/ (BaseDataProvider)",
["single-flight fetch · TTL+boundary cache",
"forming-candle DROP · validate/clean",
"paginated fetch_range · optional tick stream",
"active feed knob: data_providers.active = oanda"],
DAT_FC, DAT_EC)
box(38.5, 69.8, 33, 11.0, "news_engine.py + llm_client.py",
["calendar events + keyword sentiment (gold-inverse)",
"DeepSeek enrichment: batch headline scores,",
"event gold-direction, market blurb — display-only,",
"keyword fallback, daily request cap"],
NWS_FC, NWS_EC)
box(74.5, 69.8, 23, 11.0, "app_config.py",
["credentials & deployment:",
"env → config.json → refuse",
"(no hardcoded fallbacks)",
".env loaded at import"],
"#FAFAFA", "#9E9E9E")
# ── signal core container ────────────────────────────────────────────
ax.add_patch(FancyBboxPatch((2.5, 40.5), 95, 26.5,
boxstyle="round,pad=0.35,rounding_size=1.2",
facecolor=COR_FC, edgecolor=COR_EC, linewidth=1.6, zorder=1))
ax.text(50, 65.2, "SIGNAL CORE — runs identically in live scans and backtests (closed bars only)",
ha="center", fontsize=8.4, fontweight="bold", color="#8a6d1a", zorder=3)
box(4.5, 51.8, 28, 12.0, "engine.py — TechnicalEngine",
["9 scored indicators + ATR context:",
"EMA · RSI · MACD · BB · ADX · ROC ·",
"Keltner · divergence · candlestick",
"regime → adaptive weights → composite"],
"white", COR_EC)
box(4.5, 42.0, 28, 8.4, "pair_signals/xauusd.py — factory",
["ALWAYS used: config.json overlay +",
"validated per-TF deltas (hour blocks, trail…)"],
"white", COR_EC)
box(35.5, 42.0, 60, 20.7, "signal_aggregator.py — analyze_pair → analyze_from_dataframes",
["multi-TF agreement (M5→[M15,H1] · M15→[M5,H1] · H1→[M15,H4] · H4→[H1,D1])",
"→ news context (warn-only by default) → diminishing-decay penalties",
"→ exhaustion hard block → quality grade A+…F",
"→ ordered post-grade gate chain (session · XAU SELL vol/hour · regime · M5 noise)",
"→ setup generators: XAU ATR (A+/A) · generic swing · XAU_PB · XAU_CP",
"→ entry · SL · TP1/TP2 · trailing plan"],
"white", COR_EC, body_fs=6.9)
# ── SignalReport hub ─────────────────────────────────────────────────
box(31, 33.2, 38, 5.2, "SignalReport",
["signal (−2…+2) · grade · trade setup · reasons · news summary"],
REP_FC, REP_EC, title_fs=8.8)
# ── delivery surfaces ────────────────────────────────────────────────
band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50")
box(2.5, 15.0, 12, 12.6, "main.py",
["CLI report", "--detail", "--json"], DEL_FC, DEL_EC)
box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache",
["independent daemon · scans at candle closes",
"atomic publish to /var/www/trade:",
"dashboard.html · snapshot.json · news.json",
"+ news_summary.php — on-demand AI",
"gold-impact summary (whitelist · cache · cap)"],
DEL_FC, DEL_EC)
box(76, 15.0, 21.5, 12.6, "telegram_bot.py",
["candle-boundary auto-scans", "quality threshold + dedup",
"alerts + LLM context line", "WS level-breach monitor"],
DEL_FC, DEL_EC)
# ── backtest / QA ────────────────────────────────────────────────────
box(2.5, 3.2, 71.8, 8.0, "backtest/gpu_backtest.py — bar-by-bar replay through the SAME analyze_from_dataframes",
["realism (results-critical): next-open entries · raw-path resolution · spread charged · SL-first ties · close-only trail",
"CuPy GPU / NumPy fallback · parquet cache · fed by providers.fetch_range (news off)"],
BTS_FC, BTS_EC)
box(77.5, 3.2, 20, 8.0, "test_all.py",
["461 tests · 31 modules", "no network (mocked)"], "#FAFAFA", "#9E9E9E")
# ── arrows: short structural hops (solid dark) ───────────────────────
arrow((10, 86.2), (14, 81.2), label="OHLCV", lx=-3.4, ly=0.2) # oanda → providers
arrow((27, 86.2), (24, 81.2), ls=(0, (3, 2)), lw=1.1) # fallbacks → providers
arrow((44, 86.2), (48, 81.2)) # FF → news
arrow((59.5, 86.2), (56, 81.2)) # RSS → news
arrow((72, 86.2), (62, 81.2), label="LLM", lx=3.4, ly=0.6) # deepseek → news
arrow((19, 69.8), (19, 64.2), label="DataFrames (UTC, closed bars)", lx=0.2, ly=0.9)
arrow((33.5, 57.5), (35.5, 55.5)) # engine → aggregator
arrow((32.5, 46.0), (35.5, 48.0)) # factory → aggregator
arrow((55, 69.8), (55, 63.1), label="warn-only context", lx=0, ly=1.0)
arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core
arrow((50, 40.5), (50, 38.8)) # core → report
arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli
arrow((60, 33.2), (58, 27.95)) # report → dashboard
arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram
# ── long-haul edges (thin dashed, routed through gutters) ────────────
# telegram bot ↔ Telegram API — right-margin arc, clear of app_config
arrow((95, 86.2), (95, 27.95), conn="arc3,rad=0.15", color=HAUL,
lw=1.2, ls=(0, (4, 2)))
ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT,
rotation=90, ha="center", va="center")
# browser click → news_summary.php → DeepSeek
arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL,
lw=1.2, ls=(0, (4, 2)),
label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6)
# providers.fetch_range → backtester — far-left margin channel
arrow((1.5, 69.8), (1.5, 11.0), color=BTS_EC, lw=1.1, ls=(0, (3, 2)),
label="fetch_range\n(history)", lx=3.6, ly=-1.2)
# backtester replays the signal core — right gutter between boxes
arrow((74.0, 11.6), (74.9, 40.3), color=HAUL, lw=1.3)
ax.text(81.8, 38.9, "replays the exact signal code", fontsize=6.4,
color=MUT, ha="center", va="center", zorder=5,
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.9))
if __name__ == "__main__": # importing the module must never rewrite the PNG
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "architecture.png")
fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white")
print(f"saved {out}")
-92
View File
@@ -1,92 +0,0 @@
# Test Plan — Data Source Change Validation
**Purpose:** determine whether switching `data_providers.active` (e.g. Twelve Data → Oanda, or any future provider swap) changes signal calculations materially, and gate the switch on evidence.
**Status:** in progress, 2026-08-14
> **Progress (2026-08-14):** Phase 0 ✅, Phase 1 ✅ (`backtest/research/feed_divergence_2026-08-14.md`), Phase 2 ✅ (`backtest/research/signal_parity_2026-08-14.md`), Phase 3 ✅ (`backtest/research/repin_verification_2026-08-14.md` — re-pin already existed), Phase 4 🔨 tool ready (`scripts/shadow_scan.py`, needs a 2-week run — launch manually per owner decision), Phase 5 owner decisions recorded (dual cost reporting; Tiingo streaming-only; see bottom).
**Context:** Oanda has been the live feed since 2026-07-28. The backtest books were **re-pinned on Oanda bars on 2026-08-04** (`backtest/oanda_*.csv` — 27 books, now the baseline; all TD-era books superseded). Cost reporting is dual: $0.30 retained for cross-book comparability, $0.65 is the realistic headline (measured Oanda round trip $0.65–0.72). Twelve Data was proven signal-incompatible in Phase 1 (~19% of M5 bars disagree on direction; systematic ~$1 bias) — it is NOT a valid candle fallback.
**Why signals can differ between feeds** (mechanisms to test):
- Feeds publish different mid prices → tiny OHLC diffs shift every indicator; near a threshold (RSI band, `FB2_EMA_LO` EMA200-proximity band, composite/grade cutoffs, M5 min-score gate) one bar's discrete score flips.
- Gate edge cases turn a tiny price diff into "setup generated" vs "no signal".
- ATR-derived SL/TP/trail levels differ even when the signal is identical.
- The shared 10σ cleaning pass can drop different bars per feed.
- Volume present (Oanda) vs absent (Tiingo FX); real spread (Oanda MBA) vs pair default (Tiingo).
- Known reference point: H4 seeding divergence — same provider, different fetch depth flipped ~2% of indicator signals (composite Δ up to 12.3). Cross-feed diffs act the same way.
---
## Phase 0 — Recent-bar parity smoke test ✅ DONE
- **Tool:** `scripts/compare_oanda_tiingo.py` (removed 2026-09-06 once Phases 1-2 superseded it; the result is recorded here)
- **Result 2026-08-14:** 10/10 timestamp alignment on M5/M15/H1, Close max |Δ| ≤ $0.175 (≤ 0.004% of price), no directional bias.
- **Verdict:** recent-tail parity confirmed. Does NOT cover historical depth — proceed to Phase 1.
## Phase 1 — Historical candle divergence study
**Goal:** quantify raw-data divergence over the full window used for validation, not just the recent tail.
- [x] Extend the comparison script to use `fetch_range` (paginated, raw bars) for both providers over **≥ 12 months** for M5, M15, H1 (and H4 if in scope).
- [x] Record per TF:
- % of bar timestamps present in both feeds; list of missing/extra bars (gaps, holidays, weekend boundary handling).
- OHLC |Δ| distribution: p50 / p95 / p99 / max, in $ and % of price.
- Count of bars dropped by `validate_and_clean` on each feed (10σ outliers, malformed).
- Bar-close timestamp conventions (alignment, DST behavior around US transitions).
- [x] **Pass criteria (measured on the weekend-filtered bar grid — the aggregator's NY-anchored `_is_forex_weekend` decides which bars exist for signal purposes, so raw timestamp overlap includes coverage-only diffs like Tiingo's Sunday bars and is NOT the criterion):** ≥ 99.5% overlap of *signal-eligible* bar timestamps; Close p95 |Δ| ≤ 0.01% of price; no systematic bias (mean Δ ≈ 0); no unexplained multi-hour gaps unique to one feed. (Raw-overlap result 2026-08-14: 94.6–96.6% vs Tiingo — entirely explained by Tiingo's ~22h/week of flat Sunday bars, which the weekend filter gates out.)
- [x] Write findings to `backtest/research/feed_divergence_<date>.md` with tables and the exact commands used.
## Phase 2 — Signal-level replay parity
**Goal:** measure how often candle divergence actually changes a *decision*.
- [x] Replay bar-by-bar through the real `analyze_from_dataframes` (news off) for both feeds over the same ≥ 12-month window, per TF (M5, M15, H1). Reuse the gpu_backtest replay harness; disable the parquet cache (`--no-cache`) or use separate cache keys per provider.
- [x] Compare per bar: signal direction, composite score, quality grade, gate outcomes (which gate fired), setup presence, strategy label (XAU ATR / XAU_PB / XAU_CP), entry/SL/TP prices.
- [x] Metrics:
- % bars with identical direction+grade.
- Direction flips, grade changes, gate-only changes (setup appears/disappears).
- Distribution of composite Δ on mismatched bars.
- For matching signals: mean/max |Δ| of entry, SL, TP1/TP2 in $ and in ATR fractions.
- [x] **Result (Oanda↔Tiingo):** 98.32% / 98.16% same direction (M15/M5), zero opposite-sign flips; 93.41% / 94.21% same direction+grade+setup. The original ≥97% direction+grade proposal is too strict for benign grade-letter changes and was not met; fallback acceptance is direction ≥98%, zero sign flips, and marginal/boundary-hour-only flips. See `signal_parity_2026-08-14.md`.
## Phase 3 — Backtest re-pin on the new feed
**Goal:** a new pinned book on the new provider's bars, evaluated under rule 1 acceptance criteria.
- [x] **Provider selection:** `gpu_backtest.py` has **no `--provider` flag** — it builds the provider from `data_providers.active` in config.json (`gpu_backtest.py:1753`). A cross-provider run requires flipping that config line. The parquet cache is already keyed per provider (`gpu_backtest.py:984` — `oanda_`-prefixed files vs legacy unprefixed TD files), so TD and Oanda data cannot mix in cache; `--no-cache` is only needed to force a refetch.
- [x] Run the full backtest on new-feed bars, both cost assumptions:
```bash
python backtest/gpu_backtest.py --pair XAUUSD --days 365 --spread 0.30 --export-csv backtest/repin_<provider>_m15_030.csv
python backtest/gpu_backtest.py --pair XAUUSD --days 365 --spread 0.65 --export-csv backtest/repin_<provider>_m15_065.csv
```
(repeat for each TF under test; note the `Backend:` log line and provider in the report per `backtest/instructions_for_backtest.txt`).
- [x] Split into train/validation halves; require **positive total R in both halves and PF ≥ 1.3 at realistic cost** (rule 1), with trailing exits and next-open fills untouched.
- [x] Marginal configs additionally pass the **2024-06 → 2025-03 OOS window** (within-year splits alone have approved configs that later failed OOS).
- [x] ~~Compare against the pinned Twelve-Data book trade-by-trade~~ **AMENDED 2026-08-14:** skipped with justification — Phase 1 showed TD bars disagree on direction 19% of the time on M5 with a systematic ~$1 bias, so a TD-pinned book is a different instrument's book and a trade-level diff carries no decision value. Phantom-trade classes remain documented (e.g. the weekend-calendar fix already dropped exactly one live-impossible M5 trade).
- [x] **Deliverable:** re-pinned book + report in `backtest/research/`; update the reference numbers in CLAUDE.md if the book is adopted. Current-HEAD M5 canonical book: `backtest/oanda_m5_12mo_065_nopb_20260814.csv`, +106.82R PF 1.51.
## Phase 4 — Live shadow comparison
**Goal:** confirm parity under real fetch depths, caching, and boundary timing — not just history.
- [ ] Run a shadow scan loop (no orders, no Telegram) for **≥ 2 weeks**: each candle close, scan with both providers, log direction/grade/gates/setup to a side-by-side file or SQLite table.
- [ ] Include at least one weekend transition and one news-heavy day in the window.
- [ ] Track: scan-to-scan agreement rate, feed-lag incidents ("feed lagging — just-closed bar missing"), staleness warnings, spread readings (Oanda real vs Tiingo default).
- [ ] **Acceptance thresholds (baseline = Phase 2 replay parity):** ≥ 98% direction agreement; **zero** opposite-sign (BUY↔SELL) flips; direction flips confined to actionable↔NEUTRAL at marginal composites, clustered only at Friday-close/Sunday-reopen (UTC 22–23) and Asian (03–04) hours; setup-level drift (SL/TP) within the Phase 2 envelope (mean |Δ| ≲ $3, p95 ≲ $17 on M15); every feed-lag/staleness incident explained.
- [ ] H4 caution: live H4 fetches only 380 bars vs unbounded backtest history (known ~2% indicator-signal flip from seeding alone) — keep H4 labeled "unproven" until a dedicated campaign closes this.
## Phase 5 — Cutover checklist
- [x] `config.json` `data_providers.active: "oanda"` and `data_providers.streaming: "tiingo"` set deliberately (verified 2026-08-14 — Oanda cannot stream; Tiingo streams).
- [x] Cost model: **dual reporting adopted (owner decision 2026-08-14)** — $0.65 (measured Oanda round trip) is the realistic headline, $0.30 retained for cross-book comparability with TD-era books. ⚠️ **Cost-gate coupling:** `config.py round_trip_cost` (0.30) is NOT just a backtest knob — it feeds the live TP1 spread gate (`min_tp = round_trip_cost × min_tp_cost_multiple`, three sites in `signal_aggregator.py`). Raising it to 0.65 moves the TP1 floor from ~$9 to ~$19.50 and is a rule-1 revalidation event, not a config tweak. `round_trip_cost` stays 0.30.
- [x] QA suite green: `python test_all.py` (381 tests; verified 2026-08-14).
- [ ] Week 1 post-cutover: daily review of `logs/signals.db` rows vs shadow log; alert on grade/direction disagreement beyond the Phase 2 baseline rate.
- [ ] Rollback: revert `data_providers.active`; pinned books of the previous feed remain valid for comparison. ⚠️ Twelve Data is a permanent *registered* fallback but is **signal-incompatible** (Phase 1) — on an Oanda candle outage, stop scanning rather than switching `active` to `twelve_data`; a TD-fed book cannot be compared to the Oanda baseline.
- [x] Docs sync (CLAUDE.md rule): `docs/user_guide.html` backtest numbers reflect the Oanda-pinned books, not TD-era numbers.
## Open questions — RESOLVED 2026-08-14 (owner decisions)
- ~~Adopt which spread as canonical~~ → **dual reporting**: $0.65 realistic headline + $0.30 cross-book comparability; `round_trip_cost` stays 0.30 (gate coupling above).
- ~~Tiingo role~~ → **streaming-only**. Tiingo remains registered for the WS tick stream and comparison research, but is not a designated candle fallback; candle fallback is a manual halt-and-decide, never an automatic TD failover.
- H4 seeding campaign (parked since 2026-08-04) → **still needs an owner**; H4 stays "unproven" until then.
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env python3
"""
Phase 1 — historical candle divergence study (data source change test)
=======================================================================
Fetches the RAW closed-bar series for XAU/USD from two providers over a
multi-month window via the normal provider stack (`fetch_range`, which
paginates under the per-request bar cap and drops the forming candle),
then quantifies how the feeds diverge:
* coverage: % shared timestamps, feed-only bars, where they cluster
* OHLC |Δ| distribution (mean/median/p90/p95/p99/max, $ and bias)
* |ΔClose| vs mean ATR14 (the scale the signal engine cares about)
* threshold exceedance (how many bars differ by more than X $)
* bar-direction agreement (sign of close−open)
* close-return correlation on consecutive bars
* per-hour and per-month drift
Both feeds are mid-price (Oanda mid of MBA candles; Tiingo resampled mid;
Twelve Data its own mid). Frames are cached to parquet so re-runs are
instant; pass --no-cache to force a fresh fetch.
Usage:
venv/bin/python scripts/feed_divergence_study.py --a oanda --b tiingo \
--tfs M15,H1,H4 --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 PlatformConfig, Timeframe, XAUUSD
from providers import make_data_provider
REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = REPO / "backtest" / "cache"
RESEARCH_DIR = REPO / "backtest" / "research"
_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,
})
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
hl = df["High"] - df["Low"]
hc = (df["High"] - df["Close"].shift(1)).abs()
lc = (df["Low"] - df["Close"].shift(1)).abs()
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
return tr.ewm(alpha=1.0 / period, adjust=False).mean()
def fmt_stats(name: str, s: pd.Series) -> str:
a = s.abs()
return (f" {name:<6} mean ${a.mean():.3f} med ${a.median():.3f} "
f"p90 ${a.quantile(0.90):.3f} p95 ${a.quantile(0.95):.3f} "
f"p99 ${a.quantile(0.99):.3f} max ${a.max():.2f} "
f"bias {s.mean():+.4f}")
def cache_path(name: str, tf: Timeframe, start: pd.Timestamp,
end: pd.Timestamp) -> Path:
return (CACHE_DIR /
f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet")
def load_or_fetch(provider, name: str, tf: Timeframe, start, end,
no_cache: bool):
cp = cache_path(name, tf, start, end)
if not no_cache and cp.exists():
df = pd.read_parquet(cp)
print(f" [{name}] loaded {len(df):,} bars from cache "
f"{cp.name}")
return df
t0 = time.time()
print(f" [{name}] fetching {tf.value} {start.date()} → {end.date()} ...")
df = provider.fetch_range(XAUUSD, tf, start, end)
print(f" [{name}] fetched {len(df):,} bars in {time.time() - t0:.1f}s")
df.to_parquet(cp)
print(f" [{name}] cached → {cp.name}")
return df
def compare(tf: Timeframe, a_name: str, b_name: str, a: pd.DataFrame,
b: pd.DataFrame):
print("\n" + "=" * 80)
print(f"{tf.value} — {a_name.upper()} vs {b_name.upper()}, XAU/USD")
print("=" * 80)
common = a.index.intersection(b.index)
a_only = a.index.difference(b.index)
b_only = b.index.difference(a.index)
print(f"\nCOVERAGE")
print(f" {a_name} bars : {len(a):>8,} head {a.index[0]} tail {a.index[-1]}")
print(f" {b_name} bars : {len(b):>8,} head {b.index[0]} tail {b.index[-1]}")
print(f" shared : {len(common):>8,} "
f"({100 * len(common) / max(len(a.index.union(b.index)), 1):.2f}% of union)")
print(f" {a_name}-only : {len(a_only):>8,} "
f"({100 * len(a_only) / max(len(a), 1):.2f}% of {a_name})")
print(f" {b_name}-only : {len(b_only):>8,} "
f"({100 * len(b_only) / max(len(b), 1):.2f}% of {b_name})")
if len(common) == 0:
print(" !! no shared timestamps — feeds on different bar grids")
return
for label, idx in ((f"{a_name}-only", a_only),
(f"{b_name}-only", b_only)):
if not len(idx):
continue
by_date = pd.Series(1, index=idx).groupby(idx.date).sum()
top = by_date.sort_values(ascending=False).head(6)
print(f" {label} clusters on: "
+ ", ".join(f"{d} ({n})" for d, n in top.items()))
x = a.loc[common]
y = b.loc[common]
atr = wilder_atr(x).dropna()
mean_price = x["Close"].mean()
print(f"\nSCALE REFERENCES ({a_name} feed)")
print(f" mean close : ${mean_price:,.2f}")
print(f" mean bar range: ${(x['High'] - x['Low']).mean():.3f}")
print(f" mean ATR14 : ${atr.mean():.3f} median ${atr.median():.3f}")
print(f"\nPER-BAR DIFFS ({a_name} − {b_name}, $)")
diffs = {}
for col in ("Open", "High", "Low", "Close"):
d = x[col] - y[col]
diffs[col] = d
print(fmt_stats(col, d))
dclose = diffs["Close"].abs()
print(f"\n |ΔClose| as % of mean ATR14 : "
f"mean {100 * dclose.mean() / atr.mean():.1f}% "
f"p95 {100 * dclose.quantile(0.95) / atr.mean():.1f}%")
print(f"\nTHRESHOLD EXCEEDANCE (|ΔClose|)")
for thr in (0.10, 0.30, 0.65, 1.00, 2.00, 5.00):
pct = 100 * (dclose > thr).mean()
print(f" > ${thr:<5.2f}: {pct:6.2f}% of bars ({(dclose > thr).sum():,})")
# direction agreement
a_dir = np.sign(x["Close"] - x["Open"])
b_dir = np.sign(y["Close"] - y["Open"])
both_nonflat = (a_dir != 0) & (b_dir != 0)
dir_agree = 100 * (a_dir[both_nonflat] == b_dir[both_nonflat]).mean()
print(f"\nAGREEMENT")
print(f" bar direction (sign of close−open) agree: {dir_agree:.2f}%"
f" (n={both_nonflat.sum():,})")
step = pd.Series(common, index=common).diff().median()
a_ret = x["Close"].pct_change()
b_ret = y["Close"].pct_change()
step_ok = pd.Series(common, index=common).diff().le(step + pd.Timedelta(seconds=1))
corr = a_ret[step_ok].corr(b_ret[step_ok])
print(f" close-return correlation (consecutive bars): {corr:.5f}")
# by-hour
hours = common.hour
rows = []
for h in range(24):
m = hours == h
if not m.any():
continue
rows.append({"hour_utc": h, "bars": int(m.sum()),
"mean_abs_dclose": dclose[m].mean(),
"p95_abs_dclose": dclose[m].quantile(0.95)})
by_hour = pd.DataFrame(rows)
print(f"\nBY UTC HOUR (mean |ΔClose|, $)")
print(by_hour.to_string(index=False,
float_format=lambda v: f"{v:.3f}"))
print(f"\nBY MONTH (mean |ΔClose|, $)")
months = dclose.groupby(common.tz_localize(None).to_period("M")).mean()
for p, v in months.items():
print(f" {p}: {v:.3f}")
return {
"tf": tf.value, "a": a_name, "b": b_name,
"a_bars": len(a), "b_bars": len(b), "common": len(common),
"a_only": len(a_only), "b_only": len(b_only),
"dir_agree_pct": round(dir_agree, 3),
"ret_corr": round(corr, 5),
"mean_abs_dclose": dclose.mean(),
"p95_abs_dclose": dclose.quantile(0.95),
"max_abs_dclose": dclose.max(),
"dclose_pct_atr_mean": 100 * dclose.mean() / atr.mean(),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--a", required=True)
ap.add_argument("--b", required=True)
ap.add_argument("--tfs", default="M15,H1,H4",
help="comma-separated timeframe values (default M15,H1,H4)")
ap.add_argument("--days", type=int, default=365)
ap.add_argument("--end", default=None, help="UTC end date (default now)")
ap.add_argument("--no-cache", action="store_true")
args = ap.parse_args()
tfs = []
for s in args.tfs.split(","):
s = s.strip()
tf = _TF_LOOKUP.get(s)
if tf is None:
sys.exit(f"unknown timeframe '{s}'; use one of "
f"{sorted(_TF_LOOKUP)}")
tfs.append(tf)
end = pd.Timestamp(args.end, tz="UTC") if args.end \
else pd.Timestamp.now(tz="UTC")
start = end - pd.Timedelta(days=args.days)
cfg = PlatformConfig()
pa = make_data_provider(cfg, args.a)
pb = make_data_provider(cfg, args.b)
summary = []
for tf in tfs:
print(f"\n{'─' * 80}\nWINDOW {start.date()} → {end.date()} "
f"({args.days} days), {tf.value}")
try:
a = load_or_fetch(pa, args.a, tf, start, end, args.no_cache)
except Exception as e:
print(f" !! {args.a} {tf.value} fetch failed: {e}")
continue
try:
b = load_or_fetch(pb, args.b, tf, start, end, args.no_cache)
except Exception as e:
print(f" !! {args.b} {tf.value} fetch failed: {e}")
continue
r = compare(tf, args.a, args.b, a, b)
if r:
summary.append(r)
print("\n" + "=" * 80)
print("SUMMARY TABLE")
print("=" * 80)
sdf = pd.DataFrame(summary)
print(sdf.to_string(index=False,
float_format=lambda v: f"{v:.4f}"))
out = RESEARCH_DIR / f"feed_divergence_{args.a}_vs_{args.b}.csv"
if len(sdf):
sdf.to_csv(out, index=False, float_format="%.6f")
print(f"\nSummary written to {out}")
if __name__ == "__main__":
main()
-246
View File
@@ -1,246 +0,0 @@
#!/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()