feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -679,11 +679,51 @@ class TestSimulatePortfolio:
|
||||
assert sim["trades"] == 1
|
||||
assert callback_dates == [self.ORD + 1]
|
||||
|
||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
||||
def test_gate_reset_waits_for_failed_evaluation_then_requalification(self):
|
||||
closes = [100.0] * 95
|
||||
entry_ord = self.ORD + bt.MIN_LOOKBACK - 1
|
||||
stop_ord = entry_ord + 1
|
||||
reentry_ord = entry_ord + 3
|
||||
closes[bt.MIN_LOOKBACK] = 94.0
|
||||
closes[bt.MIN_LOOKBACK + 1] = 95.0
|
||||
closes[bt.MIN_LOOKBACK + 2] = 96.0
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
candidates = [
|
||||
_sim_cand("AAA", entry_ord, entry=100.0, stop=95.0, target=120.0),
|
||||
# Still qualified on the stop day: this must not unlock re-entry.
|
||||
_sim_cand("AAA", stop_ord, entry=94.0, stop=89.0, target=110.0),
|
||||
# No candidate on the intervening session means the daily gate
|
||||
# failed. A fresh qualification on the next session may re-enter.
|
||||
_sim_cand("AAA", reentry_ord, entry=96.0, stop=90.0, target=115.0),
|
||||
]
|
||||
gate_reset = bt._make_gate_reset_reentry_fn(
|
||||
candidates,
|
||||
prices,
|
||||
cadence="daily",
|
||||
)
|
||||
|
||||
sim = bt._simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
30,
|
||||
post_stop_reentry_fn=gate_reset,
|
||||
include_trades=True,
|
||||
)
|
||||
|
||||
assert sim is not None
|
||||
assert sim["post_stop_reentries"] == 1
|
||||
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||
reentry_ord
|
||||
).isoformat()
|
||||
assert sim["reentry_events"][0]["wait_sessions"] == 2
|
||||
|
||||
def test_production_monitor_applies_live_gate_reset(self, monkeypatch):
|
||||
def fake_simulator(*_args, **kwargs):
|
||||
return {
|
||||
"trades": 0,
|
||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
|
||||
"applied_gate_reset": kwargs.get("post_stop_reentry_fn") is not None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||
@@ -694,37 +734,38 @@ class TestSimulatePortfolio:
|
||||
production_rows = [
|
||||
row for row in monitor["runs"] if row["is_production"]
|
||||
]
|
||||
comparison_rows = [
|
||||
row for row in monitor["runs"] if not row["is_production"]
|
||||
immediate_rows = [
|
||||
row for row in monitor["runs"]
|
||||
if row["comparison_arm"] == "live_immediate"
|
||||
]
|
||||
|
||||
assert production_rows
|
||||
assert all(
|
||||
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
row["reentry_policy"] == "gate_reset"
|
||||
and row["applied_gate_reset"] is True
|
||||
for row in production_rows
|
||||
)
|
||||
assert comparison_rows
|
||||
assert immediate_rows
|
||||
assert all(
|
||||
row["reentry_lockdown_sessions"] == 0
|
||||
and row["applied_reentry_lockdown"] == 0
|
||||
for row in comparison_rows
|
||||
row["reentry_policy"] == "immediate"
|
||||
and row["applied_gate_reset"] is False
|
||||
for row in immediate_rows
|
||||
)
|
||||
|
||||
def test_production_cadence_comparison_names_exact_two_arms(self):
|
||||
monitor = {
|
||||
"runs": [
|
||||
{
|
||||
"comparison_arm": "live_no_lockdown",
|
||||
"comparison_arm": "live_immediate",
|
||||
"lookback": "all",
|
||||
"reentry_lockdown_sessions": 0,
|
||||
"reentry_policy": "immediate",
|
||||
"trades": 10,
|
||||
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||
},
|
||||
{
|
||||
"comparison_arm": "live_lockdown_5",
|
||||
"comparison_arm": "live_gate_reset",
|
||||
"lookback": "all",
|
||||
"reentry_lockdown_sessions": 5,
|
||||
"reentry_policy": "gate_reset",
|
||||
"trades": 8,
|
||||
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||
},
|
||||
@@ -736,7 +777,7 @@ class TestSimulatePortfolio:
|
||||
assert comparison is not None
|
||||
assert [row["arm"] for row in comparison["arms"]] == [
|
||||
"prod_live_setup_daily",
|
||||
"cooldown_5_daily",
|
||||
"gate_reset_daily",
|
||||
]
|
||||
assert all("equity_curve" not in row for row in comparison["arms"])
|
||||
assert all("benchmark_curve" not in row for row in comparison["arms"])
|
||||
@@ -997,7 +1038,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
|
||||
})
|
||||
assert rec["headline"] is not None
|
||||
assert "3x ATR trailing exit" in rec["headline"]
|
||||
assert "5-session re-entry lockdown" in rec["headline"]
|
||||
assert "after the gate fails" in rec["headline"]
|
||||
assert any(item["topic"] == "production" for item in rec["items"])
|
||||
|
||||
|
||||
@@ -1261,10 +1302,7 @@ async def test_run_backtest_smoke(session):
|
||||
assert report["params"]["is_production_target_model"] is True
|
||||
assert report["params"]["entry_cadence"] == "weekly"
|
||||
assert report["params"]["step_sessions"] == 5
|
||||
assert (
|
||||
report["params"]["production_reentry_lockdown_sessions"]
|
||||
== bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
)
|
||||
assert report["params"]["production_reentry_policy"] == "gate_reset"
|
||||
assert "net_avg_r" in report["overall_all"]
|
||||
|
||||
# ablation baseline reproduces the qualified set exactly, and every row
|
||||
|
||||
@@ -48,22 +48,18 @@ async def test_create_and_list_open(session):
|
||||
assert row["current_price"] == 110.0 # marked to the latest close
|
||||
|
||||
|
||||
async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(session):
|
||||
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
|
||||
blocked_id = await _seed(session, "LOCKQ", close=100.0)
|
||||
released_id = await _seed(session, "FREEQ", close=100.0)
|
||||
today = date.today()
|
||||
market_sessions = [
|
||||
today - timedelta(days=8),
|
||||
today - timedelta(days=7),
|
||||
today - timedelta(days=6),
|
||||
today - timedelta(days=3),
|
||||
today - timedelta(days=2),
|
||||
today - timedelta(days=1),
|
||||
]
|
||||
for market_date in market_sessions:
|
||||
session.add(BenchmarkPrice(symbol="SPY", date=market_date, close=400.0))
|
||||
|
||||
def stopped_trade(ticker_id: int, closed_on: date) -> PaperTrade:
|
||||
def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
|
||||
closed_on = today - timedelta(days=10)
|
||||
reset_at = datetime.combine(
|
||||
closed_on + timedelta(days=1),
|
||||
datetime.min.time(),
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
return PaperTrade(
|
||||
user_id=1,
|
||||
ticker_id=ticker_id,
|
||||
@@ -81,17 +77,21 @@ async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(sess
|
||||
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||
),
|
||||
close_reason="stop",
|
||||
reentry_gate_failed_at=reset_at if gate_reset_complete else None,
|
||||
reentry_gate_requalified_at=(
|
||||
reset_at + timedelta(days=1) if gate_reset_complete else None
|
||||
),
|
||||
)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
stopped_trade(blocked_id, market_sessions[1]),
|
||||
stopped_trade(released_id, market_sessions[0]),
|
||||
stopped_trade(blocked_id, gate_reset_complete=False),
|
||||
stopped_trade(released_id, gate_reset_complete=True),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(ValidationError, match="1 market session remaining"):
|
||||
with pytest.raises(ValidationError, match="requires a post-stop gate reset"):
|
||||
await svc.create_trade(
|
||||
session,
|
||||
1,
|
||||
|
||||
@@ -20,7 +20,6 @@ from hypothesis import given, settings, HealthCheck, strategies as st
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
@@ -609,11 +608,10 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
async def test_get_trade_setups_applies_initial_stop_gate_reset_lock(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
today = now.date()
|
||||
if await db_session.get(User, 1) is None:
|
||||
db_session.add(
|
||||
User(id=1, username="u", password_hash="x", role="user", has_access=True)
|
||||
@@ -626,38 +624,6 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
db_session.add_all([blocked, released, trailing])
|
||||
await db_session.flush()
|
||||
|
||||
# Six SPY sessions D0..D5 form the canonical market calendar. A stop on
|
||||
# D0 has five later sessions and is released; a stop on D1 has only four.
|
||||
market_sessions = [
|
||||
today - timedelta(days=8),
|
||||
today - timedelta(days=7),
|
||||
today - timedelta(days=6),
|
||||
today - timedelta(days=3),
|
||||
today - timedelta(days=2),
|
||||
today - timedelta(days=1),
|
||||
]
|
||||
for market_date in market_sessions:
|
||||
db_session.add(
|
||||
BenchmarkPrice(
|
||||
symbol="SPY",
|
||||
date=market_date,
|
||||
close=400.0,
|
||||
)
|
||||
)
|
||||
# A bar from an unrelated/scanner-specific calendar must not release the
|
||||
# ticker one session early. The old universe-wide DISTINCT query did.
|
||||
db_session.add(
|
||||
OHLCVRecord(
|
||||
ticker_id=blocked.id,
|
||||
date=today,
|
||||
open=100.0,
|
||||
high=101.0,
|
||||
low=99.0,
|
||||
close=100.0,
|
||||
volume=1_000,
|
||||
)
|
||||
)
|
||||
|
||||
for ticker in (blocked, released, trailing):
|
||||
db_session.add(
|
||||
TradeSetup(
|
||||
@@ -672,7 +638,13 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
)
|
||||
)
|
||||
|
||||
def closed_trade(ticker: Ticker, closed_on: date, reason: str) -> PaperTrade:
|
||||
def closed_trade(
|
||||
ticker: Ticker,
|
||||
reason: str,
|
||||
*,
|
||||
gate_reset_complete: bool = False,
|
||||
) -> PaperTrade:
|
||||
closed_on = now.date() - timedelta(days=10)
|
||||
return PaperTrade(
|
||||
user_id=1,
|
||||
ticker_id=ticker.id,
|
||||
@@ -690,13 +662,19 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||
),
|
||||
close_reason=reason,
|
||||
reentry_gate_failed_at=(
|
||||
now - timedelta(days=9) if gate_reset_complete else None
|
||||
),
|
||||
reentry_gate_requalified_at=(
|
||||
now - timedelta(days=8) if gate_reset_complete else None
|
||||
),
|
||||
)
|
||||
|
||||
db_session.add_all(
|
||||
[
|
||||
closed_trade(blocked, market_sessions[1], "stop"),
|
||||
closed_trade(released, market_sessions[0], "stop"),
|
||||
closed_trade(trailing, market_sessions[-1], "trailing"),
|
||||
closed_trade(blocked, "stop"),
|
||||
closed_trade(released, "stop", gate_reset_complete=True),
|
||||
closed_trade(trailing, "trailing"),
|
||||
]
|
||||
)
|
||||
await db_session.flush()
|
||||
@@ -710,7 +688,7 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
row["symbol"]
|
||||
for row in await get_trade_setups(
|
||||
db_session,
|
||||
exclude_reentry_lockdown_tickers=True,
|
||||
exclude_reentry_gate_locked_tickers=True,
|
||||
)
|
||||
}
|
||||
assert "STOP4" not in available_symbols
|
||||
@@ -719,10 +697,10 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
annotated = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="STOP4",
|
||||
include_reentry_lockdown=True,
|
||||
include_reentry_gate_lock=True,
|
||||
)
|
||||
assert len(annotated) == 1
|
||||
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1
|
||||
assert annotated[0]["reentry_gate_reset_required"] is True
|
||||
|
||||
|
||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.user import User
|
||||
from app.services.trade_policy import (
|
||||
get_reentry_gate_locks,
|
||||
observe_reentry_gate_transitions,
|
||||
)
|
||||
from tests.conftest import _test_session_factory # type: ignore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session():
|
||||
async with _test_session_factory() as db:
|
||||
yield db
|
||||
|
||||
|
||||
def _stopped_trade(
|
||||
ticker_id: int,
|
||||
*,
|
||||
closed_at: datetime,
|
||||
gate_failed_at: datetime | None = None,
|
||||
gate_requalified_at: datetime | None = None,
|
||||
) -> PaperTrade:
|
||||
return PaperTrade(
|
||||
user_id=1,
|
||||
ticker_id=ticker_id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
shares=10.0,
|
||||
stop_loss=95.0,
|
||||
target=115.0,
|
||||
status="closed",
|
||||
opened_at=closed_at - timedelta(days=5),
|
||||
close_price=95.0,
|
||||
closed_at=closed_at,
|
||||
close_reason="stop",
|
||||
reentry_gate_failed_at=gate_failed_at,
|
||||
reentry_gate_requalified_at=gate_requalified_at,
|
||||
)
|
||||
|
||||
|
||||
async def test_observation_releases_only_evaluated_unqualified_tickers(session):
|
||||
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||
tickers = [
|
||||
Ticker(symbol=symbol)
|
||||
for symbol in ("FAILQ", "PASSQ", "ERRORQ", "LATEQ")
|
||||
]
|
||||
session.add_all(tickers)
|
||||
await session.flush()
|
||||
|
||||
stopped_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
trades = [
|
||||
_stopped_trade(ticker.id, closed_at=stopped_at)
|
||||
for ticker in tickers[:3]
|
||||
]
|
||||
observed_at = datetime.now(timezone.utc)
|
||||
trades.append(
|
||||
_stopped_trade(
|
||||
tickers[3].id,
|
||||
closed_at=observed_at + timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
session.add_all(trades)
|
||||
await session.commit()
|
||||
|
||||
updated = await observe_reentry_gate_transitions(
|
||||
session,
|
||||
evaluated_ticker_ids={tickers[0].id, tickers[1].id, tickers[3].id},
|
||||
qualified_ticker_ids={tickers[1].id},
|
||||
observed_at=observed_at,
|
||||
)
|
||||
|
||||
assert updated == {tickers[0].id}
|
||||
locks = await get_reentry_gate_locks(session)
|
||||
assert set(locks) == {ticker.id for ticker in tickers}
|
||||
assert trades[0].reentry_gate_failed_at == observed_at
|
||||
assert trades[0].reentry_gate_requalified_at is None
|
||||
assert trades[1].reentry_gate_failed_at is None
|
||||
assert trades[2].reentry_gate_failed_at is None
|
||||
assert trades[3].reentry_gate_failed_at is None
|
||||
|
||||
requalified_at = observed_at + timedelta(days=1)
|
||||
updated = await observe_reentry_gate_transitions(
|
||||
session,
|
||||
evaluated_ticker_ids={tickers[0].id},
|
||||
qualified_ticker_ids={tickers[0].id},
|
||||
observed_at=requalified_at,
|
||||
)
|
||||
|
||||
assert updated == {tickers[0].id}
|
||||
assert trades[0].reentry_gate_requalified_at == requalified_at
|
||||
assert set(await get_reentry_gate_locks(session)) == {
|
||||
tickers[1].id,
|
||||
tickers[2].id,
|
||||
tickers[3].id,
|
||||
}
|
||||
|
||||
|
||||
async def test_latest_stop_starts_a_new_gate_reset_episode(session):
|
||||
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||
ticker = Ticker(symbol="TWOSTOP")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
|
||||
first_stop = datetime.now(timezone.utc) - timedelta(days=20)
|
||||
session.add_all(
|
||||
[
|
||||
_stopped_trade(
|
||||
ticker.id,
|
||||
closed_at=first_stop,
|
||||
gate_failed_at=first_stop + timedelta(days=1),
|
||||
gate_requalified_at=first_stop + timedelta(days=2),
|
||||
),
|
||||
_stopped_trade(
|
||||
ticker.id,
|
||||
closed_at=first_stop + timedelta(days=10),
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert ticker.id in await get_reentry_gate_locks(session)
|
||||
Reference in New Issue
Block a user