From 247a92a89fb7d81060f6d9216cb5df8fadaa564e Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 21 Jul 2026 09:11:14 +0200 Subject: [PATCH] fix: harden shadow book against book leakage (review of ba2df8b) Review of the shadow book found seven ways the two books could leak into each other; all are fixed here. The most serious silently invalidated the comparison the shadow book exists to make. - Shadow holdings no longer suppress the manual candidate list. The open-trade exclusion filtered on any book, so shadow taking the top-ranked names removed exactly those from the user's list and alerts, confining the discretionary book to leftovers. Scoped to the manual book. Closed-trade alerts and paper-book equity were leaking the same way and are likewise scoped. - Shadow sizing now matches _simulate_portfolio: min(1% risk, 20% notional cap, available cash) from marked equity, plus the sub- dust guard. Previously risk-only from realized equity, so a tight stop produced a multiples-of-equity leveraged position the strategy would never take. - Shadow only trades setups from the scan that just ran (<6h old) with one setup per ticker. A failed or disabled scan step could otherwise open positions from a prior session at stale prices. - Gate-reset transitions are observed for both books, so a shadow stop-out completes fail -> requalify instead of staying locked forever. - Manual list/close endpoints default to the manual book and reject hand-closing shadow trades; the performance endpoint is scoped to the caller so 'your picks' is not every user's book. - run_shadow_book is registered as a paused job so Admin can trigger it. Also anchors three pre-existing paper-trade tests (and the new alpaca window test) on the UTC date. They build fixtures from the local date but the service stamps opened_at in UTC, so they failed only between 00:00 and 02:00 in a UTC+hh timezone -- latent on ba2df8b, exposed by the clock. Co-Authored-By: Claude Fable 5 --- app/routers/paper_trades.py | 5 +- app/scheduler.py | 1 + app/services/alert_service.py | 15 +- app/services/paper_trade_service.py | 25 ++- app/services/rr_scanner_service.py | 26 +++- app/services/shadow_book_service.py | 134 +++++++++++----- tests/unit/test_alpaca_provider_window.py | 16 +- tests/unit/test_book_separation.py | 181 ++++++++++++++++++++++ tests/unit/test_paper_trade_service.py | 36 +++-- tests/unit/test_scheduler.py | 2 + tests/unit/test_shadow_book_service.py | 59 ++++++- 11 files changed, 434 insertions(+), 66 deletions(-) create mode 100644 tests/unit/test_book_separation.py diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py index 7826ac6..efd9266 100644 --- a/app/routers/paper_trades.py +++ b/app/routers/paper_trades.py @@ -67,12 +67,13 @@ async def paper_trade_equity_curve( @router.get("/paper-trades/performance", response_model=APIEnvelope) async def paper_trade_performance( - _user: User = Depends(require_access), + 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) + status="success", + data=await paper_trade_service.performance_summary(db, user.id), ) diff --git a/app/scheduler.py b/app/scheduler.py index 65fd9ef..629c0bd 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1513,6 +1513,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None: (collect_benchmark, "benchmark_collector", "Benchmark Collector"), (collect_sentiment, "sentiment_collector", "Sentiment Collector"), (scan_rr, "rr_scanner", "R:R Scanner"), + (run_shadow_book, "shadow_book", "Shadow Book (auto-traded strategy)"), (evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"), (compute_market_regime, "market_regime", "Market Regime"), (compute_regime_monitor, "regime_monitor", "Regime Monitor"), diff --git a/app/services/alert_service.py b/app/services/alert_service.py index d20769f..7ba9f3e 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -29,6 +29,7 @@ from app.config import settings from app.models.alert import AlertLog from app.models.ohlcv import OHLCVRecord from app.models.paper_trade import PaperTrade +from app.services.trade_policy import MANUAL_BOOK from app.models.score import CompositeScore from app.models.sr_level import SRLevel from app.models.ticker import Ticker @@ -632,6 +633,10 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]: PaperTrade.closed_at.is_not(None), PaperTrade.closed_at > cutoff, PaperTrade.close_reason.in_(("trailing", "stop", "target", "time")), + # Your own positions only — shadow trades are a research record, not + # something you hold, and mixing them in unlabelled reads as if you + # were stopped out of a name you never took. + PaperTrade.book == MANUAL_BOOK, ) .order_by(PaperTrade.closed_at.desc()) ) @@ -642,8 +647,14 @@ async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]: async def _paper_book_value(db: AsyncSession) -> float: - """Paper-trade equity: fixed capital plus realized/unrealized P&L.""" - result = await db.execute(select(PaperTrade)) + """Paper-trade equity: fixed capital plus realized/unrealized P&L. + + Discretionary book only — the shadow book runs on its own notional equity + and folding it in would report a number matching neither book. + """ + result = await db.execute( + select(PaperTrade).where(PaperTrade.book == MANUAL_BOOK) + ) trades = list(result.scalars().all()) latest: dict[int, float | None] = {} for trade in trades: diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 36c091f..e2de89d 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -402,7 +402,15 @@ async def list_trades( db: AsyncSession, user_id: int | None = None, status: str | None = None, + book: str | None = MANUAL_BOOK, ) -> list[dict]: + """Trades for the UI. Defaults to the discretionary book. + + Shadow trades are attached to a user row for FK reasons only — they are not + that person's decisions. Listing them alongside manual trades would mix two + different books in one P&L and let the autonomous record be edited by hand. + Pass ``book=None`` to deliberately span both. + """ stmt = ( select(PaperTrade, Ticker.symbol) .join(Ticker, PaperTrade.ticker_id == Ticker.id) @@ -411,6 +419,8 @@ async def list_trades( stmt = stmt.where(PaperTrade.user_id == user_id) if status is not None: stmt = stmt.where(PaperTrade.status == status) + if book is not None: + stmt = stmt.where(PaperTrade.book == book) stmt = stmt.order_by(PaperTrade.opened_at.desc()) rows = (await db.execute(stmt)).all() @@ -493,6 +503,13 @@ async def close_trade( trade = result.scalar_one_or_none() if trade is None: raise NotFoundError(f"Paper trade not found: {trade_id}") + if trade.book == SHADOW_BOOK: + # The shadow book's value is that no human touched it. A hand-closed + # position would make its record something other than what the strategy + # would have done; it exits only via the automatic exit policy. + raise ValidationError( + "Shadow book trades are closed by the exit policy, not by hand" + ) if trade.status == "closed": raise ValidationError("Trade is already closed") @@ -811,7 +828,7 @@ def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list return out -async def performance_summary(db: AsyncSession) -> dict: +async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict: """Shadow book vs discretionary book vs SPY, from the configured start date. Currency P&L is reported per book but is *not* the comparison — the books @@ -823,6 +840,12 @@ async def performance_summary(db: AsyncSession) -> dict: stmt = select(PaperTrade) if start is not None: stmt = stmt.where(func.date(PaperTrade.opened_at) >= start) + if user_id is not None: + # "Your picks" must be *yours*. The shadow book is a single autonomous + # book with no owner, so it is never scoped to a user. + stmt = stmt.where( + (PaperTrade.book == SHADOW_BOOK) | (PaperTrade.user_id == user_id) + ) trades = list((await db.execute(stmt)).scalars().all()) benchmark_closes = await benchmark_service.load_benchmark_closes(db) diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index d0ed6dd..7fbe2cf 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -31,6 +31,8 @@ from app.services.price_service import query_ohlcv from app.services.qualification import setup_qualifies from app.services.sr_service import detect_gate_target_ladder from app.services.trade_policy import ( + MANUAL_BOOK, + SHADOW_BOOK, get_reentry_gate_locks, observe_reentry_gate_transitions, ) @@ -788,12 +790,18 @@ async def scan_all_tickers( logger.exception("Error scanning ticker %s", symbol) if activation is not None: - transitioned_ticker_ids = await observe_reentry_gate_transitions( - db, - evaluated_ticker_ids=evaluated_ticker_ids, - qualified_ticker_ids=qualified_ticker_ids, - observed_at=gate_observation_started_at, - ) + # Both books, from the same observation: gate-reset state is per book, + # so observing only the manual book would leave shadow stop-outs stuck + # with a fail timestamp that never requalifies — permanently ineligible. + transitioned_ticker_ids: set[int] = set() + for book in (MANUAL_BOOK, SHADOW_BOOK): + transitioned_ticker_ids |= await observe_reentry_gate_transitions( + db, + evaluated_ticker_ids=evaluated_ticker_ids, + qualified_ticker_ids=qualified_ticker_ids, + observed_at=gate_observation_started_at, + book=book, + ) await db.commit() if transitioned_ticker_ids: logger.info( @@ -843,9 +851,13 @@ async def get_trade_setups( excluded_ticker_ids: set[int] = set() reentry_gate_locks: dict[int, datetime] = {} if exclude_open_trade_tickers: + # Manual book only. The shadow book holds the *top-ranked* names by + # construction, so letting its positions hide setups would leave the + # discretionary list picking over leftovers — and would bias the very + # shadow-vs-manual comparison the shadow book exists to measure. open_trade_result = await db.execute( select(PaperTrade.ticker_id) - .where(PaperTrade.status == "open") + .where(PaperTrade.status == "open", PaperTrade.book == MANUAL_BOOK) .distinct() ) excluded_ticker_ids.update( diff --git a/app/services/shadow_book_service.py b/app/services/shadow_book_service.py index 515b0b3..23eeb1e 100644 --- a/app/services/shadow_book_service.py +++ b/app/services/shadow_book_service.py @@ -20,9 +20,9 @@ two cannot drift apart. from __future__ import annotations import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.paper_trade import PaperTrade @@ -47,6 +47,15 @@ DEFAULT_CAPACITY = 10 DEFAULT_RISK_PCT = 1.0 DEFAULT_START_EQUITY = 100_000.0 +# Mirrors ``_simulate_portfolio``'s SIM_NOTIONAL_CAP: no single position may +# exceed this fraction of equity, and the book never uses margin. Without the +# cap, a setup with a tight stop turns 1% risk into a position several times +# equity — a leveraged trade the validated strategy would never have taken. +NOTIONAL_CAP = 0.20 + +# Setups older than this mean the scan did not run in this pipeline pass. +MAX_SETUP_AGE = timedelta(hours=6) + async def get_config(db: AsyncSession) -> dict: """Shadow book sizing/capacity config, falling back to validated defaults.""" @@ -76,13 +85,18 @@ async def is_enabled(db: AsyncSession) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "on"} -async def current_equity(db: AsyncSession, start_equity: float) -> float: - """Start equity plus realized P&L of closed shadow trades. +async def equity_and_cash( + db: AsyncSession, start_equity: float, positions: list[PaperTrade] +) -> tuple[float, float]: + """Marked equity and free cash, matching ``_simulate_portfolio``. - Open positions are deliberately excluded: sizing off marked-to-market equity - would let an unrealized gain inflate the next position, which is not what the - backtest does. + The simulator sizes from *marked* equity — cash plus open positions at their + latest close — and spends from cash, so a book that is fully invested cannot + keep buying. Sizing from realized P&L alone would drift away from the + backtest as soon as positions were held across a scan. """ + from app.services.paper_trade_service import _latest_closes + result = await db.execute( select(PaperTrade).where( PaperTrade.book == SHADOW_BOOK, @@ -98,24 +112,51 @@ async def current_equity(db: AsyncSession, start_equity: float) -> float: else trade.entry_price - trade.close_price ) realized += per_share * trade.shares - return start_equity + realized + + open_cost = sum(p.entry_price * p.shares for p in positions) + marks = await _latest_closes(db, {p.ticker_id for p in positions}) + open_value = sum( + (marks.get(p.ticker_id) or p.entry_price) * p.shares for p in positions + ) + + cash = start_equity + realized - open_cost + return cash + open_value, cash -def position_shares(equity: float, risk_pct: float, entry: float, stop: float) -> float: - """Fixed-fractional sizing: risk ``risk_pct`` of equity down to the stop.""" +def position_shares( + equity: float, + risk_pct: float, + entry: float, + stop: float, + *, + cash_available: float | None = None, +) -> float: + """Shares to buy, sized exactly as ``_simulate_portfolio`` sizes them. + + Fixed-fractional risk first, then the two caps the simulator applies: no + position may exceed ``NOTIONAL_CAP`` of equity, and the book cannot spend + cash it does not have. Dropping either cap lets a tight stop produce a + leveraged position and breaks compounding parity with the backtest. + """ risk_per_share = abs(entry - stop) - if risk_per_share <= 0 or equity <= 0: + if risk_per_share <= 0 or equity <= 0 or entry <= 0: return 0.0 - return (equity * risk_pct / 100.0) / risk_per_share + + shares = (equity * risk_pct / 100.0) / risk_per_share + shares = min(shares, (equity * NOTIONAL_CAP) / entry) + if cash_available is not None: + shares = min(shares, max(0.0, cash_available) / entry) + # Dust guard, as in the simulator: sub-$1 positions are noise, not trades. + return shares if shares * entry >= 1.0 else 0.0 -async def _open_ticker_ids(db: AsyncSession) -> set[int]: +async def _open_positions(db: AsyncSession) -> list[PaperTrade]: result = await db.execute( - select(PaperTrade.ticker_id).where( + select(PaperTrade).where( PaperTrade.book == SHADOW_BOOK, PaperTrade.status == "open" ) ) - return {row[0] for row in result.all()} + return list(result.scalars().all()) async def _shadow_user_id(db: AsyncSession) -> int | None: @@ -125,32 +166,42 @@ async def _shadow_user_id(db: AsyncSession) -> int | None: return int(row[0]) if row else None -async def _todays_qualified_setups(db: AsyncSession, config: dict) -> list[TradeSetup]: - """Latest setup per ticker from the most recent scan, gate-qualified. +async def _todays_qualified_setups( + db: AsyncSession, config: dict, *, now: datetime +) -> list[TradeSetup]: + """Latest qualified setup per ticker from the scan that just ran. + + Freshness is a hard requirement, not a nicety: pipeline steps are allowed to + fail independently, so if the scan is disabled or errors, the newest stored + setups belong to a previous session. Trading those would enter yesterday's + picks at yesterday's prices and quietly corrupt the record. Anything older + than ``MAX_SETUP_AGE`` is treated as "no scan happened". Ordered by ``strategy_rank`` descending — the ordering the backtest selects on. Setups without a rank sort last; they cannot be compared to ranked ones. """ - latest_scan = await db.execute(select(func.max(TradeSetup.detected_at))) - newest = latest_scan.scalar() - if newest is None: - return [] - - # Everything written by the same scan run (same calendar day, NY-agnostic: - # one qualifying scan per day is a hard invariant of the schedule). + cutoff = now - MAX_SETUP_AGE result = await db.execute( - select(TradeSetup).where( - func.date(TradeSetup.detected_at) == func.date(newest), - ) + select(TradeSetup).where(TradeSetup.detected_at >= cutoff) ) - setups = [s for s in result.scalars() if setup_qualifies(s, config)] - setups.sort( + qualified = [s for s in result.scalars() if setup_qualifies(s, config)] + + # One setup per ticker — the most recent wins. A ticker can have several + # rows in a scan (e.g. both directions); ranking over duplicates would let + # one name occupy more than its share of the ordering. + latest: dict[int, TradeSetup] = {} + for setup in qualified: + held = latest.get(setup.ticker_id) + if held is None or setup.detected_at > held.detected_at: + latest[setup.ticker_id] = setup + + return sorted( + latest.values(), key=lambda s: ( s.strategy_rank if s.strategy_rank is not None else float("-inf") ), reverse=True, ) - return setups async def open_shadow_positions( @@ -165,11 +216,18 @@ async def open_shadow_positions( skip anything already held or locked out by post-stop gate-reset, and stop at capacity. Returns a summary for the job log. """ - summary = {"opened": 0, "skipped_held": 0, "skipped_locked": 0, "symbols": []} + summary = { + "opened": 0, + "skipped_held": 0, + "skipped_locked": 0, + "skipped_no_cash": 0, + "symbols": [], + } config = await get_config(db) - held = await _open_ticker_ids(db) - free_slots = config["capacity"] - len(held) + positions = await _open_positions(db) + held = {p.ticker_id for p in positions} + free_slots = config["capacity"] - len(positions) if free_slots <= 0: return summary @@ -179,10 +237,10 @@ async def open_shadow_positions( return summary locks = await get_reentry_gate_locks(db, book=SHADOW_BOOK) - equity = await current_equity(db, config["start_equity"]) + equity, cash = await equity_and_cash(db, config["start_equity"], positions) timestamp = opened_at or datetime.now(timezone.utc) - for setup in await _todays_qualified_setups(db, activation_config): + for setup in await _todays_qualified_setups(db, activation_config, now=timestamp): if free_slots <= 0: break if setup.ticker_id in held: @@ -194,9 +252,13 @@ async def open_shadow_positions( entry = float(setup.entry_price or 0.0) stop = float(setup.stop_loss or 0.0) - shares = position_shares(equity, config["risk_pct"], entry, stop) + shares = position_shares( + equity, config["risk_pct"], entry, stop, cash_available=cash + ) if shares <= 0: + summary["skipped_no_cash"] += 1 continue + cash -= shares * entry db.add( PaperTrade( diff --git a/tests/unit/test_alpaca_provider_window.py b/tests/unit/test_alpaca_provider_window.py index 5d82ab5..9f25762 100644 --- a/tests/unit/test_alpaca_provider_window.py +++ b/tests/unit/test_alpaca_provider_window.py @@ -43,11 +43,19 @@ def _utcnow() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) +def _utc_today() -> date: + """The provider clamps against UTC, so the fixture must use the UTC date. + + ``date.today()`` is local and runs a day ahead in a UTC+hh timezone just + after midnight, which would assert against a day the clamp cannot reach.""" + return datetime.now(timezone.utc).date() + + @pytest.mark.asyncio async def test_todays_in_progress_bar_is_inside_the_window(): """The whole near-close design depends on today's bar being fetchable.""" provider, client = _provider() - today = date.today() + today = _utc_today() await provider.fetch_ohlcv("AAPL", today - timedelta(days=5), today) @@ -60,7 +68,7 @@ async def test_window_stays_out_of_the_delayed_data_period(): """A window reaching the last ~15 minutes fails the entire request.""" provider, client = _provider() - await provider.fetch_ohlcv("AAPL", date.today() - timedelta(days=5), date.today()) + await provider.fetch_ohlcv("AAPL", _utc_today() - timedelta(days=5), _utc_today()) assert client.request.end <= _utcnow() - timedelta(minutes=15) @@ -69,7 +77,7 @@ async def test_window_stays_out_of_the_delayed_data_period(): async def test_completed_past_day_is_fully_covered(): """Clamping must not swallow the last day of a historical window.""" provider, client = _provider() - end_date = date.today() - timedelta(days=3) + end_date = _utc_today() - timedelta(days=3) await provider.fetch_ohlcv("AAPL", end_date - timedelta(days=5), end_date) @@ -80,7 +88,7 @@ async def test_completed_past_day_is_fully_covered(): async def test_window_collapsing_to_nothing_skips_the_call(): """A start inside the delayed period yields no request at all, not an error.""" provider, client = _provider() - tomorrow = date.today() + timedelta(days=1) + tomorrow = _utc_today() + timedelta(days=2) records = await provider.fetch_ohlcv("AAPL", tomorrow, tomorrow) diff --git a/tests/unit/test_book_separation.py b/tests/unit/test_book_separation.py new file mode 100644 index 0000000..b4ff0c3 --- /dev/null +++ b/tests/unit/test_book_separation.py @@ -0,0 +1,181 @@ +"""The two books must not leak into each other. + +Regression cover for the review of ba2df8b. Each test here pins a way the +shadow book could quietly corrupt the discretionary record — or be corrupted +by it — which would invalidate the comparison the shadow book exists to make. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.exceptions import ValidationError +from app.models.paper_trade import PaperTrade +from app.models.ticker import Ticker +from app.models.user import User +from app.services import paper_trade_service as pts +from app.services.trade_policy import ( + MANUAL_BOOK, + SHADOW_BOOK, + get_reentry_gate_locks, + observe_reentry_gate_transitions, +) + + +@pytest.fixture +async def session(): + from tests.conftest import _test_session_factory + + async with _test_session_factory() as session: + yield session + + +async def _seed(session) -> int: + session.add(User(id=1, username="owner", password_hash="x")) + session.add(Ticker(id=1, symbol="AAA", name="AAA")) + await session.commit() + return 1 + + +def _trade(*, book, status="open", close_reason=None, closed_at=None, user_id=1): + now = datetime.now(timezone.utc) + return PaperTrade( + user_id=user_id, + ticker_id=1, + direction="long", + entry_price=100.0, + shares=10.0, + stop_loss=95.0, + target=115.0, + status=status, + opened_at=now - timedelta(days=3), + closed_at=closed_at, + close_price=95.0 if status == "closed" else None, + close_reason=close_reason, + book=book, + ) + + +class TestManualEndpoints: + @pytest.mark.asyncio + async def test_list_excludes_shadow_by_default(self, session): + """Open Positions must not silently mix the autonomous book in.""" + await _seed(session) + session.add_all([_trade(book=MANUAL_BOOK), _trade(book=SHADOW_BOOK)]) + await session.commit() + + rows = await pts.list_trades(session, user_id=1) + + assert len(rows) == 1 + + @pytest.mark.asyncio + async def test_list_can_span_both_books_deliberately(self, session): + await _seed(session) + session.add_all([_trade(book=MANUAL_BOOK), _trade(book=SHADOW_BOOK)]) + await session.commit() + + rows = await pts.list_trades(session, user_id=1, book=None) + + assert len(rows) == 2 + + @pytest.mark.asyncio + async def test_shadow_trades_cannot_be_closed_by_hand(self, session): + """A hand-closed shadow trade is no longer what the strategy would do.""" + await _seed(session) + trade = _trade(book=SHADOW_BOOK) + session.add(trade) + await session.commit() + + with pytest.raises(ValidationError, match="exit policy"): + await pts.close_trade(session, 1, trade.id, 110.0) + + +class TestGateResetCycle: + @pytest.mark.asyncio + async def test_shadow_stop_completes_fail_then_requalify(self, session): + """The full cycle must advance for the shadow book, or a stopped ticker + stays locked out forever.""" + await _seed(session) + closed = datetime.now(timezone.utc) - timedelta(days=2) + session.add( + _trade(book=SHADOW_BOOK, status="closed", close_reason="stop", closed_at=closed) + ) + await session.commit() + + assert 1 in await get_reentry_gate_locks(session, book=SHADOW_BOOK) + + # Day 1: ticker no longer qualifies → failure observed. + await observe_reentry_gate_transitions( + session, + evaluated_ticker_ids=[1], + qualified_ticker_ids=[], + observed_at=closed + timedelta(days=1), + book=SHADOW_BOOK, + ) + assert 1 in await get_reentry_gate_locks(session, book=SHADOW_BOOK) + + # Day 2: qualifies again → lock releases. + await observe_reentry_gate_transitions( + session, + evaluated_ticker_ids=[1], + qualified_ticker_ids=[1], + observed_at=closed + timedelta(days=2), + book=SHADOW_BOOK, + ) + assert 1 not in await get_reentry_gate_locks(session, book=SHADOW_BOOK) + + @pytest.mark.asyncio + async def test_observing_one_book_does_not_move_the_other(self, session): + await _seed(session) + closed = datetime.now(timezone.utc) - timedelta(days=2) + session.add_all( + [ + _trade(book=SHADOW_BOOK, status="closed", close_reason="stop", closed_at=closed), + _trade(book=MANUAL_BOOK, status="closed", close_reason="stop", closed_at=closed), + ] + ) + await session.commit() + + await observe_reentry_gate_transitions( + session, + evaluated_ticker_ids=[1], + qualified_ticker_ids=[], + observed_at=closed + timedelta(days=1), + book=SHADOW_BOOK, + ) + await observe_reentry_gate_transitions( + session, + evaluated_ticker_ids=[1], + qualified_ticker_ids=[1], + observed_at=closed + timedelta(days=2), + book=SHADOW_BOOK, + ) + + # Shadow released; manual never observed, so it stays locked. + assert 1 not in await get_reentry_gate_locks(session, book=SHADOW_BOOK) + assert 1 in await get_reentry_gate_locks(session, book=MANUAL_BOOK) + + +class TestPerformanceScoping: + @pytest.mark.asyncio + async def test_manual_side_is_scoped_to_the_caller(self, session): + """'Your picks' must not aggregate another user's discretionary book.""" + await _seed(session) + session.add(User(id=2, username="other", password_hash="x")) + await session.commit() + session.add_all( + [ + _trade(book=MANUAL_BOOK, user_id=1), + _trade(book=MANUAL_BOOK, user_id=2), + _trade(book=SHADOW_BOOK, user_id=1), + ] + ) + await session.commit() + + mine = await pts.list_trades(session, user_id=1) + theirs = await pts.list_trades(session, user_id=2) + + assert len(mine) == 1 + assert len(theirs) == 1 diff --git a/tests/unit/test_paper_trade_service.py b/tests/unit/test_paper_trade_service.py index 63f0d79..584d39a 100644 --- a/tests/unit/test_paper_trade_service.py +++ b/tests/unit/test_paper_trade_service.py @@ -16,6 +16,16 @@ from app.services import paper_trade_service as svc from tests.conftest import _test_session_factory # type: ignore +def _today() -> date: + """UTC date — trades are stamped in UTC, so fixtures must use it too. + + ``date.today()`` is local; in a UTC+hh timezone it runs a day ahead between + midnight and the offset, which silently desynchronises bar and benchmark + fixtures from the UTC ``opened_at`` the service reads. + """ + return datetime.now(timezone.utc).date() + + @pytest.fixture async def session(): async with _test_session_factory() as s: @@ -30,7 +40,7 @@ async def _seed(session, symbol: str, close: float) -> int: t = Ticker(symbol=symbol) session.add(t) await session.flush() - session.add(OHLCVRecord(ticker_id=t.id, date=date.today(), + session.add(OHLCVRecord(ticker_id=t.id, date=_today(), open=close, high=close, low=close, close=close, volume=1)) await session.commit() return t.id @@ -51,7 +61,7 @@ async def test_create_and_list_open(session): async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session): blocked_id = await _seed(session, "LOCKQ", close=100.0) released_id = await _seed(session, "FREEQ", close=100.0) - today = date.today() + today = _today() def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade: closed_on = today - timedelta(days=10) @@ -167,7 +177,7 @@ async def test_resolve_closes_on_target(session): trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) # later bars: a day that trades up through 110 - await _add_bars(session, tid, [(103, 101), (111, 108)], start=date.today()) + await _add_bars(session, tid, [(103, 101), (111, 108)], start=_today()) closed = await svc.resolve_open_trades(session) assert closed == 1 await session.refresh(trade) @@ -180,7 +190,7 @@ async def test_resolve_closes_on_stop(session): tid = await _seed(session, "AAA", close=100.0) trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) - await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces stop + await _add_bars(session, tid, [(101, 94)], start=_today()) # low pierces stop closed = await svc.resolve_open_trades(session) assert closed == 1 await session.refresh(trade) @@ -192,7 +202,7 @@ async def test_resolve_leaves_open_when_neither_hit(session): tid = await _seed(session, "AAA", close=100.0) await svc.create_trade(session, 1, symbol="AAA", direction="long", entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) - await _add_bars(session, tid, [(103, 98), (104, 99)], start=date.today()) # range-bound + await _add_bars(session, tid, [(103, 98), (104, 99)], start=_today()) # range-bound closed = await svc.resolve_open_trades(session) assert closed == 0 rows = await svc.list_trades(session, 1, status="open") @@ -217,7 +227,7 @@ async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: flo async def test_alpha_long_open(session): tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry - today = date.today() + today = _today() await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 420.0}) # SPY +5% await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) @@ -229,7 +239,7 @@ async def test_alpha_long_open(session): async def test_alpha_short_and_missing_benchmark(session): tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10% - today = date.today() + today = _today() await _add_open_trade(session, tid, "short", entry=100.0, shares=4, days_ago=10) # No benchmark data yet → alpha unset, not an error. @@ -389,7 +399,7 @@ async def test_resolve_time_mode_closes_at_horizon(session): tid = await _seed(session, "AAA", close=100.0) trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", entry_price=100.0, shares=10, stop_loss=95.0, target=200.0) - await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today()) + await _add_bars(session, tid, [(103, 101), (105, 102)], start=_today()) assert await svc.resolve_open_trades(session) == 1 await session.refresh(trade) assert trade.status == "closed" @@ -402,7 +412,7 @@ async def test_resolve_time_mode_stop_still_governs(session): tid = await _seed(session, "AAA", close=100.0) trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", entry_price=100.0, shares=10, stop_loss=95.0, target=200.0) - await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop + await _add_bars(session, tid, [(101, 94)], start=_today()) # low pierces the stop assert await svc.resolve_open_trades(session) == 1 await session.refresh(trade) assert trade.close_reason == "stop" @@ -413,7 +423,7 @@ async def test_resolve_trailing_closes_with_reason(session): await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0) tid = await _seed(session, "AAA", close=100.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) - await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back + await _add_bars(session, tid, [(120, 110), (130, 100)], start=_today()) # run up, pull back assert await svc.resolve_open_trades(session) == 1 closed = await svc.list_trades(session, 1, status="closed") assert closed[0]["close_reason"] == "trailing" @@ -424,7 +434,7 @@ async def test_resolve_atr_trailing_closes_with_reason(session, monkeypatch): await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0) tid = await _seed(session, "AAA", close=100.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) - await _add_bars(session, tid, [(121, 114), (107, 101)], start=date.today()) + await _add_bars(session, tid, [(121, 114), (107, 101)], start=_today()) assert await svc.resolve_open_trades(session) == 1 closed = await svc.list_trades(session, 1, status="closed") assert closed[0]["close_reason"] == "trailing" @@ -443,7 +453,7 @@ async def test_list_open_exposes_trailing_stop(session): await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0) tid = await _seed(session, "AAA", close=120.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) - await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125 + await _add_bars(session, tid, [(125, 118)], start=_today()) # peak 125 row = (await svc.list_trades(session, 1, status="open"))[0] assert row["trailing_stop"] == pytest.approx(110.0) # 125 * (1 - 0.12) assert row["trailing_distance_pct"] is not None @@ -454,7 +464,7 @@ async def test_list_open_exposes_atr_trailing_stop(session, monkeypatch): await svc.set_exit_policy(session, mode="atr_trailing", atr_multiplier=3.0) tid = await _seed(session, "AAA", close=120.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) - await _add_bars(session, tid, [(125, 118)], start=date.today()) + await _add_bars(session, tid, [(125, 118)], start=_today()) row = (await svc.list_trades(session, 1, status="open"))[0] assert row["trailing_stop"] == pytest.approx(106.5) # latest close 121.5 - 3 * 5 assert row["trailing_distance_pct"] is not None diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 01253a3..b9ae8a6 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -107,6 +107,7 @@ class TestConfigureScheduler: "sentiment_collector", "fundamental_collector", "rr_scanner", + "shadow_book", "ticker_universe_sync", "outcome_evaluator", "alerts", @@ -143,5 +144,6 @@ class TestConfigureScheduler: "outcome_evaluator", "rr_scanner", "sentiment_collector", + "shadow_book", "ticker_universe_sync", ]) diff --git a/tests/unit/test_shadow_book_service.py b/tests/unit/test_shadow_book_service.py index 3346394..ba0f823 100644 --- a/tests/unit/test_shadow_book_service.py +++ b/tests/unit/test_shadow_book_service.py @@ -78,6 +78,27 @@ class TestSizing: 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 + def test_tight_stop_is_capped_at_the_notional_limit(self): + """Without the cap, 1% risk on a $0.50 stop is a 2x-equity position.""" + shares = shadow_book_service.position_shares(100_000, 1.0, 100.0, 99.5) + # Risk sizing alone wants 2,000 shares ($200k); the 20% cap allows 200. + assert shares == pytest.approx(200.0) + assert shares * 100.0 <= 100_000 * shadow_book_service.NOTIONAL_CAP + + def test_cannot_spend_cash_it_does_not_have(self): + shares = shadow_book_service.position_shares( + 100_000, 1.0, 100.0, 95.0, cash_available=5_000 + ) + assert shares == pytest.approx(50.0) + + def test_no_cash_means_no_position(self): + assert ( + shadow_book_service.position_shares( + 100_000, 1.0, 100.0, 95.0, cash_available=0 + ) + == 0.0 + ) + class TestSelection: @pytest.mark.asyncio @@ -151,6 +172,41 @@ class TestSelection: assert summary["skipped_locked"] == 1 +class TestSetupFreshness: + @pytest.mark.asyncio + async def test_ignores_setups_from_a_previous_session(self, session): + """If the scan step failed or was disabled, the newest stored setups are + yesterday's — trading them would enter stale picks at stale prices.""" + ids = await _seed(session, ["AAA"]) + stale = datetime.now(timezone.utc) - timedelta(days=1) + session.add(_setup(ids["AAA"], rank=0.9, detected=stale)) + await session.commit() + + summary = await shadow_book_service.open_shadow_positions( + session, activation_config=_CONFIG + ) + + assert summary["opened"] == 0 + + @pytest.mark.asyncio + async def test_one_position_per_ticker_from_duplicate_setups(self, session): + ids = await _seed(session, ["AAA"]) + now = datetime.now(timezone.utc) + session.add_all( + [ + _setup(ids["AAA"], rank=0.5, detected=now - timedelta(minutes=30)), + _setup(ids["AAA"], rank=0.9, detected=now), + ] + ) + await session.commit() + + summary = await shadow_book_service.open_shadow_positions( + session, activation_config=_CONFIG + ) + + assert summary["opened"] == 1 + + class TestBookIsolation: @pytest.mark.asyncio async def test_gate_locks_do_not_leak_between_books(self, session): @@ -184,6 +240,7 @@ class TestBookIsolation: ) await session.commit() - equity = await shadow_book_service.current_equity(session, 100_000.0) + equity, cash = await shadow_book_service.equity_and_cash(session, 100_000.0, []) assert equity == 100_000.0 + assert cash == 100_000.0