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
+22 -20
View File
@@ -47,13 +47,11 @@ from app.services.recommendation_service import (
logger = logging.getLogger(__name__)
# Boundary of the most recent *successful* scan. Written together, only when
# scan_all_tickers completes: STARTED bounds which setups belong to the run
# (detected_at >= STARTED), COMPLETED gives its freshness, and RUN_ID identifies
# the pipeline invocation that produced it (or a fresh id for a manual scan).
# The shadow book matches RUN_ID exactly rather than trusting timestamps, so a
# concurrent manual scan cannot be mistaken for the pipeline's own.
KEY_LAST_SCAN_STARTED = "last_scan_run_started_at"
# Markers of the most recent *successful* scan, written together only when
# scan_all_tickers completes. COMPLETED gives its freshness; RUN_ID identifies
# the run — the same id stamped on every setup row it produced. The shadow book
# matches RUN_ID exactly and then selects setups by that id, so neither a
# concurrent manual scan nor a stale prior run can be mistaken for it.
KEY_LAST_SCAN_COMPLETED = "last_scan_run_completed_at"
KEY_LAST_SCAN_RUN_ID = "last_scan_run_id"
@@ -527,6 +525,7 @@ async def scan_ticker(
volatility_percentile: float | None = None,
primary_min_rr: float | None = None,
gate_levels_override: list[Any] | None = None,
scan_run_id: str | None = None,
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
@@ -693,6 +692,9 @@ async def scan_ticker(
enhanced_setups.append(setup)
for setup in enhanced_setups:
# Stamp identity after enhancement so it survives regardless of how the
# enhancer rebuilds the row; the shadow book selects its batch by this.
setup.scan_run_id = scan_run_id
db.add(setup)
await db.commit()
@@ -753,6 +755,13 @@ async def scan_all_tickers(
evaluated_ticker_ids: set[int] = set()
qualified_ticker_ids: set[int] = set()
gate_observation_started_at = datetime.now(timezone.utc)
# One id for the whole run: stamped on every setup row and written to the
# completion marker, so the shadow book can select this run's batch by
# identity. From the pipeline when run as its scan step; a fresh id (never
# matching any pipeline's) when triggered standalone.
from app.services import pipeline_run
scan_run_id = pipeline_run.current() or pipeline_run.new_run_id()
for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None:
progress_callback(index, total, symbol)
@@ -785,6 +794,7 @@ async def scan_all_tickers(
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
primary_min_rr=PRIMARY_TARGET_MIN_RR,
scan_run_id=scan_run_id,
)
all_setups.extend(setups)
if activation is not None:
@@ -823,22 +833,14 @@ async def scan_all_tickers(
if progress_callback is not None and total:
progress_callback(total, total, "")
# Record the run boundary only now that the scan has completed, stamped with
# the run id of the pipeline this scan ran inside (or a fresh id when run
# standalone — a manual scan then can never match a pipeline's expected id).
# All three markers are written in one commit so a reader sees a consistent
# (started, completed, run_id) triple, and a hard failure above leaves the
# previous, now-superseded, markers in place.
from app.services import pipeline_run
run_id = pipeline_run.current() or pipeline_run.new_run_id()
await settings_store.upsert_setting(
db, KEY_LAST_SCAN_STARTED, gate_observation_started_at.isoformat()
)
# Publish the run markers only now that the scan has completed: COMPLETED for
# freshness and RUN_ID (the same id stamped on this run's setup rows) for
# identity, in one commit. A hard failure above leaves the previous,
# now-superseded, markers in place — so the shadow book will not match.
await settings_store.upsert_setting(
db, KEY_LAST_SCAN_COMPLETED, datetime.now(timezone.utc).isoformat()
)
await settings_store.upsert_setting(db, KEY_LAST_SCAN_RUN_ID, run_id)
await settings_store.upsert_setting(db, KEY_LAST_SCAN_RUN_ID, scan_run_id)
await db.commit()
return all_setups