feat: add daily reentry policy matrix
This commit is contained in:
@@ -543,6 +543,7 @@ class TestSimulatePortfolio:
|
||||
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 1
|
||||
assert sim["cost_per_side_pct"] == pytest.approx(0.1)
|
||||
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
|
||||
# pnl = 2120 − 2000 − 2.00 entry cost − 2.12 exit cost = 115.88
|
||||
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
|
||||
@@ -556,6 +557,29 @@ class TestSimulatePortfolio:
|
||||
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
|
||||
]
|
||||
|
||||
def test_cost_parameter_changes_cash_and_position_path(self):
|
||||
closes = [100.0, 102.0, 104.0, 106.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||
|
||||
free = bt._simulate_portfolio(
|
||||
[cand], prices, None, "hold", 3, cost_per_side=0.0
|
||||
)
|
||||
stressed = bt._simulate_portfolio(
|
||||
[cand], prices, None, "hold", 3, cost_per_side=0.002
|
||||
)
|
||||
|
||||
assert free is not None and stressed is not None
|
||||
assert free["final_equity"] == pytest.approx(10_120.0, abs=0.01)
|
||||
assert stressed["cost_per_side_pct"] == pytest.approx(0.2)
|
||||
assert stressed["final_equity"] == pytest.approx(10_111.76, abs=0.01)
|
||||
|
||||
def test_cost_parameter_rejects_invalid_rate(self):
|
||||
with pytest.raises(ValueError, match="cost_per_side"):
|
||||
bt._simulate_portfolio(
|
||||
[], {}, None, "hold", 3, cost_per_side=-0.001
|
||||
)
|
||||
|
||||
def test_target_policy_exits_at_target(self):
|
||||
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
@@ -626,6 +650,35 @@ class TestSimulatePortfolio:
|
||||
self.ORD + 6
|
||||
).isoformat()
|
||||
|
||||
def test_post_stop_reentry_cannot_cross_holdout_end(self):
|
||||
prices = {"AAA": _sim_prices(self.ORD, [100.0, 94.0, 96.0, 98.0])}
|
||||
candidate = _sim_cand(
|
||||
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
|
||||
)
|
||||
callback_dates: list[int] = []
|
||||
|
||||
def reenter_after_split(symbol, asof_ord, _state, _bar):
|
||||
callback_dates.append(asof_ord)
|
||||
if asof_ord < self.ORD + 2:
|
||||
return None
|
||||
return _sim_cand(
|
||||
symbol, asof_ord, entry=96.0, stop=90.0, target=115.0
|
||||
)
|
||||
|
||||
sim = bt._simulate_portfolio(
|
||||
[candidate],
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
3,
|
||||
end_date=date.fromordinal(self.ORD + 2),
|
||||
post_stop_reentry_fn=reenter_after_split,
|
||||
)
|
||||
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 1
|
||||
assert callback_dates == [self.ORD + 1]
|
||||
|
||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
||||
def fake_simulator(*_args, **kwargs):
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from datetime import date
|
||||
|
||||
from scripts.run_daily_reentry_matrix import PrecomputedDailyEngine, ReentryPolicy
|
||||
|
||||
|
||||
RANKING_KEY = "strategy_rank"
|
||||
ORD = date(2025, 1, 6).toordinal()
|
||||
|
||||
|
||||
def _candidate(day_ord: int, *, stop: float = 91.0, rank: float = 81.0) -> dict:
|
||||
return {
|
||||
"qualified": True,
|
||||
"direction": "long",
|
||||
"symbol": "AAA",
|
||||
"date": date.fromordinal(day_ord).isoformat(),
|
||||
"entry": 100.0,
|
||||
"stop": stop,
|
||||
"target": 120.0,
|
||||
RANKING_KEY: rank,
|
||||
}
|
||||
|
||||
|
||||
def _state(sessions: int = 0) -> dict:
|
||||
return {
|
||||
"sessions_since_stop": sessions,
|
||||
"previous_stop": 90.0,
|
||||
"previous_rank": 80.0,
|
||||
"gate_went_unqualified": False,
|
||||
}
|
||||
|
||||
|
||||
def _call(policy: ReentryPolicy, day_ord: int, state: dict, sessions: int):
|
||||
state["sessions_since_stop"] = sessions
|
||||
return policy("AAA", day_ord, state, object())
|
||||
|
||||
|
||||
def test_next_session_blocks_only_stop_day():
|
||||
engine = PrecomputedDailyEngine([_candidate(ORD), _candidate(ORD + 1)])
|
||||
policy = ReentryPolicy("next_session", engine, RANKING_KEY)
|
||||
state = _state()
|
||||
|
||||
assert _call(policy, ORD, state, 0) is None
|
||||
assert _call(policy, ORD + 1, state, 1) is not None
|
||||
|
||||
|
||||
def test_five_session_cooldown_unlocks_at_exact_boundary():
|
||||
engine = PrecomputedDailyEngine([_candidate(ORD + 4), _candidate(ORD + 5)])
|
||||
policy = ReentryPolicy("cooldown_5", engine, RANKING_KEY)
|
||||
state = _state()
|
||||
|
||||
assert _call(policy, ORD + 4, state, 4) is None
|
||||
assert _call(policy, ORD + 5, state, 5) is not None
|
||||
|
||||
|
||||
def test_gate_reset_requires_failure_before_requalification():
|
||||
engine = PrecomputedDailyEngine([_candidate(ORD), _candidate(ORD + 2)])
|
||||
policy = ReentryPolicy("gate_reset", engine, RANKING_KEY)
|
||||
state = _state()
|
||||
|
||||
assert _call(policy, ORD, state, 0) is None
|
||||
assert _call(policy, ORD + 1, state, 1) is None
|
||||
emitted = _call(policy, ORD + 2, state, 2)
|
||||
assert emitted is not None
|
||||
assert emitted["_reentry_reason"] == "gate_failed_then_requalified"
|
||||
|
||||
|
||||
def test_improved_gate_reset_requires_better_stop_and_non_weaker_rank():
|
||||
engine = PrecomputedDailyEngine([
|
||||
_candidate(ORD + 1, stop=89.0, rank=82.0),
|
||||
_candidate(ORD + 2, stop=92.0, rank=79.0),
|
||||
_candidate(ORD + 3, stop=92.0, rank=81.0),
|
||||
])
|
||||
policy = ReentryPolicy("gate_reset_improved", engine, RANKING_KEY)
|
||||
state = _state()
|
||||
|
||||
assert _call(policy, ORD, state, 0) is None
|
||||
assert _call(policy, ORD + 1, state, 1) is None
|
||||
assert _call(policy, ORD + 2, state, 2) is None
|
||||
emitted = _call(policy, ORD + 3, state, 3)
|
||||
assert emitted is not None
|
||||
assert emitted["_reentry_reason"] == "gate_reset_with_improved_stop_and_rank"
|
||||
|
||||
|
||||
def test_two_session_confirmation_excludes_stop_day_close():
|
||||
engine = PrecomputedDailyEngine([
|
||||
_candidate(ORD),
|
||||
_candidate(ORD + 1),
|
||||
_candidate(ORD + 2),
|
||||
])
|
||||
policy = ReentryPolicy("two_session_confirmation", engine, RANKING_KEY)
|
||||
state = _state()
|
||||
|
||||
assert _call(policy, ORD, state, 0) is None
|
||||
assert _call(policy, ORD + 1, state, 1) is None
|
||||
emitted = _call(policy, ORD + 2, state, 2)
|
||||
assert emitted is not None
|
||||
assert emitted["_reentry_reason"] == "two_qualified_post_stop_closes"
|
||||
Reference in New Issue
Block a user