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:
2026-07-20 23:44:41 +02:00
co-authored by Claude Fable 5
parent 29715ef3d1
commit ba2df8b9fd
17 changed files with 1334 additions and 40 deletions
+59
View File
@@ -0,0 +1,59 @@
"""paper trade book tag (manual vs shadow) + weekday cron repair
Revision ID: 024
Revises: 023
Create Date: 2026-07-20 00:00:00.000000
Two things ship together because both are corrections to 023's stored state.
1. ``paper_trades.book`` separates the discretionary book from the automatic
shadow book. Everything that exists today was opened by hand, so the
backfill value is "manual".
2. 023 wrote weekday crons with a numeric day-of-week. APScheduler's
from_crontab() feeds field 5 to its own day_of_week where 0=Monday, so
"1-5" resolved to Tue-Sat: every Monday was skipped and the scanner ran on
Saturdays against stale data. Rewrite only the rows that still hold the
broken numeric form, so a hand-corrected setting is never clobbered.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "024"
down_revision: Union[str, None] = "023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# key -> (broken numeric form written by 023, corrected named form)
_CRON_REPAIR: dict[str, tuple[str, str]] = {
"schedule_near_close_pipeline_cron": ("30 15 * * 1-5", "30 15 * * mon-fri"),
"schedule_after_close_pipeline_cron": ("45 16 * * 1-5", "45 16 * * mon-fri"),
"schedule_intraday_pipeline_cron": ("0 10-15 * * 1-5", "0 10-15 * * mon-fri"),
"schedule_fundamentals_cron": ("0 1 * * 1", "0 1 * * mon"),
}
def upgrade() -> None:
# server_default backfills existing rows, so no separate UPDATE is needed.
op.add_column(
"paper_trades",
sa.Column("book", sa.String(length=10), nullable=False, server_default="manual"),
)
# Literals are inlined rather than bound because bound parameters render as
# NULL under `alembic upgrade --sql`, which would silently produce a script
# that matches nothing. Every value here is a constant defined above.
for key, (broken, fixed) in _CRON_REPAIR.items():
op.execute(
f"UPDATE system_settings SET value = '{fixed}' " # noqa: S608
f"WHERE key = '{key}' AND value = '{broken}'"
)
def downgrade() -> None:
op.drop_column("paper_trades", "book")
# Crons are deliberately left corrected — restoring the numeric form would
# reintroduce the skipped-Monday bug.
+9
View File
@@ -49,3 +49,12 @@ class PaperTrade(Base):
# Execution era for forward vs backtest comparison: # Execution era for forward vs backtest comparison:
# null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover. # null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover.
fill_mode: Mapped[str | None] = mapped_column(String(20), nullable=True) fill_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Which book this trade belongs to:
# "manual" — discretionary, opened by the user from a qualified setup
# "shadow" — opened automatically by the validated strategy (top-ranked
# qualified up to capacity, 1% risk). The shadow book is the
# faithful live twin of the backtest; the two books share the
# same exit policy so the only difference is *selection*.
# Gate-reset re-entry state is tracked per book — the books diverge as soon
# as their entries differ, and each must see its own trade history.
book: Mapped[str] = mapped_column(String(10), nullable=False, default="manual")
+46
View File
@@ -16,8 +16,10 @@ from app.schemas.admin import (
JobTriggerRequest, JobTriggerRequest,
JobToggle, JobToggle,
RecommendationConfigUpdate, RecommendationConfigUpdate,
PerformanceConfigUpdate,
ScheduleConfigUpdate, ScheduleConfigUpdate,
SentimentConfigUpdate, SentimentConfigUpdate,
ShadowBookConfigUpdate,
SentimentTestRequest, SentimentTestRequest,
PasswordReset, PasswordReset,
RegistrationToggle, RegistrationToggle,
@@ -201,6 +203,50 @@ async def update_schedule_settings(
return APIEnvelope(status="success", data=updated) return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/performance", response_model=APIEnvelope)
async def get_performance_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return APIEnvelope(
status="success", data=await admin_service.get_performance_config(db)
)
@router.put("/admin/settings/performance", response_model=APIEnvelope)
async def update_performance_settings(
body: PerformanceConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
updated = await admin_service.update_performance_config(
db, body.model_dump(exclude_unset=True)
)
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/shadow-book", response_model=APIEnvelope)
async def get_shadow_book_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return APIEnvelope(
status="success", data=await admin_service.get_shadow_book_config(db)
)
@router.put("/admin/settings/shadow-book", response_model=APIEnvelope)
async def update_shadow_book_settings(
body: ShadowBookConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
updated = await admin_service.update_shadow_book_config(
db, body.model_dump(exclude_unset=True, exclude_none=True)
)
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/sentiment", response_model=APIEnvelope) @router.get("/admin/settings/sentiment", response_model=APIEnvelope)
async def get_sentiment_settings( async def get_sentiment_settings(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
+11
View File
@@ -65,6 +65,17 @@ async def paper_trade_equity_curve(
) )
@router.get("/paper-trades/performance", response_model=APIEnvelope)
async def paper_trade_performance(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Shadow book vs discretionary book vs SPY since the configured start date."""
return APIEnvelope(
status="success", data=await paper_trade_service.performance_summary(db)
)
@router.put("/paper-trades/exit-policy", response_model=APIEnvelope) @router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
async def write_exit_policy( async def write_exit_policy(
body: ExitPolicyUpdate, body: ExitPolicyUpdate,
+58 -1
View File
@@ -33,7 +33,13 @@ from app.exceptions import ProviderError
from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.protocol import SentimentData from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store from app.services import (
fundamental_service,
ingestion_service,
sentiment_service,
settings_store,
shadow_book_service,
)
from app.services.alert_service import dispatch_alerts from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import ( from app.services.backtest_service import (
BACKTEST_TARGET_MODELS, BACKTEST_TARGET_MODELS,
@@ -610,6 +616,54 @@ async def backfill_ohlcv() -> None:
await collect_ohlcv(full_backfill=True, job_name="data_backfill") await collect_ohlcv(full_backfill=True, job_name="data_backfill")
async def run_shadow_book() -> None:
"""Open the strategy's own positions from the latest qualifying scan.
The shadow book is the faithful live twin of the backtest: top-ranked
qualified setups, up to capacity, 1% risk, no human input. It runs straight
after the near-close scan so its entries are marked at the same near-close
prices the discretionary book sees, leaving *selection* as the only
difference between the two books.
Opt-in (``shadow_book_enabled``) because it writes live trades.
"""
job_name = "shadow_book"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return
if not await shadow_book_service.is_enabled(db):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="not enabled in settings")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Not enabled")
return
from app.services.admin_service import get_activation_config
activation_config = await get_activation_config(db)
summary = await shadow_book_service.open_shadow_positions(
db, activation_config=activation_config
)
symbols = await shadow_book_service.symbols_for(db, summary["symbols"])
_runtime_progress(job_name, processed=1, total=1)
_runtime_finish(
job_name, "completed", processed=1, total=1,
message=(
f"Opened {summary['opened']} ({', '.join(symbols) if symbols else 'none'}); "
f"held {summary['skipped_held']}, gate-locked {summary['skipped_locked']}"
),
)
_log_event(logging.INFO, "job_complete", job=job_name, opened=summary["opened"], symbols=symbols)
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
async def collect_ohlcv_final() -> None: async def collect_ohlcv_final() -> None:
"""After-close OHLCV refresh that replaces the day's partial bar. """After-close OHLCV refresh that replaces the day's partial bar.
@@ -1238,6 +1292,9 @@ _NEAR_CLOSE_PIPELINE_STEPS = [
# back to the previous close and execution degrades to the stale_close floor. # back to the previous close and execution degrades to the stale_close floor.
("data_collector", "collect_ohlcv"), ("data_collector", "collect_ohlcv"),
("rr_scanner", "scan_rr"), ("rr_scanner", "scan_rr"),
# Straight after the scan so shadow entries mark at the same near-close
# prices the discretionary book is looking at.
("shadow_book", "run_shadow_book"),
("alerts", "dispatch_alerts_job"), ("alerts", "dispatch_alerts_job"),
] ]
+19
View File
@@ -84,6 +84,25 @@ class ScheduleConfigUpdate(BaseModel):
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120) schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
class PerformanceConfigUpdate(BaseModel):
"""Window for the Performance comparison.
``start_date`` is an ISO date, or empty string to show all history. The
strategy has been revised repeatedly; pinning a start keeps the shadow-vs-
manual comparison inside one configuration instead of averaging across
rules that no longer exist.
"""
start_date: str | None = Field(default=None, max_length=10)
class ShadowBookConfigUpdate(BaseModel):
"""Auto-traded shadow book: the validated strategy with no human input."""
enabled: bool | None = None
capacity: int | None = Field(default=None, ge=1, le=100)
risk_pct: float | None = Field(default=None, gt=0, le=10)
start_equity: float | None = Field(default=None, ge=1000)
class SentimentConfigUpdate(BaseModel): class SentimentConfigUpdate(BaseModel):
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep """Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
the stored key.""" the stored key."""
+58
View File
@@ -204,6 +204,61 @@ async def update_activation_config(
return await get_activation_config(db) return await get_activation_config(db)
# ---------------------------------------------------------------------------
# Performance window + shadow book
# ---------------------------------------------------------------------------
async def get_performance_config(db: AsyncSession) -> dict:
"""Start date for the Performance comparison ('' = all history)."""
from app.services.paper_trade_service import KEY_PERFORMANCE_START
return {"start_date": await settings_store.get_value(db, KEY_PERFORMANCE_START, "") or ""}
async def update_performance_config(db: AsyncSession, updates: dict) -> dict:
"""Set (or clear) the performance start date. Empty string means all history."""
from datetime import date as _date
from app.services.paper_trade_service import KEY_PERFORMANCE_START
if "start_date" in updates:
raw = (updates.get("start_date") or "").strip()
if raw:
try:
_date.fromisoformat(raw)
except ValueError as exc:
raise ValidationError("start_date must be an ISO date (YYYY-MM-DD)") from exc
await update_setting(db, KEY_PERFORMANCE_START, raw)
return await get_performance_config(db)
async def get_shadow_book_config(db: AsyncSession) -> dict:
"""Shadow book switch + sizing, with the validated defaults filled in."""
from app.services import shadow_book_service
config = await shadow_book_service.get_config(db)
config["enabled"] = await shadow_book_service.is_enabled(db)
return config
async def update_shadow_book_config(db: AsyncSession, updates: dict) -> dict:
"""Update the shadow book. Enabling it starts automatic live entries."""
from app.services import shadow_book_service
if "enabled" in updates:
await update_setting(
db, shadow_book_service.KEY_ENABLED, "true" if updates["enabled"] else "false"
)
for key, storage_key in (
("capacity", shadow_book_service.KEY_CAPACITY),
("risk_pct", shadow_book_service.KEY_RISK_PCT),
("start_equity", shadow_book_service.KEY_START_EQUITY),
):
if key in updates:
await update_setting(db, storage_key, str(updates[key]))
return await get_shadow_book_config(db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pipeline schedule (cron) # Pipeline schedule (cron)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -569,6 +624,7 @@ VALID_JOB_NAMES = {
"near_close_pipeline", "near_close_pipeline",
"after_close_pipeline", "after_close_pipeline",
"intraday_pipeline", "intraday_pipeline",
"shadow_book",
} }
JOB_LABELS = { JOB_LABELS = {
@@ -589,6 +645,7 @@ JOB_LABELS = {
"near_close_pipeline": "Near-Close Pipeline (scan+alert)", "near_close_pipeline": "Near-Close Pipeline (scan+alert)",
"after_close_pipeline": "After-Close Pipeline (outcome)", "after_close_pipeline": "After-Close Pipeline (outcome)",
"intraday_pipeline": "Intraday Pipeline", "intraday_pipeline": "Intraday Pipeline",
"shadow_book": "Shadow Book (auto-traded strategy)",
} }
# Jobs driven by a pipeline (in order) rather than their own auto timer. # Jobs driven by a pipeline (in order) rather than their own auto timer.
@@ -601,6 +658,7 @@ PIPELINE_MEMBERS = {
"alerts", "alerts",
"market_regime", "market_regime",
"regime_monitor", "regime_monitor",
"shadow_book",
} }
+173 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import bisect import bisect
import logging
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
@@ -20,7 +21,9 @@ from app.services.outcome_service import (
Bar, Bar,
evaluate_setup_against_bars, evaluate_setup_against_bars,
) )
from app.services.trade_policy import get_reentry_gate_locks from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK, get_reentry_gate_locks
logger = logging.getLogger(__name__)
# Exit policy for OPEN paper trades (auto-close). Production defaults to the # Exit policy for OPEN paper trades (auto-close). Production defaults to the
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max # July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
@@ -690,6 +693,66 @@ def build_equity_curve(
return out return out
KEY_PERFORMANCE_START = "performance_start_date"
async def get_performance_start(db: AsyncSession) -> date | None:
"""Date the performance view starts from, or None for 'all history'.
The strategy has been revised repeatedly, so early trades were taken under
rules that no longer exist. Pinning a start date keeps the comparison inside
one regime instead of averaging across configurations that were replaced.
"""
raw = await settings_store.get_value(db, KEY_PERFORMANCE_START, "")
if not raw or not str(raw).strip():
return None
try:
return date.fromisoformat(str(raw).strip())
except ValueError:
logger.warning("invalid %s: %r", KEY_PERFORMANCE_START, raw)
return None
def trade_r_multiple(trade, mark: float | None) -> float | None:
"""Result in R — profit measured in units of the trade's own initial risk.
R is the only sizing-independent yardstick available here: the shadow book
sizes at a fixed 1% of equity while manual trades were sized by hand, so
currency P&L cannot compare them. Open trades are marked to ``mark``.
"""
risk_per_share = abs(trade.entry_price - trade.stop_loss)
if risk_per_share <= 0:
return None
exit_price = trade.close_price if trade.status == "closed" else mark
if exit_price is None:
return None
per_share = (
exit_price - trade.entry_price
if trade.direction == "long"
else trade.entry_price - exit_price
)
return per_share / risk_per_share
def book_stats(trades: list, marks: dict[int, float]) -> dict:
"""Sizing-independent summary of one book: counts, win rate, R-multiples."""
rs = [
r
for r in (trade_r_multiple(t, marks.get(t.ticker_id)) for t in trades)
if r is not None
]
closed = [t for t in trades if t.status == "closed"]
wins = [r for r in rs if r > 0]
return {
"trades": len(trades),
"closed": len(closed),
"open": len(trades) - len(closed),
"win_rate": round(100.0 * len(wins) / len(rs), 1) if rs else None,
"total_r": round(sum(rs), 2) if rs else 0.0,
"avg_r": round(sum(rs) / len(rs), 3) if rs else None,
}
async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]: async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
"""Equity-curve series for a user's paper book (empty without benchmark data).""" """Equity-curve series for a user's paper book (empty without benchmark data)."""
trades = ( trades = (
@@ -714,3 +777,112 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
for tid, day, close in rows.all(): for tid, day, close in rows.all():
ticker_closes.setdefault(tid, {})[day] = float(close) ticker_closes.setdefault(tid, {})[day] = float(close)
return build_equity_curve(list(trades), ticker_closes, benchmark_closes) return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]:
"""Cumulative realized + mark-to-market P&L of one book on each day."""
sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()}
out: list[float] = []
for d in days:
total = 0.0
for t in trades:
if t.opened_at.date() > d:
continue
closed_on = (
t.closed_at.date()
if (t.status == "closed" and t.closed_at is not None)
else None
)
if closed_on is not None and closed_on <= d and t.close_price is not None:
ref = float(t.close_price)
else:
ref = _value_on_or_before(
sorted_dates.get(t.ticker_id) or [],
ticker_closes.get(t.ticker_id) or {},
d,
)
if ref is None:
continue
per_share = (
ref - t.entry_price if t.direction == "long" else t.entry_price - ref
)
total += per_share * t.shares
out.append(round(total, 2))
return out
async def performance_summary(db: AsyncSession) -> 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
size differently, so the honest read is the R-multiple stats. SPY is a plain
buy-and-hold reference over the same window rather than a per-trade
counterfactual, so one line serves both books.
"""
start = await get_performance_start(db)
stmt = select(PaperTrade)
if start is not None:
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
trades = list((await db.execute(stmt)).scalars().all())
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
empty = {
"start_date": start.isoformat() if start else None,
"series": [],
"stats": {},
}
if not trades or not benchmark_closes:
return empty
first = min(t.opened_at.date() for t in trades)
if start is not None:
first = max(first, start)
days = [d for d in sorted(benchmark_closes) if d >= first]
if not days:
return empty
ticker_ids = {t.ticker_id for t in trades}
rows = await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.date, OHLCVRecord.close).where(
OHLCVRecord.ticker_id.in_(ticker_ids), OHLCVRecord.date >= first
)
)
ticker_closes: dict[int, dict[date, float]] = {}
for tid, day, close in rows.all():
ticker_closes.setdefault(tid, {})[day] = float(close)
books = {
MANUAL_BOOK: [t for t in trades if (t.book or MANUAL_BOOK) == MANUAL_BOOK],
SHADOW_BOOK: [t for t in trades if t.book == SHADOW_BOOK],
}
pnl = {
name: _cumulative_pnl(book_trades, ticker_closes, days)
for name, book_trades in books.items()
}
bench_dates = sorted(benchmark_closes)
spy0 = _value_on_or_before(bench_dates, benchmark_closes, days[0])
spy_pct = [
round(100.0 * (benchmark_closes[d] / spy0 - 1.0), 2) if spy0 else 0.0
for d in days
]
# Latest close per ticker, for marking open positions in the R stats.
marks = {
tid: closes[max(closes)] for tid, closes in ticker_closes.items() if closes
}
stats = {name: book_stats(bt, marks) for name, bt in books.items()}
for name in books:
stats[name]["pnl"] = pnl[name][-1] if pnl[name] else 0.0
stats["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0}
series = [
{
"date": d.isoformat(),
"manual_pnl": pnl[MANUAL_BOOK][i],
"shadow_pnl": pnl[SHADOW_BOOK][i],
"spy_pct": spy_pct[i],
}
for i, d in enumerate(days)
]
return {"start_date": start.isoformat() if start else None, "series": series, "stats": stats}
+231
View File
@@ -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()]
+18 -4
View File
@@ -22,12 +22,22 @@ def _ny_trading_date(moment: datetime) -> date:
return moment.astimezone(_REENTRY_DAY_TZ).date() return moment.astimezone(_REENTRY_DAY_TZ).date()
MANUAL_BOOK = "manual"
SHADOW_BOOK = "shadow"
async def _latest_initial_stop_trades( async def _latest_initial_stop_trades(
db: AsyncSession, db: AsyncSession,
*, *,
closed_before: datetime | None = None, closed_before: datetime | None = None,
book: str = MANUAL_BOOK,
) -> dict[int, PaperTrade]: ) -> dict[int, PaperTrade]:
"""Return a ticker's latest closed trade only when it was an initial stop.""" """Return a ticker's latest closed trade only when it was an initial stop.
Scoped to one ``book``: the discretionary and shadow books diverge as soon
as their entries differ, so each must see only its own stop history when
deciding whether a ticker is locked out of re-entry.
"""
ranked_stmt = ( ranked_stmt = (
select( select(
PaperTrade.id.label("trade_id"), PaperTrade.id.label("trade_id"),
@@ -41,6 +51,7 @@ async def _latest_initial_stop_trades(
.where( .where(
PaperTrade.status == "closed", PaperTrade.status == "closed",
PaperTrade.closed_at.is_not(None), PaperTrade.closed_at.is_not(None),
PaperTrade.book == book,
) )
) )
if closed_before is not None: if closed_before is not None:
@@ -58,7 +69,9 @@ async def _latest_initial_stop_trades(
return {trade.ticker_id: trade for trade in result.scalars()} return {trade.ticker_id: trade for trade in result.scalars()}
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]: async def get_reentry_gate_locks(
db: AsyncSession, *, book: str = MANUAL_BOOK
) -> dict[int, datetime]:
"""Return tickers still waiting for a post-stop gate failure. """Return tickers still waiting for a post-stop gate failure.
A later qualified setup is actionable only after the daily scanner has A later qualified setup is actionable only after the daily scanner has
@@ -66,7 +79,7 @@ async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
then a fresh qualification. The returned timestamp is the stop time and is then a fresh qualification. The returned timestamp is the stop time and is
useful for diagnostics; callers normally only need the keys. useful for diagnostics; callers normally only need the keys.
""" """
latest = await _latest_initial_stop_trades(db) latest = await _latest_initial_stop_trades(db, book=book)
return { return {
ticker_id: trade.closed_at ticker_id: trade.closed_at
for ticker_id, trade in latest.items() for ticker_id, trade in latest.items()
@@ -80,6 +93,7 @@ async def observe_reentry_gate_transitions(
evaluated_ticker_ids: Iterable[int], evaluated_ticker_ids: Iterable[int],
qualified_ticker_ids: Iterable[int], qualified_ticker_ids: Iterable[int],
observed_at: datetime | None = None, observed_at: datetime | None = None,
book: str = MANUAL_BOOK,
) -> set[int]: ) -> set[int]:
"""Persist gate-failure and later requalification observations. """Persist gate-failure and later requalification observations.
@@ -93,7 +107,7 @@ async def observe_reentry_gate_transitions(
return set() return set()
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids} qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
timestamp = observed_at or datetime.now(timezone.utc) timestamp = observed_at or datetime.now(timezone.utc)
latest = await _latest_initial_stop_trades(db, closed_before=timestamp) latest = await _latest_initial_stop_trades(db, closed_before=timestamp, book=book)
updated: set[int] = set() updated: set[int] = set()
for ticker_id in evaluated: for ticker_id in evaluated:
trade = latest.get(ticker_id) trade = latest.get(ticker_id)
+35
View File
@@ -92,6 +92,41 @@ export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
.then((r) => r.data); .then((r) => r.data);
} }
export interface PerformanceConfig {
start_date: string;
}
export function getPerformanceSettings() {
return apiClient
.get<PerformanceConfig>('admin/settings/performance')
.then((r) => r.data);
}
export function updatePerformanceSettings(payload: Partial<PerformanceConfig>) {
return apiClient
.put<PerformanceConfig>('admin/settings/performance', payload)
.then((r) => r.data);
}
export interface ShadowBookConfig {
enabled: boolean;
capacity: number;
risk_pct: number;
start_equity: number;
}
export function getShadowBookSettings() {
return apiClient
.get<ShadowBookConfig>('admin/settings/shadow-book')
.then((r) => r.data);
}
export function updateShadowBookSettings(payload: Partial<ShadowBookConfig>) {
return apiClient
.put<ShadowBookConfig>('admin/settings/shadow-book', payload)
.then((r) => r.data);
}
export function getSentimentSettings() { export function getSentimentSettings() {
return apiClient return apiClient
.get<SentimentProviderConfig>('admin/settings/sentiment') .get<SentimentProviderConfig>('admin/settings/sentiment')
+33
View File
@@ -38,6 +38,39 @@ export function getEquityCurve() {
return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data); return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data);
} }
export interface PerfPoint {
date: string;
manual_pnl: number;
shadow_pnl: number;
spy_pct: number;
}
export interface BookStats {
trades: number;
closed: number;
open: number;
win_rate: number | null;
total_r: number;
avg_r: number | null;
pnl: number;
}
export interface PerformanceSummary {
start_date: string | null;
series: PerfPoint[];
stats: {
manual?: BookStats;
shadow?: BookStats;
spy?: { pct: number };
};
}
export function getPerformance() {
return apiClient
.get<PerformanceSummary>('paper-trades/performance')
.then((r) => r.data);
}
export function closePaperTrade(id: number, closePrice?: number) { export function closePaperTrade(id: number, closePrice?: number) {
return apiClient return apiClient
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, { .post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getPerformanceSettings,
getShadowBookSettings,
updatePerformanceSettings,
updateShadowBookSettings,
type ShadowBookConfig,
} from '../../api/admin';
import { SkeletonCard } from '../ui/Skeleton';
/** Performance window + the auto-traded shadow book.
*
* These belong together: the shadow book is what the comparison measures, and
* the start date is what keeps the comparison inside a single strategy
* configuration.
*/
export function PerformanceSettings() {
const qc = useQueryClient();
const window = useQuery({ queryKey: ['admin', 'performance'], queryFn: getPerformanceSettings });
const shadow = useQuery({ queryKey: ['admin', 'shadow-book'], queryFn: getShadowBookSettings });
const [startDate, setStartDate] = useState('');
const [book, setBook] = useState<ShadowBookConfig | null>(null);
useEffect(() => {
if (window.data) setStartDate(window.data.start_date ?? '');
}, [window.data]);
useEffect(() => {
if (shadow.data) setBook(shadow.data);
}, [shadow.data]);
const saveWindow = useMutation({
mutationFn: () => updatePerformanceSettings({ start_date: startDate }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
},
});
const saveBook = useMutation({
mutationFn: (payload: Partial<ShadowBookConfig>) => updateShadowBookSettings(payload),
onSuccess: (data) => {
setBook(data);
qc.invalidateQueries({ queryKey: ['admin', 'shadow-book'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
},
});
if (window.isLoading || shadow.isLoading || !book) return <SkeletonCard />;
return (
<div className="glass space-y-5 p-5">
<div>
<h3 className="text-sm font-semibold text-gray-200">Performance &amp; Shadow Book</h3>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The <span className="text-gray-300">shadow book</span> trades the validated strategy with no
human input: top-ranked qualified setups up to capacity, sized to a fixed risk, entered right
after the near-close scan. It shares the paper exit policy with your own trades, so the only
difference between the two books is <span className="text-gray-300">which setups get taken</span>.
</p>
</div>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Performance since</span>
<div className="flex gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="input-glass w-48 px-3 py-2 text-sm"
/>
<button
type="button"
onClick={() => saveWindow.mutate()}
disabled={saveWindow.isPending}
className="btn-glass px-3 py-2 text-sm"
>
{saveWindow.isPending ? 'Saving…' : 'Save'}
</button>
{startDate && (
<button
type="button"
onClick={() => {
setStartDate('');
updatePerformanceSettings({ start_date: '' }).then(() => {
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
});
}}
className="btn-glass px-3 py-2 text-sm text-gray-400"
>
Clear
</button>
)}
</div>
<span className="block text-[11px] leading-relaxed text-gray-500">
Trades opened before this date are excluded from the Performance card. The strategy has been
revised repeatedly pinning a start keeps the comparison inside one configuration instead of
averaging across rules that no longer exist. Empty shows all history.
</span>
</label>
<div className="border-t border-white/5 pt-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={book.enabled}
onChange={(e) => saveBook.mutate({ enabled: e.target.checked })}
className="mt-0.5"
/>
<span>
<span className="text-sm text-gray-200">Shadow book enabled</span>
<span className="block text-[11px] leading-relaxed text-gray-500">
Starts opening real paper positions automatically on the next near-close scan. Verify its
first selections match a backtest of that day's cross-section before trusting the curve.
</span>
</span>
</label>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Capacity (positions)</span>
<input
type="number"
min={1}
max={100}
value={book.capacity}
onChange={(e) => setBook({ ...book, capacity: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ capacity: book.capacity })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Risk per trade (%)</span>
<input
type="number"
step="0.05"
min={0.05}
max={10}
value={book.risk_pct}
onChange={(e) => setBook({ ...book, risk_pct: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ risk_pct: book.risk_pct })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Start equity ($)</span>
<input
type="number"
min={1000}
step={1000}
value={book.start_equity}
onChange={(e) => setBook({ ...book, start_equity: Number(e.target.value) })}
onBlur={() => saveBook.mutate({ start_equity: book.start_equity })}
className="input-glass w-full px-3 py-2 text-sm"
/>
</label>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-500">
Defaults match the validated configuration: 10 positions, 1% fixed-fractional risk. Start
equity is only a sizing base the books are compared in R-multiples, not currency.
</p>
</div>
</div>
);
}
+125 -34
View File
@@ -1,11 +1,17 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getEquityCurve } from '../../api/paperTrades'; import { getPerformance, type BookStats } from '../../api/paperTrades';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
const W = 760; const W = 1040;
const H = 220; const H = 260;
const PAD = { top: 14, right: 84, bottom: 26, left: 56 }; const PAD = { top: 16, right: 92, bottom: 28, left: 60 };
const COLORS = {
shadow: 'var(--up)',
manual: 'var(--accent, #7aa2f7)',
spy: 'var(--ink-3)',
} as const;
function money(v: number): string { function money(v: number): string {
const sign = v > 0 ? '+' : v < 0 ? '' : ''; const sign = v > 0 ? '+' : v < 0 ? '' : '';
@@ -25,17 +31,54 @@ function niceTicks(lo: number, hi: number, count = 4): number[] {
return out; return out;
} }
/** Paper book vs the same dollars riding SPY — cumulative P&L since first trade. */ /** R-multiple stats read straight across; currency P&L does not, because the
* books size differently. Kept adjacent so the comparison is hard to misread. */
function StatCell({ label, stats, color }: { label: string; stats?: BookStats; color: string }) {
if (!stats || stats.trades === 0) {
return (
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
{label}
</div>
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
</div>
);
}
return (
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
{label}
</div>
<div className="num mt-1 text-lg font-semibold text-gray-100">
{stats.total_r > 0 ? '+' : ''}
{stats.total_r.toFixed(2)}R
</div>
<div className="num text-[11px] leading-relaxed text-gray-500">
{stats.trades} trades · {stats.win_rate ?? '—'}% win
<br />
avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)}
</div>
</div>
);
}
/** Shadow book (the strategy, traded automatically) vs the discretionary book
* vs SPY. Both books share an exit policy, so the only difference is which
* qualified setups get taken. */
export function PerfChart() { export function PerfChart() {
const curve = useQuery({ queryKey: ['paper-trades', 'equity-curve'], queryFn: getEquityCurve }); const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const data = curve.data ?? []; const data = perf.data?.series ?? [];
const stats = perf.data?.stats ?? {};
const startDate = perf.data?.start_date ?? null;
const geom = useMemo(() => { const geom = useMemo(() => {
if (data.length < 2) return null; if (data.length < 2) return null;
const values = data.flatMap((p) => [p.book_pnl, p.benchmark_pnl, 0]); const values = data.flatMap((p) => [p.manual_pnl, p.shadow_pnl, 0]);
const lo = Math.min(...values); const lo = Math.min(...values);
const hi = Math.max(...values); const hi = Math.max(...values);
const pad = (hi - lo) * 0.08 || 1; const pad = (hi - lo) * 0.08 || 1;
@@ -45,9 +88,20 @@ export function PerfChart() {
const plotH = H - PAD.top - PAD.bottom; const plotH = H - PAD.top - PAD.bottom;
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW; const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH; const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
const line = (key: 'book_pnl' | 'benchmark_pnl') => const line = (key: 'manual_pnl' | 'shadow_pnl') =>
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' '); data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
// Month boundaries for the x axis.
// SPY is a percentage reference, so it rides its own scale pinned to the
// same zero line — otherwise a flat book would squash it out of view.
const spyLo = Math.min(...data.map((p) => p.spy_pct), 0);
const spyHi = Math.max(...data.map((p) => p.spy_pct), 0);
const spySpan = Math.max(Math.abs(spyLo), Math.abs(spyHi)) || 1;
const bookSpan = Math.max(Math.abs(yLo), Math.abs(yHi)) || 1;
const spyY = (v: number) => py((v / spySpan) * bookSpan);
const spyLine = data
.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${spyY(p.spy_pct).toFixed(1)}`)
.join(' ');
const xTicks: { i: number; label: string }[] = []; const xTicks: { i: number; label: string }[] = [];
let lastMonth = ''; let lastMonth = '';
data.forEach((p, i) => { data.forEach((p, i) => {
@@ -57,15 +111,24 @@ export function PerfChart() {
xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) }); xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) });
} }
}); });
if (xTicks.length > 8) { if (xTicks.length > 10) {
const keep = Math.ceil(xTicks.length / 8); const keep = Math.ceil(xTicks.length / 10);
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1); for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
} }
return { yLo, yHi, plotH, px, py, line, yTicks: niceTicks(yLo, yHi), xTicks }; return { yLo, yHi, plotH, px, py, line, spyLine, spyY, yTicks: niceTicks(yLo, yHi), xTicks };
}, [data]); }, [data]);
if (!geom) return null; if (!geom) {
const { px, py, line, yTicks, xTicks, plotH } = geom; return (
<Section title="Performance" hint="shadow book vs your picks vs SPY">
<div className="glass p-5 text-sm text-gray-500">
No trades in the selected window yet
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
</div>
</Section>
);
}
const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom;
const onMove = (e: React.MouseEvent<SVGSVGElement>) => { const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect(); const rect = svgRef.current?.getBoundingClientRect();
@@ -81,11 +144,32 @@ export function PerfChart() {
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return ( return (
<Section title="Performance" hint="paper book vs the same dollars in SPY · cumulative P&L"> <Section
title="Performance"
hint={`shadow book vs your picks vs SPY${startDate ? ` · since ${startDate}` : ''}`}
>
<div className="glass p-5 pb-2"> <div className="glass p-5 pb-2">
<div className="flex justify-end gap-4 text-xs text-gray-400"> <div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--up)' }} /> Book</span> <div className="flex flex-wrap gap-x-8 gap-y-3">
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--ink-3)' }} /> Same $ in SPY</span> <StatCell label="Shadow (strategy)" stats={stats.shadow} color={COLORS.shadow} />
<StatCell label="Your picks" stats={stats.manual} color={COLORS.manual} />
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
SPY
</div>
<div className="num mt-1 text-lg font-semibold text-gray-300">
{(stats.spy?.pct ?? 0) > 0 ? '+' : ''}
{(stats.spy?.pct ?? 0).toFixed(1)}%
</div>
<div className="num text-[11px] text-gray-500">buy &amp; hold</div>
</div>
</div>
<p className="max-w-[22rem] text-[11px] leading-relaxed text-gray-500">
Books share the same exit, so the difference is <b className="text-gray-400">selection</b>.
Compare on <b className="text-gray-400">R</b>, not $ sizing differs. Expect months of
noise before a gap means anything.
</p>
</div> </div>
<svg <svg
ref={svgRef} ref={svgRef}
@@ -94,7 +178,9 @@ export function PerfChart() {
onMouseMove={onMove} onMouseMove={onMove}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
role="img" role="img"
aria-label={`Paper book cumulative P&L ${money(data[last].book_pnl)} versus ${money(data[last].benchmark_pnl)} for the same dollars in SPY`} aria-label={`Shadow book ${money(data[last].shadow_pnl)}, your picks ${money(
data[last].manual_pnl,
)}, SPY ${(data[last].spy_pct ?? 0).toFixed(1)} percent`}
> >
{yTicks.map((t) => ( {yTicks.map((t) => (
<g key={t}> <g key={t}>
@@ -104,35 +190,40 @@ export function PerfChart() {
</text> </text>
</g> </g>
))} ))}
{/* zero baseline slightly stronger when it's inside the plot */}
<line x1={PAD.left} x2={W - PAD.right} y1={py(0)} y2={py(0)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" /> <line x1={PAD.left} x2={W - PAD.right} y1={py(0)} y2={py(0)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
{xTicks.map(({ i, label }) => ( {xTicks.map(({ i, label }) => (
<text key={`${label}-${i}`} x={px(i)} y={H - 6} textAnchor="middle" className="num" fill="var(--ink-3)" fontSize="10"> <text key={`${label}-${i}`} x={px(i)} y={H - 6} textAnchor="middle" className="num" fill="var(--ink-3)" fontSize="10">
{label} {label}
</text> </text>
))} ))}
<path d={line('benchmark_pnl')} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" /> <path d={spyLine} fill="none" stroke={COLORS.spy} strokeWidth="1.5" strokeDasharray="4 3" strokeLinejoin="round" />
<path d={line('book_pnl')} fill="none" stroke="var(--up)" strokeWidth="2" strokeLinejoin="round" /> <path d={line('manual_pnl')} fill="none" stroke={COLORS.manual} strokeWidth="2" strokeLinejoin="round" />
<path d={line('shadow_pnl')} fill="none" stroke={COLORS.shadow} strokeWidth="2" strokeLinejoin="round" />
{hover !== null && ( {hover !== null && (
<g> <g>
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" /> <line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
<circle cx={px(hover)} cy={py(data[hover].book_pnl)} r="4.5" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" /> <circle cx={px(hover)} cy={py(data[hover].shadow_pnl)} r="4.5" fill={COLORS.shadow} stroke="var(--surface)" strokeWidth="2" />
<circle cx={px(hover)} cy={py(data[hover].benchmark_pnl)} r="4.5" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" /> <circle cx={px(hover)} cy={py(data[hover].manual_pnl)} r="4.5" fill={COLORS.manual} stroke="var(--surface)" strokeWidth="2" />
<circle cx={px(hover)} cy={spyY(data[hover].spy_pct)} r="4" fill={COLORS.spy} stroke="var(--surface)" strokeWidth="2" />
</g> </g>
)} )}
<circle cx={px(last)} cy={py(data[last].book_pnl)} r="4" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" /> <text x={px(last) + 10} y={py(data[last].shadow_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
<circle cx={px(last)} cy={py(data[last].benchmark_pnl)} r="4" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" /> {money(data[last].shadow_pnl)}
<text x={px(last) + 10} y={py(data[last].book_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
{money(data[last].book_pnl)}
</text> </text>
<text x={px(last) + 10} y={py(data[last].benchmark_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11"> <text x={px(last) + 10} y={py(data[last].manual_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
{money(data[last].benchmark_pnl)} {money(data[last].manual_pnl)}
</text> </text>
</svg> </svg>
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite"> <p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
{hb {hb ? (
? <>{fmtDate(hb.date)} book <b className="text-gray-200">{money(hb.book_pnl)}</b> · SPY <b className="text-gray-200">{money(hb.benchmark_pnl)}</b></> <>
: <>hover for daily values · realized + mark-to-market, since first paper trade</>} {fmtDate(hb.date)} shadow <b className="text-gray-200">{money(hb.shadow_pnl)}</b> · yours{' '}
<b className="text-gray-200">{money(hb.manual_pnl)}</b> · SPY{' '}
<b className="text-gray-200">{hb.spy_pct.toFixed(1)}%</b>
</>
) : (
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>
)}
</p> </p>
</div> </div>
</Section> </Section>
+2
View File
@@ -5,6 +5,7 @@ import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls'; import { JobControls } from '../components/admin/JobControls';
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel'; import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
import { RecommendationSettings } from '../components/admin/RecommendationSettings'; import { RecommendationSettings } from '../components/admin/RecommendationSettings';
@@ -36,6 +37,7 @@ export default function AdminPage() {
<div className="space-y-4"> <div className="space-y-4">
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings /> <ExitPolicySettings />
<PerformanceSettings />
<AlertSettings /> <AlertSettings />
<SentimentProviderSettings /> <SentimentProviderSettings />
<TickerUniverseBootstrap /> <TickerUniverseBootstrap />
+101
View File
@@ -0,0 +1,101 @@
"""Performance comparison: per-book series, R-multiples, and the start-date window."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from app.services import paper_trade_service as pts
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK
def _trade(*, book, entry=100.0, stop=95.0, close=None, shares=10.0, opened_days_ago=5):
now = datetime.now(timezone.utc)
return SimpleNamespace(
ticker_id=1,
direction="long",
entry_price=entry,
stop_loss=stop,
shares=shares,
book=book,
status="closed" if close is not None else "open",
close_price=close,
opened_at=now - timedelta(days=opened_days_ago),
closed_at=now if close is not None else None,
)
class TestRMultiple:
def test_winner_measured_in_units_of_initial_risk(self):
# Entry 100, stop 95 → 5 of risk. Exit 115 → +15 → +3R.
trade = _trade(book=SHADOW_BOOK, close=115.0)
assert pts.trade_r_multiple(trade, None) == pytest.approx(3.0)
def test_full_stop_is_minus_one_r(self):
trade = _trade(book=SHADOW_BOOK, close=95.0)
assert pts.trade_r_multiple(trade, None) == pytest.approx(-1.0)
def test_open_trade_marks_to_the_latest_close(self):
trade = _trade(book=SHADOW_BOOK)
assert pts.trade_r_multiple(trade, 110.0) == pytest.approx(2.0)
def test_no_risk_distance_has_no_r(self):
trade = _trade(book=SHADOW_BOOK, entry=100.0, stop=100.0, close=120.0)
assert pts.trade_r_multiple(trade, None) is None
class TestBookStats:
def test_r_is_independent_of_position_size(self):
"""The whole point: a 10-share and a 1000-share book compare equally."""
small = pts.book_stats([_trade(book=SHADOW_BOOK, close=115.0, shares=10)], {})
large = pts.book_stats([_trade(book=MANUAL_BOOK, close=115.0, shares=1000)], {})
assert small["total_r"] == large["total_r"] == pytest.approx(3.0)
def test_counts_and_win_rate(self):
trades = [
_trade(book=SHADOW_BOOK, close=115.0),
_trade(book=SHADOW_BOOK, close=95.0),
_trade(book=SHADOW_BOOK),
]
stats = pts.book_stats(trades, {1: 110.0})
assert stats["trades"] == 3
assert stats["closed"] == 2
assert stats["open"] == 1
# +3R, -1R, +2R marked → 2 of 3 positive.
assert stats["win_rate"] == pytest.approx(66.7)
assert stats["total_r"] == pytest.approx(4.0)
class TestPerformanceStartDate:
@pytest.fixture
async def session(self):
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
@pytest.mark.asyncio
async def test_unset_means_all_history(self, session):
assert await pts.get_performance_start(session) is None
@pytest.mark.asyncio
async def test_reads_an_iso_date(self, session):
await pts.settings_store.upsert_setting(
session, pts.KEY_PERFORMANCE_START, "2026-07-20"
)
assert await pts.get_performance_start(session) == date(2026, 7, 20)
@pytest.mark.asyncio
async def test_garbage_falls_back_to_all_history(self, session):
"""A bad setting must not blank the whole performance card."""
await pts.settings_store.upsert_setting(
session, pts.KEY_PERFORMANCE_START, "not-a-date"
)
assert await pts.get_performance_start(session) is None
@pytest.mark.asyncio
async def test_empty_string_means_all_history(self, session):
await pts.settings_store.upsert_setting(session, pts.KEY_PERFORMANCE_START, "")
assert await pts.get_performance_start(session) is None
+189
View File
@@ -0,0 +1,189 @@
"""Shadow book selection, sizing and book isolation.
The shadow book only has evidentiary value if it selects what the backtest
would select: top-ranked qualified setups, up to capacity, skipping held names
and post-stop gate-reset lockouts. These tests pin that contract.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import pytest
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 shadow_book_service
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK, get_reentry_gate_locks
@pytest.fixture
async def session():
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
# Floors the gate applies; every setup below clears them so tests exercise
# ranking rather than qualification.
_CONFIG = {
"min_rr": 2.0,
"min_confidence": 0.0,
"min_momentum_percentile": 80.0,
"exclude_neutral": False,
}
async def _seed(session, symbols: list[str]) -> dict[str, int]:
session.add(User(id=1, username="owner", password_hash="x"))
ids: dict[str, int] = {}
for i, symbol in enumerate(symbols, start=1):
ticker = Ticker(id=i, symbol=symbol, name=symbol)
session.add(ticker)
ids[symbol] = i
await session.commit()
return ids
def _setup(ticker_id: int, *, rank: float, detected: datetime, entry=100.0, stop=95.0):
target = entry + 3 * (entry - stop)
return TradeSetup(
ticker_id=ticker_id,
direction="long",
entry_price=entry,
stop_loss=stop,
target=target,
rr_ratio=3.0,
composite_score=70.0,
confidence_score=70.0,
detected_at=detected,
strategy_rank=rank,
momentum_percentile=90.0,
recommended_action="buy",
targets_json=json.dumps(
[{"price": target, "probability": 45.0, "is_primary": True, "rr": 3.0}]
),
)
class TestSizing:
def test_risks_one_percent_down_to_the_stop(self):
shares = shadow_book_service.position_shares(100_000, 1.0, 100.0, 95.0)
assert shares == pytest.approx(200.0) # $1,000 risk / $5 per share
def test_zero_risk_distance_takes_no_position(self):
assert shadow_book_service.position_shares(100_000, 1.0, 100.0, 100.0) == 0.0
class TestSelection:
@pytest.mark.asyncio
async def test_takes_top_ranked_up_to_capacity(self, session):
ids = await _seed(session, ["AAA", "BBB", "CCC"])
now = datetime.now(timezone.utc)
session.add_all(
[
_setup(ids["AAA"], rank=0.10, detected=now),
_setup(ids["BBB"], rank=0.90, detected=now),
_setup(ids["CCC"], rank=0.50, detected=now),
]
)
await session.commit()
await shadow_book_service.settings_store.upsert_setting(
session, shadow_book_service.KEY_CAPACITY, "2"
)
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG
)
assert summary["opened"] == 2
# Highest strategy_rank first — the backtest's ordering key.
assert summary["symbols"] == [ids["BBB"], ids["CCC"]]
@pytest.mark.asyncio
async def test_skips_names_already_held(self, session):
ids = await _seed(session, ["AAA", "BBB"])
now = datetime.now(timezone.utc)
session.add_all(
[_setup(ids["AAA"], rank=0.9, detected=now), _setup(ids["BBB"], rank=0.5, detected=now)]
)
session.add(
PaperTrade(
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
shares=10.0, stop_loss=95.0, target=115.0, status="open",
opened_at=now, book=SHADOW_BOOK,
)
)
await session.commit()
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG
)
assert summary["skipped_held"] == 1
assert summary["symbols"] == [ids["BBB"]]
@pytest.mark.asyncio
async def test_respects_post_stop_gate_lock(self, session):
ids = await _seed(session, ["AAA"])
now = datetime.now(timezone.utc)
session.add(_setup(ids["AAA"], rank=0.9, detected=now))
# Stopped out and never requalified — locked out of re-entry.
session.add(
PaperTrade(
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
shares=10.0, stop_loss=95.0, target=115.0, status="closed",
opened_at=now - timedelta(days=5), closed_at=now - timedelta(days=1),
close_price=95.0, close_reason="stop", book=SHADOW_BOOK,
)
)
await session.commit()
summary = await shadow_book_service.open_shadow_positions(
session, activation_config=_CONFIG
)
assert summary["opened"] == 0
assert summary["skipped_locked"] == 1
class TestBookIsolation:
@pytest.mark.asyncio
async def test_gate_locks_do_not_leak_between_books(self, session):
"""A manual stop must not lock the shadow book out of the same name."""
ids = await _seed(session, ["AAA"])
now = datetime.now(timezone.utc)
session.add(
PaperTrade(
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
shares=10.0, stop_loss=95.0, target=115.0, status="closed",
opened_at=now - timedelta(days=5), closed_at=now - timedelta(days=1),
close_price=95.0, close_reason="stop", book=MANUAL_BOOK,
)
)
await session.commit()
assert ids["AAA"] in await get_reentry_gate_locks(session, book=MANUAL_BOOK)
assert ids["AAA"] not in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
@pytest.mark.asyncio
async def test_shadow_equity_ignores_manual_pnl(self, session):
ids = await _seed(session, ["AAA"])
now = datetime.now(timezone.utc)
session.add(
PaperTrade(
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
shares=100.0, stop_loss=95.0, target=115.0, status="closed",
opened_at=now - timedelta(days=5), closed_at=now,
close_price=150.0, close_reason="trailing", book=MANUAL_BOOK,
)
)
await session.commit()
equity = await shadow_book_service.current_equity(session, 100_000.0)
assert equity == 100_000.0