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.

544 lines
22 KiB
Python

from __future__ import annotations
import asyncio
from libs.backtest.domain import WalkForwardAggregate, WalkForwardGapStats, WalkForwardSummary
from libs.intraday.domain import CacheParams, IntradayConfig, IntradayMetrics, StrategyParams
from apps.intraday_bt.momentum_research import (
_normalize_momentum_research_strategy,
_quarterly_score_payload,
_wfv_score_payload,
MomentumResearchContext,
MomentumResearchSnapshotStore,
build_momentum_strategy,
build_momentum_research_context,
group_trading_days_by_quarter,
intraday_metrics_to_momentum_split_result,
simulate_momentum_params,
)
def _summary(
*,
mean_return: float,
positive_rate: float,
worst_return: float,
) -> WalkForwardSummary:
return WalkForwardSummary(
train_days=84,
test_days=21,
step_days=21,
fold_count=5,
folds=[],
train_aggregate=WalkForwardAggregate(mean_return_pct=4.0),
test_aggregate=WalkForwardAggregate(
mean_return_pct=mean_return,
median_return_pct=mean_return,
worst_return_pct=worst_return,
positive_fold_rate_pct=positive_rate,
mean_profit_factor=1.5,
mean_max_drawdown_pct=4.0,
mean_trade_count=40.0,
mean_win_rate=0.52,
),
gap_stats=WalkForwardGapStats(
mean_train_test_return_gap_pct=2.0,
worst_train_test_return_gap_pct=4.0,
fold_return_cv=0.4,
),
engine_reliability_ratio=1.0,
)
def test_wfv_score_prefers_better_walk_forward_and_holdout() -> None:
strong = _wfv_score_payload(
_summary(mean_return=2.5, positive_rate=80.0, worst_return=-1.0),
IntradayMetrics(total_return_pct=0.10, sharpe_ratio=1.4),
)
weak = _wfv_score_payload(
_summary(mean_return=0.3, positive_rate=40.0, worst_return=-6.0),
IntradayMetrics(total_return_pct=0.01, sharpe_ratio=0.2),
)
assert strong["selection_score"] > weak["selection_score"]
assert strong["holdout_return_pct"] > weak["holdout_return_pct"]
def test_wfv_score_rewards_better_holdout_loss_containment() -> None:
summary = _summary(mean_return=1.2, positive_rate=60.0, worst_return=-2.0)
strong = _wfv_score_payload(
summary,
IntradayMetrics(
total_return_pct=0.06,
sharpe_ratio=0.9,
avg_loss_day_pct=-0.004,
tail_loss_20_pct=-0.009,
loss_containment_score=89.0,
),
)
weak = _wfv_score_payload(
summary,
IntradayMetrics(
total_return_pct=0.06,
sharpe_ratio=0.9,
avg_loss_day_pct=-0.012,
tail_loss_20_pct=-0.026,
loss_containment_score=54.0,
),
)
assert strong["selection_score"] > weak["selection_score"]
assert strong["holdout_loss_containment_score"] > weak["holdout_loss_containment_score"]
def test_group_trading_days_by_quarter_preserves_calendar_order() -> None:
grouped = group_trading_days_by_quarter(
[
"2025-01-02",
"2025-03-31",
"2025-04-01",
"2025-07-01",
"2025-10-01",
]
)
assert grouped == [
("2025Q1", ["2025-01-02", "2025-03-31"]),
("2025Q2", ["2025-04-01"]),
("2025Q3", ["2025-07-01"]),
("2025Q4", ["2025-10-01"]),
]
def test_quarterly_score_prefers_stable_positive_quarters() -> None:
wfv = _wfv_score_payload(
_summary(mean_return=1.8, positive_rate=80.0, worst_return=-1.0),
IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.1),
)
strong, _ = _quarterly_score_payload(
wfv,
[
("2025Q1", IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.2, max_drawdown_pct=-0.03)),
("2025Q2", IntradayMetrics(total_return_pct=0.06, sharpe_ratio=1.0, max_drawdown_pct=-0.02)),
("2025Q3", IntradayMetrics(total_return_pct=0.07, sharpe_ratio=1.1, max_drawdown_pct=-0.03)),
("2025Q4", IntradayMetrics(total_return_pct=0.05, sharpe_ratio=0.9, max_drawdown_pct=-0.02)),
],
)
weak, _ = _quarterly_score_payload(
wfv,
[
("2025Q1", IntradayMetrics(total_return_pct=0.16, sharpe_ratio=1.8, max_drawdown_pct=-0.08)),
("2025Q2", IntradayMetrics(total_return_pct=-0.09, sharpe_ratio=-0.7, max_drawdown_pct=-0.10)),
("2025Q3", IntradayMetrics(total_return_pct=0.02, sharpe_ratio=0.2, max_drawdown_pct=-0.05)),
("2025Q4", IntradayMetrics(total_return_pct=-0.03, sharpe_ratio=-0.3, max_drawdown_pct=-0.06)),
],
)
assert strong["quarter_mean_return_pct"] > weak["quarter_mean_return_pct"]
assert strong["quarter_worst_return_pct"] > weak["quarter_worst_return_pct"]
assert strong["quarterly_selection_score"] > weak["quarterly_selection_score"]
def test_build_momentum_strategy_applies_overrides_without_mutating_base() -> None:
config = IntradayConfig(strategy_mode="momentum", strategy=StrategyParams(top_n=5, max_vix=30.0))
updated = build_momentum_strategy(config, {"top_n": 4, "max_vix": 28.0})
assert updated.top_n == 4
assert updated.max_vix == 28.0
assert config.strategy.top_n == 5
assert config.strategy.max_vix == 30.0
def test_normalize_momentum_research_strategy_forces_reset_simple_mode() -> None:
strategy = StrategyParams(
top_n=5,
compound_returns=True,
daily_budget_reset=False,
)
normalized = _normalize_momentum_research_strategy(strategy)
assert normalized.compound_returns is False
assert normalized.daily_budget_reset is True
assert strategy.compound_returns is True
assert strategy.daily_budget_reset is False
def test_intraday_metrics_to_momentum_split_result_maps_simple_returns() -> None:
strategy = StrategyParams(top_n=5)
result = intraday_metrics_to_momentum_split_result(
IntradayMetrics(
run_id="mwf",
trading_days=20,
days_with_trades=8,
total_trades=12,
total_return_pct=0.1234,
annualized_return_pct=0.4567,
max_drawdown_pct=-0.089,
sharpe_ratio=1.8,
profit_factor=1.4,
win_rate=0.55,
),
strategy,
)
assert result.run_id == "mwf"
assert result.trade_count == 12
assert result.total_return_pct == 12.34
assert result.annualized_return_pct == 45.67
assert result.max_drawdown_pct == 8.9
assert result.avg_gross_exposure_pct == 100.0
assert result.days_in_market_pct == 40.0
def test_build_momentum_research_context_uses_snapshot_cache(tmp_path, monkeypatch) -> None:
config = IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(top_n=5),
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
)
trading_days = ["2025-01-02", "2025-01-03"]
daily_bars = {
"AAA": [{"date": "2025-01-02", "close": 10.0}],
"BBB": [{"date": "2025-01-02", "close": 11.0}],
}
candidates = {"2025-01-02": ["AAA"], "2025-01-03": ["BBB"]}
all_intraday = {
"2025-01-02": {"AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.5, "low": 9.9, "close": 10.3, "volume": 1000}]},
"2025-01-03": {"BBB": [{"timestamp": "2025-01-03T14:30:00+00:00", "open": 11.0, "high": 11.4, "low": 10.8, "close": 11.2, "volume": 1200}]},
}
async def _resolve_universe(_universe, _client):
return ["AAA", "BBB"]
async def _get_trading_days(_client, _start, _end, lookback=0):
assert lookback == 0
return trading_days
async def _fetch_daily_bars_bulk(*args, **kwargs):
return daily_bars
def _pre_screen_candidates(*args, **kwargs):
return candidates
async def _fetch_intraday_bulk(*args, **kwargs):
return all_intraday
monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe)
monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk)
monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _pre_screen_candidates)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk)
first = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None))
assert first.candidate_pairs == 2
assert first.research_snapshot_key is not None
async def _should_not_fetch(*args, **kwargs):
raise AssertionError("fetch path should not run after snapshot is saved")
def _should_not_screen(*args, **kwargs):
raise AssertionError("screen path should not run after snapshot is saved")
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _should_not_fetch)
monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _should_not_screen)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _should_not_fetch)
second = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None))
assert second.research_snapshot_key == first.research_snapshot_key
assert second.candidates == candidates
assert second.all_intraday == all_intraday
def test_momentum_research_snapshot_key_changes_when_seed_overlay_changes(tmp_path) -> None:
config_a = IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(top_n=5, candidate_source_mode="intraday_first"),
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
)
config_b = IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(
top_n=5,
candidate_source_mode="intraday_first",
candidate_seed_liquid_overlay_slots=1,
),
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
)
key_a = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key(
config_a,
start_date="2025-01-02",
end_date="2025-01-03",
tickers=["AAA"],
trading_days=["2025-01-02", "2025-01-03"],
)
key_b = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key(
config_b,
start_date="2025-01-02",
end_date="2025-01-03",
tickers=["AAA"],
trading_days=["2025-01-02", "2025-01-03"],
)
assert key_a != key_b
def test_build_momentum_research_context_applies_seed_overlay_before_intraday_fetch(
tmp_path,
monkeypatch,
) -> None:
config = IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(
top_n=5,
candidate_source_mode="intraday_first",
candidate_seed_liquid_overlay_slots=1,
),
cache=CacheParams(enabled=False, dir=str(tmp_path / "intraday")),
)
async def _resolve_universe(_universe, _client):
return ["AAA", "BBB"]
async def _get_trading_days(_client, _start, _end, lookback=0):
assert lookback == 0
return ["2025-01-02"]
async def _fetch_daily_bars_bulk(*args, **kwargs):
return {"AAA": [{"date": "2025-01-02", "close": 10.0}], "BBB": [{"date": "2025-01-02", "close": 11.0}]}
def _enrichment(*args, **kwargs):
return {}
def _seed_candidates(*args, **kwargs):
return {"2025-01-02": ["AAA"]}
def _augment(candidates, *args, **kwargs):
assert candidates == {"2025-01-02": ["AAA"]}
return {"2025-01-02": ["AAA", "BBB"]}, {}
captured: dict[str, object] = {}
async def _fetch_intraday_bulk(candidates, *args, **kwargs):
captured["candidates"] = candidates
return {
"2025-01-02": {
"AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 1000}],
"BBB": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 1000}],
}
}
def _intraday_first_candidates(*args, **kwargs):
return {"2025-01-02": ["AAA", "BBB"]}
monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe)
monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk)
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_enrichment_for_days", _enrichment)
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_intraday_seed_candidates", _seed_candidates)
monkeypatch.setattr(
"apps.intraday_bt.momentum_research._augment_momentum_seed_candidates_with_liquid_overlay",
_augment,
)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk)
monkeypatch.setattr(
"apps.intraday_bt.momentum_research.momentum_intraday_first_candidates",
_intraday_first_candidates,
)
context = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-02", client=None))
assert captured["candidates"] == {"2025-01-02": ["AAA", "BBB"]}
assert context.candidates == {"2025-01-02": ["AAA", "BBB"]}
def test_build_momentum_research_context_fetches_all_tickers_for_candidate_stage_catalyst(
tmp_path,
monkeypatch,
) -> None:
config = IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(
top_n=5,
candidate_source_mode="intraday_first",
candidate_seed_event_overlay_slots=1,
),
cache=CacheParams(enabled=False, dir=str(tmp_path / "intraday")),
)
async def _resolve_universe(_universe, _client):
return ["AAA", "BBB", "CCC"]
async def _get_trading_days(_client, _start, _end, lookback=0):
assert lookback == 0
return ["2025-01-02"]
async def _fetch_daily_bars_bulk(*args, **kwargs):
return {
"AAA": [{"date": "2025-01-02", "close": 10.0}],
"BBB": [{"date": "2025-01-02", "close": 11.0}],
"CCC": [{"date": "2025-01-02", "close": 12.0}],
}
def _enrichment(*args, **kwargs):
return {
"AAA": {"2025-01-02": {"gap_pct": 0.03}},
"BBB": {"2025-01-02": {"gap_pct": 0.02}},
"CCC": {"2025-01-02": {"gap_pct": 0.01}},
}
def _seed_candidates(*args, **kwargs):
return {"2025-01-02": ["AAA"]}
async def _fetch_filing_event_features_bulk(tickers, *args, **kwargs):
assert tickers == ["AAA", "BBB", "CCC"]
return {}
async def _fetch_intraday_bulk(*args, **kwargs):
return {}
monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe)
monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk)
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_enrichment_for_days", _enrichment)
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_intraday_seed_candidates", _seed_candidates)
monkeypatch.setattr(
"apps.intraday_bt.momentum_research.fetch_filing_event_features_bulk",
_fetch_filing_event_features_bulk,
)
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk)
monkeypatch.setattr(
"apps.intraday_bt.momentum_research.momentum_intraday_first_candidates",
lambda *args, **kwargs: {},
)
context = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-02", client=None))
assert context.candidates == {}
def test_simulate_momentum_params_recomputes_candidates_for_strategy() -> None:
context = MomentumResearchContext(
config=IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(top_n=2),
),
tickers=["AAA", "BBB"],
ticker_sectors={},
trading_days=["2026-01-05"],
daily_bars={
"AAA": [
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1000},
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2000},
],
"BBB": [
{"date": "2026-01-02", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 1000},
{"date": "2026-01-05", "open": 11.4, "high": 11.9, "low": 11.3, "close": 11.7, "volume": 2000},
],
},
all_intraday={
"2026-01-05": {
"AAA": [
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.3, "high": 10.5, "low": 10.2, "close": 10.4, "volume": 50000},
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.4, "high": 10.6, "low": 10.3, "close": 10.5, "volume": 50000},
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.5, "high": 10.7, "low": 10.4, "close": 10.6, "volume": 50000},
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 50000},
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.7, "high": 10.9, "low": 10.6, "close": 10.8, "volume": 50000},
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.9, "high": 11.0, "low": 10.8, "close": 10.95, "volume": 50000},
],
"BBB": [
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.4, "high": 11.5, "low": 11.3, "close": 11.45, "volume": 50000},
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.45, "high": 11.6, "low": 11.4, "close": 11.55, "volume": 50000},
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.55, "high": 11.8, "low": 11.5, "close": 11.75, "volume": 50000},
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.75, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 50000},
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.85, "high": 12.0, "low": 11.8, "close": 11.95, "volume": 50000},
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.9, "high": 12.0, "low": 11.8, "close": 11.92, "volume": 50000},
],
}
},
daily_enrichment={
"AAA": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.01, "entropy_20d": 0.8, "avg_dollar_vol_30d": 20_000_000.0, "atr_14": 1.0, "event_flag": False}},
"BBB": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.02, "entropy_20d": 0.7, "avg_dollar_vol_30d": 25_000_000.0, "atr_14": 1.1, "event_flag": True, "event_score": 1.0}},
},
vix_by_day=None,
candidates={"2026-01-05": ["AAA", "BBB"]},
candidate_pairs=2,
research_snapshot_key=None,
)
strategy = StrategyParams(
top_n=2,
entry_minutes_after_open=20,
min_morning_gain_pct=0.0,
min_entry_volume=0,
candidate_require_event_flag=True,
exit_minutes_before_close=5,
)
day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test")
assert metrics.total_trades == 1
assert len(day_results) == 1
assert day_results[0].trades[0].ticker == "BBB"
def test_simulate_momentum_params_recomputes_intraday_first_candidates_for_strategy() -> None:
context = MomentumResearchContext(
config=IntradayConfig(
strategy_mode="momentum",
strategy=StrategyParams(top_n=2, candidate_source_mode="intraday_first"),
),
tickers=["AAA", "BBB"],
ticker_sectors={},
trading_days=["2026-01-05"],
daily_bars={},
all_intraday={
"2026-01-05": {
"AAA": [
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 60_000},
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 60_000},
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.5, "low": 10.0, "close": 10.3, "volume": 60_000},
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.3, "high": 10.4, "low": 10.1, "close": 10.2, "volume": 60_000},
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.2, "high": 10.3, "low": 10.1, "close": 10.2, "volume": 60_000},
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.2, "high": 10.3, "low": 10.0, "close": 10.1, "volume": 60_000},
],
"BBB": [
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 70_000},
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 70_000},
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.1, "high": 11.6, "low": 11.0, "close": 11.4, "volume": 70_000},
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.4, "high": 11.8, "low": 11.3, "close": 11.7, "volume": 70_000},
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.7, "high": 11.9, "low": 11.6, "close": 11.8, "volume": 70_000},
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.8, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 70_000},
],
}
},
daily_enrichment={
"AAA": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
"BBB": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
},
vix_by_day=None,
candidates={"2026-01-05": ["AAA", "BBB"]},
candidate_pairs=2,
research_snapshot_key=None,
)
strategy = StrategyParams(
top_n=2,
candidate_source_mode="intraday_first",
candidate_final_max_per_day=1,
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_confirmation_return_pct=0.0,
min_morning_gain_pct=0.01,
min_entry_volume=0,
exit_minutes_before_close=5,
)
day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test_if")
assert metrics.total_trades == 1
assert len(day_results) == 1
assert day_results[0].trades[0].ticker == "BBB"