fix: bind shadow book to its own pipeline's scan, not wall-clock freshness

A 6-hour freshness window proves only that some scan ran recently, which a
manual mid-day scan satisfies. Scenario: a manual scan succeeds at 13:00;
the 15:30 near-close pipeline's scan step is disabled or fails; at 15:30
the 13:00 completion is still 'fresh', so the shadow step trades that
earlier batch despite no successful scan in the current pipeline.

_run_pipeline now records its start in a per-task contextvar, visible to
the steps it awaits. run_shadow_book reads it and requires the scan
completion marker to be at/after the pipeline start, so a scan that failed
or was disabled in this pass (marker left at a prior run, before the
pipeline began) cannot be substituted by an earlier manual scan. A direct
Admin trigger has no pipeline context and falls back to the freshness
window -- an explicit operator action, not an automated one.

Tests pin the reported case: a fresh manual scan predating the pipeline
start is refused; the pipeline's own post-start scan is accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:03:44 +02:00
co-authored by Claude Fable 5
parent 6a10c8ff09
commit 807cc4bdfa
3 changed files with 103 additions and 12 deletions
+23 -1
View File
@@ -16,6 +16,7 @@ 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
@@ -625,9 +626,16 @@ 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
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()
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
@@ -646,7 +654,9 @@ async def run_shadow_book() -> None:
activation_config = await get_activation_config(db)
summary = await shadow_book_service.open_shadow_positions(
db, activation_config=activation_config
db,
activation_config=activation_config,
require_scan_after=require_scan_after,
)
symbols = await shadow_book_service.symbols_for(db, summary["symbols"])
@@ -1319,6 +1329,15 @@ _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.
@@ -1337,6 +1356,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))
try:
for step_name, func_name in steps:
_runtime_progress(job_name, processed=done, total=total, current_ticker=step_name)
@@ -1350,6 +1370,8 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
except Exception as exc:
_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)
async def run_daily_pipeline() -> None: