You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3240 lines
139 KiB
Python
3240 lines
139 KiB
Python
"""Integration tests for the full backtest pipeline (no DB/HTTP — uses SnapshotStore directly)."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
_UTC = ZoneInfo("UTC")
|
|
|
|
|
|
def _build_synthetic_store() -> object:
|
|
"""Build a SnapshotStore with synthetic data for end-to-end testing."""
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
# 5 trading days, 2 symbols
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]]
|
|
|
|
candidates = {
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::001",
|
|
"symbol": "AAPL",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"entry_price": 150.0,
|
|
"score": 0.85,
|
|
"sector": "Technology",
|
|
"event_type": "earnings",
|
|
"event_timestamp": "2026-01-02T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-02",
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
},
|
|
],
|
|
dt.date(2026, 1, 6): [
|
|
{
|
|
"event_id": "EVT::002",
|
|
"symbol": "MSFT",
|
|
"execution_date": dt.date(2026, 1, 6),
|
|
"entry_date": "2026-01-06",
|
|
"entry_price": 300.0,
|
|
"score": 0.70,
|
|
"sector": "Technology",
|
|
"event_type": "guidance",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"avg_dollar_volume": 10_000_000.0,
|
|
"atr_14": 5.0,
|
|
},
|
|
],
|
|
}
|
|
|
|
bars = {
|
|
"AAPL": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 158.0, "high": 170.0, "low": 155.0, "close": 165.0, "volume": 900_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 165.0, "high": 175.0, "low": 160.0, "close": 170.0, "volume": 800_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 170.0, "high": 180.0, "low": 165.0, "close": 175.0, "volume": 750_000},
|
|
dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 175.0, "high": 185.0, "low": 170.0, "close": 180.0, "volume": 700_000},
|
|
},
|
|
"MSFT": {
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 300.0, "high": 305.0, "low": 280.0, "close": 282.0, "volume": 500_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 282.0, "high": 290.0, "low": 270.0, "close": 272.0, "volume": 480_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 272.0, "high": 280.0, "low": 260.0, "close": 265.0, "volume": 450_000},
|
|
dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 265.0, "high": 270.0, "low": 255.0, "close": 258.0, "volume": 420_000},
|
|
},
|
|
}
|
|
|
|
return SnapshotStore(
|
|
candidates_by_exec_date=candidates,
|
|
bars_by_symbol_date=bars,
|
|
)
|
|
|
|
|
|
def _build_multi_engine_store() -> object:
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
candidates = {
|
|
dt.date(2026, 1, 7): [
|
|
{
|
|
"event_id": "EVT::SD::SHORT",
|
|
"symbol": "NFLX",
|
|
"execution_date": dt.date(2026, 1, 7),
|
|
"entry_date": "2026-01-07",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 400.0,
|
|
"entry_price": 399.0,
|
|
"score": 0.90,
|
|
"sector": "Communication Services",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"reaction_day_return": -0.12,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::AC::LONG",
|
|
"symbol": "AMD",
|
|
"execution_date": dt.date(2026, 1, 7),
|
|
"entry_date": "2026-01-07",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 122.0,
|
|
"entry_price": 123.0,
|
|
"score": 0.88,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-07",
|
|
"reaction_day_return": 0.14,
|
|
"avg_dollar_volume": 9_000_000.0,
|
|
"atr_14": 3.0,
|
|
},
|
|
],
|
|
}
|
|
|
|
bars = {
|
|
"NFLX": {
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 395.0, "high": 405.0, "low": 390.0, "close": 400.0, "volume": 1_200_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 390.0, "high": 392.0, "low": 380.0, "close": 382.0, "volume": 1_100_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 382.0, "high": 384.0, "low": 370.0, "close": 372.0, "volume": 1_000_000},
|
|
},
|
|
"AMD": {
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 123.0, "high": 130.0, "low": 122.0, "close": 129.0, "volume": 1_500_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 129.0, "high": 135.0, "low": 128.0, "close": 134.0, "volume": 1_300_000},
|
|
dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 134.0, "high": 138.0, "low": 133.0, "close": 137.0, "volume": 1_250_000},
|
|
},
|
|
}
|
|
|
|
return SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars)
|
|
|
|
|
|
def _make_config(strategy_engines=None):
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
UniverseConfig,
|
|
)
|
|
|
|
return BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
max_daily_new_risk_pct=0.05,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=10.0,
|
|
commission_per_share=0.005,
|
|
same_bar_priority="stop_first_conservative",
|
|
max_holding_days=10,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
strategy_engines=strategy_engines or [],
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestBacktestRunIntegration:
|
|
def test_run_completes(self, tmp_path):
|
|
"""Full run completes without error."""
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest
|
|
|
|
store = _build_synthetic_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
assert result.run_id.startswith("bt_")
|
|
assert result.total_trading_days >= 0
|
|
assert result.metrics.trade_count >= 0
|
|
|
|
def test_engine_allowed_macro_regimes_filters_engine_by_date(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
import pyarrow.parquet as pq
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::ROFF",
|
|
"symbol": "AAPL",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"entry_price": 100.0,
|
|
"score": 0.80,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-02T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-02",
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
"reaction_day_return": 0.02,
|
|
},
|
|
],
|
|
dt.date(2026, 1, 6): [
|
|
{
|
|
"event_id": "EVT::RON",
|
|
"symbol": "MSFT",
|
|
"execution_date": dt.date(2026, 1, 6),
|
|
"entry_date": "2026-01-06",
|
|
"entry_price": 110.0,
|
|
"score": 0.82,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
"reaction_day_return": 0.02,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={
|
|
"AAPL": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 104.0, "low": 98.0, "close": 103.0, "volume": 1_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 103.0, "high": 105.0, "low": 102.0, "close": 104.0, "volume": 1_000_000},
|
|
},
|
|
"MSFT": {
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 110.0, "high": 114.0, "low": 109.0, "close": 113.0, "volume": 1_000_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 113.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_000_000},
|
|
},
|
|
},
|
|
macro_by_date={
|
|
dt.date(2026, 1, 5): {
|
|
"spy_close": 90.0,
|
|
"spy_sma_20": 100.0,
|
|
"qqq_close": 180.0,
|
|
"qqq_sma_20": 200.0,
|
|
},
|
|
dt.date(2026, 1, 6): {
|
|
"spy_close": 110.0,
|
|
"spy_sma_20": 100.0,
|
|
"qqq_close": 210.0,
|
|
"qqq_sma_20": 200.0,
|
|
},
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="macro_gate_engine_test",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="macro_gated_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
allowed_macro_regimes=["risk_on"],
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
max_daily_new_risk_pct=0.10,
|
|
max_positions=5,
|
|
max_positions_per_sector=5,
|
|
macro_regime_enabled=True,
|
|
macro_regime_mode="spy_qqq_scaler",
|
|
macro_regime_neutral_size_scaler=1.0,
|
|
macro_regime_risk_off_size_scaler=1.0,
|
|
macro_regime_risk_off_a_tier_only=False,
|
|
veto_unknown_direction=False,
|
|
veto_bearish_direction=False,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
max_holding_days=1,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
event_type_profiles={
|
|
"earnings_release": EventTypeProfile(enabled=True),
|
|
},
|
|
strategy_engines=manifest.strategy_engines,
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
trade_blotter = pq.read_table(tmp_path / result.run_id / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
|
|
assert [row["symbol"] for row in trade_blotter] == ["MSFT"]
|
|
|
|
def test_output_files_created(self, tmp_path):
|
|
"""All expected output files are written."""
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest
|
|
|
|
store = _build_synthetic_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
run_dir = tmp_path / result.run_id
|
|
assert run_dir.exists()
|
|
assert (run_dir / "metadata.json").exists()
|
|
assert (run_dir / "manifest.json").exists()
|
|
|
|
def test_same_day_cash_recycle_replaces_stale_core(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
d1 = dt.date(2026, 1, 5)
|
|
d2 = dt.date(2026, 1, 6)
|
|
d3 = dt.date(2026, 1, 7)
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
d1: [
|
|
{
|
|
"event_id": "EVT::OLD",
|
|
"symbol": "OLD",
|
|
"execution_date": d1,
|
|
"entry_date": d1.isoformat(),
|
|
"event_date": d1.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "not_provided",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": d1.isoformat(),
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.74,
|
|
"volume_ratio_20d": 2.1,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
d2: [
|
|
{
|
|
"event_id": "EVT::NEW",
|
|
"symbol": "NEW",
|
|
"execution_date": d2,
|
|
"entry_date": d2.isoformat(),
|
|
"event_date": d2.isoformat(),
|
|
"event_close": 50.0,
|
|
"entry_price": 50.0,
|
|
"score": 0.80,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "not_provided",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": d2.isoformat(),
|
|
"reaction_day_return": 0.09,
|
|
"close_location": 0.76,
|
|
"volume_ratio_20d": 2.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={
|
|
"OLD": {
|
|
d1: {"date": d1, "open": 100.0, "high": 100.0, "low": 99.0, "close": 100.0, "volume": 1_000_000},
|
|
d2: {"date": d2, "open": 100.0, "high": 102.0, "low": 100.0, "close": 101.0, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 101.0, "high": 101.0, "low": 101.0, "close": 101.0, "volume": 1_000_000},
|
|
},
|
|
"NEW": {
|
|
d2: {"date": d2, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 50.0, "high": 55.0, "low": 50.0, "close": 55.0, "volume": 1_000_000},
|
|
},
|
|
},
|
|
)
|
|
|
|
engine = StrategyEngineConfig(
|
|
engine_id="core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
score_threshold_override=0.5,
|
|
recycle_on_cash_block=True,
|
|
recycle_min_days_held=1,
|
|
recycle_min_score_delta=0.05,
|
|
recycle_allowed_victim_engine_ids=["core"],
|
|
recycle_positive_pnl_only=True,
|
|
enabled=True,
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_same_day_recycle",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[engine],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=1.0,
|
|
max_daily_new_risk_pct=1.0,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
max_position_value_pct=1.0,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=10),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=[engine],
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
run_dir = tmp_path / result.run_id
|
|
|
|
recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"]
|
|
assert recycle_trades
|
|
assert recycle_trades[0].symbol == "OLD"
|
|
assert any(trade.symbol == "NEW" for trade in runner._closed_trades)
|
|
assert result.metrics.trade_count >= 2
|
|
assert (run_dir / "resolved_config.json").exists()
|
|
assert (run_dir / "metrics" / "metrics_summary.json").exists()
|
|
assert (run_dir / "plots").exists() # empty dir
|
|
|
|
def test_next_open_cash_recycle_replaces_stale_next_open_position(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
d1 = dt.date(2026, 1, 5)
|
|
d2 = dt.date(2026, 1, 6)
|
|
d3 = dt.date(2026, 1, 7)
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
d1: [
|
|
{
|
|
"event_id": "EVT::OLD::NEXT",
|
|
"symbol": "OLDN",
|
|
"execution_date": d1,
|
|
"entry_date": d1.isoformat(),
|
|
"event_date": d1.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-05T13:00:00+00:00",
|
|
"filing_time_bucket": "pre_market",
|
|
"reaction_date": d1.isoformat(),
|
|
"reaction_day_return": 0.01,
|
|
"close_location": 0.65,
|
|
"volume_ratio_20d": 1.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
d2: [
|
|
{
|
|
"event_id": "EVT::NEW::NEXT",
|
|
"symbol": "NEWN",
|
|
"execution_date": d2,
|
|
"entry_date": d2.isoformat(),
|
|
"event_date": d2.isoformat(),
|
|
"event_close": 50.0,
|
|
"entry_price": 50.0,
|
|
"score": 0.80,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-06T13:00:00+00:00",
|
|
"filing_time_bucket": "pre_market",
|
|
"reaction_date": d2.isoformat(),
|
|
"reaction_day_return": 0.01,
|
|
"close_location": 0.66,
|
|
"volume_ratio_20d": 1.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={
|
|
"OLDN": {
|
|
d1: {"date": d1, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000},
|
|
d2: {"date": d2, "open": 100.0, "high": 102.0, "low": 100.0, "close": 101.0, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 101.0, "high": 101.0, "low": 100.0, "close": 100.5, "volume": 1_000_000},
|
|
},
|
|
"NEWN": {
|
|
d2: {"date": d2, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 50.0, "high": 54.0, "low": 50.0, "close": 53.0, "volume": 1_000_000},
|
|
},
|
|
},
|
|
)
|
|
|
|
engine = StrategyEngineConfig(
|
|
engine_id="next_open_guidance",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
score_threshold_override=0.5,
|
|
recycle_on_cash_block=True,
|
|
recycle_min_days_held=1,
|
|
recycle_min_score_delta=0.05,
|
|
recycle_allowed_victim_engine_ids=["next_open_guidance"],
|
|
recycle_positive_pnl_only=True,
|
|
enabled=True,
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_next_open_recycle",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[engine],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=1.0,
|
|
max_daily_new_risk_pct=1.0,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
max_position_value_pct=1.0,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=10),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=[engine],
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"]
|
|
assert recycle_trades
|
|
assert recycle_trades[0].symbol == "OLDN"
|
|
assert any(trade.symbol == "NEWN" for trade in runner._closed_trades)
|
|
assert result.metrics.trade_count >= 2
|
|
|
|
def test_next_open_cash_recycle_can_target_stale_cross_engine_victim(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
d1 = dt.date(2026, 1, 5)
|
|
d2 = dt.date(2026, 1, 6)
|
|
d3 = dt.date(2026, 1, 7)
|
|
d4 = dt.date(2026, 1, 8)
|
|
d5 = dt.date(2026, 1, 9)
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
d1: [
|
|
{
|
|
"event_id": "EVT::OLD::CROSS",
|
|
"symbol": "OLDX",
|
|
"execution_date": d1,
|
|
"entry_date": d1.isoformat(),
|
|
"event_date": d1.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.55,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-05T13:00:00+00:00",
|
|
"filing_time_bucket": "pre_market",
|
|
"reaction_date": d1.isoformat(),
|
|
"reaction_day_return": 0.01,
|
|
"close_location": 0.60,
|
|
"volume_ratio_20d": 1.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
d5: [
|
|
{
|
|
"event_id": "EVT::NEW::CROSS",
|
|
"symbol": "NEWX",
|
|
"execution_date": d5,
|
|
"entry_date": d5.isoformat(),
|
|
"event_date": d5.isoformat(),
|
|
"event_close": 50.0,
|
|
"entry_price": 50.0,
|
|
"score": 0.80,
|
|
"sector": "Technology",
|
|
"event_type": "other_material_event",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "not_provided",
|
|
"event_timestamp": "2026-01-09T13:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": d5.isoformat(),
|
|
"reaction_day_return": 0.01,
|
|
"close_location": 0.70,
|
|
"volume_ratio_20d": 1.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={
|
|
"OLDX": {
|
|
d1: {"date": d1, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000},
|
|
d2: {"date": d2, "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 100.2, "high": 100.8, "low": 99.8, "close": 100.3, "volume": 1_000_000},
|
|
d4: {"date": d4, "open": 100.3, "high": 100.7, "low": 100.0, "close": 100.4, "volume": 1_000_000},
|
|
d5: {"date": d5, "open": 100.4, "high": 100.8, "low": 100.2, "close": 100.5, "volume": 1_000_000},
|
|
},
|
|
"NEWX": {
|
|
d5: {"date": d5, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000},
|
|
},
|
|
},
|
|
)
|
|
|
|
old_engine = StrategyEngineConfig(
|
|
engine_id="stale_next",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
score_threshold_override=0.5,
|
|
enabled=True,
|
|
)
|
|
new_engine = StrategyEngineConfig(
|
|
engine_id="fresh_next",
|
|
event_types=["other_material_event"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
score_threshold_override=0.5,
|
|
recycle_on_cash_block=True,
|
|
recycle_min_days_held=3,
|
|
recycle_min_score_delta=0.05,
|
|
recycle_allow_any_victim_engine=True,
|
|
recycle_max_victim_fitness=0.45,
|
|
recycle_max_victim_unrealized_r=0.30,
|
|
recycle_positive_pnl_only=True,
|
|
enabled=True,
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_cross_engine_recycle",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[old_engine, new_engine],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=1.0,
|
|
max_daily_new_risk_pct=1.0,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
max_position_value_pct=1.0,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=5),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=[old_engine, new_engine],
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"]
|
|
assert recycle_trades
|
|
assert recycle_trades[0].symbol == "OLDX"
|
|
assert any(trade.symbol == "NEWX" for trade in runner._closed_trades)
|
|
assert result.metrics.trade_count >= 2
|
|
|
|
def test_rotation_can_skip_large_unrealized_winner(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
d1 = dt.date(2026, 1, 5)
|
|
d2 = dt.date(2026, 1, 6)
|
|
d3 = dt.date(2026, 1, 7)
|
|
d4 = dt.date(2026, 1, 8)
|
|
d5 = dt.date(2026, 1, 9)
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
d1: [
|
|
{
|
|
"event_id": "EVT::OLD::ROT",
|
|
"symbol": "OLDR",
|
|
"execution_date": d1,
|
|
"entry_date": d1.isoformat(),
|
|
"event_date": d1.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": d1.isoformat(),
|
|
"reaction_day_return": 0.03,
|
|
"close_location": 0.70,
|
|
"volume_ratio_20d": 1.5,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
d5: [
|
|
{
|
|
"event_id": "EVT::NEW::ROT",
|
|
"symbol": "NEWR",
|
|
"execution_date": d5,
|
|
"entry_date": d5.isoformat(),
|
|
"event_date": d5.isoformat(),
|
|
"event_close": 50.0,
|
|
"entry_price": 50.0,
|
|
"score": 0.85,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-09T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": d5.isoformat(),
|
|
"reaction_day_return": 0.04,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 1.6,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={
|
|
"OLDR": {
|
|
d1: {"date": d1, "open": 100.0, "high": 100.0, "low": 99.0, "close": 100.0, "volume": 1_000_000},
|
|
d2: {"date": d2, "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.8, "volume": 1_000_000},
|
|
d3: {"date": d3, "open": 100.8, "high": 102.5, "low": 100.6, "close": 102.4, "volume": 1_000_000},
|
|
d4: {"date": d4, "open": 102.4, "high": 103.4, "low": 102.2, "close": 103.0, "volume": 1_000_000},
|
|
d5: {"date": d5, "open": 103.0, "high": 103.8, "low": 102.8, "close": 103.5, "volume": 1_000_000},
|
|
},
|
|
"NEWR": {
|
|
d5: {"date": d5, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000},
|
|
},
|
|
},
|
|
)
|
|
|
|
engine = StrategyEngineConfig(
|
|
engine_id="rot_next",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
score_threshold_override=0.5,
|
|
target_1_r_override=6.0,
|
|
rotation_enabled=True,
|
|
rotation_min_days_held=3,
|
|
rotation_fitness_threshold=0.45,
|
|
rotation_min_candidate_score=0.8,
|
|
rotation_max_unrealized_r=0.5,
|
|
enabled=True,
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_rotation_max_unrealized_r",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[engine],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=1.0,
|
|
max_daily_new_risk_pct=1.0,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
max_position_value_pct=1.0,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=5),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=[engine],
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
rotation_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "ROTATION"]
|
|
assert not rotation_trades
|
|
assert not any(trade.symbol == "NEWR" for trade in runner._closed_trades)
|
|
assert result.metrics.trade_count == 1
|
|
|
|
def test_equity_curve_has_all_days(self, tmp_path):
|
|
"""Equity curve has one entry per candidate date."""
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest
|
|
|
|
store = _build_synthetic_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store)
|
|
result = runner.run()
|
|
|
|
# Should have simulated days covering the range (all_trading_days between
|
|
# first and last execution date), plus the initial equity state
|
|
assert result.total_trading_days >= 2
|
|
|
|
def test_deterministic_results(self, tmp_path):
|
|
"""Two runs with same inputs produce identical metrics."""
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
|
|
store1 = _build_synthetic_store()
|
|
runner1 = BacktestRunner(manifest=manifest, config=config, store=store1)
|
|
result1 = runner1.run()
|
|
|
|
store2 = _build_synthetic_store()
|
|
runner2 = BacktestRunner(manifest=manifest, config=config, store=store2)
|
|
result2 = runner2.run()
|
|
|
|
assert result1.metrics.trade_count == result2.metrics.trade_count
|
|
assert result1.metrics.win_rate == result2.metrics.win_rate
|
|
assert result1.metrics.total_return_pct == result2.metrics.total_return_pct
|
|
assert result1.total_candidates_seen == result2.total_candidates_seen
|
|
assert result1.total_orders_rejected == result2.total_orders_rejected
|
|
|
|
def test_no_future_data_used(self, tmp_path):
|
|
"""Candidates for day D should not appear in a simulation of day D-1."""
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
candidates = {
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::001",
|
|
"symbol": "AAPL",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"entry_price": 150.0,
|
|
"score": 0.85,
|
|
"sector": "Technology",
|
|
"event_type": "earnings",
|
|
"event_timestamp": "2026-01-02T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-02",
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 6): [
|
|
{
|
|
"event_id": "EVT::FUTURE",
|
|
"symbol": "FUTURE_TICKER",
|
|
"execution_date": dt.date(2026, 1, 6),
|
|
"entry_date": "2026-01-06",
|
|
"entry_price": 50.0,
|
|
"score": 0.99,
|
|
"sector": "Technology",
|
|
"event_type": "earnings",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"avg_dollar_volume": 1_000_000.0,
|
|
"atr_14": 1.0,
|
|
}
|
|
],
|
|
}
|
|
bars = {
|
|
"AAPL": {dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000}},
|
|
"FUTURE_TICKER": {dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 50.0, "high": 55.0, "low": 48.0, "close": 52.0, "volume": 500_000}},
|
|
}
|
|
store = SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars)
|
|
|
|
# Querying Jan 5 should NOT return FUTURE_TICKER candidate
|
|
rows = store.get_candidates_for_date(dt.date(2026, 1, 5))
|
|
symbols = [r["symbol"] for r in rows]
|
|
assert "FUTURE_TICKER" not in symbols
|
|
assert "AAPL" in symbols
|
|
|
|
def test_multi_engine_run_writes_per_engine_metrics(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig
|
|
import json
|
|
import pyarrow.parquet as pq
|
|
|
|
store = _build_multi_engine_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="portfolio_v2",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="earnings_same_day_short_v1",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="short_only",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=3,
|
|
engine_risk_budget_pct=0.40,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="earnings_after_close_long_v1",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=5,
|
|
engine_risk_budget_pct=0.25,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="earnings_after_close_short_v1",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="short_only",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=3,
|
|
engine_risk_budget_pct=0.10,
|
|
shadow_only=True,
|
|
),
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
run_dir = tmp_path / result.run_id
|
|
per_engine_metrics = run_dir / "metrics" / "per_engine_metrics.json"
|
|
attribution_by_engine = run_dir / "metrics" / "attribution_by_engine.csv"
|
|
|
|
assert per_engine_metrics.exists()
|
|
assert attribution_by_engine.exists()
|
|
|
|
payload = per_engine_metrics.read_text()
|
|
assert "earnings_same_day_short_v1" in payload
|
|
assert "earnings_after_close_long_v1" in payload
|
|
assert "earnings_after_close_short_v1" in payload
|
|
assert result.metrics.trade_count >= 1
|
|
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
engine_ids = {row["engine_id"] for row in trade_blotter}
|
|
assert "earnings_after_close_short_v1" not in engine_ids
|
|
|
|
equity_curve = pq.read_table(run_dir / "artifacts" / "daily_equity_curve.parquet").to_pylist()
|
|
assert any(float(row["net_exposure"]) < 0 for row in equity_curve if float(row["gross_exposure"]) > 0)
|
|
|
|
metrics_summary = json.loads((run_dir / "metrics" / "metrics_summary.json").read_text())
|
|
assert "avg_gross_exposure_pct" in metrics_summary
|
|
assert "avg_net_exposure_pct" in metrics_summary
|
|
assert "days_in_market_pct" in metrics_summary
|
|
|
|
def test_engine_execution_overrides_flow_into_effective_execution_config(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import Candidate, ExperimentManifest, StrategyEngineConfig
|
|
|
|
store = _build_multi_engine_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="portfolio_exec_overrides",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="earnings_same_day_long_trend_v1",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
max_holding_days=12,
|
|
engine_risk_budget_pct=0.25,
|
|
target_atr_multiplier_override=2.5,
|
|
target_1_r_override=3.25,
|
|
target_1_fraction_override=0.33,
|
|
trailing_model_override="pct_10",
|
|
trailing_warmup_days_override=2,
|
|
),
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
|
|
candidate = Candidate(
|
|
event_id="EVT::SD::LONG",
|
|
symbol="AMD",
|
|
issuer_id="ISSUER::AMD",
|
|
score=0.92,
|
|
sector="Technology",
|
|
event_type="earnings_release",
|
|
event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC),
|
|
event_date=dt.date(2026, 1, 6),
|
|
filing_time_bucket="post_market",
|
|
timing_class="same_day",
|
|
reaction_date=dt.date(2026, 1, 6),
|
|
execution_date=dt.date(2026, 1, 6),
|
|
entry_price_est=122.0,
|
|
avg_dollar_volume=9_000_000.0,
|
|
atr_14=3.0,
|
|
score_bucket="high",
|
|
engine_id="earnings_same_day_long_trend_v1",
|
|
entry_timing_policy="reaction_close",
|
|
shadow_only=False,
|
|
engine_max_holding_days=12,
|
|
engine_risk_budget_pct=0.25,
|
|
engine_target_atr_multiplier=2.5,
|
|
engine_target_1_r=3.25,
|
|
engine_target_1_fraction=0.33,
|
|
engine_trailing_model="pct_10",
|
|
engine_trailing_warmup_days=2,
|
|
trade_direction="long",
|
|
)
|
|
effective_exec = runner._build_effective_execution_config(candidate)
|
|
|
|
assert candidate.engine_id == "earnings_same_day_long_trend_v1"
|
|
assert effective_exec.max_holding_days == 12
|
|
assert effective_exec.target_atr_multiplier == pytest.approx(2.5)
|
|
assert effective_exec.target_1_r == pytest.approx(3.25)
|
|
assert effective_exec.target_1_fraction == pytest.approx(0.33)
|
|
assert effective_exec.trailing_model == "pct_10"
|
|
assert effective_exec.trailing_warmup_days == 2
|
|
|
|
def test_engine_target_overrides_take_precedence_over_tiered_targets(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import Candidate, ExecutionConfig, ExperimentManifest, SignalConfig, StrategyEngineConfig
|
|
|
|
store = _build_multi_engine_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="portfolio_exec_target_override_precedence",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="earnings_same_day_long_trend_v1",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
target_1_r_override=3.5,
|
|
target_1_fraction_override=0.1,
|
|
),
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
config = config.model_copy(
|
|
update={
|
|
"signal": SignalConfig(score_threshold=0.5, max_candidates_per_day=5, a_tier_score_threshold=0.8),
|
|
"execution": ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=10.0,
|
|
commission_per_share=0.005,
|
|
same_bar_priority="stop_first_conservative",
|
|
max_holding_days=10,
|
|
target_1_r=1.5,
|
|
target_1_fraction=0.5,
|
|
use_tiered_targets=True,
|
|
a_tier_target_1_r=2.0,
|
|
a_tier_target_1_fraction=0.25,
|
|
non_a_tier_target_1_r=1.25,
|
|
non_a_tier_target_1_fraction=0.6,
|
|
),
|
|
}
|
|
)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
|
|
candidate = Candidate(
|
|
event_id="EVT::SD::LONG::TIER",
|
|
symbol="AMD",
|
|
issuer_id="ISSUER::AMD",
|
|
score=0.92,
|
|
sector="Technology",
|
|
event_type="earnings_release",
|
|
event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC),
|
|
event_date=dt.date(2026, 1, 6),
|
|
filing_time_bucket="post_market",
|
|
timing_class="same_day",
|
|
reaction_date=dt.date(2026, 1, 6),
|
|
execution_date=dt.date(2026, 1, 6),
|
|
entry_price_est=122.0,
|
|
avg_dollar_volume=9_000_000.0,
|
|
atr_14=3.0,
|
|
score_bucket="high",
|
|
engine_id="earnings_same_day_long_trend_v1",
|
|
entry_timing_policy="reaction_close",
|
|
shadow_only=False,
|
|
engine_target_1_r=3.5,
|
|
engine_target_1_fraction=0.1,
|
|
trade_direction="long",
|
|
)
|
|
effective_exec = runner._build_effective_execution_config(candidate)
|
|
|
|
assert effective_exec.target_1_r == pytest.approx(3.5)
|
|
assert effective_exec.target_1_fraction == pytest.approx(0.1)
|
|
|
|
def test_funding_optimizer_prefers_more_capital_efficient_candidate(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import Candidate, DailyPortfolioState, ExperimentManifest
|
|
|
|
store = _build_multi_engine_store()
|
|
manifest = ExperimentManifest(
|
|
experiment_name="portfolio_cap_efficiency_order",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
config = config.model_copy(update={"strategy_engine_selection_mode": "interleave_cap_efficiency_soft"})
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
|
|
expensive = Candidate(
|
|
event_id="EVT::EXPENSIVE",
|
|
symbol="EXP",
|
|
issuer_id="ISSUER::EXP",
|
|
score=0.9,
|
|
sector="Technology",
|
|
event_type="earnings_release",
|
|
event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC),
|
|
event_date=dt.date(2026, 1, 6),
|
|
filing_time_bucket="post_market",
|
|
timing_class="same_day",
|
|
reaction_date=dt.date(2026, 1, 6),
|
|
execution_date=dt.date(2026, 1, 6),
|
|
entry_price_est=100.0,
|
|
avg_dollar_volume=20_000_000.0,
|
|
atr_14=2.0,
|
|
score_bucket="high",
|
|
engine_id="core",
|
|
entry_timing_policy="reaction_close",
|
|
shadow_only=False,
|
|
trade_direction="long",
|
|
forced_shares=500,
|
|
)
|
|
cheap = Candidate(
|
|
event_id="EVT::CHEAP",
|
|
symbol="CHP",
|
|
issuer_id="ISSUER::CHP",
|
|
score=0.9,
|
|
sector="Technology",
|
|
event_type="earnings_release",
|
|
event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC),
|
|
event_date=dt.date(2026, 1, 6),
|
|
filing_time_bucket="post_market",
|
|
timing_class="same_day",
|
|
reaction_date=dt.date(2026, 1, 6),
|
|
execution_date=dt.date(2026, 1, 6),
|
|
entry_price_est=100.0,
|
|
avg_dollar_volume=20_000_000.0,
|
|
atr_14=2.0,
|
|
score_bucket="high",
|
|
engine_id="core",
|
|
entry_timing_policy="reaction_close",
|
|
shadow_only=False,
|
|
trade_direction="long",
|
|
forced_shares=50,
|
|
)
|
|
portfolio_state = DailyPortfolioState(
|
|
date=dt.date(2026, 1, 6),
|
|
equity=100_000.0,
|
|
cash_available=100_000.0,
|
|
gross_exposure=0.0,
|
|
net_exposure=0.0,
|
|
reserved_risk_budget=0.0,
|
|
unrealized_pnl=0.0,
|
|
realized_pnl=0.0,
|
|
open_positions=[],
|
|
daily_new_risk_used=0.0,
|
|
peak_equity=100_000.0,
|
|
current_drawdown_pct=0.0,
|
|
)
|
|
|
|
ordered = runner._reorder_candidates_for_funding([expensive, cheap], portfolio_state, macro_data=None)
|
|
assert [candidate.symbol for candidate in ordered[:2]] == ["CHP", "EXP"]
|
|
|
|
def test_attention_gate_filters_engine_candidates(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures
|
|
|
|
date = dt.date(2026, 1, 7)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
date: [
|
|
{
|
|
"event_id": "EVT::ATTN::PASS",
|
|
"symbol": "PASS",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-07",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 100.0,
|
|
"gap_size": 0.12,
|
|
"entry_price": 100.0,
|
|
"score": 0.90,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"reaction_day_return": -0.12,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::ATTN::FAIL",
|
|
"symbol": "FAIL",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-07",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 101.0,
|
|
"gap_size": 0.11,
|
|
"entry_price": 101.0,
|
|
"score": 0.89,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"reaction_day_return": -0.11,
|
|
"avg_dollar_volume": 7_500_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
]
|
|
},
|
|
bars_by_symbol_date={
|
|
"PASS": {date: {"date": date, "open": 100.0, "high": 100.0, "low": 95.0, "close": 96.0, "volume": 1_000_000}},
|
|
"FAIL": {date: {"date": date, "open": 101.0, "high": 102.0, "low": 98.0, "close": 99.0, "volume": 900_000}},
|
|
},
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="attention_gate_test",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="same_day_short_attention",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="short_only",
|
|
attention_min_wiki_spike_10d=1.2,
|
|
)
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store)
|
|
|
|
def _fake_attention(candidate):
|
|
spike = 1.5 if candidate.symbol == "PASS" else 0.9
|
|
return EventAttentionResponse(
|
|
ticker=candidate.symbol,
|
|
event_date="2026-01-06",
|
|
entity=EntityInfo(
|
|
ticker=candidate.symbol,
|
|
canonical_name=candidate.symbol,
|
|
resolver_confidence=0.9,
|
|
),
|
|
wiki=WikiFeatures(spike_10d=spike, zscore_20d=1.0),
|
|
news=NewsFeatures(),
|
|
metadata={},
|
|
)
|
|
|
|
runner._get_event_attention = _fake_attention # type: ignore[method-assign]
|
|
selected = runner._select_candidates_for_date(date)
|
|
|
|
assert [candidate.symbol for candidate in selected] == ["PASS"]
|
|
assert selected[0].features["attention_wiki_spike_10d"] == pytest.approx(1.5)
|
|
|
|
def test_attention_max_gate_allows_missing_payload(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
date = dt.date(2026, 1, 6)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
date: [
|
|
{
|
|
"event_id": "EVT::ATTN::KNOWN",
|
|
"symbol": "KNOWN",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-06",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 100.0,
|
|
"gap_size": 0.12,
|
|
"entry_price": 100.0,
|
|
"score": 0.90,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"reaction_day_return": 0.15,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::ATTN::MISSING",
|
|
"symbol": "MISSING",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-06",
|
|
"event_date": "2026-01-06",
|
|
"event_close": 101.0,
|
|
"gap_size": 0.11,
|
|
"entry_price": 101.0,
|
|
"score": 0.89,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-06T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"reaction_day_return": 0.14,
|
|
"avg_dollar_volume": 7_500_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
]
|
|
},
|
|
bars_by_symbol_date={
|
|
"KNOWN": {date: {"date": date, "open": 100.0, "high": 105.0, "low": 99.0, "close": 103.0, "volume": 1_000_000}},
|
|
"MISSING": {date: {"date": date, "open": 101.0, "high": 104.0, "low": 100.0, "close": 102.0, "volume": 900_000}},
|
|
},
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="attention_max_gate_test",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="same_day_long_attention_cap",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
gap_size_min=0.10,
|
|
attention_max_wiki_spike_10d=1.5,
|
|
)
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store)
|
|
|
|
def _fake_attention(candidate):
|
|
if candidate.symbol == "KNOWN":
|
|
from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures
|
|
|
|
return EventAttentionResponse(
|
|
ticker=candidate.symbol,
|
|
event_date="2026-01-06",
|
|
entity=EntityInfo(
|
|
ticker=candidate.symbol,
|
|
canonical_name=candidate.symbol,
|
|
resolver_confidence=0.9,
|
|
),
|
|
wiki=WikiFeatures(spike_10d=1.4, zscore_20d=0.5),
|
|
news=NewsFeatures(),
|
|
metadata={},
|
|
)
|
|
return None
|
|
|
|
runner._get_event_attention = _fake_attention # type: ignore[method-assign]
|
|
selected = runner._select_candidates_for_date(date)
|
|
|
|
assert [candidate.symbol for candidate in selected] == ["KNOWN", "MISSING"]
|
|
assert selected[0].features["attention_wiki_spike_10d"] == pytest.approx(1.4)
|
|
assert "attention_wiki_spike_10d" not in selected[1].features
|
|
|
|
def test_attention_path_honors_custom_ranking_fields(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures
|
|
|
|
date = dt.date(2026, 1, 8)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
date: [
|
|
{
|
|
"event_id": "EVT::ATTN::DOCWIN",
|
|
"symbol": "DOCWIN",
|
|
"execution_date": date,
|
|
"entry_date": date.isoformat(),
|
|
"event_date": date.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.90,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-08T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": date.isoformat(),
|
|
"reaction_day_return": 0.09,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"parse_confidence_overall": 0.82,
|
|
"parse_confidence_event_direction": 0.82,
|
|
"parse_confidence_guidance": 0.82,
|
|
"document_quality_score": 0.95,
|
|
"signal_strength_score": 0.95,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.05,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::ATTN::CLOSEWIN",
|
|
"symbol": "CLOSEWIN",
|
|
"execution_date": date,
|
|
"entry_date": date.isoformat(),
|
|
"event_date": date.isoformat(),
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.89,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-08T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": date.isoformat(),
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.92,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"parse_confidence_overall": 0.70,
|
|
"parse_confidence_event_direction": 0.70,
|
|
"parse_confidence_guidance": 0.70,
|
|
"document_quality_score": 0.60,
|
|
"signal_strength_score": 0.60,
|
|
"guidance_direction_score": 0.5,
|
|
"oneoff_penalty": 0.05,
|
|
"avg_dollar_volume": 7_500_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
]
|
|
},
|
|
bars_by_symbol_date={},
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="attention_ranking_fields_test",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="same_day_long_attention",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
attention_max_wiki_spike_10d=2.0,
|
|
)
|
|
],
|
|
)
|
|
config = _make_config(strategy_engines=manifest.strategy_engines)
|
|
config.signal.scoring_model = "return_max_long_v2"
|
|
config.signal.score_threshold = 0.45
|
|
config.signal.max_candidates_per_day = 1
|
|
config.signal.ranking_fields = ["-close_location", "-score"]
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store)
|
|
|
|
def _fake_attention(candidate):
|
|
return EventAttentionResponse(
|
|
ticker=candidate.symbol,
|
|
event_date=date.isoformat(),
|
|
entity=EntityInfo(
|
|
ticker=candidate.symbol,
|
|
canonical_name=candidate.symbol,
|
|
resolver_confidence=0.9,
|
|
),
|
|
wiki=WikiFeatures(spike_10d=1.0, zscore_20d=0.5),
|
|
news=NewsFeatures(),
|
|
metadata={},
|
|
)
|
|
|
|
runner._get_event_attention = _fake_attention # type: ignore[method-assign]
|
|
selected = runner._select_candidates_for_date(date)
|
|
|
|
assert [candidate.symbol for candidate in selected] == ["CLOSEWIN"]
|
|
|
|
def test_return_max_long_strategy_schedules_add_on_and_writes_benchmark_metrics(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
import json
|
|
import pyarrow.parquet as pq
|
|
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]]
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::LONG::001",
|
|
"symbol": "NVDA",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.82,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.9,
|
|
"parse_confidence_event_direction": 0.85,
|
|
"parse_confidence_guidance": 0.8,
|
|
"document_quality_score": 0.8,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 120_000_000.0,
|
|
"market_cap_proxy": 10_000_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 8): [],
|
|
},
|
|
bars_by_symbol_date={
|
|
"NVDA": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000},
|
|
}
|
|
},
|
|
macro_by_date={
|
|
dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0},
|
|
dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0},
|
|
dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0},
|
|
dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0},
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="reaction_close_long_core",
|
|
event_types=["earnings_release", "guidance_update"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
max_holding_days=20,
|
|
engine_risk_budget_pct=0.5,
|
|
reaction_day_return_min=0.04,
|
|
reaction_day_return_max=0.12,
|
|
close_location_min=0.70,
|
|
volume_ratio_min=1.5,
|
|
score_threshold_override=0.62,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="delayed_add_on_long",
|
|
event_types=["earnings_release", "guidance_update"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
synthetic_only=True,
|
|
max_holding_days=15,
|
|
engine_risk_budget_pct=1.0,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
universe=UniverseConfig(
|
|
min_price=15.0,
|
|
min_avg_dollar_volume=75_000_000.0,
|
|
min_market_cap_proxy=2_000_000_000.0,
|
|
),
|
|
signal=SignalConfig(
|
|
score_threshold=0.62,
|
|
max_candidates_per_day=6,
|
|
scoring_model="return_max_long_v1",
|
|
a_tier_score_threshold=0.75,
|
|
),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.004,
|
|
per_trade_risk_pct_a_tier=0.005,
|
|
max_daily_new_risk_pct=0.015,
|
|
max_positions=6,
|
|
max_positions_per_sector=2,
|
|
macro_regime_enabled=True,
|
|
macro_regime_mode="spy_qqq_scaler",
|
|
macro_regime_neutral_size_scaler=0.6,
|
|
macro_regime_risk_off_size_scaler=0.35,
|
|
macro_regime_risk_off_a_tier_only=True,
|
|
stop_atr_multiplier=2.25,
|
|
veto_oneoff_penalty=0.4,
|
|
veto_parse_confidence_min=0.6,
|
|
veto_unknown_direction=True,
|
|
veto_bearish_direction=True,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
target_model="fixed_r",
|
|
target_1_r=1.5,
|
|
target_1_fraction=0.5,
|
|
use_tiered_targets=True,
|
|
a_tier_target_1_r=2.0,
|
|
a_tier_target_1_fraction=0.25,
|
|
non_a_tier_target_1_r=1.5,
|
|
non_a_tier_target_1_fraction=0.5,
|
|
trailing_model="pct_6",
|
|
trailing_warmup_days=3,
|
|
max_holding_days=15,
|
|
early_failure_close_below_entry_and_reaction_close=True,
|
|
early_failure_no_progress_days=2,
|
|
early_failure_no_progress_r=0.5,
|
|
early_failure_no_progress_fraction=0.5,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
event_type_profiles={
|
|
"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20),
|
|
"guidance_update": EventTypeProfile(enabled=True, max_holding_days_override=12),
|
|
"unknown": EventTypeProfile(enabled=False),
|
|
},
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
run_dir = tmp_path / result.run_id
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
assert any(bool(row["is_add_on"]) for row in trade_blotter)
|
|
add_on_rows = [row for row in trade_blotter if row["engine_id"] == "delayed_add_on_long"]
|
|
assert add_on_rows
|
|
assert all(bool(row["is_add_on"]) for row in add_on_rows)
|
|
assert all(row["parent_position_id"] is not None for row in add_on_rows)
|
|
|
|
metrics_summary = json.loads((run_dir / "metrics" / "metrics_summary.json").read_text())
|
|
assert metrics_summary["qqq_benchmark_return_pct"] is not None
|
|
assert metrics_summary["excess_vs_qqq_pct"] is not None
|
|
assert metrics_summary["long_pnl_contribution_pct"] is not None
|
|
|
|
def test_residual_priority_engine_excludes_selected_names_from_core_queue(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
date = dt.date(2026, 1, 5)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
date: [
|
|
{
|
|
"event_id": "EVT::A",
|
|
"symbol": "AAPL",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "regular_hours",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.10,
|
|
"close_location": 0.75,
|
|
"volume_ratio_20d": 2.5,
|
|
"gap_size": 0.03,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.90,
|
|
"parse_confidence_event_direction": 0.85,
|
|
"parse_confidence_guidance": 0.80,
|
|
"document_quality_score": 0.80,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 120_000_000.0,
|
|
"market_cap_proxy": 10_000_000_000.0,
|
|
"atr_14": 3.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::B",
|
|
"symbol": "MSFT",
|
|
"execution_date": date,
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 201.0,
|
|
"entry_price": 201.0,
|
|
"score": 0.58,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "regular_hours",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 2.1,
|
|
"gap_size": 0.02,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.88,
|
|
"parse_confidence_event_direction": 0.84,
|
|
"parse_confidence_guidance": 0.79,
|
|
"document_quality_score": 0.79,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 150_000_000.0,
|
|
"market_cap_proxy": 20_000_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
],
|
|
},
|
|
bars_by_symbol_date={},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="residual_priority_test",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="priority",
|
|
event_types=["earnings_release"],
|
|
event_directions=["bullish"],
|
|
guidance_statuses=["raised"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
reaction_day_return_min=0.08,
|
|
reaction_day_return_max=0.18,
|
|
volume_ratio_min=2.0,
|
|
residual_reserve_selected=True,
|
|
score_threshold_override=0.50,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
reaction_day_return_min=0.03,
|
|
reaction_day_return_max=0.18,
|
|
volume_ratio_min=1.0,
|
|
score_threshold_override=0.50,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0),
|
|
signal=SignalConfig(
|
|
score_threshold=0.50,
|
|
max_candidates_per_day=2,
|
|
scoring_model="return_max_long_v2",
|
|
),
|
|
risk=RiskConfig(max_positions=4, max_positions_per_sector=4),
|
|
execution=ExecutionConfig(),
|
|
reporting=ReportingConfig(generate_plots=False),
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store)
|
|
selected = runner._select_candidates_for_date(date)
|
|
|
|
assert [candidate.symbol for candidate in selected] == ["AAPL", "MSFT"]
|
|
assert [candidate.engine_id for candidate in selected] == ["priority", "core"]
|
|
|
|
def test_return_max_long_staged_add_on_can_scale_twice(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
import pyarrow.parquet as pq
|
|
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9, 12]]
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::LONG::STAGED",
|
|
"symbol": "NVDA",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.84,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "regular_hours",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.9,
|
|
"parse_confidence_event_direction": 0.85,
|
|
"parse_confidence_guidance": 0.8,
|
|
"document_quality_score": 0.8,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 120_000_000.0,
|
|
"market_cap_proxy": 10_000_000_000.0,
|
|
"atr_14": 3.0,
|
|
"reaction_day_high": 102.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 12): [],
|
|
},
|
|
bars_by_symbol_date={
|
|
"NVDA": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 106.0, "low": 101.0, "close": 105.0, "volume": 1_900_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 106.0, "high": 109.0, "low": 105.0, "close": 108.0, "volume": 1_850_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 109.0, "high": 114.0, "low": 108.0, "close": 113.0, "volume": 1_800_000},
|
|
dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 114.0, "high": 118.0, "low": 113.0, "close": 117.0, "volume": 1_750_000},
|
|
dt.date(2026, 1, 12): {"date": dt.date(2026, 1, 12), "open": 118.0, "high": 120.0, "low": 116.0, "close": 119.0, "volume": 1_700_000},
|
|
}
|
|
},
|
|
macro_by_date={
|
|
day: {"spy_close": 500.0 + idx, "spy_sma_20": 490.0, "qqq_close": 420.0 + idx, "qqq_sma_20": 410.0}
|
|
for idx, day in enumerate(dates)
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="return_max_long_v1_staged_add_on",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="reaction_close_long_core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
max_holding_days=20,
|
|
engine_risk_budget_pct=1.0,
|
|
reaction_day_return_min=0.04,
|
|
reaction_day_return_max=0.12,
|
|
close_location_min=0.70,
|
|
volume_ratio_min=1.5,
|
|
score_threshold_override=0.62,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="delayed_add_on_long",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
synthetic_only=True,
|
|
max_holding_days=15,
|
|
engine_risk_budget_pct=1.0,
|
|
add_on_parent_score_min=0.75,
|
|
add_on_max_parent_days_held=4,
|
|
add_on_schedule_days=[1, 3],
|
|
add_on_progress_r_levels=[0.50, 1.50],
|
|
add_on_max_count=2,
|
|
add_on_size_fraction=0.25,
|
|
add_on_close_location_min=0.70,
|
|
add_on_require_above_reaction_high=True,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0),
|
|
signal=SignalConfig(score_threshold=0.62, max_candidates_per_day=6, scoring_model="return_max_long_v1", a_tier_score_threshold=0.75),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
per_trade_risk_pct_a_tier=0.01,
|
|
max_daily_new_risk_pct=0.20,
|
|
max_positions=6,
|
|
max_positions_per_sector=3,
|
|
macro_regime_enabled=True,
|
|
macro_regime_mode="spy_qqq_scaler",
|
|
macro_regime_neutral_size_scaler=1.0,
|
|
macro_regime_risk_off_size_scaler=1.0,
|
|
macro_regime_risk_off_a_tier_only=False,
|
|
stop_atr_multiplier=2.25,
|
|
veto_oneoff_penalty=0.4,
|
|
veto_parse_confidence_min=0.6,
|
|
veto_unknown_direction=False,
|
|
veto_bearish_direction=True,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
target_model="fixed_r",
|
|
target_1_r=10.0,
|
|
target_1_fraction=0.0,
|
|
trailing_model=None,
|
|
trailing_warmup_days=20,
|
|
max_holding_days=20,
|
|
),
|
|
reporting=ReportingConfig(write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False),
|
|
event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20)},
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
run_dir = tmp_path / result.run_id
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
add_on_rows = [row for row in trade_blotter if row["engine_id"] == "delayed_add_on_long"]
|
|
assert len(add_on_rows) >= 2
|
|
assert sorted(int(row["shares"]) for row in add_on_rows)[0] > 0
|
|
assert all(bool(row["is_add_on"]) for row in add_on_rows)
|
|
|
|
def test_return_max_long_add_on_parent_score_gate_blocks_synthetic_child(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
import pyarrow.parquet as pq
|
|
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]]
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::LONG::002",
|
|
"symbol": "NVDA",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.70,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.9,
|
|
"document_quality_score": 0.8,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 120_000_000.0,
|
|
"market_cap_proxy": 10_000_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 8): [],
|
|
},
|
|
bars_by_symbol_date={
|
|
"NVDA": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000},
|
|
}
|
|
},
|
|
macro_by_date={
|
|
dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0},
|
|
dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0},
|
|
dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0},
|
|
dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0},
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="return_max_long_v1_add_on_parent_gate",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="reaction_close_long_core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
max_holding_days=20,
|
|
engine_risk_budget_pct=1.0,
|
|
reaction_day_return_min=0.04,
|
|
reaction_day_return_max=0.12,
|
|
close_location_min=0.70,
|
|
volume_ratio_min=1.5,
|
|
score_threshold_override=0.62,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="delayed_add_on_long",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
synthetic_only=True,
|
|
max_holding_days=15,
|
|
engine_risk_budget_pct=1.0,
|
|
add_on_parent_score_min=0.80,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
universe=UniverseConfig(
|
|
min_price=15.0,
|
|
min_avg_dollar_volume=75_000_000.0,
|
|
min_market_cap_proxy=2_000_000_000.0,
|
|
),
|
|
signal=SignalConfig(
|
|
score_threshold=0.62,
|
|
max_candidates_per_day=6,
|
|
scoring_model="return_max_long_v1",
|
|
a_tier_score_threshold=0.75,
|
|
),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.004,
|
|
per_trade_risk_pct_a_tier=0.005,
|
|
max_daily_new_risk_pct=0.05,
|
|
max_positions=6,
|
|
max_positions_per_sector=2,
|
|
macro_regime_enabled=True,
|
|
macro_regime_mode="spy_qqq_scaler",
|
|
macro_regime_neutral_size_scaler=0.6,
|
|
macro_regime_risk_off_size_scaler=0.35,
|
|
macro_regime_risk_off_a_tier_only=False,
|
|
stop_atr_multiplier=2.25,
|
|
veto_oneoff_penalty=0.4,
|
|
veto_parse_confidence_min=0.6,
|
|
veto_unknown_direction=False,
|
|
veto_bearish_direction=True,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
target_model="fixed_r",
|
|
target_1_r=1.5,
|
|
target_1_fraction=0.5,
|
|
use_tiered_targets=True,
|
|
a_tier_target_1_r=2.0,
|
|
a_tier_target_1_fraction=0.25,
|
|
non_a_tier_target_1_r=1.5,
|
|
non_a_tier_target_1_fraction=0.5,
|
|
trailing_model="pct_6",
|
|
trailing_warmup_days=3,
|
|
max_holding_days=15,
|
|
early_failure_close_below_entry_and_reaction_close=True,
|
|
early_failure_no_progress_days=2,
|
|
early_failure_no_progress_r=0.5,
|
|
early_failure_no_progress_fraction=0.5,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
event_type_profiles={
|
|
"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20),
|
|
"unknown": EventTypeProfile(enabled=False),
|
|
},
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
run_dir = tmp_path / result.run_id
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
assert not any(bool(row["is_add_on"]) for row in trade_blotter)
|
|
assert not any(row["engine_id"] == "delayed_add_on_long" for row in trade_blotter)
|
|
|
|
def test_return_max_long_add_on_parent_engine_gate_blocks_non_matching_parent(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
import pyarrow.parquet as pq
|
|
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]]
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 5): [
|
|
{
|
|
"event_id": "EVT::LONG::003",
|
|
"symbol": "NVDA",
|
|
"execution_date": dt.date(2026, 1, 5),
|
|
"entry_date": "2026-01-05",
|
|
"event_date": "2026-01-05",
|
|
"event_close": 101.0,
|
|
"entry_price": 101.0,
|
|
"score": 0.72,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.9,
|
|
"document_quality_score": 0.8,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.1,
|
|
"avg_dollar_volume": 120_000_000.0,
|
|
"market_cap_proxy": 10_000_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 8): [],
|
|
},
|
|
bars_by_symbol_date={
|
|
"NVDA": {
|
|
dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000},
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000},
|
|
dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000},
|
|
}
|
|
},
|
|
macro_by_date={
|
|
dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0},
|
|
dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0},
|
|
dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0},
|
|
dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0},
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="return_max_long_v1_add_on_parent_engine_gate",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="next_open_controlled_long",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=15,
|
|
engine_risk_budget_pct=1.0,
|
|
reaction_day_return_min=0.03,
|
|
reaction_day_return_max=0.10,
|
|
close_location_min=0.65,
|
|
volume_ratio_min=1.5,
|
|
score_threshold_override=0.60,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="delayed_add_on_long",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
synthetic_only=True,
|
|
max_holding_days=15,
|
|
engine_risk_budget_pct=1.0,
|
|
add_on_parent_engine_ids=["reaction_close_long_core"],
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
universe=UniverseConfig(
|
|
min_price=15.0,
|
|
min_avg_dollar_volume=75_000_000.0,
|
|
min_market_cap_proxy=2_000_000_000.0,
|
|
),
|
|
signal=SignalConfig(
|
|
score_threshold=0.55,
|
|
max_candidates_per_day=6,
|
|
scoring_model="return_max_long_v2",
|
|
a_tier_score_threshold=0.68,
|
|
),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.0045,
|
|
per_trade_risk_pct_a_tier=0.0055,
|
|
max_daily_new_risk_pct=0.04,
|
|
max_positions=10,
|
|
max_positions_per_sector=3,
|
|
macro_regime_enabled=True,
|
|
macro_regime_mode="spy_qqq_scaler",
|
|
macro_regime_neutral_size_scaler=0.85,
|
|
macro_regime_risk_off_size_scaler=0.60,
|
|
macro_regime_risk_off_a_tier_only=False,
|
|
stop_atr_multiplier=2.25,
|
|
veto_oneoff_penalty=0.4,
|
|
veto_parse_confidence_min=0.6,
|
|
veto_unknown_direction=False,
|
|
veto_bearish_direction=True,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
target_model="fixed_r",
|
|
target_1_r=1.5,
|
|
target_1_fraction=0.5,
|
|
use_tiered_targets=True,
|
|
a_tier_target_1_r=2.0,
|
|
a_tier_target_1_fraction=0.25,
|
|
non_a_tier_target_1_r=1.5,
|
|
non_a_tier_target_1_fraction=0.5,
|
|
trailing_model="pct_6",
|
|
trailing_warmup_days=3,
|
|
max_holding_days=15,
|
|
early_failure_close_below_entry_and_reaction_close=True,
|
|
early_failure_no_progress_days=2,
|
|
early_failure_no_progress_r=0.5,
|
|
early_failure_no_progress_fraction=0.5,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
event_type_profiles={
|
|
"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20),
|
|
"unknown": EventTypeProfile(enabled=False),
|
|
},
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
|
|
run_dir = tmp_path / result.run_id
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
assert any(row["engine_id"] == "next_open_controlled_long" for row in trade_blotter)
|
|
assert not any(bool(row["is_add_on"]) for row in trade_blotter)
|
|
assert not any(row["engine_id"] == "delayed_add_on_long" for row in trade_blotter)
|
|
|
|
def test_evaluate_pending_open_exit_can_schedule_early_pop_giveback(self):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
Candidate,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
OpenPosition,
|
|
PlannedOrder,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
store = SnapshotStore(candidates_by_exec_date={}, bars_by_symbol_date={})
|
|
manifest = ExperimentManifest(
|
|
experiment_name="giveback_eval",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="midlarge-liquid-long-v1",
|
|
universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0),
|
|
signal=SignalConfig(score_threshold=0.45, max_candidates_per_day=10, scoring_model="return_max_long_v2"),
|
|
risk=RiskConfig(per_trade_risk_pct=0.05, max_daily_new_risk_pct=0.5, max_positions=20),
|
|
execution=ExecutionConfig(
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
max_holding_days=25,
|
|
early_pop_giveback_days_min=3,
|
|
early_pop_giveback_days_max=5,
|
|
early_pop_giveback_trigger_r=0.75,
|
|
early_pop_giveback_min_r=0.40,
|
|
early_pop_giveback_from_peak_pct=0.035,
|
|
early_pop_giveback_fraction=1.0,
|
|
),
|
|
reporting=ReportingConfig(generate_plots=False),
|
|
)
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
candidate = Candidate(
|
|
event_id="EVT::GIVEBACK",
|
|
symbol="AAPL",
|
|
score=0.7,
|
|
sector="Technology",
|
|
event_type="earnings_release",
|
|
event_timestamp=dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC),
|
|
event_date=dt.date(2026, 1, 5),
|
|
filing_time_bucket="regular_hours",
|
|
reaction_date=dt.date(2026, 1, 5),
|
|
execution_date=dt.date(2026, 1, 6),
|
|
entry_price_est=100.0,
|
|
avg_dollar_volume=120_000_000.0,
|
|
atr_14=2.0,
|
|
score_bucket="high",
|
|
engine_id="reaction_close_long_core",
|
|
entry_timing_policy="reaction_close",
|
|
features={"event_direction": "bullish", "guidance_status": "raised"},
|
|
)
|
|
plan = PlannedOrder(
|
|
candidate=candidate,
|
|
shares=100,
|
|
entry_price_limit=100.0,
|
|
stop_price=95.0,
|
|
target_price=112.0,
|
|
risk_dollars=500.0,
|
|
)
|
|
position = OpenPosition(
|
|
position_id="POS::1",
|
|
plan=plan,
|
|
entry_date=dt.date(2026, 1, 6),
|
|
entry_price=100.0,
|
|
entry_fill_slippage_bps=0.0,
|
|
current_stop=95.0,
|
|
target_price=112.0,
|
|
peak_price=110.0,
|
|
shares_open=100,
|
|
shares_total=100,
|
|
days_held=4,
|
|
)
|
|
payload = runner._evaluate_pending_open_exit(
|
|
position=position,
|
|
bar={"date": dt.date(2026, 1, 10), "open": 101.0, "high": 103.0, "low": 100.0, "close": 101.0},
|
|
execution_config=config.execution,
|
|
date=dt.date(2026, 1, 10),
|
|
)
|
|
assert payload is not None
|
|
assert payload["reason"] == "GIVEBACK"
|
|
assert payload["fraction"] == pytest.approx(1.0)
|
|
|
|
def test_global_score_selection_uses_custom_ranking_fields(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
model_path = tmp_path / "ranker.json"
|
|
model_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"model_type": "bucket_blend_v1",
|
|
"global_mean": 0.0,
|
|
"features": [
|
|
{
|
|
"name": "direction_guidance_combo",
|
|
"weight": 1.0,
|
|
"values": {
|
|
"bullish|raised": 0.25,
|
|
"unknown|not_provided": 0.05,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
event_date = dt.date(2026, 1, 7)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
event_date: [
|
|
{
|
|
"event_id": "EVT::PRIORITY",
|
|
"symbol": "AAPL",
|
|
"execution_date": event_date,
|
|
"entry_date": event_date.isoformat(),
|
|
"event_date": event_date.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-07T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": event_date.isoformat(),
|
|
"reaction_day_return": 0.10,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 2.2,
|
|
"gap_size": 0.03,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::CORE",
|
|
"symbol": "MSFT",
|
|
"execution_date": event_date,
|
|
"entry_date": event_date.isoformat(),
|
|
"event_date": event_date.isoformat(),
|
|
"event_close": 200.0,
|
|
"entry_price": 200.0,
|
|
"score": 0.90,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "unknown",
|
|
"guidance_status": "not_provided",
|
|
"event_timestamp": "2026-01-07T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": event_date.isoformat(),
|
|
"reaction_day_return": 0.09,
|
|
"close_location": 0.74,
|
|
"volume_ratio_20d": 2.1,
|
|
"gap_size": 0.02,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
]
|
|
},
|
|
bars_by_symbol_date={},
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_global_ranker",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="priority",
|
|
event_types=["earnings_release"],
|
|
event_directions=["bullish"],
|
|
guidance_statuses=["raised"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
enabled=True,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
enabled=True,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(
|
|
score_threshold=0.5,
|
|
max_candidates_per_day=1,
|
|
ranking_model_path=str(model_path),
|
|
ranking_fields=["-learned_rank_score", "-score"],
|
|
),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
max_daily_new_risk_pct=0.05,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=5),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="global_score",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
candidates = runner._select_candidates_for_date(event_date)
|
|
|
|
assert [candidate.symbol for candidate in candidates] == ["AAPL"]
|
|
|
|
def test_interleave_head_score_prioritizes_stronger_core_head(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
event_date = dt.date(2026, 1, 7)
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
event_date: [
|
|
{
|
|
"event_id": "EVT::PRIORITY",
|
|
"symbol": "AAPL",
|
|
"execution_date": event_date,
|
|
"entry_date": event_date.isoformat(),
|
|
"event_date": event_date.isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.60,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"event_timestamp": "2026-01-07T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": event_date.isoformat(),
|
|
"reaction_day_return": 0.10,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 2.2,
|
|
"gap_size": 0.03,
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
},
|
|
{
|
|
"event_id": "EVT::CORE",
|
|
"symbol": "MSFT",
|
|
"execution_date": event_date,
|
|
"entry_date": event_date.isoformat(),
|
|
"event_date": event_date.isoformat(),
|
|
"event_close": 200.0,
|
|
"entry_price": 200.0,
|
|
"score": 0.90,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_direction": "unknown",
|
|
"guidance_status": "not_provided",
|
|
"event_timestamp": "2026-01-07T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": event_date.isoformat(),
|
|
"reaction_day_return": 0.09,
|
|
"close_location": 0.74,
|
|
"volume_ratio_20d": 2.1,
|
|
"gap_size": 0.02,
|
|
"avg_dollar_volume": 8_000_000.0,
|
|
"atr_14": 4.0,
|
|
},
|
|
]
|
|
},
|
|
bars_by_symbol_date={},
|
|
)
|
|
manifest = ExperimentManifest(
|
|
experiment_name="test_interleave_head_score",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="priority",
|
|
event_types=["earnings_release"],
|
|
event_directions=["bullish"],
|
|
guidance_statuses=["raised"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
enabled=True,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
enabled=True,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="test_strategy",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000),
|
|
signal=SignalConfig(
|
|
score_threshold=0.5,
|
|
max_candidates_per_day=2,
|
|
),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
max_daily_new_risk_pct=0.05,
|
|
max_positions=10,
|
|
max_positions_per_sector=5,
|
|
),
|
|
execution=ExecutionConfig(max_holding_days=5),
|
|
reporting=ReportingConfig(),
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave_head_score",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
candidates = runner._select_candidates_for_date(event_date)
|
|
|
|
assert [candidate.symbol for candidate in candidates] == ["MSFT", "AAPL"]
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestWalkForwardRunIntegration:
|
|
def test_run_walk_forward_generates_train_and_test_folds(self, tmp_path, monkeypatch):
|
|
from apps.backtester.run import run_walk_forward
|
|
from libs.backtest.domain import ExperimentManifest
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
trading_dates = [
|
|
dt.date(2026, 1, 5),
|
|
dt.date(2026, 1, 6),
|
|
dt.date(2026, 1, 7),
|
|
dt.date(2026, 1, 8),
|
|
dt.date(2026, 1, 9),
|
|
dt.date(2026, 1, 12),
|
|
dt.date(2026, 1, 13),
|
|
]
|
|
bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {}
|
|
candidates_by_date: dict[dt.date, list[dict[str, object]]] = {}
|
|
for idx, exec_date in enumerate(trading_dates):
|
|
symbol = f"SYM{idx}"
|
|
candidates_by_date[exec_date] = [
|
|
{
|
|
"event_id": f"EVT::{idx}",
|
|
"symbol": symbol,
|
|
"execution_date": exec_date,
|
|
"entry_date": exec_date.isoformat(),
|
|
"event_date": (exec_date - dt.timedelta(days=1)).isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.9,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": exec_date.isoformat(),
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
}
|
|
]
|
|
bars[symbol] = {
|
|
exec_date: {
|
|
"date": exec_date,
|
|
"open": 100.0,
|
|
"high": 106.0,
|
|
"low": 99.0,
|
|
"close": 105.0,
|
|
"volume": 1_000_000,
|
|
},
|
|
exec_date + dt.timedelta(days=1): {
|
|
"date": exec_date + dt.timedelta(days=1),
|
|
"open": 105.0,
|
|
"high": 110.0,
|
|
"low": 104.0,
|
|
"close": 109.0,
|
|
"volume": 900_000,
|
|
},
|
|
}
|
|
|
|
store_map = {
|
|
"train": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[:3]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
"valid": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[3:5]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
"test": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[5:]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
}
|
|
|
|
def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None):
|
|
return store_map[split_name]
|
|
|
|
monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="wf_test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
|
|
summary = run_walk_forward(
|
|
manifest=manifest,
|
|
config=config,
|
|
snapshot_dir_override=None,
|
|
initial_equity=100_000.0,
|
|
output_root=str(tmp_path),
|
|
train_days=3,
|
|
test_days=2,
|
|
step_days=2,
|
|
)
|
|
|
|
assert summary.fold_count == 2
|
|
assert summary.folds[0].train_end < summary.folds[0].test_start
|
|
assert summary.folds[0].train_run_id
|
|
assert summary.folds[0].test_run_id
|
|
assert summary.test_aggregate.mean_return_pct is not None
|
|
assert (tmp_path / "walk_forward" / "walk_forward_summary.json").exists()
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestRobustnessMatrixRunIntegration:
|
|
def test_run_robustness_matrix_generates_summary(self, tmp_path, monkeypatch):
|
|
from apps.backtester.run import run_robustness_matrix
|
|
from libs.backtest.domain import ExperimentManifest
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
trading_dates = [
|
|
dt.date(2026, 1, 5),
|
|
dt.date(2026, 1, 6),
|
|
dt.date(2026, 1, 7),
|
|
dt.date(2026, 1, 8),
|
|
dt.date(2026, 1, 9),
|
|
dt.date(2026, 1, 12),
|
|
dt.date(2026, 1, 13),
|
|
]
|
|
bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {}
|
|
candidates_by_date: dict[dt.date, list[dict[str, object]]] = {}
|
|
for idx, exec_date in enumerate(trading_dates):
|
|
symbol = f"RM{idx}"
|
|
candidates_by_date[exec_date] = [
|
|
{
|
|
"event_id": f"RM::{idx}",
|
|
"symbol": symbol,
|
|
"execution_date": exec_date,
|
|
"entry_date": exec_date.isoformat(),
|
|
"event_date": (exec_date - dt.timedelta(days=1)).isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.9,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": exec_date.isoformat(),
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
}
|
|
]
|
|
bars[symbol] = {
|
|
exec_date: {
|
|
"date": exec_date,
|
|
"open": 100.0,
|
|
"high": 106.0,
|
|
"low": 99.0,
|
|
"close": 105.0,
|
|
"volume": 1_000_000,
|
|
},
|
|
exec_date + dt.timedelta(days=1): {
|
|
"date": exec_date + dt.timedelta(days=1),
|
|
"open": 105.0,
|
|
"high": 109.0,
|
|
"low": 104.0,
|
|
"close": 108.0,
|
|
"volume": 900_000,
|
|
},
|
|
}
|
|
|
|
store_map = {
|
|
"train": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[:3]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
"valid": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[3:5]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
"test": SnapshotStore(
|
|
candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[5:]},
|
|
bars_by_symbol_date=bars,
|
|
),
|
|
}
|
|
|
|
def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None):
|
|
return store_map[split_name]
|
|
|
|
monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="rm_test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
|
|
summary = run_robustness_matrix(
|
|
manifest=manifest,
|
|
config=config,
|
|
snapshot_dir_override=None,
|
|
initial_equity=100_000.0,
|
|
output_root=str(tmp_path),
|
|
horizons_days=[2, 4],
|
|
step_days=2,
|
|
)
|
|
|
|
assert summary.overall_window_count > 0
|
|
assert [item.horizon_days for item in summary.horizon_summaries] == [2, 4]
|
|
assert all(item.window_count > 0 for item in summary.horizon_summaries)
|
|
assert (tmp_path / "robustness_matrix" / "robustness_matrix_summary.json").exists()
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestRobustnessMatrixRunIntegration:
|
|
def test_run_robustness_matrix_generates_summary(self, tmp_path, monkeypatch):
|
|
from apps.backtester.run import run_robustness_matrix
|
|
from libs.backtest.domain import ExperimentManifest
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
trading_dates = [
|
|
dt.date(2026, 1, 5),
|
|
dt.date(2026, 1, 6),
|
|
dt.date(2026, 1, 7),
|
|
dt.date(2026, 1, 8),
|
|
dt.date(2026, 1, 9),
|
|
dt.date(2026, 1, 12),
|
|
dt.date(2026, 1, 13),
|
|
]
|
|
bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {}
|
|
candidates_by_date: dict[dt.date, list[dict[str, object]]] = {}
|
|
for idx, exec_date in enumerate(trading_dates):
|
|
symbol = f"RB{idx}"
|
|
candidates_by_date[exec_date] = [
|
|
{
|
|
"event_id": f"EVT::RB::{idx}",
|
|
"symbol": symbol,
|
|
"execution_date": exec_date,
|
|
"entry_date": exec_date.isoformat(),
|
|
"event_date": (exec_date - dt.timedelta(days=1)).isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.9,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": exec_date.isoformat(),
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 2.0,
|
|
}
|
|
]
|
|
bars[symbol] = {
|
|
exec_date: {
|
|
"date": exec_date,
|
|
"open": 100.0,
|
|
"high": 106.0,
|
|
"low": 99.0,
|
|
"close": 105.0,
|
|
"volume": 1_000_000,
|
|
},
|
|
exec_date + dt.timedelta(days=1): {
|
|
"date": exec_date + dt.timedelta(days=1),
|
|
"open": 105.0,
|
|
"high": 110.0,
|
|
"low": 104.0,
|
|
"close": 109.0,
|
|
"volume": 900_000,
|
|
},
|
|
}
|
|
|
|
merged_store = SnapshotStore(
|
|
candidates_by_exec_date=candidates_by_date,
|
|
bars_by_symbol_date=bars,
|
|
)
|
|
|
|
def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None):
|
|
return merged_store
|
|
|
|
monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="rb_test_exp",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
config = _make_config()
|
|
|
|
summary = run_robustness_matrix(
|
|
manifest=manifest,
|
|
config=config,
|
|
snapshot_dir_override=None,
|
|
initial_equity=100_000.0,
|
|
output_root=str(tmp_path),
|
|
horizons_days=[2, 4],
|
|
step_days=2,
|
|
)
|
|
|
|
assert summary.horizons_days == [2, 4]
|
|
assert summary.step_days == 2
|
|
assert summary.overall_window_count > 0
|
|
assert len(summary.horizon_summaries) == 2
|
|
assert all(item.window_count > 0 for item in summary.horizon_summaries)
|
|
assert all(item.mean_return_pct is not None for item in summary.horizon_summaries)
|
|
assert (tmp_path / "robustness_matrix" / "robustness_matrix_summary.json").exists()
|
|
|
|
def test_delayed_entry_can_use_shadow_only_source_candidates(self, tmp_path):
|
|
from apps.backtester.run import BacktestRunner
|
|
import pyarrow.parquet as pq
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
EventTypeProfile,
|
|
ExperimentManifest,
|
|
ExecutionConfig,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]]
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dates[0]: [
|
|
{
|
|
"event_id": "EVT::DRIFT::001",
|
|
"symbol": "AAPL",
|
|
"execution_date": dates[0],
|
|
"entry_date": dates[0].isoformat(),
|
|
"event_date": dates[0].isoformat(),
|
|
"event_close": 100.0,
|
|
"entry_price": 100.0,
|
|
"score": 0.82,
|
|
"sector": "Technology",
|
|
"event_type": "earnings_release",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": dates[0].isoformat(),
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 2.2,
|
|
"gap_size": 0.01,
|
|
"avg_dollar_volume": 9_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dates[1]: [],
|
|
dates[2]: [],
|
|
dates[3]: [],
|
|
dates[4]: [],
|
|
},
|
|
bars_by_symbol_date={
|
|
"AAPL": {
|
|
dates[0]: {"date": dates[0], "open": 98.0, "high": 101.0, "low": 97.0, "close": 100.0, "volume": 1_000_000},
|
|
dates[1]: {"date": dates[1], "open": 101.0, "high": 104.0, "low": 100.0, "close": 103.0, "volume": 900_000},
|
|
dates[2]: {"date": dates[2], "open": 103.0, "high": 106.0, "low": 102.0, "close": 105.0, "volume": 850_000},
|
|
dates[3]: {"date": dates[3], "open": 105.0, "high": 109.0, "low": 104.0, "close": 108.0, "volume": 800_000},
|
|
dates[4]: {"date": dates[4], "open": 109.0, "high": 111.0, "low": 108.0, "close": 110.0, "volume": 780_000},
|
|
}
|
|
},
|
|
)
|
|
|
|
manifest = ExperimentManifest(
|
|
experiment_name="return_max_long_v1_shadow_delayed",
|
|
dataset_snapshot_id="test_snapshot",
|
|
base_config="configs/backtest/return_max_long_v1.json",
|
|
overrides={},
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="reaction_close_long_core",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
entry_timing_policy="reaction_close",
|
|
max_holding_days=10,
|
|
engine_risk_budget_pct=1.0,
|
|
reaction_day_return_min=0.04,
|
|
reaction_day_return_max=0.20,
|
|
close_location_min=0.70,
|
|
volume_ratio_min=1.5,
|
|
score_threshold_override=0.60,
|
|
shadow_only=True,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="delayed_primary_long_drift",
|
|
event_types=["earnings_release"],
|
|
timing_class="any",
|
|
direction="long_only",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=10,
|
|
engine_risk_budget_pct=1.0,
|
|
delayed_entry_lookback_days=3,
|
|
delayed_entry_source_engine_ids=["reaction_close_long_core"],
|
|
delayed_entry_min_drift_pct=0.05,
|
|
delayed_entry_close_location_min=0.50,
|
|
score_threshold_override=0.0,
|
|
per_trade_risk_pct_override=0.01,
|
|
synthetic_only=True,
|
|
),
|
|
],
|
|
)
|
|
config = BacktestConfig(
|
|
strategy_name="return_max_long_v1",
|
|
dataset_snapshot_id="test_snapshot",
|
|
universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=100_000.0),
|
|
signal=SignalConfig(score_threshold=0.60, max_candidates_per_day=6, scoring_model="patient_drift"),
|
|
risk=RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
per_trade_risk_pct_a_tier=0.01,
|
|
max_daily_new_risk_pct=0.05,
|
|
max_positions=6,
|
|
max_positions_per_sector=3,
|
|
),
|
|
execution=ExecutionConfig(
|
|
entry_fill_model="next_open",
|
|
exit_fill_model="daily_bar_approximation",
|
|
slippage_bps_base=0.0,
|
|
commission_per_share=0.0,
|
|
same_bar_priority="stop_first_conservative",
|
|
target_model="fixed_r",
|
|
target_1_r=99.0,
|
|
target_1_fraction=0.0,
|
|
trailing_model=None,
|
|
trailing_warmup_days=10,
|
|
max_holding_days=10,
|
|
),
|
|
reporting=ReportingConfig(
|
|
write_trade_blotter=True,
|
|
write_equity_curve=True,
|
|
write_metrics_summary=True,
|
|
generate_plots=False,
|
|
),
|
|
event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=10)},
|
|
strategy_engines=manifest.strategy_engines,
|
|
strategy_engine_selection_mode="interleave",
|
|
)
|
|
|
|
runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0)
|
|
result = runner.run(output_root=tmp_path)
|
|
run_dir = tmp_path / result.run_id
|
|
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
|
|
|
|
engine_ids = [row["engine_id"] for row in trade_blotter]
|
|
assert "reaction_close_long_core" not in engine_ids
|
|
assert "delayed_primary_long_drift" in engine_ids
|