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
+38
View File
@@ -283,6 +283,44 @@ class TestScanFreshness:
assert summary["opened"] == 0
class TestPipelineScanBinding:
@pytest.mark.asyncio
async def test_fresh_manual_scan_before_pipeline_is_refused(self, session):
"""A manual scan at 13:00 is still 'fresh' at 15:30, but the 15:30
pipeline's own scan failed. Binding to the pipeline start rejects the
13:00 batch — no successful scan happened in *this* pipeline pass."""
ids = await _seed(session, ["AAA"])
pipeline_start = datetime.now(timezone.utc)
manual_scan = pipeline_start - timedelta(hours=2, minutes=30)
session.add(_setup(ids["AAA"], rank=0.9, detected=manual_scan))
await session.commit()
await _mark_scan(session, started=manual_scan - timedelta(minutes=5),
completed=manual_scan)
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG, require_scan_after=pipeline_start
)
assert summary["opened"] == 0
@pytest.mark.asyncio
async def test_pipeline_scan_after_start_is_accepted(self, session):
"""The pipeline's own scan completes just after the pipeline began."""
ids = await _seed(session, ["AAA"])
pipeline_start = datetime.now(timezone.utc)
scan_completed = pipeline_start + timedelta(minutes=1)
session.add(_setup(ids["AAA"], rank=0.9, detected=scan_completed))
await session.commit()
await _mark_scan(session, started=pipeline_start + timedelta(seconds=1),
completed=scan_completed)
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG, require_scan_after=pipeline_start
)
assert summary["opened"] == 1
class TestLongOnly:
@pytest.mark.asyncio
async def test_shorts_are_never_taken_even_with_gate_disabled(self, session):