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.

564 lines
19 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import pytest
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
"""
ORB_ADVANCED_CONFIG = """\
_meta:
name: ORB Advanced
description: Advanced ORB config
id: 42
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 5
entry_direction: long_only
order_timeout_minutes: 45
min_price: 15.0
min_avg_dollar_volume: 40000000
min_premarket_dollar_vol: 2500000
min_breakout_rel_vol: 1.2
min_atr_14: 0.50
min_rvol: 1.5
max_candidates: 20
risk_per_trade_pct: 0.05
max_position_pct: 0.70
atr_stop_multiplier: 0.75
breakeven_at_r: 1.0
trailing_at_r: 1.0
trailing_stop_atr_multiplier: 0.8
daily_max_loss_pct: 0.05
max_stops_per_day: 5
settlement_days: 1
max_gap_pct: 0.04
compound_returns: false
daily_budget_reset: true
daily_bar_snapshot_id: orb_daily_v46_20260423
prior_event_snapshot_id: orb_pead_d10_v46_20260423
universe:
source: broad
backtest:
lookback_trading_days: 200
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false
"""
ORB_EXTENDS_CHILD_CONFIG = """\
extends: orb_advanced_base.yaml
_meta:
name: ORB Extends Child
description: Child config
id: 43
orb_strategy:
min_rvol: 3.0
"""
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_load_user_strategy_exposes_orb_advanced_fields(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced", ORB_ADVANCED_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
payload = intraday._load_user_strategy("orb_advanced")
assert payload is not None
assert payload["strategy_mode"] == "orb"
assert payload["universe"] == "broad"
assert payload["min_price"] == 15.0
assert payload["min_avg_dollar_volume"] == 40000000
assert payload["min_premarket_dollar_vol"] == 2500000
assert payload["min_breakout_rel_vol"] == 1.2
assert payload["daily_budget_reset"] is True
def test_create_comp_strategy_materializes_deployment_overlay(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced", ORB_ADVANCED_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
generated = intraday.create_comp_strategy("orb_advanced")
generated_path = tmp_path / "configs" / "intraday" / "strategies" / f"{generated['slug']}.yaml"
raw = intraday.yaml.safe_load(generated_path.read_text())
assert generated["slug"] == "orb_advanced_comp"
assert generated["name"] == "ORB Advanced Comp"
assert generated["compound_returns"] is True
assert generated["daily_budget_reset"] is False
assert generated["single_trade_loss_cap_basis"] == "equity"
assert generated["is_comp_equity_deployment"] is True
assert raw["orb_strategy"]["compound_returns"] is True
assert raw["orb_strategy"]["daily_budget_reset"] is False
assert raw["orb_strategy"]["single_trade_loss_cap_basis"] == "equity"
assert raw["_meta"]["parent"] is None
assert raw["_meta"]["deployment_kind"] == "compound_equity_losscap"
assert raw["_meta"]["source_slug"] == "orb_advanced"
def test_load_strategy_yaml_rejects_extends(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced_base", ORB_ADVANCED_CONFIG)
_write_config(tmp_path, "orb_extends_child", ORB_EXTENDS_CHILD_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
with pytest.raises(ValueError, match="extends.*no longer supported"):
intraday._load_strategy_yaml_standalone(
tmp_path / "configs" / "intraday" / "strategies" / "orb_extends_child.yaml"
)
def test_create_comp_strategy_refreshes_existing_generated_variant(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced", ORB_ADVANCED_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
first = intraday.create_comp_strategy("orb_advanced")
first_path = tmp_path / "configs" / "intraday" / "strategies" / f"{first['slug']}.yaml"
raw = intraday.yaml.safe_load(first_path.read_text())
raw["orb_strategy"]["min_rvol"] = 9.9
first_path.write_text(intraday.yaml.dump(raw, default_flow_style=False, sort_keys=False))
second = intraday.create_comp_strategy("orb_advanced")
refreshed = intraday.yaml.safe_load(first_path.read_text())
assert second["slug"] == first["slug"]
assert second["id"] == first["id"]
assert refreshed["orb_strategy"]["min_rvol"] == 1.5
assert refreshed["orb_strategy"]["single_trade_loss_cap_basis"] == "equity"
def test_load_user_strategy_does_not_load_extends(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced_base", ORB_ADVANCED_CONFIG)
_write_config(tmp_path, "orb_extends_child", ORB_EXTENDS_CHILD_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
payload = intraday._load_user_strategy("orb_extends_child")
assert payload is None
def test_update_strategy_preserves_unedited_orb_advanced_fields(monkeypatch, tmp_path: Path) -> None:
path = _write_config(tmp_path, "orb_advanced", ORB_ADVANCED_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
updated = intraday.update_strategy(
"orb_advanced",
intraday.UpdateStrategyRequest(name="ORB Advanced Updated"),
)
raw = intraday.yaml.safe_load(path.read_text())
assert updated["name"] == "ORB Advanced Updated"
assert raw["orb_strategy"]["min_price"] == 15.0
assert raw["orb_strategy"]["min_avg_dollar_volume"] == 40000000
assert raw["orb_strategy"]["min_premarket_dollar_vol"] == 2500000
assert raw["orb_strategy"]["daily_bar_snapshot_id"] == "orb_daily_v46_20260423"
assert raw["orb_strategy"]["prior_event_snapshot_id"] == "orb_pead_d10_v46_20260423"
def test_copy_strategy_preserves_orb_advanced_fields(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced", ORB_ADVANCED_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
copied = intraday.copy_strategy("orb_advanced")
copied_path = tmp_path / "configs" / "intraday" / "strategies" / f"{copied['slug']}.yaml"
raw = intraday.yaml.safe_load(copied_path.read_text())
assert copied["name"] == "ORB Advanced (copy)"
assert raw["orb_strategy"]["min_price"] == 15.0
assert raw["orb_strategy"]["min_avg_dollar_volume"] == 40000000
assert raw["orb_strategy"]["min_premarket_dollar_vol"] == 2500000
assert raw["orb_strategy"]["daily_bar_snapshot_id"] == "orb_daily_v46_20260423"
def test_copy_strategy_rejects_extends(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced_base", ORB_ADVANCED_CONFIG)
_write_config(tmp_path, "orb_extends_child", ORB_EXTENDS_CHILD_CONFIG)
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
with pytest.raises(intraday.HTTPException) as exc_info:
intraday.copy_strategy("orb_extends_child")
assert exc_info.value.status_code == 400
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_submit_intraday_backtest_accepts_broad_universe(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="broad",
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"] == "broad"
assert task["universe_label"] == "broad"
assert "--universe" in cmd
assert cmd[cmd.index("--universe") + 1] == "broad"
def test_submit_intraday_backtest_records_command_and_compound_flags(monkeypatch, tmp_path: Path) -> None:
_write_config(tmp_path, "orb_advanced", ORB_ADVANCED_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="orb_advanced",
universe="midlarge",
days=200,
compound_returns=True,
daily_budget_reset=False,
)
resp = intraday.submit_intraday_backtest(req)
task = intraday._tasks[resp["task_id"]]
cmd = captured["cmd"]
log_text = (tmp_path / "runs" / ".intraday_tasks" / f"{resp['task_id']}.log").read_text()
assert task["command"] == cmd
assert "--compound-returns" in cmd
assert "--no-daily-budget-reset" in cmd
assert "Command:" in log_text
assert "--compound-returns" in log_text
assert "--no-daily-budget-reset" in log_text
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
def test_detect_result_file_from_log_prefers_explicit_saved_path(tmp_path: Path) -> None:
good = tmp_path / "runs" / "intraday_orb" / "intraday_good.json"
good.parent.mkdir(parents=True, exist_ok=True)
good.write_text("{}")
bad = tmp_path / "runs" / "intraday_orb" / "intraday_bad.json"
bad.write_text("{}")
log_path = tmp_path / "runs" / ".intraday_tasks" / "task.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(
"\n".join([
f"Results saved to: {bad}",
f"Results saved to: {good}",
])
)
assert intraday._detect_result_file_from_log(log_path) == good
def test_try_resolve_dead_task_repairs_completed_result_from_log(monkeypatch, tmp_path: Path) -> None:
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
results_dir = tmp_path / "runs" / "intraday_orb"
results_dir.mkdir(parents=True, exist_ok=True)
wrong_result = results_dir / "intraday_wrong.json"
wrong_result.write_text(
json.dumps({"metrics": {"total_return_pct": -0.1177, "max_drawdown_pct": -0.1765}})
)
right_result = results_dir / "intraday_right.json"
right_result.write_text(
json.dumps({"metrics": {"total_return_pct": 1.302, "max_drawdown_pct": -0.11}})
)
task_id = "task-123"
log_path = tmp_path / "runs" / ".intraday_tasks" / f"{task_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(f"Results saved to: {right_result}\n")
intraday._tasks[task_id] = {
"task_id": task_id,
"status": "completed",
"pid": None,
"started_at": "2026-04-22T23:28:35.765827+00:00",
"finished_at": "2026-04-23T00:33:45.996122+00:00",
"returncode": None,
"error": None,
"result_file": str(wrong_result),
"result_summary": {"return_pct": -0.1177, "max_dd_pct": -0.1765},
"output_dir": "runs/intraday_orb",
}
intraday._persist_task(intraday._tasks[task_id])
intraday._try_resolve_dead_task(task_id)
task = intraday._tasks[task_id]
persisted = json.loads((tmp_path / "runs" / ".intraday_tasks" / f"{task_id}.task.json").read_text())
assert task["result_file"] == str(right_result)
assert task["result_summary"]["return_pct"] == 1.302
assert task["result_summary"]["max_dd_pct"] == -0.11
assert task["returncode"] == 0
assert persisted["result_file"] == str(right_result)
assert persisted["result_summary"]["return_pct"] == 1.302