fix: harden shadow book against book leakage (review of ba2df8b)

Review of the shadow book found seven ways the two books could leak into
each other; all are fixed here. The most serious silently invalidated the
comparison the shadow book exists to make.

- Shadow holdings no longer suppress the manual candidate list. The
  open-trade exclusion filtered on any book, so shadow taking the
  top-ranked names removed exactly those from the user's list and alerts,
  confining the discretionary book to leftovers. Scoped to the manual
  book. Closed-trade alerts and paper-book equity were leaking the same
  way and are likewise scoped.

- Shadow sizing now matches _simulate_portfolio: min(1% risk, 20% notional
  cap, available cash) from marked equity, plus the sub- dust guard.
  Previously risk-only from realized equity, so a tight stop produced a
  multiples-of-equity leveraged position the strategy would never take.

- Shadow only trades setups from the scan that just ran (<6h old) with one
  setup per ticker. A failed or disabled scan step could otherwise open
  positions from a prior session at stale prices.

- Gate-reset transitions are observed for both books, so a shadow stop-out
  completes fail -> requalify instead of staying locked forever.

- Manual list/close endpoints default to the manual book and reject
  hand-closing shadow trades; the performance endpoint is scoped to the
  caller so 'your picks' is not every user's book.

- run_shadow_book is registered as a paused job so Admin can trigger it.

