Files
momentum-system/eod_replay.py
T
momentum devandClaude Fable 5 61ca8ae096 population: learn_params + eod_replay consume the shared gate (kills look-ahead)
Both offline consumers now screen through strategy.population.population_reject
instead of divergent, look-ahead admits:

- learn_params.load_sessions: replaces the `gap>=0.03 OR day_rvol>=2.0` admit,
  whose day_rvol was a FULL-DAY volume sum (outcome-time look-ahead, the
  2026-07-08 class). gap is already a FRACTION (open vs prev close) — passed
  as-is; the OPEN price is passed for the price band. Stored daily bars expose
  no open-knowable volume, so volume=None (fail-open on the day-volume floor) —
  no day-end volume is smuggled into admission.
- eod_replay._daily_gappers: screens on the OPEN price + STATIC open gap (was
  t_close look-ahead) via the shared gate; the local gap is converted to a
  FRACTION before the call, so it inherits the >=150% squeeze exception. Deletes
  the stale $1-5 / 3-60% WATCHER_/TRIGGER_ constants (KEEPS RISK_PCT). The
  second-stage scan_gappers is made permissive (membership already decided by
  the shared gate), removing the `gap_pct <= 60%` cap that wrongly dropped
  squeezes. Prod thresholds passed explicitly (bare from_env would disable gap).

Unit test replaces the live-DB smoke run: $15/+12% qualifies, $4/+45% rejected
unless >=150%+rvol5. research/labeler.py deferred to plan 006 (no locatable
price/gap admission screen); sweep_exits/sweep_filters likewise deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:43:12 -04:00

