diff --git a/alembic/versions/024_paper_trade_book.py b/alembic/versions/024_paper_trade_book.py new file mode 100644 index 0000000..20bf97d --- /dev/null +++ b/alembic/versions/024_paper_trade_book.py @@ -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. diff --git a/app/models/paper_trade.py b/app/models/paper_trade.py index 96f612d..16b8f9d 100644 --- a/app/models/paper_trade.py +++ b/app/models/paper_trade.py @@ -49,3 +49,12 @@ class PaperTrade(Base): # Execution era for forward vs backtest comparison: # null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover. 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") diff --git a/app/routers/admin.py b/app/routers/admin.py index 1b7da35..73582f9 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -16,8 +16,10 @@ from app.schemas.admin import ( JobTriggerRequest, JobToggle, RecommendationConfigUpdate, + PerformanceConfigUpdate, ScheduleConfigUpdate, SentimentConfigUpdate, + ShadowBookConfigUpdate, SentimentTestRequest, PasswordReset, RegistrationToggle, @@ -201,6 +203,50 @@ async def update_schedule_settings( 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) async def get_sentiment_settings( _admin: User = Depends(require_admin), diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py index dc4e95d..7826ac6 100644 --- a/app/routers/paper_trades.py +++ b/app/routers/paper_trades.py @@ -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) async def write_exit_policy( body: ExitPolicyUpdate, diff --git a/app/scheduler.py b/app/scheduler.py index 680a326..65fd9ef 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -33,7 +33,13 @@ from app.exceptions import ProviderError from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.fundamentals_chain import build_fundamental_provider_chain 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.backtest_service import ( BACKTEST_TARGET_MODELS, @@ -610,6 +616,54 @@ async def backfill_ohlcv() -> None: 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: """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. ("data_collector", "collect_ohlcv"), ("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"), ] diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 1eb5f9e..4c46639 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -84,6 +84,25 @@ class ScheduleConfigUpdate(BaseModel): 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): """Runtime sentiment LLM config. api_key is write-only; omit/empty to keep the stored key.""" diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 02e3a90..6797f44 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -204,6 +204,61 @@ async def update_activation_config( 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) # --------------------------------------------------------------------------- @@ -569,6 +624,7 @@ VALID_JOB_NAMES = { "near_close_pipeline", "after_close_pipeline", "intraday_pipeline", + "shadow_book", } JOB_LABELS = { @@ -589,6 +645,7 @@ JOB_LABELS = { "near_close_pipeline": "Near-Close Pipeline (scan+alert)", "after_close_pipeline": "After-Close Pipeline (outcome)", "intraday_pipeline": "Intraday Pipeline", + "shadow_book": "Shadow Book (auto-traded strategy)", } # Jobs driven by a pipeline (in order) rather than their own auto timer. @@ -601,6 +658,7 @@ PIPELINE_MEMBERS = { "alerts", "market_regime", "regime_monitor", + "shadow_book", } diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 2da72cd..36c091f 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import bisect +import logging from datetime import date, datetime, timezone from sqlalchemy import and_, func, select @@ -20,7 +21,9 @@ from app.services.outcome_service import ( Bar, 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 # July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max @@ -690,6 +693,66 @@ def build_equity_curve( 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]: """Equity-curve series for a user's paper book (empty without benchmark data).""" trades = ( @@ -714,3 +777,112 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]: for tid, day, close in rows.all(): ticker_closes.setdefault(tid, {})[day] = float(close) 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} diff --git a/app/services/shadow_book_service.py b/app/services/shadow_book_service.py new file mode 100644 index 0000000..515b0b3 --- /dev/null +++ b/app/services/shadow_book_service.py @@ -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()] diff --git a/app/services/trade_policy.py b/app/services/trade_policy.py index b4419a2..6c6f408 100644 --- a/app/services/trade_policy.py +++ b/app/services/trade_policy.py @@ -22,12 +22,22 @@ def _ny_trading_date(moment: datetime) -> date: return moment.astimezone(_REENTRY_DAY_TZ).date() +MANUAL_BOOK = "manual" +SHADOW_BOOK = "shadow" + + async def _latest_initial_stop_trades( db: AsyncSession, *, closed_before: datetime | None = None, + book: str = MANUAL_BOOK, ) -> 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 = ( select( PaperTrade.id.label("trade_id"), @@ -41,6 +51,7 @@ async def _latest_initial_stop_trades( .where( PaperTrade.status == "closed", PaperTrade.closed_at.is_not(None), + PaperTrade.book == book, ) ) 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()} -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. 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 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 { ticker_id: trade.closed_at for ticker_id, trade in latest.items() @@ -80,6 +93,7 @@ async def observe_reentry_gate_transitions( evaluated_ticker_ids: Iterable[int], qualified_ticker_ids: Iterable[int], observed_at: datetime | None = None, + book: str = MANUAL_BOOK, ) -> set[int]: """Persist gate-failure and later requalification observations. @@ -93,7 +107,7 @@ async def observe_reentry_gate_transitions( return set() qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids} 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() for ticker_id in evaluated: trade = latest.get(ticker_id) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index d941603..20993a4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -92,6 +92,41 @@ export function updateScheduleSettings(payload: Partial) { .then((r) => r.data); } +export interface PerformanceConfig { + start_date: string; +} + +export function getPerformanceSettings() { + return apiClient + .get('admin/settings/performance') + .then((r) => r.data); +} + +export function updatePerformanceSettings(payload: Partial) { + return apiClient + .put('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('admin/settings/shadow-book') + .then((r) => r.data); +} + +export function updateShadowBookSettings(payload: Partial) { + return apiClient + .put('admin/settings/shadow-book', payload) + .then((r) => r.data); +} + export function getSentimentSettings() { return apiClient .get('admin/settings/sentiment') diff --git a/frontend/src/api/paperTrades.ts b/frontend/src/api/paperTrades.ts index 62d2c70..d3b78b1 100644 --- a/frontend/src/api/paperTrades.ts +++ b/frontend/src/api/paperTrades.ts @@ -38,6 +38,39 @@ export function getEquityCurve() { return apiClient.get('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('paper-trades/performance') + .then((r) => r.data); +} + export function closePaperTrade(id: number, closePrice?: number) { return apiClient .post<{ id: number; status: string }>(`paper-trades/${id}/close`, { diff --git a/frontend/src/components/admin/PerformanceSettings.tsx b/frontend/src/components/admin/PerformanceSettings.tsx new file mode 100644 index 0000000..015c131 --- /dev/null +++ b/frontend/src/components/admin/PerformanceSettings.tsx @@ -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(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) => 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 ; + + return ( +
+
+

