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>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -85,7 +86,34 @@ async def create_trade(
|
||||
return trade
|
||||
|
||||
|
||||
def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dict:
|
||||
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,
|
||||
@@ -98,8 +126,10 @@ def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dic
|
||||
"opened_at": trade.opened_at,
|
||||
"close_price": trade.close_price,
|
||||
"closed_at": trade.closed_at,
|
||||
# For open trades, mark to market; for closed, the realized exit price.
|
||||
"current_price": current_price if trade.status == "open" else trade.close_price,
|
||||
"current_price": ref,
|
||||
"benchmark_return_pct": benchmark_return,
|
||||
"alpha_pct": alpha_pct,
|
||||
"alpha_usd": alpha_usd,
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +150,13 @@ async def list_trades(
|
||||
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)
|
||||
return [_to_dict(t, sym, prices.get(t.ticker_id)) for t, sym in rows]
|
||||
|
||||
# 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(
|
||||
|
||||
Reference in New Issue
Block a user