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:
+23
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user