30effa89b7
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>
241 lines
7.8 KiB
Python
241 lines
7.8 KiB
Python
"""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
|