fix: harden post-stop reentry lockdown
This commit is contained in:
@@ -590,19 +590,47 @@ class TestSimulatePortfolio:
|
||||
None,
|
||||
"hold",
|
||||
30,
|
||||
reentry_cooldown_days=5,
|
||||
reentry_cooldown_sessions=5,
|
||||
)
|
||||
|
||||
assert baseline is not None and baseline["trades"] == 2
|
||||
assert cooldown is not None and cooldown["trades"] == 1
|
||||
assert cooldown["skipped_cooldown"] == 1
|
||||
assert cooldown["reentry_cooldown_days"] == 5
|
||||
assert cooldown["reentry_cooldown_sessions"] == 5
|
||||
|
||||
def test_initial_stop_cooldown_unlocks_exactly_after_session_five(self):
|
||||
closes = [100.0, 94.0, 96.0, 96.0, 96.0, 96.0, 97.0, 98.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
candidates = [
|
||||
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
|
||||
# Four completed sessions since the stop: still locked.
|
||||
_sim_cand("AAA", self.ORD + 5, entry=96.0, stop=90.0, target=115.0),
|
||||
# Five completed sessions since the stop: first permitted re-entry.
|
||||
_sim_cand("AAA", self.ORD + 6, entry=97.0, stop=90.0, target=118.0),
|
||||
]
|
||||
|
||||
sim = bt._simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
30,
|
||||
reentry_cooldown_sessions=5,
|
||||
include_trades=True,
|
||||
)
|
||||
|
||||
assert sim is not None
|
||||
assert sim["trades"] == 2
|
||||
assert sim["skipped_cooldown"] == 1
|
||||
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||
self.ORD + 6
|
||||
).isoformat()
|
||||
|
||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
||||
def fake_simulator(*_args, **kwargs):
|
||||
return {
|
||||
"trades": 0,
|
||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_days", 0),
|
||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||
|
||||
@@ -48,6 +48,74 @@ 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):
|
||||
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:
|
||||
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=datetime.combine(
|
||||
closed_on - timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
|
||||
),
|
||||
close_price=95.0,
|
||||
closed_at=datetime.combine(
|
||||
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||
),
|
||||
close_reason="stop",
|
||||
)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
stopped_trade(blocked_id, market_sessions[1]),
|
||||
stopped_trade(released_id, market_sessions[0]),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(ValidationError, match="1 market session remaining"):
|
||||
await svc.create_trade(
|
||||
session,
|
||||
1,
|
||||
symbol="LOCKQ",
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
shares=10.0,
|
||||
stop_loss=95.0,
|
||||
target=115.0,
|
||||
)
|
||||
|
||||
trade = await svc.create_trade(
|
||||
session,
|
||||
1,
|
||||
symbol="FREEQ",
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
shares=10.0,
|
||||
stop_loss=95.0,
|
||||
target=115.0,
|
||||
)
|
||||
assert trade.ticker_id == released_id
|
||||
|
||||
|
||||
async def test_close_uses_current_price(session):
|
||||
await _seed(session, "AAA", close=112.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -625,21 +626,37 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
db_session.add_all([blocked, released, trailing])
|
||||
await db_session.flush()
|
||||
|
||||
# Six synthetic stored market sessions D0..D5. A stop on D0 has five
|
||||
# later sessions and is released; a stop on D1 has only four and is not.
|
||||
market_sessions = [today - timedelta(days=offset) for offset in range(5, -1, -1)]
|
||||
# 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(
|
||||
OHLCVRecord(
|
||||
ticker_id=blocked.id,
|
||||
BenchmarkPrice(
|
||||
symbol="SPY",
|
||||
date=market_date,
|
||||
open=100.0,
|
||||
high=101.0,
|
||||
low=99.0,
|
||||
close=100.0,
|
||||
volume=1_000,
|
||||
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(
|
||||
@@ -699,6 +716,14 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
||||
assert "STOP4" not in available_symbols
|
||||
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
|
||||
|
||||
annotated = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="STOP4",
|
||||
include_reentry_lockdown=True,
|
||||
)
|
||||
assert len(annotated) == 1
|
||||
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1
|
||||
|
||||
|
||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||
|
||||
Reference in New Issue
Block a user