Adds three new synthetic-Candidate emitter engines parallel to the
existing leader_follower scheduler hook, plus look-ahead defenses
(LookaheadViolationError + per-engine assertions). Each engine is
covered by a standalone PoC config (no PEAD/parking/idle alpha) for
isolation backtests against the midlarge or broad snapshot.
Engines:
EarningsRunup (libs/backtest/earnings_runup.py)
- Trigger: days_to_earnings ∈ [3,7] AND attention_zscore_20d ≥ 1.5
AND dollar_volume_20d_zscore ≥ 1.0 (all evaluated at T-1 close)
- Entry: T+1 next_open. Exit: -4% / +8% / max_holding_days =
days_to_earnings - buffer (forced flat by close before announcement)
- PIT calendar: PointInTimeEarningsCalendar adapter for backtest;
oracle_surprise_prefetch fallback when parquet calendar absent
- PoC verdict (configs/experiments/earnings_runup_poc_v1.json):
119 trades over 1051 days, +37.27% total return, 44.46% MDD,
SQS 45.2 (profitability=55.5, risk=23.5, robustness=50.1).
VIABLE BUT NEEDS WORK — signal exists; standalone risk profile
too aggressive for v7.356 baseline (8.8% MDD on v7.364). Path
forward: per_trade_risk reduction, VIX gate, position cap, or
integrate as PEAD sleeve adjunct (not as standalone replacement).
PeerSympathy (libs/backtest/peer_sympathy.py)
- Trigger: leader passes PEAD filter (earnings_release / guidance_update
/ material_contract) AND leader reaction_close ≥ +5% AND peer 60d
correlation ≥ 0.55 over [T-65, T-5]. Top-2 peers by correlation
from leader_follower_extra_peer_symbols_by_sector + sector ETF
holdings.
- Entry: T+1 next_open on peer. Exit: -3.5% / +6% / max_holding=3 /
peer-earnings blackout
- PoC verdict (configs/experiments/peer_sympathy_poc_v1.json):
256 trades over 1051 days, -52.92% total return, 54.47% MDD,
SQS 19.6 (profitability=0.0, risk=5.4, robustness=100.0).
DEAD. The leader's catalyst is already absorbed by T+1 next_open
— peers gap up overnight before entry. robustness=100 confirms
the negative result is not noise. Salvage paths (not implemented):
reaction_close entry, raised-guidance-only restriction.
- Note: initial run_id was 0 trades due to a select_candidates
filter mismatch (engine.event_types=['peer_sympathy'] dropping
real event_type='earnings_release' rows). The runner adapter
was patched to bypass strategy_engine filtering for leader
selection; the manual peer_sympathy_leader_event_types filter
does the gating.
VolBreakout52w (libs/backtest/vol_breakout_52w.py)
- Trigger: close_T-1 > max(high[T-252:T-2]) AND volume_T-1 ≥
2 × median_volume_20d_T-2 AND ATR_14_T-1/close ∈ [0.015, 0.06].
Entry T next_open, exit -3% / +5% / max_holding=2 / MOC.
- Honest, look-ahead-safe descendant of the retired topgainer v1-v54
family. Five layers of strict-before assertions guard the bar
provider, candidate construction, trigger evaluation, and feature
timestamps. A leaky-provider proof-by-contradiction test
demonstrates the categorical catch.
- PoC verdict (configs/experiments/vol_breakout_52w_poc_v1.json,
broad-liquid universe): 1,332 trades, -87.28% total return,
88.74% MDD, SQS 24.4 (profitability=0.0, robustness=100.0).
DEAD AND HONEST. This is the most important finding of the three
PoCs: the topgainer v1-v54 lineage's headline returns (+267%
Sharpe 13.73 in best variants) were 100% lookahead bug. With
the bug removed, the 52w-high + volume + ATR signal has no real
alpha — the lookahead-corrected -4.3% from prior memory is
confirmed and amplified to -87% on a fuller universe and longer
horizon. Future "revive topgainer" proposals can cite this run
(bt_return_max_long_v1_broad-liquid_20260509042903892342_3bb473d9)
as definitive falsification.
- Pre-open gap guard inactive (no premarket data in broad snapshot).
skip_if_no_gap_data=true; the +4% gap-fade guard would not move
the result given the magnitude.
Shared infrastructure additions:
- libs/backtest/domain.py: LookaheadViolationError class +
StrategyEngineConfig fields (11 EarningsRunup + 11 PeerSympathy
+ 13 VolBreakout52w = 35 new fields)
- apps/backtester/run.py: _BacktestAttentionZscoreAdapter,
_RunnerPeerResolver, _schedule_earnings_runup_candidates,
_schedule_peer_sympathy_candidates,
_schedule_vol_breakout_52w_candidates wired into the daily
scheduler block. PeerSympathy adapter bypasses strategy_engine
filtering on leader selection (manual filter handles gating).
Tests: 21 (EarningsRunup) + 27 (PeerSympathy) + 38 (VolBreakout52w)
= 86 new unit tests, all passing. Broader unit suite: 1392 passed,
2 pre-existing failures unrelated.
Net engine state: EarningsRunup is the only viable new engine class.
PeerSympathy and VolBreakout52w are kept in-tree as falsification
evidence, not as production engines.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- registry.json: change _ftb_fix_v2 from manual_only to auto_full_rebuild so
backtest auto-refreshes when snapshot doesn't cover the requested period
- run.py: return [] (not all_trading_days fallback) when parking cap pushes
requested_end before requested_start, preventing silent wrong-date-range runs
- run.py: allow 1-trading-day lag tolerance in parking cap so a single lagging
symbol (e.g. QQQM shortly after close) doesn't cap the whole simulation
- backtest_sim.py: log incremental_update_failed_falling_back warning so
silent fallback to full rebuild is visible in direct-mode logs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On the last simulation day, parking was entered at EOD close price
(when _had_event_activity_today=True) and immediately liquidated at
close by end-of-backtest cleanup → entry == exit → PnL = 0.
Fix: force all six parking entry code paths to use "open" price when
date == last_simulation_date, so entry and cleanup-close are always
different prices.
Also adds _parking_cap logic in _extend_store_to_requested_window to
cap _requested_end_date at the last date where QQQM/TQQQ/SGOV all
have Oracle close-price data, preventing the simulation from including
days where macro is incomplete and the exit fallback would fire.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New mode (risk.daily_budget_reset=True) where cash_available and sizing
equity reset to initial_equity at the start of each day, regardless of
how many open positions or realized P&L exist. Unlike fixed_capital_sizing
(단리, sizing only), this also treats buying power as if no positions are
held — useful for evaluating signal quality independent of capital constraints.
- domain.py: daily_budget_reset field on RiskConfig
- run.py: _daily_budget_reset flag; _sizing_equity / _sleeve_equity_est /
_build_portfolio_state all honor the new flag
- backtest_sim.py: daily_budget_reset param threaded through
- direct_runner.py: --daily-budget-reset CLI flag
- routers/backtest.py: BacktestRequest field + cmd arg
- client.ts: BacktestParams / BacktestTask types updated
- Backtest.tsx: checkbox in form + DBR badge in task list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug #2 (paper trader): lookback entries sized using historical entry_price_est
but filled at current market price, causing cash overdraft. Fix: override
entry_price_est with get_latest_bars() close before entering _process_entries.
Bug #3 (paper trader + backtester): paper trader was missing the per-candidate
MHD expiration check that the backtester already had. Also adds
lookback_min_remaining_days (default 3) to reject candidates with too little
holding time remaining — prevents entering a position the day before forced exit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete v7.360-v7.363 experiment configs (rotation/momentum tests)
- Remove _schedule_momentum_breakout_candidates() from backtester run.py
- Remove MomentumBreakoutConfig from domain.py
- Delete momentum_calendar.py, momentum_screener.py, build_momentum_calendar.py
- Delete data/momentum_calendar/ parquet data
Valid period performance was -31.36% vs +152.4% baseline — sleeve is not viable
without walk-forward validation. Abandoning for now.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a backtest starts mid-stream (via --start), events that fired
before the start date but are still within their max_holding_days
window can now be entered on the first simulation day.
- Add `lookback_entry_enabled: bool = False` to ExecutionConfig
- On first sim day, _collect_lookback_candidates() gathers pre-start
events, runs them through the same select_candidates() pipeline,
and injects them before normal candidates
- Entry fills at the first day's open price; gap-cap check is skipped
since the event is multi-days old
- days_held is initialized to the elapsed trading days so TIME exits
fire at the correct time relative to the original event date
- Store slice is extended backward by max_mhd calendar buffer so
pre-start rows survive slice_by_date_range when feature is enabled
- Enabled in return_max_long_v7.119 for testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backtester (run.py):
- cash_available = (self._cash + parking_value) * multiplier caused trades to be
approved even when self._cash ≈ 0 (all money in SGOV/QQQ). Trades executed
by deducting from self._cash → negative cash (phantom money).
- Fix: after simulate_entry, if self._cash < actual trade cost and parking exists,
call _liquidate_parking_for_cash(shortfall) before deducting from cash.
- Verified: 2022-2026 backtest with qqqm_low_dd shows 0 cash_negative events.
Live engine (engine.py):
- Add _parking_liquidate_for_event(): frees parking cash to fund event entries.
SGOV (virtual) reduces entry_value in DB; QQQM/QQQ sells real shares via broker.
- Both entry loops (engines mode + flat/reaction_close mode) now attempt parking
liquidation when plan.skip_reason == "insufficient_cash" before giving up.
Also includes prior session work (accumulated since last commit):
- 6 novel parking gate signals: VRP, Market Temperature, Hurst exponent, Rolling
Kurtosis, Return Autocorrelation, SPY-QQQ Correlation (composite risk score v2)
- QQQM parking symbol support (lower expense ratio vs QQQ)
- Snapshot auto-refresh + bar extension cache (pickle) to avoid 10-min re-fetches
- Bar extension clamps to last market-closed date (ET 4PM check)
- fithia2 refresh command; --no-refresh flag for paper backtest
- Paper backtest macro extension beyond last event date (parking-only periods)
- parking_state DB schema: 7 new columns (peak_price, gate_in_sgov,
committed_target, pending_target, pending_days, sgov_entry_value, sold_today)
- Live engine: target confirmation (2-day), top-up drawdown gate, trailing stop,
SGOV interest accrual, full 6-signal gate evaluation
- New PARKING_PRESETS: qqqm_low_dd, composite_v2, vv_24_vrp8, vt_24_t13, etc.
- Web GUI / CLI result parity fix (Oracle URL via get_settings().stock_oracle_url)
- Force-close uses last_exec_date (has bar data); parking liquidates at last_date
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends selector with new scoring model support, adds execution
enhancements, and improves snapshot store loading and split handling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bounce engine (buy negative reaction, bet on mean reversion) could not
execute: system architecture ties scoring to single model per backtest,
and selector/store indexes are optimized for positive-reaction PEAD.
Negative-reaction candidates get score=0 from PEAD scoring, blocking
engine selection regardless of engine-level threshold overrides.
Implementing bounce trades requires: dual scoring model support,
selector changes for negative-reaction candidate routing, and
store indexing changes. Deferred to future refactor.
Current best CW return: 293.2% (v6new.255)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Data analysis revealed OBV Q1 (distribution) has 56.4% WR vs Q5 51.2% —
contrarian signal confirmed. Previous OBV bonus was applied in wrong
direction. Corrected with v15 scoring models.
Best result: v6new.185 (entropy + risk 0.058) CW 274.4% but SQS 72.2,
still below v6new.122 (72.4). WFV/robustness offsets CW gains.
v6new.122 confirmed as optimal under current SQS v4 formula.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New data source integration:
- EarningsSurpriseService: GET /api/v1/earnings/surprise/{symbol}
Returns actual vs estimated EPS with surprise_percentage
- Feature builder: creates earnings_surprise_v1 snapshots for earnings events
- Backfill script runs for existing 1,273 tickers (Alpha Vantage rate limited)
New scoring (v11):
- Small beat (0-3% surprise): +10% bonus (82.4% WR in sample)
- Medium beat (3-8%): +5% bonus
- Big beat (>8%): no bonus (already priced in)
- Miss (<=0%): -5% penalty
Signal validation (n=66 sample):
Small beat: 82.4% WR, +1.79% mean 5d return
Big beat: 54.8% WR, +0.47%
Miss: 55.6% WR, -0.10%
Backfill running (~4 hours). Experiment pending data completion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1-4 of engine unification to eliminate research/live divergence.
Phase 1 — Scoring (event_detector.py):
EventDetector now uses config's scoring_model (v5/v9 etc.) when
event_v1 features are present (parse_confidence_overall not null).
Falls back to compute_entry_score only for incomplete events.
Phase 2 — Execution config (execution.py):
Extracted build_effective_execution_config() as shared function.
BacktestRunner delegates to it. PaperTradingEngine can now use
identical per-engine overrides, adaptive exit, tiered targets.
Phase 3 — Attention filtering (attention.py):
New AttentionFilterService class extracted from BacktestRunner.
Provides: engine_requires_attention, apply_filters, rescoring.
BacktestRunner now delegates to this service.
PaperTradingEngine can import and use the same service.
Phase 4 — Gap cap (execution.py):
check_next_open_gap_cap() shared function for next-open gap rejection.
All 450 unit tests pass. Paper backtest verified working.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New features:
- _enrich_macro_features() in snapshot_export: adds macro_vix, macro_hy_spread from FRED
- compute_return_max_long_score_v10: +12% bonus in favorable regime (VIX>18+HY>3.25)
- _macro_regime_score(): regime-aware scoring component
Findings:
- VIX signal is statistically strong: 62.3% WR (VIX>18+HY>3.25) vs 50.8% (other)
- But scoring bonus promotes marginal trades, diluting OOS quality
- Same pattern as eps_growth, drift bonus: raw signal ≠ scoring improvement
- v6new.17 SQS 59.1 < v6new.9 SQS 63.3
v6new.9 remains best at SQS 63.3 after 17 experiments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track experiment cycles with SQS scoring (0-100), JSONL journal, and
auto-generated leaderboard to prevent duplicate experiments and enable
data-driven strategy decisions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove 5 non-alpha features (earnings surprise, risk penalty, parse confidence,
direction clarity, LM sentiment) from composite score to eliminate double-counting
with hard gates and noise sources. Redistribute weights to 5 alpha features.
Add default-deny for unknown event types, no-follow-through early exit (D+1),
kill switch log-only mode, macro regime size scaler. Remove SUE gate (Gate 8).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix kill switch reset: remove unreachable drawdown recovery condition
(equity can't change while trading is halted), reset peak_equity and
drawdown_pct to 0 on cooldown expiry
- Raise veto_oneoff_penalty threshold 0.5 → 0.7 (was blocking 67% of
candidates due to high median oneoff_penalty in dataset)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Flip scoring weights so event/document quality is primary signal (55%)
and market confirmation is secondary (35%). Add research mode with
kill-switch cooldown/reset, veto gates for bad events, reduced portfolio
risk, and 4 diagnostic analysis scripts.
Phase A: Research mode kill-switch reset, risk reduction (0.5%/trade,
max 4 positions), bullish-only direction for all event types.
Phase B: 2 new sub-scorers (parse_confidence, direction_clarity),
4 veto gates (oneoff risk, parse confidence, unknown/bearish direction).
Phase C: signal_quality, event_type_decomposition, kill_switch_impact,
concurrent_position analysis scripts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
equity was computed as cash + unrealized_pnl where unrealized_pnl =
(close - entry) × shares. Since cash already had entry cost subtracted,
this double-counted the cost basis:
buggy: equity = (initial - entry×shares) + (close - entry)×shares
= initial + close×shares − 2×entry×shares ← WRONG
correct: equity = cash + market_value
= (initial - entry×shares) + close×shares
= initial + (close − entry)×shares ← RIGHT
This caused drawdown to spike to ~73% the instant a position opened
(e.g. TSLA $330 × 222 shares → equity appeared to drop from 100k to
27k), falsely triggering the kill switch at 25% and blocking all
subsequent entries.
Before fix: 3 trades, +0.08% return, 39.2% max drawdown (fake)
After fix: 10 trades, -2.63% return, 4.24% max drawdown (real)
Also: when bar data is missing, positions now use entry_price as
fallback market value instead of treating the position as worthless.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BacktestRunner.run() now iterates all NYSE trading days (not just candidate
days) via SnapshotStore.all_trading_days() so stop/target/time exits are
checked every day, not only on days with new candidates
- Record initial DailyPortfolioState before simulation loop starts so
total_return_pct is computed relative to the true initial equity (100k),
not the first post-entry equity snapshot
- SnapshotStore._fetch_event_metadata() now synthesises event_timestamp from
event_date + 21:00 UTC when filed_at_utc is NULL (transparent enrichment at
loader boundary, not silent substitution in selector)
- SnapshotStore._async_load() maps event_close → entry_price_est when the
column is absent, and derives score from abs(reaction_day_return) when the
Parquet snapshot has no score column
- Add --snapshot-dir CLI flag to BacktestRunner to override the default
parquet_dir base path (needed for non-standard snapshot locations)
- Fix integration test assertion: total_trading_days >= 2 (was == 2)
- Add configs/experiments/realdata_test_v1.json for real Phase 3 snapshot runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>