feat: ticker search, watchlist momentum column, alpha vs S&P 500
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped

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:
2026-06-28 08:44:40 +02:00
parent 4a96f85cd9
commit 30effa89b7
21 changed files with 506 additions and 31 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Benchmark price store + alpha helpers.
Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so
paper-trade alpha — a trade's return minus the benchmark's return over the same
holding period — can be computed. The benchmark is a standalone series, NOT a
tracked ``Ticker``, so it never contaminates the scanner, momentum-percentile
ranking, or rankings.
"""
from __future__ import annotations
import bisect
import logging
from datetime import date, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.benchmark_price import BenchmarkPrice
from app.providers.alpaca import AlpacaOHLCVProvider
logger = logging.getLogger(__name__)
BENCHMARK_SYMBOL = "SPY"
# ~800 calendar days ≈ 550 trading days — comfortably covers any realistic paper
# holding period plus a margin for the nearest-prior-trading-day lookup.
_HISTORY_DAYS = 800
async def refresh_benchmark_prices(
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL, days: int = _HISTORY_DAYS
) -> int:
"""Fetch the benchmark's daily closes and upsert them. Returns rows written.
Idempotent: inserts new dates, updates a close only if it changed (e.g. after
a split adjustment). Best-effort — returns 0 when Alpaca keys are unset.
"""
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
logger.warning("Benchmark refresh skipped: Alpaca keys not configured")
return 0
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
end = date.today()
start = end - timedelta(days=days)
bars = await provider.fetch_ohlcv(symbol, start, end)
existing = {
row.date: row
for row in (
await db.execute(select(BenchmarkPrice).where(BenchmarkPrice.symbol == symbol))
).scalars()
}
written = 0
for bar in bars:
current = existing.get(bar.date)
if current is None:
db.add(BenchmarkPrice(symbol=symbol, date=bar.date, close=float(bar.close)))
written += 1
elif abs(current.close - float(bar.close)) > 1e-9:
current.close = float(bar.close)
written += 1
if written:
await db.commit()
logger.info("Benchmark %s refreshed: %d rows written", symbol, written)
return written
async def load_benchmark_closes(
db: AsyncSession, symbol: str = BENCHMARK_SYMBOL
) -> dict[date, float]:
"""Return ``{date: close}`` for the benchmark (empty if none stored yet)."""
rows = await db.execute(
select(BenchmarkPrice.date, BenchmarkPrice.close).where(BenchmarkPrice.symbol == symbol)
)
return {d: float(c) for d, c in rows.all()}
def benchmark_return_pct(
closes: dict[date, float], open_date: date, as_of_date: date
) -> float | None:
"""Benchmark % return between two dates, using the nearest close on/before each.
Returns ``None`` when there's no benchmark data at or before either endpoint
(e.g. a trade opened before the stored history, or the table is empty).
"""
if not closes:
return None
dates = sorted(closes)
def _close_on_or_before(target: date) -> float | None:
idx = bisect.bisect_right(dates, target) - 1
return closes[dates[idx]] if idx >= 0 else None
start = _close_on_or_before(open_date)
end = _close_on_or_before(as_of_date)
if start is None or end is None or start == 0:
return None
return (end - start) / start * 100.0
+41 -5
View File
@@ -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(
+3
View File
@@ -173,6 +173,9 @@ async def _enrich_entry(
"dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction if setup else None,
# 12-1 cross-sectional momentum percentile (the top-pick selector); ticker-
# level, so any of the ticker's setups carries the same value.
"momentum_percentile": setup.momentum_percentile if setup else None,
"sr_levels": sr_levels,
"last_close": last_close,
"change_pct": change_pct,