fix: grandfather pre-cutover stop episodes

This commit is contained in:
2026-07-17 20:28:42 +02:00
parent d858475ddb
commit d13c54e3c7
5 changed files with 179 additions and 16 deletions
@@ -30,6 +30,22 @@ def upgrade() -> None:
nullable=True, nullable=True,
), ),
) )
# The policy starts at this deployment. Historical NULL values mean the
# scanner never recorded reset observations, not that those old episodes
# are still active. Mark both transitions complete so only stops created
# after the migration can open a re-entry lock.
op.execute(
sa.text(
"""
UPDATE paper_trades
SET reentry_gate_failed_at = closed_at,
reentry_gate_requalified_at = closed_at
WHERE status = 'closed'
AND close_reason = 'stop'
AND closed_at IS NOT NULL
"""
)
)
def downgrade() -> None: def downgrade() -> None:
+23 -15
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
@@ -16,27 +16,35 @@ async def _latest_initial_stop_trades(
*, *,
closed_before: datetime | None = None, closed_before: datetime | None = None,
) -> dict[int, PaperTrade]: ) -> dict[int, PaperTrade]:
"""Return the most recent initial-stop trade for each ticker.""" """Return a ticker's latest closed trade only when it was an initial stop."""
stmt = ( ranked_stmt = (
select(PaperTrade) select(
PaperTrade.id.label("trade_id"),
func.row_number()
.over(
partition_by=PaperTrade.ticker_id,
order_by=(PaperTrade.closed_at.desc(), PaperTrade.id.desc()),
)
.label("recency"),
)
.where( .where(
PaperTrade.status == "closed", PaperTrade.status == "closed",
PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None), PaperTrade.closed_at.is_not(None),
) )
.order_by(
PaperTrade.ticker_id.asc(),
PaperTrade.closed_at.desc(),
PaperTrade.id.desc(),
)
) )
if closed_before is not None: if closed_before is not None:
stmt = stmt.where(PaperTrade.closed_at <= closed_before) ranked_stmt = ranked_stmt.where(PaperTrade.closed_at <= closed_before)
ranked = ranked_stmt.subquery()
stmt = (
select(PaperTrade)
.join(ranked, ranked.c.trade_id == PaperTrade.id)
.where(
ranked.c.recency == 1,
PaperTrade.close_reason == "stop",
)
)
result = await db.execute(stmt) result = await db.execute(stmt)
latest: dict[int, PaperTrade] = {} return {trade.ticker_id: trade for trade in result.scalars()}
for trade in result.scalars():
latest.setdefault(trade.ticker_id, trade)
return latest
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]: async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
+8
View File
@@ -18,6 +18,14 @@ Scanner errors do not count as a gate failure. The two transitions are persisted
on the latest initial-stop `PaperTrade`, so neither a service call nor a restart on the latest initial-stop `PaperTrade`, so neither a service call nor a restart
can bypass the rule. can bypass the rule.
Migration 022 applies the policy prospectively. Existing initial-stop rows are
grandfathered by marking both reset timestamps complete at their historical
`closed_at`; otherwise their new NULL columns would be mistaken for active
locks despite no scanner observations having existed. At runtime, only the
actual latest closed trade per ticker can start a lock, and only when that exit
was an initial stop. A newer trailing, time, target, or manual exit therefore
cannot revive an older stop episode.
This replaces the previously proposed fixed five-session lockdown. The normal This replaces the previously proposed fixed five-session lockdown. The normal
reset counts an unqualified stop-day close when that close is observed after the reset counts an unqualified stop-day close when that close is observed after the
stop. The stricter experiment, which required a failed close on a later session, stop. The stricter experiment, which required a failed close on a later session,
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[2]
/ "alembic"
/ "versions"
/ "022_add_paper_trade_reentry_gate_reset.py"
)
spec = importlib.util.spec_from_file_location("migration_022", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_upgrade_grandfathers_only_preexisting_initial_stops():
migration = _load_migration_module()
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(
sa.text(
"""
CREATE TABLE paper_trades (
id INTEGER PRIMARY KEY,
status VARCHAR NOT NULL,
close_reason VARCHAR,
closed_at DATETIME
)
"""
)
)
connection.execute(
sa.text(
"""
INSERT INTO paper_trades (id, status, close_reason, closed_at)
VALUES
(1, 'closed', 'stop', '2026-07-01 12:00:00'),
(2, 'closed', 'manual', '2026-07-02 12:00:00'),
(3, 'open', NULL, NULL)
"""
)
)
context = MigrationContext.configure(connection)
migration.op = Operations(context)
migration.upgrade()
historical = connection.execute(
sa.text(
"""
SELECT closed_at, reentry_gate_failed_at,
reentry_gate_requalified_at
FROM paper_trades
WHERE id = 1
"""
)
).one()
assert historical[1] == historical[0]
assert historical[2] == historical[0]
unaffected = connection.execute(
sa.text(
"""
SELECT reentry_gate_failed_at, reentry_gate_requalified_at
FROM paper_trades
WHERE id IN (2, 3)
ORDER BY id
"""
)
).all()
assert unaffected == [(None, None), (None, None)]
connection.execute(
sa.text(
"""
INSERT INTO paper_trades (id, status, close_reason, closed_at)
VALUES (4, 'closed', 'stop', '2026-07-18 12:00:00')
"""
)
)
new_stop = connection.execute(
sa.text(
"""
SELECT reentry_gate_failed_at, reentry_gate_requalified_at
FROM paper_trades
WHERE id = 4
"""
)
).one()
assert new_stop == (None, None)
+32 -1
View File
@@ -24,6 +24,7 @@ def _stopped_trade(
ticker_id: int, ticker_id: int,
*, *,
closed_at: datetime, closed_at: datetime,
close_reason: str = "stop",
gate_failed_at: datetime | None = None, gate_failed_at: datetime | None = None,
gate_requalified_at: datetime | None = None, gate_requalified_at: datetime | None = None,
) -> PaperTrade: ) -> PaperTrade:
@@ -39,7 +40,7 @@ def _stopped_trade(
opened_at=closed_at - timedelta(days=5), opened_at=closed_at - timedelta(days=5),
close_price=95.0, close_price=95.0,
closed_at=closed_at, closed_at=closed_at,
close_reason="stop", close_reason=close_reason,
reentry_gate_failed_at=gate_failed_at, reentry_gate_failed_at=gate_failed_at,
reentry_gate_requalified_at=gate_requalified_at, reentry_gate_requalified_at=gate_requalified_at,
) )
@@ -126,3 +127,33 @@ async def test_latest_stop_starts_a_new_gate_reset_episode(session):
await session.commit() await session.commit()
assert ticker.id in await get_reentry_gate_locks(session) assert ticker.id in await get_reentry_gate_locks(session)
async def test_newer_non_stop_exit_supersedes_historical_stop(session):
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
ticker = Ticker(symbol="LATEREXIT")
session.add(ticker)
await session.flush()
stopped_at = datetime.now(timezone.utc) - timedelta(days=20)
old_stop = _stopped_trade(ticker.id, closed_at=stopped_at)
later_manual_exit = _stopped_trade(
ticker.id,
closed_at=stopped_at + timedelta(days=10),
close_reason="manual",
)
session.add_all([old_stop, later_manual_exit])
await session.commit()
assert ticker.id not in await get_reentry_gate_locks(session)
observed_at = datetime.now(timezone.utc)
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={ticker.id},
qualified_ticker_ids=set(),
observed_at=observed_at,
)
assert updated == set()
assert old_stop.reentry_gate_failed_at is None