Performance & Shadow Book

+

+ The shadow book 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 which setups get taken. +

+
+ + + +
+ + +
+ + + +
+

+ 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. +

+
+
+ ); +} diff --git a/frontend/src/components/dashboard/PerfChart.tsx b/frontend/src/components/dashboard/PerfChart.tsx index df06668..1862d8b 100644 --- a/frontend/src/components/dashboard/PerfChart.tsx +++ b/frontend/src/components/dashboard/PerfChart.tsx @@ -1,11 +1,17 @@ import { useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getEquityCurve } from '../../api/paperTrades'; +import { getPerformance, type BookStats } from '../../api/paperTrades'; import { Section } from '../ui/Section'; -const W = 760; -const H = 220; -const PAD = { top: 14, right: 84, bottom: 26, left: 56 }; +const W = 1040; +const H = 260; +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 { const sign = v > 0 ? '+' : v < 0 ? '−' : ''; @@ -25,17 +31,54 @@ function niceTicks(lo: number, hi: number, count = 4): number[] { 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 ( +
+
+ + {label} +
+
no trades yet
+
+ ); + } + return ( +
+
+ + {label} +
+
+ {stats.total_r > 0 ? '+' : ''} + {stats.total_r.toFixed(2)}R +
+
+ {stats.trades} trades · {stats.win_rate ?? '—'}% win +
+ avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)} +
+
+ ); +} + +/** 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() { - const curve = useQuery({ queryKey: ['paper-trades', 'equity-curve'], queryFn: getEquityCurve }); + const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance }); const [hover, setHover] = useState(null); const svgRef = useRef(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(() => { 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 hi = Math.max(...values); const pad = (hi - lo) * 0.08 || 1; @@ -45,9 +88,20 @@ export function PerfChart() { const plotH = H - PAD.top - PAD.bottom; const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW; 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(' '); - // 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 }[] = []; let lastMonth = ''; 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' }) }); } }); - if (xTicks.length > 8) { - const keep = Math.ceil(xTicks.length / 8); + if (xTicks.length > 10) { + const keep = Math.ceil(xTicks.length / 10); 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]); - if (!geom) return null; - const { px, py, line, yTicks, xTicks, plotH } = geom; + if (!geom) { + return ( +
+
+ No trades in the selected window yet + {startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled. +
+
+ ); + } + const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom; const onMove = (e: React.MouseEvent) => { 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' }); return ( -
+
-
- Book - Same $ in SPY +
+
+ + +
+
+ + SPY +
+
+ {(stats.spy?.pct ?? 0) > 0 ? '+' : ''} + {(stats.spy?.pct ?? 0).toFixed(1)}% +
+
buy & hold
+
+
+

+ Books share the same exit, so the difference is selection. + Compare on R, not $ — sizing differs. Expect months of + noise before a gap means anything. +

setHover(null)} 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) => ( @@ -104,35 +190,40 @@ export function PerfChart() { ))} - {/* zero baseline slightly stronger when it's inside the plot */} {xTicks.map(({ i, label }) => ( {label} ))} - - + + + {hover !== null && ( - - + + + )} - - - - {money(data[last].book_pnl)} + + {money(data[last].shadow_pnl)} - - {money(data[last].benchmark_pnl)} + + {money(data[last].manual_pnl)}

- {hb - ? <>{fmtDate(hb.date)} — book {money(hb.book_pnl)} · SPY {money(hb.benchmark_pnl)} - : <>hover for daily values · realized + mark-to-market, since first paper trade} + {hb ? ( + <> + {fmtDate(hb.date)} — shadow {money(hb.shadow_pnl)} · yours{' '} + {money(hb.manual_pnl)} · SPY{' '} + {hb.spy_pct.toFixed(1)}% + + ) : ( + <>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''} + )}

diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index d49e42b..57f11e2 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -5,6 +5,7 @@ import { AlertSettings } from '../components/admin/AlertSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { DataCleanup } from '../components/admin/DataCleanup'; import { JobControls } from '../components/admin/JobControls'; +import { PerformanceSettings } from '../components/admin/PerformanceSettings'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; import { SystemEventsPanel } from '../components/admin/SystemEventsPanel'; import { RecommendationSettings } from '../components/admin/RecommendationSettings'; @@ -36,6 +37,7 @@ export default function AdminPage() {
+ diff --git a/tests/unit/test_performance_summary.py b/tests/unit/test_performance_summary.py new file mode 100644 index 0000000..62cf0b1 --- /dev/null +++ b/tests/unit/test_performance_summary.py @@ -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 diff --git a/tests/unit/test_shadow_book_service.py b/tests/unit/test_shadow_book_service.py new file mode 100644 index 0000000..3346394 --- /dev/null +++ b/tests/unit/test_shadow_book_service.py @@ -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