Files
signal-platform/app/services/paper_trade_service.py
T
dennisthiessen 30effa89b7
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped
feat: ticker search, watchlist momentum column, alpha vs S&P 500
Three usability fixes:

1. Global ticker search in the sidebar (TickerSearch) — typeahead over the
   tracked universe that opens a ticker's detail page without adding it to the
   watchlist. Also wired into the mobile nav.

2. Watchlist table shows the ticker's 12-1 momentum percentile (the top-pick
   selector) instead of the noisy full S/R-level list. Enriched from the setup
   already loaded in watchlist_service._enrich_entry — no extra query.

3. Alpha vs the S&P 500 on paper trades (open + closed). New benchmark_prices
   table + benchmark_service store SPY daily closes (a standalone series, not a
   Ticker, so it never enters the scanner / momentum ranking / rankings) via a
   new daily-pipeline step. paper_trade_service computes per-trade
   benchmark_return / alpha_pct / alpha_usd over each holding period; the open-
   trades table, dashboard, and closed-trades panel surface per-trade and total
   alpha. The list read path never makes a provider call.

Deploy: alembic upgrade head, then run the benchmark/daily job once to populate
SPY closes (alpha shows "—" until then).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:44:40 +02:00

241 lines
7.8 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.
"""Paper-trading service: take, mark-to-market, and close simulated trades."""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.services import benchmark_service
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT,
OUTCOME_TARGET_HIT,
Bar,
evaluate_setup_against_bars,
)
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
return ticker
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Latest stored close per ticker."""
if not ticker_ids:
return {}
latest = (
select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md"))
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
latest,
and_(
OHLCVRecord.ticker_id == latest.c.ticker_id,
OHLCVRecord.date == latest.c.md,
),
)
result = await db.execute(stmt)
return {tid: float(close) for tid, close in result.all()}
async def create_trade(
db: AsyncSession,
user_id: int,
*,
symbol: str,
direction: str,
entry_price: float,
shares: float,
stop_loss: float,
target: float,
) -> PaperTrade:
direction = direction.strip().lower()
if direction not in ("long", "short"):
raise ValidationError("direction must be 'long' or 'short'")
if shares <= 0 or entry_price <= 0:
raise ValidationError("shares and entry_price must be positive")
ticker = await _get_ticker(db, symbol)
trade = PaperTrade(
user_id=user_id,
ticker_id=ticker.id,
direction=direction,
entry_price=entry_price,
shares=shares,
stop_loss=stop_loss,
target=target,
status="open",
opened_at=datetime.now(timezone.utc),
)
db.add(trade)
await db.commit()
await db.refresh(trade)
return trade
def _to_dict(
trade: PaperTrade,
symbol: str,
current_price: float | None,
benchmark_closes: dict[date, float] | None = None,
) -> dict:
# For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price
# Alpha = trade return benchmark (SPY) return over the same holding period.
benchmark_return = None
alpha_pct = None
alpha_usd = None
if ref is not None and trade.entry_price and benchmark_closes:
sign = 1.0 if trade.direction == "long" else -1.0
trade_return = (ref - trade.entry_price) / trade.entry_price * 100.0 * sign
as_of = (
trade.closed_at.date()
if trade.status == "closed" and trade.closed_at is not None
else date.today()
)
benchmark_return = benchmark_service.benchmark_return_pct(
benchmark_closes, trade.opened_at.date(), as_of
)
if benchmark_return is not None:
alpha_pct = trade_return - benchmark_return
alpha_usd = alpha_pct / 100.0 * trade.entry_price * trade.shares
return {
"id": trade.id,
"symbol": symbol,
"direction": trade.direction,
"entry_price": trade.entry_price,
"shares": trade.shares,
"stop_loss": trade.stop_loss,
"target": trade.target,
"status": trade.status,
"opened_at": trade.opened_at,
"close_price": trade.close_price,
"closed_at": trade.closed_at,
"current_price": ref,
"benchmark_return_pct": benchmark_return,
"alpha_pct": alpha_pct,
"alpha_usd": alpha_usd,
}
async def list_trades(
db: AsyncSession,
user_id: int,
status: str | None = None,
) -> list[dict]:
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
.where(PaperTrade.user_id == user_id)
)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
rows = (await db.execute(stmt)).all()
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
prices = await _latest_closes(db, open_ids)
# Benchmark closes for alpha — populated by the daily/benchmark job. Empty until
# that runs once, in which case alpha is simply left unset (a read path never
# makes a provider call).
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
return [_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes) for t, sym in rows]
async def close_trade(
db: AsyncSession,
user_id: int,
trade_id: int,
close_price: float | None = None,
) -> PaperTrade:
result = await db.execute(
select(PaperTrade).where(
PaperTrade.id == trade_id,
PaperTrade.user_id == user_id,
)
)
trade = result.scalar_one_or_none()
if trade is None:
raise NotFoundError(f"Paper trade not found: {trade_id}")
if trade.status == "closed":
raise ValidationError("Trade is already closed")
if close_price is None:
prices = await _latest_closes(db, {trade.ticker_id})
close_price = prices.get(trade.ticker_id)
if close_price is None:
raise ValidationError("No current price available to close at; supply close_price")
trade.status = "closed"
trade.close_price = float(close_price)
trade.closed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(trade)
return trade
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades whose stop or target was hit in the daily bars.
Walks the bars after each trade's open (same logic as the outcome evaluator).
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
close at the stop. Trades that have hit neither stay open. Returns the count
closed.
"""
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all())
if not open_trades:
return 0
closed = 0
for trade in open_trades:
bars_result = await db.execute(
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
.where(
OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(),
)
.order_by(OHLCVRecord.date.asc())
)
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
if not bars:
continue
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
)
if outcome == OUTCOME_TARGET_HIT:
trade.close_price = trade.target
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
trade.close_price = trade.stop_loss
else:
continue
trade.status = "closed"
trade.closed_at = datetime.combine(
outcome_date, datetime.min.time(), tzinfo=timezone.utc
)
closed += 1
if closed:
await db.commit()
return closed