fix: match shadow book to its pipeline's scan by run id, not timestamp

A manually triggered rr_scanner and the scheduled near-close pipeline are
separate APScheduler jobs; max_instances=1 serialises a job only against
itself, so they can overlap. A manual scan starting just before the
pipeline can finish just after it began and overwrite the scan markers.
Its completion timestamp is then later than the pipeline start, so the
previous 'completed >= pipeline_start' check accepted its batch as though
it were the pipeline's own -- exactly when the pipeline's scan may have
failed.

Replace the timestamp comparison with an exact run-id match. A new
pipeline_run module holds a per-task run-id contextvar (separate module so
the scanner and scheduler import it without a cycle). _run_pipeline binds a
fresh id per invocation; scan_all_tickers stamps that id -- or a fresh one
when run standalone -- into the scan markers, written with started/completed
in a single commit. The shadow step requires the stored run id to equal its
pipeline's id exactly, so a concurrent manual scan (its own id) or a failed
pipeline scan (a prior run's id) can never be mistaken for it. Direct Admin
triggers have no pipeline context and keep the freshness fallback.

Known residual: the id match governs whether shadow proceeds; setup
selection remains detected_at >= scan start, so a fully per-run setup
isolation would need a run_id column on trade_setups (not required here).

Tests cover the reported race (manual scan finishing last is refused), a
failed pipeline scan, the id-match accept path, and contextvar propagation
and non-leakage across tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:47:32 +02:00
co-authored by Claude Fable 5
parent 807cc4bdfa
commit 05ba138d35
6 changed files with 220 additions and 71 deletions
+15 -18
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import json
import logging
import asyncio
import contextvars
from datetime import date, datetime, timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -37,6 +36,7 @@ from app.providers.protocol import SentimentData
from app.services import (
fundamental_service,
ingestion_service,
pipeline_run,
sentiment_service,
settings_store,
shadow_book_service,
@@ -626,16 +626,17 @@ async def run_shadow_book() -> None:
prices the discretionary book sees, leaving *selection* as the only
difference between the two books.
When run as a pipeline step it only acts on a scan that completed *inside
this pipeline pass* (``require_scan_after``): if the pipeline's own scan was
disabled or failed, an earlier still-fresh manual scan must not stand in for
it. Triggered directly from Admin (no pipeline context) it falls back to the
When run as a pipeline step it acts only on the scan that stamped *this
pipeline's* run id (``expected_run_id``): if the pipeline's own scan was
disabled or failed, the stored run id is some other scan's — including a
manual scan that overlapped and finished last — and shadow refuses.
Triggered directly from Admin (no pipeline context) it falls back to the
scan-freshness window — an explicit operator action.
Opt-in (``shadow_book_enabled``) because it writes live trades.
"""
job_name = "shadow_book"
require_scan_after = _pipeline_started_at.get()
expected_run_id = pipeline_run.current()
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
@@ -656,7 +657,7 @@ async def run_shadow_book() -> None:
summary = await shadow_book_service.open_shadow_positions(
db,
activation_config=activation_config,
require_scan_after=require_scan_after,
expected_run_id=expected_run_id,
)
symbols = await shadow_book_service.symbols_for(db, summary["symbols"])
@@ -1329,20 +1330,16 @@ _INTRADAY_PIPELINE_STEPS = [
_NEAR_CLOSE_DURATION_WARN_SECONDS = 600
# Wall-clock start of the pipeline invocation currently running, visible to its
# steps via the shared task context. The shadow book uses it to require that the
# scan it acts on completed *inside this pipeline pass* — a fresh but earlier
# manual scan must not stand in for a scan that failed or was disabled here.
_pipeline_started_at: contextvars.ContextVar[datetime | None] = contextvars.ContextVar(
"pipeline_started_at", default=None
)
async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
"""Run an ordered list of (step_name, coroutine_name) steps.
Each step respects its own enable flag and manages its own runtime status; a
failing step is logged and the pipeline continues with the next one.
A unique run id is bound for the invocation and visible to every step via the
shared task context: the scan step stamps it into its completion markers and
the shadow step requires an exact match, so only a scan that ran inside this
pipeline can drive the shadow book.
"""
_log_event(logging.INFO, "job_start", job=job_name)
async with async_session_factory() as db:
@@ -1356,7 +1353,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
funcs = globals()
done = 0
token = _pipeline_started_at.set(datetime.now(timezone.utc))
token = pipeline_run.bind(pipeline_run.new_run_id())
try:
for step_name, func_name in steps:
_runtime_progress(job_name, processed=done, total=total, current_ticker=step_name)
@@ -1371,7 +1368,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
_runtime_finish(job_name, "error", processed=done, total=total, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
finally:
_pipeline_started_at.reset(token)
pipeline_run.release(token)
async def run_daily_pipeline() -> None: