Files
signal-platform/tests/unit/test_paper_trade_service.py
T
dennisthiessenandClaude Fable 5 247a92a89f 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 <noreply@anthropic.com>
2026-07-21 09:11:14 +02:00

471 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the paper-trading service."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from app.exceptions import ValidationError
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord
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 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:
yield s
async def _seed(session, symbol: str, close: float) -> int:
user = await session.get(User, 1)
if user is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
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
async def test_create_and_list_open(session):
await _seed(session, "AAA", close=110.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=120.0)
rows = await svc.list_trades(session, 1)
assert len(rows) == 1
row = rows[0]
assert row["symbol"] == "AAA"
assert row["status"] == "open"
assert row["current_price"] == 110.0 # marked to the latest close
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 = _today()
def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
closed_on = today - timedelta(days=10)
reset_at = datetime.combine(
closed_on + timedelta(days=1),
datetime.min.time(),
tzinfo=timezone.utc,
)
return PaperTrade(
user_id=1,
ticker_id=ticker_id,
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
status="closed",
opened_at=datetime.combine(
closed_on - timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
),
close_price=95.0,
closed_at=datetime.combine(
closed_on, datetime.min.time(), tzinfo=timezone.utc
),
close_reason="stop",
reentry_gate_failed_at=reset_at if gate_reset_complete else None,
reentry_gate_requalified_at=(
reset_at + timedelta(days=1) if gate_reset_complete else None
),
)
session.add_all(
[
stopped_trade(blocked_id, gate_reset_complete=False),
stopped_trade(released_id, gate_reset_complete=True),
]
)
await session.commit()
with pytest.raises(ValidationError, match="requires a post-stop gate reset"):
await svc.create_trade(
session,
1,
symbol="LOCKQ",
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
)
trade = await svc.create_trade(
session,
1,
symbol="FREEQ",
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
)
assert trade.ticker_id == released_id
async def test_close_uses_current_price(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
closed = await svc.close_trade(session, 1, trade.id)
assert closed.status == "closed"
assert closed.close_price == 112.0
assert closed.closed_at is not None
rows = await svc.list_trades(session, 1, status="closed")
assert rows[0]["current_price"] == 112.0 # closed → realized exit
async def test_close_with_explicit_price(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="short",
entry_price=100.0, shares=5, stop_loss=105.0, target=90.0)
closed = await svc.close_trade(session, 1, trade.id, close_price=93.0)
assert closed.close_price == 93.0
async def test_invalid_direction_rejected(session):
await _seed(session, "AAA", close=100.0)
with pytest.raises(ValidationError):
await svc.create_trade(session, 1, symbol="AAA", direction="sideways",
entry_price=100.0, shares=1, stop_loss=95.0, target=110.0)
async def test_double_close_rejected(session):
await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=1, stop_loss=95.0, target=110.0)
await svc.close_trade(session, 1, trade.id)
with pytest.raises(ValidationError):
await svc.close_trade(session, 1, trade.id)
async def _add_bars(session, ticker_id: int, highs_lows: list[tuple[float, float]], start: date) -> None:
for i, (hi, lo) in enumerate(highs_lows):
mid = (hi + lo) / 2
session.add(OHLCVRecord(ticker_id=ticker_id, date=start + timedelta(days=i + 1),
open=mid, high=hi, low=lo, close=mid, volume=1))
await session.commit()
async def test_resolve_closes_on_target(session):
await svc.set_exit_policy(session, mode="target")
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)
# later bars: a day that trades up through 110
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)
assert trade.status == "closed"
assert trade.close_price == 110.0 # closed at target
async def test_resolve_closes_on_stop(session):
await svc.set_exit_policy(session, mode="target")
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=_today()) # low pierces stop
closed = await svc.resolve_open_trades(session)
assert closed == 1
await session.refresh(trade)
assert trade.close_price == 95.0 # closed at stop
async def test_resolve_leaves_open_when_neither_hit(session):
await svc.set_exit_policy(session, mode="target")
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=_today()) # range-bound
closed = await svc.resolve_open_trades(session)
assert closed == 0
rows = await svc.list_trades(session, 1, status="open")
assert len(rows) == 1
async def _seed_benchmark(session, points: dict) -> None:
for d, close in points.items():
session.add(BenchmarkPrice(symbol="SPY", date=d, close=close))
await session.commit()
async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: float,
shares: float, days_ago: int) -> None:
session.add(PaperTrade(
user_id=1, ticker_id=ticker_id, direction=direction, entry_price=entry,
shares=shares, stop_loss=entry * 0.95, target=entry * 1.2, status="open",
opened_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
))
await session.commit()
async def test_alpha_long_open(session):
tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry
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)
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["benchmark_return_pct"] == pytest.approx(5.0)
assert row["alpha_pct"] == pytest.approx(5.0) # +10% trade 5% bench
assert row["alpha_usd"] == pytest.approx(50.0) # 5% of 100*10
async def test_alpha_short_and_missing_benchmark(session):
tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10%
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.
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["alpha_pct"] is None
assert row["benchmark_return_pct"] is None
# Flat benchmark → alpha equals the (direction-signed) trade return.
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 400.0})
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["benchmark_return_pct"] == pytest.approx(0.0)
assert row["alpha_pct"] == pytest.approx(10.0)
def _b(d: date, hi: float, lo: float):
return svc.Bar(date=d, high=hi, low=lo)
class TestTrailingClose:
def test_long_locks_gain(self):
# Runs to 120; the 12%-from-peak stop (120 → 105.6) is pierced on the drop.
bars = [_b(date(2026, 1, 2), 120, 110), _b(date(2026, 1, 3), 130, 100)]
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, when, reason = hit
assert price == pytest.approx(105.6)
assert reason == "trailing"
assert when == date(2026, 1, 3)
def test_initial_stop_caps_loss(self):
bars = [_b(date(2026, 1, 2), 101, 94)] # through the initial stop before running
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, _, reason = hit
assert price == pytest.approx(95.0)
assert reason == "stop"
def test_none_when_neither_hit(self):
bars = [_b(date(2026, 1, 2), 105, 99), _b(date(2026, 1, 3), 106, 100)]
assert svc._trailing_close("long", 100.0, 95.0, 0.12, bars) is None
class TestAtrTrailingClose:
def test_long_uses_ratchet_on_next_bar(self, monkeypatch):
monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
rows = [
_r(date(2026, 1, 1), 100, 100, 100, 100),
_r(date(2026, 1, 2), 115, 121, 114, 120),
_r(date(2026, 1, 3), 106, 107, 103, 104),
]
hit = svc._atr_trailing_close(
"long", 100.0, 95.0, 3.0, 30, rows, date(2026, 1, 1)
)
assert hit is not None
price, when, reason = hit
assert price == pytest.approx(105.0)
assert when == date(2026, 1, 3)
assert reason == "trailing"
def test_max_hold_still_closes(self, monkeypatch):
monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [50.0] * len(rows))
rows = [
_r(date(2026, 1, 1), 100, 100, 100, 100),
_r(date(2026, 1, 2), 101, 102, 100, 101),
_r(date(2026, 1, 3), 102, 103, 101, 102),
]
assert svc._atr_trailing_close(
"long", 100.0, 95.0, 3.0, 2, rows, date(2026, 1, 1)
) == (102.0, date(2026, 1, 3), "time")
async def test_exit_policy_defaults_and_round_trip(session):
# Default: the promoted production exit.
assert await svc.get_exit_policy(session) == {
"mode": "atr_trailing", "trailing_pct": 12.0,
"atr_multiplier": 3.0, "hold_days": 30,
}
updated = await svc.set_exit_policy(
session, mode="target", trailing_pct=15.0, atr_multiplier=2.5, hold_days=21
)
assert updated == {
"mode": "target", "trailing_pct": 15.0,
"atr_multiplier": 2.5, "hold_days": 21,
}
assert (await svc.get_exit_policy(session))["mode"] == "target"
async def test_exit_policy_rejects_bad_input(session):
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, mode="bogus")
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, trailing_pct=200.0)
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, atr_multiplier=20.0)
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, hold_days=1)
def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
return (d, open_, hi, lo, close)
def test_atr_series_matches_compute_atr_per_prefix():
"""_atr_series_from_rows[i] must equal compute_atr(rows[: i + 1])['atr'] (with
the same >0 / insufficient-history guards) at every index. The O(n) rewrite is
only valid because it reproduces the per-prefix value exactly."""
from app.services.indicator_service import compute_atr
price = 100.0
closes = []
for i in range(200):
price = max(1.0, price + (3.0 if i % 3 else -2.0) + (i % 7) * 0.25)
closes.append(price)
rows = [
_r(date(2024, 1, 1) + timedelta(days=i), c, c + 1.5, c - 1.2, c)
for i, c in enumerate(closes)
]
series = svc._atr_series_from_rows(rows)
highs = [r[2] for r in rows]
lows = [r[3] for r in rows]
closes_col = [r[4] for r in rows]
assert len(series) == len(rows)
for i in range(len(rows)):
if i < 14:
assert series[i] is None
else:
raw = compute_atr(highs[: i + 1], lows[: i + 1], closes_col[: i + 1])["atr"]
expected = float(raw) if raw and raw > 0 else None
assert series[i] == expected, f"index {i}: {series[i]} != {expected}"
class TestTimeClose:
def test_closes_at_hold_days_close(self):
rows = [
_r(date(2026, 1, 2), 101, 103, 100, 102),
_r(date(2026, 1, 3), 102, 104, 101, 103),
_r(date(2026, 1, 4), 103, 106, 102, 105),
]
assert svc._time_close("long", 95.0, 3, rows) == (105.0, date(2026, 1, 4), "time")
def test_stop_before_horizon(self):
rows = [_r(date(2026, 1, 2), 100, 101, 94, 96)]
assert svc._time_close("long", 95.0, 30, rows) == (95.0, date(2026, 1, 2), "stop")
def test_gap_through_stop_fills_at_open(self):
rows = [_r(date(2026, 1, 2), 92, 93, 90, 91)]
assert svc._time_close("long", 95.0, 30, rows) == (92.0, date(2026, 1, 2), "stop")
def test_none_before_horizon(self):
rows = [_r(date(2026, 1, 2), 101, 103, 100, 102)]
assert svc._time_close("long", 95.0, 5, rows) is None
async def test_resolve_time_mode_closes_at_horizon(session):
await svc.set_exit_policy(session, mode="time", hold_days=2)
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=_today())
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.status == "closed"
assert trade.close_reason == "time"
assert trade.close_price == pytest.approx((105 + 102) / 2) # day-2 close (= bar mid)
async def test_resolve_time_mode_stop_still_governs(session):
await svc.set_exit_policy(session, mode="time", hold_days=30)
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=_today()) # low pierces the stop
assert await svc.resolve_open_trades(session) == 1
await session.refresh(trade)
assert trade.close_reason == "stop"
assert trade.close_price == pytest.approx(95.0)
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=_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"
async def test_resolve_atr_trailing_closes_with_reason(session, monkeypatch):
monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
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=_today())
assert await svc.resolve_open_trades(session) == 1
closed = await svc.list_trades(session, 1, status="closed")
assert closed[0]["close_reason"] == "trailing"
assert closed[0]["current_price"] == pytest.approx(102.5)
async def test_manual_close_sets_reason(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
await svc.close_trade(session, 1, trade.id)
assert (await svc.list_trades(session, 1, status="closed"))[0]["close_reason"] == "manual"
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=_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
async def test_list_open_exposes_atr_trailing_stop(session, monkeypatch):
monkeypatch.setattr(svc, "_atr_series_from_rows", lambda rows, period=14: [5.0] * len(rows))
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=_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