Files
signal-platform/tests/unit/test_paper_trade_service.py
T
dennisthiessen 1e82dfad7f
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 33s
feat: adopt Phase 3 gate and paper-trade exit policy
Production strategy change based on the July 2026 backtest: paper trades now default to a 30-trading-day hold with the initial stop (classic momentum hold-and-rerank), while target and trailing exits remain available in Admin. The exit policy API/UI now carries hold_days and close_reason can be 'time'.

The activation confidence floor default is now 0/off because the gate ablation showed it added no per-trade edge while filtering out usable setups. Migration 015 clears stored activation_min_confidence and paper_exit_mode so the new defaults take effect; this intentionally resets Track Record comparability from this deploy.

Verification: 451 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:34 +02:00

303 lines
13 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
@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=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_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=date.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=date.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=date.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 = date.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 = date.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
async def test_exit_policy_defaults_and_round_trip(session):
# Default: the backtest-validated hold-to-horizon exit.
assert await svc.get_exit_policy(session) == {
"mode": "time", "trailing_pct": 12.0, "hold_days": 30,
}
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0, hold_days=21)
assert updated == {"mode": "target", "trailing_pct": 15.0, "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, hold_days=1)
def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
return (d, open_, hi, lo, close)
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=date.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=date.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=date.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_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=date.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