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.
220 lines
6.4 KiB
Python
220 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from apps.web.routers import intraday
|
|
|
|
|
|
MOMENTUM_CONFIG = """\
|
|
_meta:
|
|
name: Leader Intraday Momentum
|
|
description: Test momentum config
|
|
id: 7
|
|
strategy_mode: momentum
|
|
strategy:
|
|
entry_minutes_after_open: 10
|
|
exit_minutes_before_close: 5
|
|
stop_loss_pct: null
|
|
trailing_stop_pct: -0.06
|
|
min_morning_gain_pct: 0.02
|
|
max_morning_gain_pct: null
|
|
min_entry_volume: 250000
|
|
ticker_cooldown_days: 0
|
|
top_n: 5
|
|
initial_capital: 10000.0
|
|
slippage_bps: 5.0
|
|
market_regime_spy_threshold: null
|
|
universe:
|
|
source: midcap
|
|
min_price: 10.0
|
|
backtest:
|
|
lookback_trading_days: 200
|
|
pre_screen_threshold: 0.02
|
|
cache:
|
|
enabled: true
|
|
dir: data/cache/intraday
|
|
output:
|
|
dir: runs/intraday
|
|
verbose: false
|
|
"""
|
|
|
|
MOMENTUM_YAML_UNIVERSE_CONFIG = """\
|
|
_meta:
|
|
name: Leader Intraday Momentum
|
|
description: Test momentum config
|
|
id: 7
|
|
strategy_mode: momentum
|
|
strategy:
|
|
entry_minutes_after_open: 10
|
|
exit_minutes_before_close: 5
|
|
stop_loss_pct: null
|
|
trailing_stop_pct: -0.06
|
|
min_morning_gain_pct: 0.02
|
|
max_morning_gain_pct: null
|
|
min_entry_volume: 250000
|
|
ticker_cooldown_days: 0
|
|
top_n: 5
|
|
initial_capital: 10000.0
|
|
slippage_bps: 5.0
|
|
market_regime_spy_threshold: null
|
|
universe:
|
|
source: yaml
|
|
symbols_file: configs/symbols_midcap_smallmid.yaml
|
|
min_price: 10.0
|
|
backtest:
|
|
lookback_trading_days: 200
|
|
pre_screen_threshold: 0.02
|
|
cache:
|
|
enabled: true
|
|
dir: data/cache/intraday
|
|
output:
|
|
dir: runs/intraday
|
|
verbose: false
|
|
"""
|
|
|
|
|
|
def _write_config(tmp_path: Path, slug: str, text: str = MOMENTUM_CONFIG) -> Path:
|
|
strategies_dir = tmp_path / "configs" / "intraday" / "strategies"
|
|
strategies_dir.mkdir(parents=True, exist_ok=True)
|
|
path = strategies_dir / f"{slug}.yaml"
|
|
path.write_text(text)
|
|
return path
|
|
|
|
|
|
def test_load_user_strategy_supports_momentum(monkeypatch, tmp_path: Path) -> None:
|
|
_write_config(tmp_path, "leader_intraday_momentum")
|
|
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
|
|
|
payload = intraday._load_user_strategy("leader_intraday_momentum")
|
|
|
|
assert payload is not None
|
|
assert payload["strategy_mode"] == "momentum"
|
|
assert payload["output_dir"] == "runs/intraday"
|
|
assert payload["top_n"] == 5
|
|
assert payload["min_morning_gain_pct"] == 0.02
|
|
|
|
|
|
def test_submit_intraday_backtest_uses_config_strategy_mode(monkeypatch, tmp_path: Path) -> None:
|
|
_write_config(tmp_path, "leader_intraday_momentum")
|
|
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
|
monkeypatch.setattr(intraday, "get_runs_dir", lambda: tmp_path / "runs")
|
|
intraday._tasks.clear()
|
|
intraday._tasks_initialized = True
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyProc:
|
|
pid = 4242
|
|
|
|
def fake_popen(cmd, stdout=None, stderr=None, cwd=None): # noqa: ANN001
|
|
captured["cmd"] = cmd
|
|
captured["cwd"] = cwd
|
|
return DummyProc()
|
|
|
|
class DummyThread:
|
|
def __init__(self, target=None, args=None, daemon=None): # noqa: ANN001
|
|
captured["thread_target"] = target
|
|
captured["thread_args"] = args
|
|
|
|
def start(self) -> None:
|
|
captured["thread_started"] = True
|
|
|
|
monkeypatch.setattr(intraday.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(intraday.threading, "Thread", DummyThread)
|
|
|
|
req = intraday.IntradayBacktestRequest(
|
|
config="leader_intraday_momentum",
|
|
universe="midcap",
|
|
start_date="2026-01-02",
|
|
end_date="2026-03-31",
|
|
compound_returns=False,
|
|
)
|
|
resp = intraday.submit_intraday_backtest(req)
|
|
|
|
task = intraday._tasks[resp["task_id"]]
|
|
cmd = captured["cmd"]
|
|
|
|
assert task["strategy_mode"] == "momentum"
|
|
assert task["output_dir"] == "runs/intraday"
|
|
assert "--strategy" in cmd
|
|
assert cmd[cmd.index("--strategy") + 1] == "momentum"
|
|
assert "--output-dir" in cmd
|
|
assert cmd[cmd.index("--output-dir") + 1] == str(tmp_path / "runs" / "intraday")
|
|
|
|
|
|
def test_submit_intraday_backtest_preserves_yaml_strategy_universe(monkeypatch, tmp_path: Path) -> None:
|
|
_write_config(tmp_path, "leader_intraday_momentum_yaml", MOMENTUM_YAML_UNIVERSE_CONFIG)
|
|
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
|
monkeypatch.setattr(intraday, "get_runs_dir", lambda: tmp_path / "runs")
|
|
intraday._tasks.clear()
|
|
intraday._tasks_initialized = True
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyProc:
|
|
pid = 4242
|
|
|
|
def fake_popen(cmd, stdout=None, stderr=None, cwd=None): # noqa: ANN001
|
|
captured["cmd"] = cmd
|
|
captured["cwd"] = cwd
|
|
return DummyProc()
|
|
|
|
class DummyThread:
|
|
def __init__(self, target=None, args=None, daemon=None): # noqa: ANN001
|
|
captured["thread_target"] = target
|
|
captured["thread_args"] = args
|
|
|
|
def start(self) -> None:
|
|
captured["thread_started"] = True
|
|
|
|
monkeypatch.setattr(intraday.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(intraday.threading, "Thread", DummyThread)
|
|
|
|
req = intraday.IntradayBacktestRequest(
|
|
config="leader_intraday_momentum_yaml",
|
|
universe="yaml",
|
|
start_date="2026-01-02",
|
|
end_date="2026-03-31",
|
|
compound_returns=False,
|
|
)
|
|
resp = intraday.submit_intraday_backtest(req)
|
|
|
|
task = intraday._tasks[resp["task_id"]]
|
|
cmd = captured["cmd"]
|
|
|
|
assert task["universe"] == "yaml"
|
|
assert task["universe_label"] == "yaml:symbols_midcap_smallmid.yaml"
|
|
assert "--strategy" in cmd
|
|
assert cmd[cmd.index("--strategy") + 1] == "momentum"
|
|
assert "--universe" not in cmd
|
|
|
|
|
|
def test_result_summary_from_file_includes_loss_containment_fields(tmp_path: Path) -> None:
|
|
result_path = tmp_path / "intraday_result.json"
|
|
result_path.write_text(
|
|
"""{
|
|
"metrics": {
|
|
"total_return_pct": 0.0688,
|
|
"max_drawdown_pct": -0.1325,
|
|
"sharpe_ratio": 1.23,
|
|
"win_rate": 0.569,
|
|
"total_trades": 116,
|
|
"final_equity": 10688.0,
|
|
"calmar_ratio": 0.52,
|
|
"loss_containment_score": 43.78,
|
|
"avg_loss_day_pct": -0.0168,
|
|
"tail_loss_20_pct": -0.0377,
|
|
"worst_day_return_pct": -0.0672
|
|
}
|
|
}"""
|
|
)
|
|
|
|
summary = intraday._result_summary_from_file(result_path)
|
|
|
|
assert summary is not None
|
|
assert summary["return_pct"] == 0.0688
|
|
assert summary["loss_containment_score"] == 43.78
|
|
assert summary["avg_loss_day_pct"] == -0.0168
|
|
assert summary["tail_loss_20_pct"] == -0.0377
|
|
assert summary["worst_day_return_pct"] == -0.0672
|