"""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