feat: shadow book + shadow-vs-manual performance comparison
The manual paper book only contains trades taken by hand, inside a 20
minute window, on days someone was available. The backtest that validated
this strategy auto-takes the top-ranked qualified setups up to capacity
every session. The forward record was therefore measuring strategy plus
discretion plus availability -- and degrading silently on busy days.
The shadow book closes that gap: it mirrors the backtest's selection rule
(top strategy_rank qualified, up to capacity, 1% fixed-fractional risk)
and shares the manual book's exit policy, so the only difference between
the two books is which setups get taken. Selection ordering reuses the
strategy_rank the scanner already stores rather than recomputing it, so
the two cannot drift apart. It runs as a near-close pipeline step right
after the scan, marking entries at the same prices a human would see.
Gate-reset re-entry state is now scoped per book -- the books diverge as
soon as their entries differ, and each must see only its own stops.
Performance view rewritten around the comparison:
- three series (shadow, manual, SPY) from a new endpoint
- SPY changes from a per-trade cost-basis counterfactual to plain
buy-and-hold %, since one line has to serve two books
- headline stats are R-multiples, not currency: the books size
differently, so only R compares across them
- configurable start date, because the strategy has been revised
repeatedly and pre-cutover trades ran under rules that no longer
exist
Migration 024 also repairs the numeric weekday crons written by 023,
rewriting only rows still holding the broken form so hand-corrected
settings survive. Its literals are inlined because bound parameters
render as NULL under 'alembic upgrade --sql'.
The shadow book is opt-in and writes nothing until enabled. Verify its
first selections match a backtest of that day's cross-section before
trusting any point on the curve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Shadow book — the validated strategy, traded automatically.
|
||||
|
||||
The discretionary paper book only ever contains trades the user chose to take,
|
||||
inside a ~20 minute window, on days they were available. The backtest that
|
||||
validated this strategy does none of that: it takes the top-ranked qualified
|
||||
setups up to capacity, every session, with no human involved. That difference
|
||||
makes the manual book unusable as out-of-sample evidence — it measures the
|
||||
strategy *plus* discretion and availability.
|
||||
|
||||
The shadow book closes that gap. It mirrors ``_simulate_portfolio``'s selection
|
||||
rule exactly and shares the manual book's exit policy, so the only difference
|
||||
between the two books is *which* qualified setups get taken.
|
||||
|
||||
Parity is the load-bearing property here. Selection ordering comes from the
|
||||
stored ``strategy_rank`` the scanner already wrote (the same 80/20
|
||||
momentum/vol blend the backtest ranks on) rather than being recomputed, so the
|
||||
two cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.models.user import User
|
||||
from app.services import settings_store
|
||||
from app.services.qualification import setup_qualifies
|
||||
from app.services.trade_policy import SHADOW_BOOK, get_reentry_gate_locks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_ENABLED = "shadow_book_enabled"
|
||||
KEY_CAPACITY = "shadow_book_capacity"
|
||||
KEY_RISK_PCT = "shadow_book_risk_pct"
|
||||
KEY_START_EQUITY = "shadow_book_start_equity"
|
||||
|
||||
# Matches the validated configuration: 10-position book, 1% fixed-fractional
|
||||
# risk. Start equity is only a sizing base — comparisons are drawn in percent
|
||||
# and R-multiples, never in raw currency.
|
||||
DEFAULT_CAPACITY = 10
|
||||
DEFAULT_RISK_PCT = 1.0
|
||||
DEFAULT_START_EQUITY = 100_000.0
|
||||
|
||||
|
||||
async def get_config(db: AsyncSession) -> dict:
|
||||
"""Shadow book sizing/capacity config, falling back to validated defaults."""
|
||||
raw = await settings_store.get_map(
|
||||
db, [KEY_CAPACITY, KEY_RISK_PCT, KEY_START_EQUITY]
|
||||
)
|
||||
|
||||
def _num(key: str, default: float, *, minimum: float, maximum: float) -> float:
|
||||
try:
|
||||
value = float(raw.get(key) or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
return {
|
||||
"capacity": int(_num(KEY_CAPACITY, DEFAULT_CAPACITY, minimum=1, maximum=100)),
|
||||
"risk_pct": _num(KEY_RISK_PCT, DEFAULT_RISK_PCT, minimum=0.05, maximum=10.0),
|
||||
"start_equity": _num(
|
||||
KEY_START_EQUITY, DEFAULT_START_EQUITY, minimum=1000.0, maximum=1e9
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def is_enabled(db: AsyncSession) -> bool:
|
||||
"""Shadow book writes trades to the live book, so it is opt-in."""
|
||||
value = await settings_store.get_value(db, KEY_ENABLED, "false")
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaperTrade).where(
|
||||
PaperTrade.book == SHADOW_BOOK,
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.close_price.is_not(None),
|
||||
)
|
||||
)
|
||||
realized = 0.0
|
||||
for trade in result.scalars():
|
||||
per_share = (
|
||||
trade.close_price - trade.entry_price
|
||||
if trade.direction == "long"
|
||||
else trade.entry_price - trade.close_price
|
||||
)
|
||||
realized += per_share * trade.shares
|
||||
return start_equity + realized
|
||||
|
||||
|
||||
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."""
|
||||
risk_per_share = abs(entry - stop)
|
||||
if risk_per_share <= 0 or equity <= 0:
|
||||
return 0.0
|
||||
return (equity * risk_pct / 100.0) / risk_per_share
|
||||
|
||||
|
||||
async def _open_ticker_ids(db: AsyncSession) -> set[int]:
|
||||
result = await db.execute(
|
||||
select(PaperTrade.ticker_id).where(
|
||||
PaperTrade.book == SHADOW_BOOK, PaperTrade.status == "open"
|
||||
)
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
|
||||
async def _shadow_user_id(db: AsyncSession) -> int | None:
|
||||
"""Shadow trades are not owned by a person; attach them to the first user."""
|
||||
result = await db.execute(select(User.id).order_by(User.id.asc()).limit(1))
|
||||
row = result.first()
|
||||
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.
|
||||
|
||||
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).
|
||||
result = await db.execute(
|
||||
select(TradeSetup).where(
|
||||
func.date(TradeSetup.detected_at) == func.date(newest),
|
||||
)
|
||||
)
|
||||
setups = [s for s in result.scalars() if setup_qualifies(s, config)]
|
||||
setups.sort(
|
||||
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(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
activation_config: dict,
|
||||
opened_at: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Fill free capacity with the top-ranked qualified setups.
|
||||
|
||||
Mirrors the backtest: rank the qualified cross-section, walk it top-down,
|
||||
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": []}
|
||||
config = await get_config(db)
|
||||
|
||||
held = await _open_ticker_ids(db)
|
||||
free_slots = config["capacity"] - len(held)
|
||||
if free_slots <= 0:
|
||||
return summary
|
||||
|
||||
user_id = await _shadow_user_id(db)
|
||||
if user_id is None:
|
||||
logger.warning("shadow book skipped: no user to attach trades to")
|
||||
return summary
|
||||
|
||||
locks = await get_reentry_gate_locks(db, book=SHADOW_BOOK)
|
||||
equity = await current_equity(db, config["start_equity"])
|
||||
timestamp = opened_at or datetime.now(timezone.utc)
|
||||
|
||||
for setup in await _todays_qualified_setups(db, activation_config):
|
||||
if free_slots <= 0:
|
||||
break
|
||||
if setup.ticker_id in held:
|
||||
summary["skipped_held"] += 1
|
||||
continue
|
||||
if setup.ticker_id in locks:
|
||||
summary["skipped_locked"] += 1
|
||||
continue
|
||||
|
||||
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)
|
||||
if shares <= 0:
|
||||
continue
|
||||
|
||||
db.add(
|
||||
PaperTrade(
|
||||
user_id=user_id,
|
||||
ticker_id=setup.ticker_id,
|
||||
direction=setup.direction,
|
||||
entry_price=entry,
|
||||
shares=shares,
|
||||
stop_loss=stop,
|
||||
target=float(setup.target or 0.0),
|
||||
status="open",
|
||||
opened_at=timestamp,
|
||||
fill_mode="near_close",
|
||||
book=SHADOW_BOOK,
|
||||
)
|
||||
)
|
||||
held.add(setup.ticker_id)
|
||||
free_slots -= 1
|
||||
summary["opened"] += 1
|
||||
summary["symbols"].append(setup.ticker_id)
|
||||
|
||||
if summary["opened"]:
|
||||
await db.commit()
|
||||
return summary
|
||||
|
||||
|
||||
async def symbols_for(db: AsyncSession, ticker_ids: list[int]) -> list[str]:
|
||||
"""Resolve ticker ids to symbols for logging."""
|
||||
if not ticker_ids:
|
||||
return []
|
||||
result = await db.execute(select(Ticker.symbol).where(Ticker.id.in_(ticker_ids)))
|
||||
return [row[0] for row in result.all()]
|
||||
Reference in New Issue
Block a user