Also anchors three pre-existing paper-trade tests (and the new alpaca
window test) on the UTC date. They build fixtures from the local date but
the service stamps opened_at in UTC, so they failed only between 00:00 and
02:00 in a UTC+hh timezone -- latent on ba2df8b, exposed by the clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 09:11:14 +02:00
co-authored by Claude Fable 5
parent ba2df8b9fd
commit 247a92a89f
11 changed files with 434 additions and 66 deletions
+13 -2
View File
@@ -29,6 +29,7 @@ from app.config import settings
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.services.trade_policy import MANUAL_BOOK
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
@@ -632,6 +633,10 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]:
PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at > cutoff,
PaperTrade.close_reason.in_(("trailing", "stop", "target", "time")),
# Your own positions only — shadow trades are a research record, not
# something you hold, and mixing them in unlabelled reads as if you
# were stopped out of a name you never took.
PaperTrade.book == MANUAL_BOOK,
)
.order_by(PaperTrade.closed_at.desc())
)
@@ -642,8 +647,14 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]:
async def _paper_book_value(db: AsyncSession) -> float:
"""Paper-trade equity: fixed capital plus realized/unrealized P&L."""
result = await db.execute(select(PaperTrade))
"""Paper-trade equity: fixed capital plus realized/unrealized P&L.
Discretionary book only — the shadow book runs on its own notional equity
and folding it in would report a number matching neither book.
"""
result = await db.execute(
select(PaperTrade).where(PaperTrade.book == MANUAL_BOOK)
)
trades = list(result.scalars().all())
latest: dict[int, float | None] = {}
for trade in trades:
+24 -1
View File
@@ -402,7 +402,15 @@ async def list_trades(
db: AsyncSession,
user_id: int | None = None,
status: str | None = None,
book: str | None = MANUAL_BOOK,
) -> list[dict]:
"""Trades for the UI. Defaults to the discretionary book.
Shadow trades are attached to a user row for FK reasons only — they are not
that person's decisions. Listing them alongside manual trades would mix two
different books in one P&L and let the autonomous record be edited by hand.
Pass ``book=None`` to deliberately span both.
"""
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
@@ -411,6 +419,8 @@ async def list_trades(
stmt = stmt.where(PaperTrade.user_id == user_id)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
if book is not None:
stmt = stmt.where(PaperTrade.book == book)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
rows = (await db.execute(stmt)).all()
@@ -493,6 +503,13 @@ async def close_trade(
trade = result.scalar_one_or_none()
if trade is None:
raise NotFoundError(f"Paper trade not found: {trade_id}")
if trade.book == SHADOW_BOOK:
# The shadow book's value is that no human touched it. A hand-closed
# position would make its record something other than what the strategy
# would have done; it exits only via the automatic exit policy.
raise ValidationError(
"Shadow book trades are closed by the exit policy, not by hand"
)
if trade.status == "closed":
raise ValidationError("Trade is already closed")
@@ -811,7 +828,7 @@ def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list
return out
async def performance_summary(db: AsyncSession) -> dict:
async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict:
"""Shadow book vs discretionary book vs SPY, from the configured start date.
Currency P&L is reported per book but is *not* the comparison — the books
@@ -823,6 +840,12 @@ async def performance_summary(db: AsyncSession) -> dict:
stmt = select(PaperTrade)
if start is not None:
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
if user_id is not None:
# "Your picks" must be *yours*. The shadow book is a single autonomous
# book with no owner, so it is never scoped to a user.
stmt = stmt.where(
(PaperTrade.book == SHADOW_BOOK) | (PaperTrade.user_id == user_id)
)
trades = list((await db.execute(stmt)).scalars().all())
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
+19 -7
View File
@@ -31,6 +31,8 @@ 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.trade_policy import (
MANUAL_BOOK,
SHADOW_BOOK,
get_reentry_gate_locks,
observe_reentry_gate_transitions,
)
@@ -788,12 +790,18 @@ async def scan_all_tickers(
logger.exception("Error scanning ticker %s", symbol)
if activation is not None:
transitioned_ticker_ids = await observe_reentry_gate_transitions(
db,
evaluated_ticker_ids=evaluated_ticker_ids,
qualified_ticker_ids=qualified_ticker_ids,
observed_at=gate_observation_started_at,
)
# Both books, from the same observation: gate-reset state is per book,
# so observing only the manual book would leave shadow stop-outs stuck
# with a fail timestamp that never requalifies — permanently ineligible.
transitioned_ticker_ids: set[int] = set()
for book in (MANUAL_BOOK, SHADOW_BOOK):
transitioned_ticker_ids |= await observe_reentry_gate_transitions(
db,
evaluated_ticker_ids=evaluated_ticker_ids,
qualified_ticker_ids=qualified_ticker_ids,
observed_at=gate_observation_started_at,
book=book,
)
await db.commit()
if transitioned_ticker_ids:
logger.info(
@@ -843,9 +851,13 @@ async def get_trade_setups(
excluded_ticker_ids: set[int] = set()
reentry_gate_locks: dict[int, datetime] = {}
if exclude_open_trade_tickers:
# Manual book only. The shadow book holds the *top-ranked* names by
# 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(
select(PaperTrade.ticker_id)
.where(PaperTrade.status == "open")
.where(PaperTrade.status == "open", PaperTrade.book == MANUAL_BOOK)
.distinct()
)
excluded_ticker_ids.update(
+98 -36
View File
@@ -20,9 +20,9 @@ two cannot drift apart.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.paper_trade import PaperTrade
@@ -47,6 +47,15 @@ DEFAULT_CAPACITY = 10
DEFAULT_RISK_PCT = 1.0
DEFAULT_START_EQUITY = 100_000.0
# Mirrors ``_simulate_portfolio``'s SIM_NOTIONAL_CAP: no single position may
# exceed this fraction of equity, and the book never uses margin. Without the
# cap, a setup with a tight stop turns 1% risk into a position several times
# equity — a leveraged trade the validated strategy would never have taken.
NOTIONAL_CAP = 0.20
# Setups older than this mean the scan did not run in this pipeline pass.
MAX_SETUP_AGE = timedelta(hours=6)
async def get_config(db: AsyncSession) -> dict:
"""Shadow book sizing/capacity config, falling back to validated defaults."""
@@ -76,13 +85,18 @@ async def is_enabled(db: AsyncSession) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "on"}
async def current_equity(db: AsyncSession, start_equity: float) -> float:
"""Start equity plus realized P&L of closed shadow trades.
async def equity_and_cash(
db: AsyncSession, start_equity: float, positions: list[PaperTrade]
) -> tuple[float, float]:
"""Marked equity and free cash, matching ``_simulate_portfolio``.
Open positions are deliberately excluded: sizing off marked-to-market equity
would let an unrealized gain inflate the next position, which is not what the
backtest does.
The simulator sizes from *marked* equity — cash plus open positions at their
latest close — and spends from cash, so a book that is fully invested cannot
keep buying. Sizing from realized P&L alone would drift away from the
backtest as soon as positions were held across a scan.
"""
from app.services.paper_trade_service import _latest_closes
result = await db.execute(
select(PaperTrade).where(
PaperTrade.book == SHADOW_BOOK,
@@ -98,24 +112,51 @@ async def current_equity(db: AsyncSession, start_equity: float) -> float:
else trade.entry_price - trade.close_price
)
realized += per_share * trade.shares
return start_equity + realized
open_cost = sum(p.entry_price * p.shares for p in positions)
marks = await _latest_closes(db, {p.ticker_id for p in positions})
open_value = sum(
(marks.get(p.ticker_id) or p.entry_price) * p.shares for p in positions
)
cash = start_equity + realized - open_cost
return cash + open_value, cash
def position_shares(equity: float, risk_pct: float, entry: float, stop: float) -> float:
"""Fixed-fractional sizing: risk ``risk_pct`` of equity down to the stop."""
def position_shares(
equity: float,
risk_pct: float,
entry: float,
stop: float,
*,
cash_available: float | None = None,
) -> float:
"""Shares to buy, sized exactly as ``_simulate_portfolio`` sizes them.
Fixed-fractional risk first, then the two caps the simulator applies: no
position may exceed ``NOTIONAL_CAP`` of equity, and the book cannot spend
cash it does not have. Dropping either cap lets a tight stop produce a
leveraged position and breaks compounding parity with the backtest.
"""
risk_per_share = abs(entry - stop)
if risk_per_share <= 0 or equity <= 0:
if risk_per_share <= 0 or equity <= 0 or entry <= 0:
return 0.0
return (equity * risk_pct / 100.0) / risk_per_share
shares = (equity * risk_pct / 100.0) / risk_per_share
shares = min(shares, (equity * NOTIONAL_CAP) / entry)
if cash_available is not None:
shares = min(shares, max(0.0, cash_available) / entry)
# Dust guard, as in the simulator: sub-$1 positions are noise, not trades.
return shares if shares * entry >= 1.0 else 0.0
async def _open_ticker_ids(db: AsyncSession) -> set[int]:
async def _open_positions(db: AsyncSession) -> list[PaperTrade]:
result = await db.execute(
select(PaperTrade.ticker_id).where(
select(PaperTrade).where(
PaperTrade.book == SHADOW_BOOK, PaperTrade.status == "open"
)
)
return {row[0] for row in result.all()}
return list(result.scalars().all())
async def _shadow_user_id(db: AsyncSession) -> int | None:
@@ -125,32 +166,42 @@ async def _shadow_user_id(db: AsyncSession) -> int | None:
return int(row[0]) if row else None
async def _todays_qualified_setups(db: AsyncSession, config: dict) -> list[TradeSetup]:
"""Latest setup per ticker from the most recent scan, gate-qualified.
async def _todays_qualified_setups(
db: AsyncSession, config: dict, *, now: datetime
) -> list[TradeSetup]:
"""Latest qualified setup per ticker from the scan that just ran.
Freshness is a hard requirement, not a nicety: pipeline steps are allowed to
fail independently, so if the scan is disabled or errors, the newest stored
setups belong to a previous session. Trading those would enter yesterday's
picks at yesterday's prices and quietly corrupt the record. Anything older
than ``MAX_SETUP_AGE`` is treated as "no scan happened".
Ordered by ``strategy_rank`` descending — the ordering the backtest selects
on. Setups without a rank sort last; they cannot be compared to ranked ones.
"""
latest_scan = await db.execute(select(func.max(TradeSetup.detected_at)))
newest = latest_scan.scalar()
if newest is None:
return []
# Everything written by the same scan run (same calendar day, NY-agnostic:
# one qualifying scan per day is a hard invariant of the schedule).
cutoff = now - MAX_SETUP_AGE
result = await db.execute(
select(TradeSetup).where(
func.date(TradeSetup.detected_at) == func.date(newest),
)
select(TradeSetup).where(TradeSetup.detected_at >= cutoff)
)
setups = [s for s in result.scalars() if setup_qualifies(s, config)]
setups.sort(
qualified = [s for s in result.scalars() if setup_qualifies(s, config)]
# One setup per ticker — the most recent wins. A ticker can have several
# rows in a scan (e.g. both directions); ranking over duplicates would let
# one name occupy more than its share of the ordering.
latest: dict[int, TradeSetup] = {}
for setup in qualified:
held = latest.get(setup.ticker_id)
if held is None or setup.detected_at > held.detected_at:
latest[setup.ticker_id] = setup
return sorted(
latest.values(),
key=lambda s: (
s.strategy_rank if s.strategy_rank is not None else float("-inf")
),
reverse=True,
)
return setups
async def open_shadow_positions(
@@ -165,11 +216,18 @@ async def open_shadow_positions(
skip anything already held or locked out by post-stop gate-reset, and stop
at capacity. Returns a summary for the job log.
"""
summary = {"opened": 0, "skipped_held": 0, "skipped_locked": 0, "symbols": []}
summary = {
"opened": 0,
"skipped_held": 0,
"skipped_locked": 0,
"skipped_no_cash": 0,
"symbols": [],
}
config = await get_config(db)
held = await _open_ticker_ids(db)
free_slots = config["capacity"] - len(held)
positions = await _open_positions(db)
held = {p.ticker_id for p in positions}
free_slots = config["capacity"] - len(positions)
if free_slots <= 0:
return summary
@@ -179,10 +237,10 @@ async def open_shadow_positions(
return summary
locks = await get_reentry_gate_locks(db, book=SHADOW_BOOK)
equity = await current_equity(db, config["start_equity"])
equity, cash = await equity_and_cash(db, config["start_equity"], positions)
timestamp = opened_at or datetime.now(timezone.utc)
for setup in await _todays_qualified_setups(db, activation_config):
for setup in await _todays_qualified_setups(db, activation_config, now=timestamp):
if free_slots <= 0:
break
if setup.ticker_id in held:
@@ -194,9 +252,13 @@ async def open_shadow_positions(
entry = float(setup.entry_price or 0.0)
stop = float(setup.stop_loss or 0.0)
shares = position_shares(equity, config["risk_pct"], entry, stop)
shares = position_shares(
equity, config["risk_pct"], entry, stop, cash_available=cash
)
if shares <= 0:
summary["skipped_no_cash"] += 1
continue
cash -= shares * entry
db.add(
PaperTrade(