a69557f5d8
New paper_trades table (migration 007) + service/router. "Mark as taken" on each setup card (shares prefilled from position sizing, entry from current price, both editable) records a simulated trade. Overview gains an Open Trades table that marks each position to the latest close — P&L in $, %, and R-multiples — with a total unrealized P&L footer and a Sell button to close at the current price. Closed trades are retained for future realized-P&L reporting. Deploy: alembic upgrade (new paper_trades table). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3.1 KiB
Python
84 lines
3.1 KiB
Python
"""Tests for the paper-trading service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.exceptions import ValidationError
|
|
from app.models.ohlcv import OHLCVRecord
|
|
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)
|