183 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""End-of-day whole-market replay.
After the close, pull the day's full active universe (not just the ~20 the live
loop watched), backfill their bars, and run TODAY'S logic (entry filters + ORB +
managed exits) over every qualifying gapper — so you see what the system WOULD
have traded and the P&L, plus a perfect-hindsight line (the biggest catchable
moves) to show what was left on the table. Every run also GROWS the stored
dataset, so the nightly tuner and every verification get more trustworthy.
PYTHONPATH=. python eod_replay.py # replay today
PYTHONPATH=. python eod_replay.py 2026-06-17 # replay a specific date
Schedule after the close (after eod flatten), e.g. cron: 5 16 * * 1-5.
"""
from __future__ import annotations
import os
import sys
from datetime import date, datetime, timezone
from dotenv import load_dotenv
sys.path.insert(0, ".")
load_dotenv()
os.environ.setdefault("STRATEGY_SETUPS", "opening_range_break")
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.settings import AlpacaPaperSettings
from config import Config
from research.ingestion.market_data import (
classify_session,
ingest_daily_history,
ingest_live_minute_bars,
)
from research.ingestion.signals import scan_gappers
from research.multi_schema import open_research_db
from research.query import query_avg_daily_volume, query_minute_bars, query_previous_close
from strategy.backtest.engine import BacktestEngine
from strategy.exits import ExitConfig, TRAIL_PRIOR_LOW, parse_profit_tiers
from strategy.models import SetupCriteria
from strategy.population import PopulationConfig, population_reject
# Population is defined ONCE, in strategy/population.py — the SAME gate the live
# loop trades ($2-20 band + gap floor + 35-150% dead band + >=150% squeeze). This
# replaces the stale $1-5 / 3-60% look-ahead screen. Prod thresholds are passed
# explicitly (a bare from_env() shell would disable the gap gate).
_POP = PopulationConfig(min_entry_gap_pct=0.10)
RISK_PCT = float(os.environ.get("TRADING_RISK_PER_TRADE_PCT", "0.01"))
def _daily_gappers(con, sess, universe):
"""Identify today's qualifying gappers from DAILY bars (cheap) so we only
minute-backfill the handful that matter, not all 100 names. Screens through
the SHARED live population gate (strategy/population.py) on the OPEN price and
STATIC open gap — both open-knowable; the old screen used t_close (look-ahead)
and a hard 60% cap that dropped the >=150% squeezes production re-admits."""
out = []
for sym in universe:
# Bars ON OR BEFORE the session date, newest first — so rows[0] is the
# session day and rows[1] the prior trading day. Without the `<= sess`
# bound this pulled the LATEST bar (always the most recent date in the
# table), so replaying any past date saw rows[0] != sess and skipped
# EVERY symbol — silently reporting "no qualifying gappers" for all
# history and making multi-day backtesting impossible.
rows = con.execute(
"SELECT trade_date, open, close, volume FROM daily_bars WHERE symbol=? "
"AND trade_date <= ? ORDER BY trade_date DESC LIMIT 25", [sym, sess]).fetchall()
if len(rows) < 2 or str(rows[0][0]) != str(sess):
continue
t_open, t_vol = float(rows[0][1]), float(rows[0][3])
prev_close = float(rows[1][2])
prior = [float(r[3]) for r in rows[1:21]]
adv = sum(prior) / len(prior) if prior else 0.0
gap = (t_open / prev_close - 1) if prev_close else 0.0 # FRACTION (population contract)
rvol = (t_vol / adv) if adv else 0.0
# OPEN price + open gap are open-knowable; rvol re-admits real >=150% squeezes.
if population_reject(_POP, gap_pct=gap, price=t_open, volume=None, rvol=rvol) is None:
out.append(sym)
return out
def _exit_cfg() -> ExitConfig:
return ExitConfig(
target_r=float(os.environ.get("TRADING_REWARD_MULTIPLE", "10")),
trail_mode=os.environ.get("TRADING_EXIT_TRAIL_MODE", TRAIL_PRIOR_LOW),
trail_after_r=float(os.environ.get("TRADING_EXIT_TRAIL_AFTER_R", "1")),
profit_lock_tiers=parse_profit_tiers(os.environ.get("TRADING_EXIT_PROFIT_TIERS", "")))
def main(argv=None):
argv = argv or sys.argv[1:]
if argv:
sess = date.fromisoformat(argv[0])
else:
sess, _, _, _ = classify_session(datetime.now(timezone.utc))
con = open_research_db("market")
client = AlpacaPaperClient(AlpacaPaperSettings.from_env())
# 1) full active universe (vs the ~20 the live loop watched)
actives = client.get_most_actives(top=100, by="volume")
universe = sorted({a["symbol"] for a in actives if a.get("symbol")})
print(f"=== EOD REPLAY {sess} ===")
print(f"universe: {len(universe)} most-active names (live loop watches ~20)")
# 2) cheap DAILY backfill -> screen gappers from daily bars (no need to pull
# minute bars for all 100; only the gappers need them)
ingest_daily_history(con, client, universe, days=30)
gapper_syms = _daily_gappers(con, sess, universe)
print(f"daily-screen gappers (shared population gate: ${_POP.price_min:g}-{_POP.price_max:g}, "
f"gap >={_POP.min_entry_gap_pct:.0%} ex {_POP.gap_dead_band_lo:.0%}-{_POP.gap_dead_band_hi:.0%} "
f"dead-band, squeeze >={_POP.gap_dead_band_hi:.0%}+rvol{_POP.squeeze_rvol_min:g}): "
f"{len(gapper_syms)}")
# 3) minute-backfill ONLY the gappers (fast), then rank them, and grow the
# dataset. Use the SIP (full-volume) feed so rvol/$-volume in the replay
# reflect REAL volume — the live IEX feed sees only ~2-5% of it, which was
# silently corrupting every volume-based backtest conclusion.
ing = ingest_live_minute_bars(con, client, gapper_syms, lookback_minutes=440,
feed=client.settings.backfill_feed)
print(f"backfilled {ing.minute_rows} minute rows ({client.settings.backfill_feed}) "
f"across {len(ing.symbols)} gappers\n")
keep = set(gapper_syms)
# Population membership was already decided by the shared gate in
# _daily_gappers; scan_gappers here only computes gap/rvol/vwap for ranking, so
# pass permissive bounds and let `keep` be the sole authority (this also keeps
# the >=150% squeeze names the old `gap_pct <= GAP_MAX` cap wrongly dropped).
gappers = [g for g in scan_gappers(con, sess, min_gap_pct=0.0, min_relative_volume=0.0,
price_min=0.0, price_max=1e9, limit=200)
if g.symbol in keep]
if not gappers:
print("no qualifying gappers today.")
return
# 4) run TODAY'S logic over each
cfg = _exit_cfg()
eng = BacktestEngine(Config(), target_r=cfg.target_r, warmup_bars=3, eval_every=1,
ready_score_pct=55, min_bars=3, exit_config=cfg,
# intrabar ORB SETUP criteria (not population) — preserved
criteria=SetupCriteria(gap_pct_min=0.05, relative_volume_min=2.0))
equity = float(client.get_account().get("equity") or 0) or 100000.0
risk_dollars = equity * RISK_PCT
trades, hindsight = [], []
for g in gappers:
bars = query_minute_bars(con, g.symbol, sess)
if bars is None or not len(bars):
continue
rth = bars[bars["is_regular_hours"] == True].reset_index(drop=True) if "is_regular_hours" in bars.columns else bars # noqa: E712
if len(rth):
run_pct = (float(rth["high"].max()) / float(rth["open"].iloc[0]) - 1) * 100
hindsight.append((g.symbol, g.gap_pct, run_pct))
pc = query_previous_close(con, g.symbol, sess)
adv = query_avg_daily_volume(con, g.symbol, sess)
for t in eng.run(bars, g.symbol, previous_close=pc, avg_daily_volume=adv).trades:
trades.append((g.symbol, t))
# 5) report
print("--- what TODAY'S logic would have traded ---")
if trades:
rs = [t.r_multiple or 0 for _s, t in trades]
wins = sum(1 for r in rs if r > 0)
sumR = sum(rs)
print(f" trades: {len(trades)} win: {wins / len(trades) * 100:.0f}% "
f"total R: {sumR:+.1f} est P&L @ ${equity:,.0f} (1% risk): ${sumR * risk_dollars:+,.0f}")
for sym, t in sorted(trades, key=lambda x: -(x[1].r_multiple or 0))[:8]:
print(f" {sym:6} {t.r_multiple:+.2f}R entry {t.entry_price} -> "
f"{t.exit_reason} {t.exit_price}")
else:
print(" no trades taken.")
print("\n--- perfect hindsight (biggest intraday runs from the open) ---")
for sym, gap, run_pct in sorted(hindsight, key=lambda x: -x[2])[:8]:
print(f" {sym:6} gap {gap:+.0f}% ran +{run_pct:.0f}% intraday")
captured = sum(r for r in [t.r_multiple or 0 for _s, t in trades]) if trades else 0
print(f"\n gappers available: {len(gappers)} | traded: {len(set(s for s, _ in trades))} "
f"| captured {captured:+.1f}R of the day's opportunity")
if __name__ == "__main__":
main()