6bf58fd9d4
Walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time features, 3yr Oanda books @ $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 ships; the 12-month M5 book re-run is byte-identical to the pinned one. Behaviour-neutral plumbing: - position_sizing.build_features (65 features; multiplier path returns 1.0, no artifact) - SignalReport.penalty_tags / penalty_total / momentum_fading (record-keeping) - TradeSetup.stake_multiplier = None (reserved) - gpu_backtest --export-features (default export stays the pinned 63 columns; extras forced to the tail even when the first trade lacks an indicator) Research tools (backtest/research): book_report, burst_map, burst_precursors, open_trade_bursts, calendar_impact, burst_indicator_map, sizing_model, watch_rule_eval. Findings recorded in backtest/research/sizing_campaign_2026-09.md: M5 stretch measures are positive in both halves; every open-trade protection against violent bars loses on both TFs; high-impact USD releases cause 20-31% of violent bars but trades held through them do better; violent bars come when price is already at an extreme, not from calm. Module 29 tests (13). Suite: 435 passed. Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
112 lines
7.8 KiB
Python
112 lines
7.8 KiB
Python
"""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, COST
|
|
|
|
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()
|