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
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"
|