fix: select shadow book setups by scan run id, not a time window
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m21s
Deploy / deploy (push) Successful in 39s

The run-id marker proved which scan wrote last, but the shadow book still
selected setups by detected_at >= scan_start. An overlapping manual scan
could insert rows in that same window; if the pipeline's scan wrote the
marker last its id matched and the shadow book proceeded, then swept in --
or ranked highest -- a manual-scan row. The identity check gated entry but
selection did not.

Carry the run id onto the rows. Migration 025 adds an indexed
trade_setups.scan_run_id. scan_all_tickers computes one id per run
(pipeline's when a step, else fresh), passes it to scan_ticker which stamps
every row after enhancement, and writes the same id to the completion
marker. The shadow book selects WHERE scan_run_id == the matched id, so a
concurrent scan's rows are excluded by identity regardless of their
detected_at. The now-unused STARTED marker is dropped; COMPLETED
(freshness) and RUN_ID (identity) remain.

Decisive test: the pipeline's id matches, but a same-window manual row with
a higher rank is present and is excluded -- only the pipeline's own row is
traded. A time-window select would have swept it in and ranked it first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 11:25:03 +02:00
co-authored by Claude Fable 5
parent 05ba138d35
commit 565484de87
5 changed files with 136 additions and 71 deletions
+19 -23
View File
@@ -169,13 +169,13 @@ async def _shadow_user_id(db: AsyncSession) -> int | None:
return int(row[0]) if row else None
async def _last_scan_start(
async def _scan_run_to_trade(
db: AsyncSession,
*,
now: datetime,
expected_run_id: str | None = None,
) -> datetime | None:
"""Start of the scan we may act on, or None if there is none.
) -> str | None:
"""The run id whose setups the shadow book may act on, or None.
* ``expected_run_id`` set (pipeline step): the stored run id must match it
exactly. This is the airtight guarantee — a scan that was disabled or
@@ -184,27 +184,25 @@ async def _last_scan_start(
stamps its own id even when it finishes last, so neither can be mistaken
for the pipeline's own scan. Timestamp order alone cannot tell them apart.
* ``expected_run_id`` None (direct Admin trigger): fall back to the freshness
window. There is no pipeline scan to bind to, so acting on a recent scan is
the operator's explicit choice.
window on the last scan's own id. There is no pipeline scan to bind to, so
acting on a recent scan is the operator's explicit choice.
The three markers are written in one commit, so the returned STARTED belongs
to the same run as the matched RUN_ID and correctly bounds its setups.
Setups are then selected by ``scan_run_id`` equal to the returned id, so a
concurrent scan's rows in the same time window are excluded by identity.
"""
from app.services import rr_scanner_service as rr
started = _parse_dt(await settings_store.get_value(db, rr.KEY_LAST_SCAN_STARTED))
completed = _parse_dt(
await settings_store.get_value(db, rr.KEY_LAST_SCAN_COMPLETED)
)
run_id = await settings_store.get_value(db, rr.KEY_LAST_SCAN_RUN_ID)
if started is None or completed is None:
if completed is None or not run_id:
return None
if expected_run_id is not None:
if not run_id or run_id != expected_run_id:
return None
elif now - completed > MAX_SCAN_AGE:
return run_id if run_id == expected_run_id else None
if now - completed > MAX_SCAN_AGE:
return None
return started
return run_id
def _parse_dt(raw: str | None) -> datetime | None:
@@ -223,13 +221,13 @@ async def _todays_qualified_setups(
now: datetime,
expected_run_id: str | None = None,
) -> list[TradeSetup]:
"""Long-only qualified setups from the scan that just ran, best rank first.
"""Long-only qualified setups from the scan we may act on, best rank first.
Order matters here, and matches the review's requirement:
1. Take only rows from the current run (``detected_at >= scan start``). The
previous run's setups sit ~24h earlier and are excluded, so a stale row
can never be traded even if it once qualified.
1. Take only rows the matched scan produced (``scan_run_id == run id``). A
previous run, or a manual scan overlapping in time, carries a different
id and is excluded by identity — not by a time window it could write into.
2. Keep long only. The validated strategy is long-only, but the gate permits
shorts when ``min_momentum_percentile`` is 0 (a legal admin setting), and
the cash accounting assumes longs — so this is enforced here, not left to
@@ -239,14 +237,12 @@ async def _todays_qualified_setups(
the reverse.
4. Qualify, then rank by ``strategy_rank`` (unranked sort last).
"""
run_start = await _last_scan_start(
db, now=now, expected_run_id=expected_run_id
)
if run_start is None:
run_id = await _scan_run_to_trade(db, now=now, expected_run_id=expected_run_id)
if run_id is None:
return []
result = await db.execute(
select(TradeSetup).where(TradeSetup.detected_at >= run_start)
select(TradeSetup).where(TradeSetup.scan_run_id == run_id)
)
rows = [s for s in result.scalars() if (s.direction or "long") == "long"]
@@ -285,7 +281,7 @@ async def open_shadow_positions(
``expected_run_id`` binds this run to the scan that stamped that exact id
(the pipeline's own scan), so a scan that failed in this pipeline — or a
concurrent manual scan that finished last — cannot substitute for it. See
``_last_scan_start``.
``_scan_run_to_trade``.
"""
summary = {
"opened": 0,