fix: guarantee shadow scan freshness, long-only, user-scoped setup list

Second review round on the shadow book; all three findings were real.

- Scan freshness is now proven, not assumed. Pipeline steps run and fail
  independently, so a disabled or failed scan step still let the shadow
  step run on the newest *stored* setups -- a prior session's picks at
  stale prices. scan_all_tickers now records a run boundary
  (last_scan_run_started_at / _completed_at) only on successful
  completion; the shadow book refuses to trade unless COMPLETED is fresh
  and selects only setups with detected_at >= the run start. Deduplication
  to the latest row per ticker now happens BEFORE qualification, so a newer
  unqualified row suppresses an older qualified one rather than the reverse.

- Shadow selection is hard long-only. setup_qualifies only enforces
  long-only when min_momentum_percentile > 0, but 0 is a legal admin
  setting, and the cash accounting assumes long positions -- so the
  constraint is enforced in shadow selection regardless of gate config.

- The personal setup list excludes only the caller's own open positions.
  get_trade_setups gained exclude_open_trade_user_id; the trades route
  passes the authenticated user, while the Telegram broadcast stays global
  since it has no single owner.

New tests cover stale/absent scan markers, prior-run exclusion, newer
unqualified suppressing older qualified, long-only under a disabled gate,
and both sides of the user-scoped exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 09:31:24 +02:00
co-authored by Claude Fable 5
parent 247a92a89f
commit 6a10c8ff09
5 changed files with 230 additions and 40 deletions
+31 -1
View File
@@ -30,6 +30,7 @@ from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv
from app.services.qualification import setup_qualifies
from app.services.sr_service import detect_gate_target_ladder
from app.services import settings_store
from app.services.trade_policy import (
MANUAL_BOOK,
SHADOW_BOOK,
@@ -46,6 +47,13 @@ from app.services.recommendation_service import (
logger = logging.getLogger(__name__)
# Boundary of the most recent *successful* scan. Written only when
# scan_all_tickers completes, so a consumer can tell a scan actually ran this
# pipeline pass (freshness of COMPLETED) and which setups belong to it
# (detected_at >= STARTED). The shadow book relies on both.
KEY_LAST_SCAN_STARTED = "last_scan_run_started_at"
KEY_LAST_SCAN_COMPLETED = "last_scan_run_completed_at"
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
# A setup counts as live only while the daily scan keeps re-emitting it. The
@@ -812,6 +820,19 @@ 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. The shadow
# book refuses to trade unless COMPLETED is fresh (proving a scan ran in this
# pipeline pass, not a prior session) and selects only setups from this run
# (detected_at >= STARTED). Written after the loop so a hard failure above
# leaves the previous, now-stale, marker in place.
await settings_store.upsert_setting(
db, KEY_LAST_SCAN_STARTED, gate_observation_started_at.isoformat()
)
await settings_store.upsert_setting(
db, KEY_LAST_SCAN_COMPLETED, datetime.now(timezone.utc).isoformat()
)
await db.commit()
return all_setups
@@ -823,6 +844,7 @@ async def get_trade_setups(
symbol: str | None = None,
live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False,
exclude_open_trade_user_id: int | None = None,
exclude_reentry_gate_locked_tickers: bool = False,
include_reentry_gate_lock: bool = False,
) -> list[dict]:
@@ -855,11 +877,19 @@ async def get_trade_setups(
# construction, so letting its positions hide setups would leave the
# discretionary list picking over leftovers — and would bias the very
# shadow-vs-manual comparison the shadow book exists to measure.
open_trade_result = await db.execute(
open_trade_stmt = (
select(PaperTrade.ticker_id)
.where(PaperTrade.status == "open", PaperTrade.book == MANUAL_BOOK)
.distinct()
)
# Scope to one user for the personal setup list (don't hide a name just
# because someone else holds it); leave it global for the Telegram
# broadcast, which has no single owner.
if exclude_open_trade_user_id is not None:
open_trade_stmt = open_trade_stmt.where(
PaperTrade.user_id == exclude_open_trade_user_id
)
open_trade_result = await db.execute(open_trade_stmt)
excluded_ticker_ids.update(
ticker_id for ticker_id, in open_trade_result.all()
)