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:
@@ -0,0 +1,41 @@
|
|||||||
|
"""add benchmark_prices
|
||||||
|
|
||||||
|
Stores daily closes for a benchmark index (SPY) so paper-trade alpha — trade
|
||||||
|
return minus the benchmark's return over the same holding period — can be
|
||||||
|
computed. Kept separate from the tradeable universe: the benchmark is not a
|
||||||
|
Ticker, so it never enters the scanner, momentum ranking, or rankings.
|
||||||
|
|
||||||
|
Revision ID: 011
|
||||||
|
Revises: 010
|
||||||
|
Create Date: 2026-06-28 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "011"
|
||||||
|
down_revision: Union[str, None] = "010"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"benchmark_prices",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("symbol", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("close", sa.Float(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_benchmark_prices_symbol", "benchmark_prices", ["symbol"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_benchmark_prices_symbol", table_name="benchmark_prices")
|
||||||
|
op.drop_table("benchmark_prices")
|
||||||
@@ -11,6 +11,7 @@ from app.models.settings import SystemSetting, IngestionProgress
|
|||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ticker",
|
"Ticker",
|
||||||
@@ -28,4 +29,5 @@ __all__ = [
|
|||||||
"AlertLog",
|
"AlertLog",
|
||||||
"PaperTrade",
|
"PaperTrade",
|
||||||
"RegimeSnapshot",
|
"RegimeSnapshot",
|
||||||
|
"BenchmarkPrice",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
from sqlalchemy import Date, Float, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkPrice(Base):
|
||||||
|
"""Daily close for a benchmark index (e.g. SPY), used to compute trade alpha.
|
||||||
|
|
||||||
|
A standalone price series, deliberately NOT a tracked ``Ticker`` — so the
|
||||||
|
benchmark never enters the scanner, the momentum-percentile ranking, or the
|
||||||
|
rankings table. One row per (symbol, date).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "benchmark_prices"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("symbol", "date", name="uq_benchmark_symbol_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
symbol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
date: Mapped[date_type] = mapped_column(Date, nullable=False)
|
||||||
|
close: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
+33
-2
@@ -36,6 +36,7 @@ from app.providers.protocol import SentimentData
|
|||||||
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
|
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
|
||||||
from app.services.alert_service import dispatch_alerts
|
from app.services.alert_service import dispatch_alerts
|
||||||
from app.services.backtest_service import run_and_store as run_backtest_and_store
|
from app.services.backtest_service import run_and_store as run_backtest_and_store
|
||||||
|
from app.services.benchmark_service import refresh_benchmark_prices
|
||||||
from app.services.market_regime_service import update_market_regime
|
from app.services.market_regime_service import update_market_regime
|
||||||
from app.services.regime_monitor_service import update_regime_monitor
|
from app.services.regime_monitor_service import update_regime_monitor
|
||||||
from app.services.event_study_service import run_and_store as run_event_study_and_store
|
from app.services.event_study_service import run_and_store as run_event_study_and_store
|
||||||
@@ -866,6 +867,34 @@ async def compute_market_regime() -> None:
|
|||||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job: Benchmark Collector (SPY closes for paper-trade alpha)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_benchmark() -> None:
|
||||||
|
"""Refresh the stored benchmark (SPY) daily closes used for paper-trade alpha."""
|
||||||
|
job_name = "benchmark_collector"
|
||||||
|
_log_event(logging.INFO, "job_start", job=job_name)
|
||||||
|
_runtime_start(job_name, total=1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
if not await _is_job_enabled(db, job_name):
|
||||||
|
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||||
|
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
written = await refresh_benchmark_prices(db)
|
||||||
|
|
||||||
|
_runtime_progress(job_name, processed=1, total=1)
|
||||||
|
_runtime_finish(job_name, "completed", processed=1, total=1, message=f"{written} rows")
|
||||||
|
_log_event(logging.INFO, "job_complete", job=job_name, rows=written)
|
||||||
|
except Exception as exc:
|
||||||
|
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||||
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job: Regime Monitor
|
# Job: Regime Monitor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1016,6 +1045,7 @@ async def sync_ticker_universe() -> None:
|
|||||||
# Daily (full): the complete data→signal refresh, once a day.
|
# Daily (full): the complete data→signal refresh, once a day.
|
||||||
_DAILY_PIPELINE_STEPS = [
|
_DAILY_PIPELINE_STEPS = [
|
||||||
("data_collector", "collect_ohlcv"),
|
("data_collector", "collect_ohlcv"),
|
||||||
|
("benchmark_collector", "collect_benchmark"),
|
||||||
("sentiment_collector", "collect_sentiment"),
|
("sentiment_collector", "collect_sentiment"),
|
||||||
("rr_scanner", "scan_rr"),
|
("rr_scanner", "scan_rr"),
|
||||||
("outcome_evaluator", "evaluate_outcomes"),
|
("outcome_evaluator", "evaluate_outcomes"),
|
||||||
@@ -1068,8 +1098,8 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_daily_pipeline() -> None:
|
async def run_daily_pipeline() -> None:
|
||||||
"""Full daily flow: OHLCV → sentiment → R:R scan → outcome eval (+paper
|
"""Full daily flow: OHLCV → benchmark → sentiment → R:R scan → outcome eval
|
||||||
close) → market regime."""
|
(+paper close) → market regime."""
|
||||||
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
|
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
|
||||||
|
|
||||||
|
|
||||||
@@ -1176,6 +1206,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
|||||||
# interval job). They stay manually triggerable from Admin → Jobs.
|
# interval job). They stay manually triggerable from Admin → Jobs.
|
||||||
_members = [
|
_members = [
|
||||||
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
|
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
|
||||||
|
(collect_benchmark, "benchmark_collector", "Benchmark Collector"),
|
||||||
(collect_sentiment, "sentiment_collector", "Sentiment Collector"),
|
(collect_sentiment, "sentiment_collector", "Sentiment Collector"),
|
||||||
(scan_rr, "rr_scanner", "R:R Scanner"),
|
(scan_rr, "rr_scanner", "R:R Scanner"),
|
||||||
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
||||||
|
|||||||
@@ -33,3 +33,8 @@ class PaperTradeResponse(BaseModel):
|
|||||||
close_price: float | None = None
|
close_price: float | None = None
|
||||||
closed_at: datetime | None = None
|
closed_at: datetime | None = None
|
||||||
current_price: float | None = None
|
current_price: float | None = None
|
||||||
|
# Alpha vs the S&P 500 (SPY) over the trade's holding period. None when the
|
||||||
|
# benchmark series doesn't cover the trade's open date yet.
|
||||||
|
benchmark_return_pct: float | None = None
|
||||||
|
alpha_pct: float | None = None
|
||||||
|
alpha_usd: float | None = None
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class WatchlistEntryResponse(BaseModel):
|
|||||||
dimensions: list[DimensionScoreSummary] = []
|
dimensions: list[DimensionScoreSummary] = []
|
||||||
rr_ratio: float | None = None
|
rr_ratio: float | None = None
|
||||||
rr_direction: str | None = None
|
rr_direction: str | None = None
|
||||||
|
momentum_percentile: float | None = None
|
||||||
sr_levels: list[SRLevelSummary] = []
|
sr_levels: list[SRLevelSummary] = []
|
||||||
last_close: float | None = None
|
last_close: float | None = None
|
||||||
change_pct: float | None = None
|
change_pct: float | None = None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.ohlcv import OHLCVRecord
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
from app.services import benchmark_service
|
||||||
from app.services.outcome_service import (
|
from app.services.outcome_service import (
|
||||||
OUTCOME_AMBIGUOUS,
|
OUTCOME_AMBIGUOUS,
|
||||||
OUTCOME_STOP_HIT,
|
OUTCOME_STOP_HIT,
|
||||||
@@ -85,7 +86,34 @@ async def create_trade(
|
|||||||
return 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 {
|
return {
|
||||||
"id": trade.id,
|
"id": trade.id,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -98,8 +126,10 @@ def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dic
|
|||||||
"opened_at": trade.opened_at,
|
"opened_at": trade.opened_at,
|
||||||
"close_price": trade.close_price,
|
"close_price": trade.close_price,
|
||||||
"closed_at": trade.closed_at,
|
"closed_at": trade.closed_at,
|
||||||
# For open trades, mark to market; for closed, the realized exit price.
|
"current_price": ref,
|
||||||
"current_price": current_price if trade.status == "open" else trade.close_price,
|
"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()
|
rows = (await db.execute(stmt)).all()
|
||||||
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
|
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
|
||||||
prices = await _latest_closes(db, open_ids)
|
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(
|
async def close_trade(
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ async def _enrich_entry(
|
|||||||
"dimensions": dims,
|
"dimensions": dims,
|
||||||
"rr_ratio": setup.rr_ratio if setup else None,
|
"rr_ratio": setup.rr_ratio if setup else None,
|
||||||
"rr_direction": setup.direction 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,
|
"sr_levels": sr_levels,
|
||||||
"last_close": last_close,
|
"last_close": last_close,
|
||||||
"change_pct": change_pct,
|
"change_pct": change_pct,
|
||||||
|
|||||||
@@ -21,16 +21,21 @@ export function OpenTradesPanel() {
|
|||||||
const close = useClosePaperTrade();
|
const close = useClosePaperTrade();
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
let pnl = 0, winners = 0, losers = 0, priced = 0;
|
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||||
for (const t of trades ?? []) {
|
for (const t of trades ?? []) {
|
||||||
const p = tradePnl(t);
|
const p = tradePnl(t);
|
||||||
if (!p) continue;
|
if (p) {
|
||||||
priced += 1;
|
priced += 1;
|
||||||
pnl += p.pnl;
|
pnl += p.pnl;
|
||||||
if (p.pnl > 0) winners += 1;
|
if (p.pnl > 0) winners += 1;
|
||||||
else if (p.pnl < 0) losers += 1;
|
else if (p.pnl < 0) losers += 1;
|
||||||
|
}
|
||||||
|
if (t.alpha_usd != null) {
|
||||||
|
alphaUsd += t.alpha_usd;
|
||||||
|
alphaPriced += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { pnl, winners, losers, priced };
|
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
|
||||||
}, [trades]);
|
}, [trades]);
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
@@ -58,6 +63,7 @@ export function OpenTradesPanel() {
|
|||||||
<th className="px-4 py-3 text-right">P&L</th>
|
<th className="px-4 py-3 text-right">P&L</th>
|
||||||
<th className="px-4 py-3 text-right">%</th>
|
<th className="px-4 py-3 text-right">%</th>
|
||||||
<th className="px-4 py-3 text-right">R</th>
|
<th className="px-4 py-3 text-right">R</th>
|
||||||
|
<th className="px-4 py-3 text-right">Alpha</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -90,6 +96,9 @@ export function OpenTradesPanel() {
|
|||||||
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||||
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
|
||||||
|
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -110,12 +119,16 @@ export function OpenTradesPanel() {
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-white/[0.08]">
|
<tr className="border-t border-white/[0.08]">
|
||||||
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
||||||
Total unrealized P&L
|
Total unrealized P&L · alpha vs S&P 500
|
||||||
</td>
|
</td>
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
||||||
{money(totals.pnl)}
|
{money(totals.pnl)}
|
||||||
</td>
|
</td>
|
||||||
<td colSpan={3} />
|
<td colSpan={2} />
|
||||||
|
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||||
|
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import TickerSearch from './TickerSearch';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Overview', end: true },
|
{ to: '/', label: 'Overview', end: true },
|
||||||
@@ -46,6 +47,9 @@ export default function MobileNav() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<nav className="px-3 py-2 space-y-1">
|
<nav className="px-3 py-2 space-y-1">
|
||||||
|
<div className="pb-2">
|
||||||
|
<TickerSearch onNavigate={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
{navItems.map(({ to, label, end }) => (
|
{navItems.map(({ to, label, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { check as healthCheck } from '../../api/health';
|
import { check as healthCheck } from '../../api/health';
|
||||||
import { getRunningJobs } from '../../api/jobs';
|
import { getRunningJobs } from '../../api/jobs';
|
||||||
|
import TickerSearch from './TickerSearch';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Overview', index: '01', end: true },
|
{ to: '/', label: 'Overview', index: '01', end: true },
|
||||||
@@ -54,6 +55,10 @@ export default function Sidebar() {
|
|||||||
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
|
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pt-4">
|
||||||
|
<TickerSearch />
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||||
{navItems.map(({ to, label, index, end }) => (
|
{navItems.map(({ to, label, index, end }) => (
|
||||||
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
|
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTickers } from '../../hooks/useTickers';
|
||||||
|
import { Input } from '../ui/Field';
|
||||||
|
|
||||||
|
const MAX_RESULTS = 8;
|
||||||
|
|
||||||
|
/** Jump-to-ticker search over the tracked universe. Selecting a match opens its
|
||||||
|
* detail page — it does NOT add the ticker to the watchlist. */
|
||||||
|
export default function TickerSearch({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const tickers = useTickers();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const blurTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const query = q.trim().toUpperCase();
|
||||||
|
if (!query) return [];
|
||||||
|
const all = tickers.data ?? [];
|
||||||
|
const starts = all.filter((t) => t.symbol.toUpperCase().startsWith(query));
|
||||||
|
const contains = all.filter(
|
||||||
|
(t) => !t.symbol.toUpperCase().startsWith(query) && t.symbol.toUpperCase().includes(query),
|
||||||
|
);
|
||||||
|
return [...starts, ...contains].slice(0, MAX_RESULTS);
|
||||||
|
}, [q, tickers.data]);
|
||||||
|
|
||||||
|
const go = (symbol: string) => {
|
||||||
|
navigate(`/ticker/${symbol}`);
|
||||||
|
setQ('');
|
||||||
|
setOpen(false);
|
||||||
|
setActive(0);
|
||||||
|
onNavigate?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => Math.min(a + 1, matches.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => Math.max(a - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const m = matches[active];
|
||||||
|
if (m) go(m.symbol);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setQ('');
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showList = open && q.trim().length > 0 && matches.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
setActive(0);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
blurTimer.current = window.setTimeout(() => setOpen(false), 120);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Search ticker…"
|
||||||
|
aria-label="Search ticker"
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{showList && (
|
||||||
|
<ul className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-lg glass py-1 shadow-xl">
|
||||||
|
{matches.map((t, i) => (
|
||||||
|
<li key={t.symbol}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => go(t.symbol)}
|
||||||
|
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
|
||||||
|
i === active ? 'bg-blue-400/[0.12] text-blue-200' : 'text-gray-300 hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.symbol}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export function MyTradesPanel() {
|
|||||||
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
|
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
|
||||||
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
|
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
|
||||||
const pnls = rows.map((r) => r.p?.pnl ?? 0);
|
const pnls = rows.map((r) => r.p?.pnl ?? 0);
|
||||||
|
const alphas = rows.map((r) => r.t.alpha_usd).filter((a): a is number => a != null);
|
||||||
const wins = pnls.filter((p) => p > 0).length;
|
const wins = pnls.filter((p) => p > 0).length;
|
||||||
const losses = pnls.filter((p) => p < 0).length;
|
const losses = pnls.filter((p) => p < 0).length;
|
||||||
const decided = wins + losses;
|
const decided = wins + losses;
|
||||||
@@ -49,6 +50,7 @@ export function MyTradesPanel() {
|
|||||||
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
|
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
|
||||||
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
|
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
|
||||||
totalPnl: pnls.reduce((a, b) => a + b, 0),
|
totalPnl: pnls.reduce((a, b) => a + b, 0),
|
||||||
|
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}, [closed]);
|
}, [closed]);
|
||||||
@@ -64,11 +66,12 @@ export function MyTradesPanel() {
|
|||||||
</Callout>
|
</Callout>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
||||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
||||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
||||||
|
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
@@ -81,6 +84,7 @@ export function MyTradesPanel() {
|
|||||||
<th className="px-4 py-2.5 text-right">Exit</th>
|
<th className="px-4 py-2.5 text-right">Exit</th>
|
||||||
<th className="px-4 py-2.5 text-right">P&L</th>
|
<th className="px-4 py-2.5 text-right">P&L</th>
|
||||||
<th className="px-4 py-2.5 text-right">R</th>
|
<th className="px-4 py-2.5 text-right">R</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Alpha</th>
|
||||||
<th className="px-4 py-2.5 text-right">Closed</th>
|
<th className="px-4 py-2.5 text-right">Closed</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -97,6 +101,7 @@ export function MyTradesPanel() {
|
|||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||||||
|
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
|||||||
<th className="px-4 py-3">Dimensions</th>
|
<th className="px-4 py-3">Dimensions</th>
|
||||||
<th className="px-4 py-3">R:R</th>
|
<th className="px-4 py-3">R:R</th>
|
||||||
<th className="px-4 py-3">Direction</th>
|
<th className="px-4 py-3">Direction</th>
|
||||||
<th className="px-4 py-3">S/R Levels</th>
|
<th className="px-4 py-3">Momentum</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -114,15 +114,9 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
|||||||
<span className="text-gray-500">—</span>
|
<span className="text-gray-500">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5 num text-gray-200">
|
||||||
{entry.sr_levels.length > 0 ? (
|
{entry.momentum_percentile !== null ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
`${Math.round(entry.momentum_percentile)}%ile`
|
||||||
{entry.sr_levels.map((level, i) => (
|
|
||||||
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
||||||
{formatPrice(level.price_level)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">—</span>
|
<span className="text-gray-500">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface WatchlistEntry {
|
|||||||
dimensions: DimensionScore[];
|
dimensions: DimensionScore[];
|
||||||
rr_ratio: number | null;
|
rr_ratio: number | null;
|
||||||
rr_direction: string | null;
|
rr_direction: string | null;
|
||||||
|
momentum_percentile: number | null;
|
||||||
sr_levels: SRLevelSummary[];
|
sr_levels: SRLevelSummary[];
|
||||||
last_close: number | null;
|
last_close: number | null;
|
||||||
change_pct: number | null;
|
change_pct: number | null;
|
||||||
@@ -201,6 +202,9 @@ export interface PaperTrade {
|
|||||||
close_price: number | null;
|
close_price: number | null;
|
||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
|
benchmark_return_pct: number | null;
|
||||||
|
alpha_pct: number | null;
|
||||||
|
alpha_usd: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestBucket {
|
export interface BacktestBucket {
|
||||||
|
|||||||
@@ -100,8 +100,10 @@ export default function DashboardPage() {
|
|||||||
const exposure = useMemo(() => {
|
const exposure = useMemo(() => {
|
||||||
const rows = openTrades.data ?? [];
|
const rows = openTrades.data ?? [];
|
||||||
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
|
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
|
||||||
|
let alphaUsd = 0, alphaPriced = 0;
|
||||||
for (const t of rows) {
|
for (const t of rows) {
|
||||||
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
|
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
|
||||||
|
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
|
||||||
const p = tradePnl(t);
|
const p = tradePnl(t);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
unrealUsd += p.pnl;
|
unrealUsd += p.pnl;
|
||||||
@@ -109,7 +111,7 @@ export default function DashboardPage() {
|
|||||||
if (p.pnl > 0) winners += 1;
|
if (p.pnl > 0) winners += 1;
|
||||||
else if (p.pnl < 0) losers += 1;
|
else if (p.pnl < 0) losers += 1;
|
||||||
}
|
}
|
||||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers };
|
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
|
||||||
}, [openTrades.data]);
|
}, [openTrades.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -141,11 +143,11 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Metric strip */}
|
{/* Metric strip */}
|
||||||
{(trades.isLoading || openTrades.isLoading) ? (
|
{(trades.isLoading || openTrades.isLoading) ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<Metric
|
<Metric
|
||||||
label="Live Setups"
|
label="Live Setups"
|
||||||
value={String(trades.data?.length ?? 0)}
|
value={String(trades.data?.length ?? 0)}
|
||||||
@@ -172,6 +174,16 @@ export default function DashboardPage() {
|
|||||||
: 'mark-to-market'
|
: 'mark-to-market'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Alpha vs S&P 500"
|
||||||
|
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
|
||||||
|
valueClass={
|
||||||
|
exposure.alphaPriced > 0
|
||||||
|
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
: 'text-gray-100'
|
||||||
|
}
|
||||||
|
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Tests for benchmark return / alpha helper (pure, no DB)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.benchmark_service import benchmark_return_pct
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmark_return_basic():
|
||||||
|
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 5): 110.0}
|
||||||
|
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 5)) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmark_return_uses_nearest_prior_trading_day():
|
||||||
|
# No bar on the 4th (weekend) → falls back to the 2nd; as-of the 12th → the 9th.
|
||||||
|
closes = {date(2026, 1, 2): 100.0, date(2026, 1, 9): 120.0}
|
||||||
|
assert benchmark_return_pct(closes, date(2026, 1, 4), date(2026, 1, 12)) == pytest.approx(20.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmark_return_none_when_empty():
|
||||||
|
assert benchmark_return_pct({}, date(2026, 1, 2), date(2026, 1, 5)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmark_return_none_when_open_before_history():
|
||||||
|
closes = {date(2026, 1, 10): 100.0}
|
||||||
|
assert benchmark_return_pct(closes, date(2026, 1, 2), date(2026, 1, 12)) is None
|
||||||
@@ -7,7 +7,9 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.exceptions import ValidationError
|
from app.exceptions import ValidationError
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import paper_trade_service as svc
|
from app.services import paper_trade_service as svc
|
||||||
@@ -124,3 +126,48 @@ async def test_resolve_leaves_open_when_neither_hit(session):
|
|||||||
assert closed == 0
|
assert closed == 0
|
||||||
rows = await svc.list_trades(session, 1, status="open")
|
rows = await svc.list_trades(session, 1, status="open")
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_benchmark(session, points: dict) -> None:
|
||||||
|
for d, close in points.items():
|
||||||
|
session.add(BenchmarkPrice(symbol="SPY", date=d, close=close))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_open_trade(session, ticker_id: int, direction: str, *, entry: float,
|
||||||
|
shares: float, days_ago: int) -> None:
|
||||||
|
session.add(PaperTrade(
|
||||||
|
user_id=1, ticker_id=ticker_id, direction=direction, entry_price=entry,
|
||||||
|
shares=shares, stop_loss=entry * 0.95, target=entry * 1.2, status="open",
|
||||||
|
opened_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alpha_long_open(session):
|
||||||
|
tid = await _seed(session, "AAA", close=110.0) # current price 110 → +10% on a 100 entry
|
||||||
|
today = date.today()
|
||||||
|
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 420.0}) # SPY +5%
|
||||||
|
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
|
||||||
|
|
||||||
|
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||||
|
assert row["benchmark_return_pct"] == pytest.approx(5.0)
|
||||||
|
assert row["alpha_pct"] == pytest.approx(5.0) # +10% trade − 5% bench
|
||||||
|
assert row["alpha_usd"] == pytest.approx(50.0) # 5% of 100*10
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alpha_short_and_missing_benchmark(session):
|
||||||
|
tid = await _seed(session, "BBB", close=90.0) # price fell to 90 → short +10%
|
||||||
|
today = date.today()
|
||||||
|
await _add_open_trade(session, tid, "short", entry=100.0, shares=4, days_ago=10)
|
||||||
|
|
||||||
|
# No benchmark data yet → alpha unset, not an error.
|
||||||
|
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||||
|
assert row["alpha_pct"] is None
|
||||||
|
assert row["benchmark_return_pct"] is None
|
||||||
|
|
||||||
|
# Flat benchmark → alpha equals the (direction-signed) trade return.
|
||||||
|
await _seed_benchmark(session, {today - timedelta(days=10): 400.0, today: 400.0})
|
||||||
|
row = (await svc.list_trades(session, 1, status="open"))[0]
|
||||||
|
assert row["benchmark_return_pct"] == pytest.approx(0.0)
|
||||||
|
assert row["alpha_pct"] == pytest.approx(10.0)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class TestConfigureScheduler:
|
|||||||
assert job_ids == {
|
assert job_ids == {
|
||||||
"data_collector",
|
"data_collector",
|
||||||
"data_backfill",
|
"data_backfill",
|
||||||
|
"benchmark_collector",
|
||||||
"sentiment_collector",
|
"sentiment_collector",
|
||||||
"fundamental_collector",
|
"fundamental_collector",
|
||||||
"rr_scanner",
|
"rr_scanner",
|
||||||
@@ -103,6 +104,7 @@ class TestConfigureScheduler:
|
|||||||
assert sorted(job_ids) == sorted([
|
assert sorted(job_ids) == sorted([
|
||||||
"alerts",
|
"alerts",
|
||||||
"backtest",
|
"backtest",
|
||||||
|
"benchmark_collector",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
"intraday_pipeline",
|
"intraday_pipeline",
|
||||||
"data_collector",
|
"data_collector",
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ async def _make_ticker(session, symbol: str, *, score: float | None = None) -> i
|
|||||||
return t.id
|
return t.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_includes_momentum_percentile(session):
|
||||||
|
"""The watchlist row carries the ticker's momentum percentile (from its setup),
|
||||||
|
which replaces the old S/R-levels column in the UI."""
|
||||||
|
from app.models.trade_setup import TradeSetup
|
||||||
|
|
||||||
|
user_id = await _make_user(session)
|
||||||
|
tid = await _make_ticker(session, "AAA", score=70.0)
|
||||||
|
session.add(TradeSetup(
|
||||||
|
ticker_id=tid, direction="long", entry_price=100.0, stop_loss=95.0,
|
||||||
|
target=110.0, rr_ratio=2.0, composite_score=70.0,
|
||||||
|
momentum_percentile=88.0, detected_at=datetime.now(timezone.utc),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await add_manual_entry(session, user_id, "AAA")
|
||||||
|
rows = await get_watchlist(session, user_id)
|
||||||
|
assert rows[0]["momentum_percentile"] == 88.0
|
||||||
|
|
||||||
|
|
||||||
async def test_add_and_remove_sticks(session):
|
async def test_add_and_remove_sticks(session):
|
||||||
user_id = await _make_user(session)
|
user_id = await _make_user(session)
|
||||||
await _make_ticker(session, "AAA", score=80.0)
|
await _make_ticker(session, "AAA", score=80.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user