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.
265 lines
8.7 KiB
Python
265 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
from apps.intraday_bt.orb_research import force_simple_returns
|
|
from apps.intraday_bt.orb_validate import (
|
|
chronological_splits,
|
|
compute_orb_validation_score,
|
|
delta_payload,
|
|
metrics_payload,
|
|
period_quality_score,
|
|
promotion_gate,
|
|
tail_days,
|
|
walk_forward_verdict,
|
|
)
|
|
from libs.intraday.domain import IntradayConfig, IntradayMetrics, ORBStrategyParams
|
|
|
|
|
|
def test_tail_days_returns_recent_window() -> None:
|
|
days = [f"2026-01-{idx:02d}" for idx in range(1, 8)]
|
|
|
|
assert tail_days(days, 3) == ["2026-01-05", "2026-01-06", "2026-01-07"]
|
|
assert tail_days(days, 20) == days
|
|
|
|
|
|
def test_chronological_splits_use_fixed_two_year_layout() -> None:
|
|
days = [f"2025-01-{idx:03d}" for idx in range(1, 505)]
|
|
|
|
splits = chronological_splits(days)
|
|
|
|
assert len(splits["train"]) == 252
|
|
assert len(splits["valid"]) == 126
|
|
assert len(splits["test"]) == 126
|
|
assert splits["train"][0] == "2025-01-001"
|
|
assert splits["test"][-1] == "2025-01-504"
|
|
|
|
|
|
def test_metrics_payload_converts_decimal_metrics_to_pct_units() -> None:
|
|
payload = metrics_payload(
|
|
IntradayMetrics(
|
|
run_id="r1",
|
|
start_date="2026-01-02",
|
|
end_date="2026-01-31",
|
|
trading_days=20,
|
|
days_with_trades=10,
|
|
total_trades=15,
|
|
total_return_pct=1.2345,
|
|
max_drawdown_pct=-0.0123,
|
|
sharpe_ratio=2.345,
|
|
profit_factor=3.456,
|
|
loss_day_rate=0.25,
|
|
worst_day_return_pct=-0.02,
|
|
)
|
|
)
|
|
|
|
assert payload["return_pct"] == 123.45
|
|
assert payload["max_drawdown_pct"] == -1.23
|
|
assert payload["sharpe_ratio"] == 2.35
|
|
assert payload["profit_factor"] == 3.46
|
|
assert payload["loss_day_rate_pct"] == 25.0
|
|
assert payload["worst_day_return_pct"] == -2.0
|
|
|
|
|
|
def test_period_quality_penalizes_large_drawdown_profile() -> None:
|
|
robust = {
|
|
"period": {"trading_days": 504},
|
|
"days_with_trades": 190,
|
|
"total_trades": 300,
|
|
"stop_loss_exits": 20,
|
|
"return_pct": 140.0,
|
|
"annualized_return_pct": 70.0,
|
|
"max_drawdown_pct": -8.0,
|
|
"sharpe_ratio": 3.0,
|
|
"profit_factor": 2.8,
|
|
"loss_day_rate_pct": 25.0,
|
|
"tail_loss_20_pct": -1.2,
|
|
"worst_day_return_pct": -2.5,
|
|
}
|
|
fragile = {
|
|
**robust,
|
|
"return_pct": 155.0,
|
|
"max_drawdown_pct": -32.0,
|
|
"sharpe_ratio": 1.2,
|
|
"profit_factor": 1.3,
|
|
"tail_loss_20_pct": -5.0,
|
|
"worst_day_return_pct": -9.0,
|
|
}
|
|
|
|
robust_score, _ = period_quality_score(robust)
|
|
fragile_score, _ = period_quality_score(fragile)
|
|
|
|
assert robust_score > fragile_score
|
|
|
|
|
|
def test_delta_payload_returns_candidate_minus_baseline() -> None:
|
|
candidate = {"return_pct": 110.0, "max_drawdown_pct": -1.0, "total_trades": 15}
|
|
baseline = {"return_pct": 100.0, "max_drawdown_pct": -2.5, "total_trades": 10}
|
|
|
|
assert delta_payload(candidate, baseline) == {
|
|
"return_pct": 10.0,
|
|
"max_drawdown_pct": 1.5,
|
|
"total_trades": 5,
|
|
}
|
|
|
|
|
|
def test_walk_forward_verdict_requires_positive_stable_folds() -> None:
|
|
report = {
|
|
"fold_count": 4,
|
|
"test_aggregate": {
|
|
"positive_fold_rate_pct": 100.0,
|
|
"worst_return_pct": 1.2,
|
|
"mean_return_pct": 3.4,
|
|
},
|
|
"gap_stats": {"fold_return_cv": 0.5},
|
|
}
|
|
|
|
assert walk_forward_verdict(report)["verdict"] == "PASS"
|
|
|
|
|
|
def test_promotion_gate_fails_primary_damage_even_if_recent_guards_pass() -> None:
|
|
candidate = {
|
|
"periods": {
|
|
"primary": {"return_pct": 90.0, "max_drawdown_pct": -3.0},
|
|
"guard_200d": {"return_pct": 60.0, "max_drawdown_pct": -1.0},
|
|
"guard_60d": {"return_pct": 20.0, "max_drawdown_pct": -0.5},
|
|
},
|
|
"deltas": {
|
|
"primary": {"return_pct": -12.0, "max_drawdown_pct": -2.0},
|
|
"guard_200d": {"return_pct": 0.0, "max_drawdown_pct": 0.0},
|
|
"guard_60d": {"return_pct": 0.0, "max_drawdown_pct": 0.0},
|
|
},
|
|
"walk_forward": {"verdict": {"verdict": "PASS"}},
|
|
}
|
|
|
|
assert promotion_gate(candidate, baseline={"periods": {}})["overall"] == "FAIL"
|
|
|
|
|
|
def test_promotion_gate_ignores_guards_that_were_not_requested() -> None:
|
|
candidate = {
|
|
"periods": {
|
|
"primary": {"return_pct": 100.0, "max_drawdown_pct": -1.0},
|
|
"guard_60d": {"return_pct": 20.0, "max_drawdown_pct": -0.5},
|
|
},
|
|
"deltas": {
|
|
"primary": {"return_pct": 0.0, "max_drawdown_pct": 0.0},
|
|
"guard_60d": {"return_pct": 0.0, "max_drawdown_pct": 0.0},
|
|
},
|
|
"walk_forward": {"verdict": {"verdict": "SKIP"}},
|
|
}
|
|
|
|
assert promotion_gate(candidate, baseline={"periods": {}})["overall"] == "PASS"
|
|
|
|
|
|
def _split_result(run_id: str, ret: float, dd: float = 5.0) -> dict:
|
|
return {
|
|
"run_id": run_id,
|
|
"trade_count": 40,
|
|
"profit_factor": 2.0,
|
|
"total_return_pct": ret,
|
|
"annualized_return_pct": ret,
|
|
"win_rate": 0.65,
|
|
"max_drawdown_pct": dd,
|
|
"sharpe_ratio": 2.0,
|
|
"avg_gross_exposure_pct": 20.0,
|
|
"avg_net_exposure_pct": 20.0,
|
|
"days_in_market_pct": 40.0,
|
|
}
|
|
|
|
|
|
def _candidate_for_score(*, primary_return: float, guard_200_return: float) -> dict:
|
|
period = {
|
|
"period": {"trading_days": 504},
|
|
"days_with_trades": 180,
|
|
"total_trades": 260,
|
|
"stop_loss_exits": 25,
|
|
"return_pct": primary_return,
|
|
"annualized_return_pct": 65.0,
|
|
"max_drawdown_pct": -10.0,
|
|
"sharpe_ratio": 2.5,
|
|
"profit_factor": 2.4,
|
|
"loss_day_rate_pct": 25.0,
|
|
"tail_loss_20_pct": -1.5,
|
|
"worst_day_return_pct": -3.0,
|
|
}
|
|
return {
|
|
"periods": {
|
|
"primary": period,
|
|
"guard_200d": {**period, "period": {"trading_days": 200}, "return_pct": guard_200_return},
|
|
"guard_60d": {**period, "period": {"trading_days": 60}, "return_pct": 20.0},
|
|
},
|
|
"splits": {
|
|
"train": {"split_result": _split_result("train", 45.0)},
|
|
"valid": {"split_result": _split_result("valid", 35.0)},
|
|
"test": {"split_result": _split_result("test", 40.0)},
|
|
},
|
|
"walk_forward": {
|
|
"train_days": 252,
|
|
"test_days": 63,
|
|
"step_days": 63,
|
|
"fold_count": 4,
|
|
"folds": [],
|
|
"train_aggregate": {"mean_win_rate": 0.65},
|
|
"test_aggregate": {
|
|
"mean_return_pct": 8.0,
|
|
"median_return_pct": 8.0,
|
|
"worst_return_pct": 2.0,
|
|
"positive_fold_rate_pct": 100.0,
|
|
"mean_profit_factor": 2.0,
|
|
"mean_max_drawdown_pct": 4.0,
|
|
"mean_trade_count": 25.0,
|
|
"mean_win_rate": 0.62,
|
|
},
|
|
"gap_stats": {
|
|
"mean_train_test_return_gap_pct": 20.0,
|
|
"worst_train_test_return_gap_pct": 35.0,
|
|
"fold_return_cv": 0.4,
|
|
},
|
|
"engine_reliability_ratio": 1.0,
|
|
},
|
|
}
|
|
|
|
|
|
def test_orb_validation_score_penalizes_recent_window_concentration() -> None:
|
|
balanced = _candidate_for_score(primary_return=150.0, guard_200_return=80.0)
|
|
concentrated = _candidate_for_score(primary_return=150.0, guard_200_return=145.0)
|
|
|
|
balanced_score = compute_orb_validation_score(balanced)
|
|
concentrated_score = compute_orb_validation_score(concentrated)
|
|
|
|
assert balanced_score["score"] > concentrated_score["score"]
|
|
assert concentrated_score["temporal_breakdown"]["pre_200_return_pct"] == 5.0
|
|
|
|
|
|
def test_orb_validation_score_does_not_rank_compound_runs() -> None:
|
|
candidate = _candidate_for_score(primary_return=150.0, guard_200_return=80.0)
|
|
candidate["capital_mode"] = {
|
|
"compound_returns": True,
|
|
"daily_budget_reset": False,
|
|
"settlement_days": 1,
|
|
}
|
|
|
|
score = compute_orb_validation_score(candidate)
|
|
|
|
assert score["score"] is None
|
|
assert score["source"] == "compound_diagnostic_not_ranked"
|
|
assert score["policy"]["rank_compound_runs"] is False
|
|
assert score["requires"] == ["daily_budget_reset_primary"]
|
|
|
|
|
|
def test_force_simple_returns_uses_daily_reset_for_orb_sqs() -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
orb_strategy=ORBStrategyParams(
|
|
compound_returns=True,
|
|
daily_budget_reset=False,
|
|
),
|
|
)
|
|
|
|
normalized = force_simple_returns(config)
|
|
|
|
assert normalized.orb_strategy is not None
|
|
assert normalized.orb_strategy.compound_returns is False
|
|
assert normalized.orb_strategy.daily_budget_reset is True
|
|
assert config.orb_strategy is not None
|
|
assert config.orb_strategy.compound_returns is True
|
|
assert config.orb_strategy.daily_budget_reset is False
|