Skip to content

refactor(runtime): shared event-driven backtest core + opt-in intraday tail model (I5a) - #33

Merged
StackOverFlow11 merged 4 commits into
mainfrom
feat/i5a-event-backtest-architecture
Jun 15, 2026
Merged

refactor(runtime): shared event-driven backtest core + opt-in intraday tail model (I5a)#33
StackOverFlow11 merged 4 commits into
mainfrom
feat/i5a-event-backtest-architecture

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

What & why — ARCHITECTURE / FRAMEWORK PR (not a research result)

This is an architecture/framework I5a PR. It refactors the backtest layer into
a shared event-driven core so that daily close-to-close and intraday tail
rebalance
are two event models over one achieved-book ledger — no new
research alpha, no performance claim. The smoke score is a single PIT-safe I3
feature used solely to exercise the engine.

Motivation: the old daily BacktestDriver is explicitly close-to-close and not
minute-PIT-safe. Rather than bolt on a second, incompatible intraday backtest
stack, the fill/cash/settlement ledger is extracted once and shared.

Daily compatibility strategy & evidence

Strategy: BacktestDriver becomes a thin import-compatible wrapper around the
new BacktestEngine + DailyCloseEventModel. Same constructor and public surface;
no duplicated book/settlement logic. qt/pipeline.py, qt/oos_stability.py,
qt/phase2_baseline.py are untouched.

Evidence:

  • New test_engine_daily_equals_driver_wrapper: engine(DailyCloseEventModel) NAV /
    feasibility_log / holdings_log are assert_frame_equal to the legacy driver.
  • Existing daily suites pass unchanged (test_backtest_driver,
    test_driver_feasibility, test_phase2_baseline, test_oos_stability).
  • phase0 regression unchanged: ic_mean=0.9600, annual_return=0.8408.
  • pytest tests518 passed (502 baseline + 16 new); ruff check . clean;
    all 14 configs validate.

Intraday tail event model (opt-in)

  • event_order: intraday_tail_rebalance + intraday.enabled: true (validated;
    daily configs validate unchanged).
  • Decision at 14:50 (features need available_time <= decision_time); execution at
    the first valid 1min close in [14:51, 14:56:59]; holding return is
    exec-to-exec
    , never close-to-close.
  • A missing/NaN execution bar is an explicit block, never a daily-close
    fallback
    . Turnover/cost/holdings are the achieved book after feasible fills.
  • New run-phase-i5a-intraday CLI reads minute bars from the existing intraday
    cache only
    (a miss is a hard blocker → zero stk_mins live calls).

Real smoke (SSE50, 2026-03-03 → 2026-06-12)

periods=3, covered=58/58, stk_mins_live_calls=0, blocked_fills=0 — report at
artifacts/reports/phase_i5a_intraday_tail_framework.md (gitignored). Event table
shows 14:50 decision / 14:51 execution anchors; exec-to-exec NAV (not a perf claim).

Guarantees

No daily result drift · no duplicated fill/ledger semantics · missing minute bars
never fall back to daily close · no token leak · no raw non-1min fetch · no BJ
backfill / cache writer.

Test plan

  • pytest tests — 518 passed
  • ruff check . — clean
  • validate all config/*.yaml
  • phase0 regression ic_mean=0.9600 / annual_return=0.8408
  • real intraday smoke (zero stk_mins live calls)

Known limitations (disclosed in the report)

Execution-time feasibility is the minimum rule (missing/NaN blocks both); raw
stk_limit vs execution-minute close is deferred. ST/suspension not consulted at
14:50 (suspended names have no minute bars → blocked anyway). Selection-level daily
limit flags disabled in the smoke config (EOD, not known at 14:50).

Extract the backtest ledger from BacktestDriver into a reusable
BacktestEngine driven by an EventModel (schedule + pricing + feasibility).
DailyCloseEventModel reproduces the accepted monthly close-to-close
behaviour byte-for-byte; IntradayTailEventModel prices fills at 1min
execution bars (next_minute_close) with exec-to-exec holding returns and
explicit blocks (never a daily-close fallback). BacktestDriver is now a
thin import-compatible wrapper around the engine + DailyCloseEventModel,
so qt.pipeline / qt.oos_stability / qt.phase2_baseline are untouched.

events.py: HoldingPeriod + the shared monthly rebalance schedule.
engine.py: the single loop (universe -> scores -> constructor -> feasible
fill via SimExecution/simulate_fills -> settle -> NAV/feasibility/holdings/
event logs). No fill/cash/settlement logic is duplicated.
Add an optional IntradayCfg (enabled/decision_time/data_lag/session_open/
execution_model/execution_window/require_cache_coverage/missing_execution)
and constrain backtest.event_order to a Literal. A RootConfig validator
requires intraday.enabled=true when event_order='intraday_tail_rebalance';
the daily default needs no intraday section, so every existing config
validates unchanged. Unsupported execution models and malformed/ordered
execution windows fail with readable errors.
New CLI run mode + qt/intraday_tail_framework.py: build the daily panel/
universe via the existing P4 cache, read required 1min bars from the
EXISTING intraday cache only (a cache miss is a hard blocker, never a
silent warm -> zero stk_mins live calls), score with the PIT-safe I3
intraday_ret_0930_1450 feature, and run the engine with the intraday tail
event model. Writes an auditable report (event timestamps, exec-to-exec
statement, blocked fills, achieved holdings, turnover/cost/cash,
limitations). config/phase_i5a_intraday_tail_framework.yaml is a SH/SZ
SSE50 smoke. Tests cover the daily golden (engine == driver), event-model
units, intraday PIT (exec!=close-to-close; perturb post-cutoff bars ->
feature unchanged; perturb exec bar -> returns move; perturb daily close
-> intraday unchanged; missing bar blocks; holdings/turnover are achieved),
and config validation.
Review fixes:
- require_cache_coverage=true now fails loudly when ANY requested symbol is
  uncovered (previously it only failed when NONE were covered, silently
  dropping uncovered names and biasing the realized universe). Lenient mode
  (false) still drops + discloses, and an all-uncovered set is a clear
  blocker. Adds partial-coverage tests (required-fails / lenient-drops).
- Make the holding period's exit execution anchor explicit
  (HoldingPeriod.exit_execution_ts) so audit timing no longer has to be
  inferred from the next period (and the final period shows a real exit
  anchor, not NaT). Engine event_log adds exit_execution_ts +
  next_execution_ts; the I5a report shows planned vs ACTUAL execution-bar
  time per period (a 14:52 fill when 14:51 is missing is now visible).
  Daily behaviour unchanged (phase0 ic 0.9600/annual 0.8408).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant