diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py index c13e554..dc4e95d 100644 --- a/app/routers/paper_trades.py +++ b/app/routers/paper_trades.py @@ -54,6 +54,17 @@ async def read_exit_policy( return APIEnvelope(status="success", data=await paper_trade_service.get_exit_policy(db)) +@router.get("/paper-trades/equity-curve", response_model=APIEnvelope) +async def paper_trade_equity_curve( + user: User = Depends(require_access), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Daily cumulative P&L of the paper book vs the same dollars riding SPY.""" + return APIEnvelope( + status="success", data=await paper_trade_service.equity_curve(db, user.id) + ) + + @router.put("/paper-trades/exit-policy", response_model=APIEnvelope) async def write_exit_policy( body: ExitPolicyUpdate, diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index d0a20b1..23020ce 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import bisect from datetime import date, datetime, timezone from sqlalchemy import and_, func, select @@ -588,3 +589,119 @@ async def resolve_open_trades(db: AsyncSession) -> int: if closed: await db.commit() return closed + + +# --------------------------------------------------------------------------- +# Equity curve — the paper book's cumulative P&L vs the same dollars in SPY. + + +def _value_on_or_before( + dates_sorted: list[date], closes: dict[date, float], target: date +) -> float | None: + """Close on the nearest trading day at or before ``target`` (None if before history).""" + idx = bisect.bisect_right(dates_sorted, target) - 1 + return closes[dates_sorted[idx]] if idx >= 0 else None + + +def build_equity_curve( + trades: list, + ticker_closes: dict[int, dict[date, float]], + benchmark_closes: dict[date, float], +) -> list[dict]: + """Daily cumulative P&L of the paper book vs a benchmark counterfactual. + + For every benchmark trading day since the first trade opened: + + book_pnl = Σ realized P&L of trades closed by then + + Σ mark-to-market P&L of trades still open (ticker close + on/before that day) + benchmark_pnl = Σ per trade: the SAME cost basis (entry x shares) riding + the benchmark over the SAME window (open → close/now). + Long-benchmark regardless of trade direction — the + question is "what if this money had just sat in SPY". + + Pure function so the math is unit-testable; trades are duck-typed + (ticker_id, direction, entry_price, shares, status, opened_at, closed_at, + close_price). Trades opened before the stored benchmark history contribute + to book_pnl but not to benchmark_pnl (no baseline close to measure from). + """ + if not trades or not benchmark_closes: + return [] + first = min(t.opened_at.date() for t in trades) + bench_dates = sorted(benchmark_closes) + days = [d for d in bench_dates if d >= first] + if not days: + return [] + ticker_dates_sorted = {tid: sorted(c) for tid, c in ticker_closes.items()} + + out: list[dict] = [] + for d in days: + book = 0.0 + bench = 0.0 + any_priced = False + for t in trades: + opened = t.opened_at.date() + if opened > d: + continue + closed_on = ( + t.closed_at.date() + if (t.status == "closed" and t.closed_at is not None) + else None + ) + window_end = min(d, closed_on) if closed_on is not None else d + + if closed_on is not None and closed_on <= d and t.close_price is not None: + ref = float(t.close_price) + else: + closes = ticker_closes.get(t.ticker_id) or {} + ref_val = _value_on_or_before( + ticker_dates_sorted.get(t.ticker_id) or [], closes, d + ) + if ref_val is None: + continue + ref = ref_val + per_share = ( + ref - t.entry_price if t.direction == "long" else t.entry_price - ref + ) + book += per_share * t.shares + any_priced = True + + s0 = _value_on_or_before(bench_dates, benchmark_closes, opened) + s1 = _value_on_or_before(bench_dates, benchmark_closes, window_end) + if s0 and s1: + bench += (t.entry_price * t.shares) * (s1 - s0) / s0 + if any_priced: + out.append( + { + "date": d.isoformat(), + "book_pnl": round(book, 2), + "benchmark_pnl": round(bench, 2), + } + ) + return out + + +async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]: + """Equity-curve series for a user's paper book (empty without benchmark data).""" + trades = ( + (await db.execute(select(PaperTrade).where(PaperTrade.user_id == user_id))) + .scalars() + .all() + ) + if not trades: + return [] + benchmark_closes = await benchmark_service.load_benchmark_closes(db) + if not benchmark_closes: + return [] + first = min(t.opened_at.date() for t in trades) + ticker_ids = {t.ticker_id for t in trades} + rows = await db.execute( + select(OHLCVRecord.ticker_id, OHLCVRecord.date, OHLCVRecord.close).where( + OHLCVRecord.ticker_id.in_(ticker_ids), + OHLCVRecord.date >= first, + ) + ) + ticker_closes: dict[int, dict[date, float]] = {} + for tid, day, close in rows.all(): + ticker_closes.setdefault(tid, {})[day] = float(close) + return build_equity_curve(list(trades), ticker_closes, benchmark_closes) diff --git a/frontend/src/api/paperTrades.ts b/frontend/src/api/paperTrades.ts index e4b17a9..62d2c70 100644 --- a/frontend/src/api/paperTrades.ts +++ b/frontend/src/api/paperTrades.ts @@ -28,6 +28,16 @@ export function createPaperTrade(body: CreatePaperTradeBody) { return apiClient.post('paper-trades', body).then((r) => r.data); } +export interface EquityPoint { + date: string; + book_pnl: number; + benchmark_pnl: number; +} + +export function getEquityCurve() { + return apiClient.get('paper-trades/equity-curve').then((r) => r.data); +} + export function closePaperTrade(id: number, closePrice?: number) { return apiClient .post<{ id: number; status: string }>(`paper-trades/${id}/close`, { diff --git a/frontend/src/components/dashboard/PerfChart.tsx b/frontend/src/components/dashboard/PerfChart.tsx new file mode 100644 index 0000000..df06668 --- /dev/null +++ b/frontend/src/components/dashboard/PerfChart.tsx @@ -0,0 +1,140 @@ +import { useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getEquityCurve } from '../../api/paperTrades'; +import { Section } from '../ui/Section'; + +const W = 760; +const H = 220; +const PAD = { top: 14, right: 84, bottom: 26, left: 56 }; + +function money(v: number): string { + const sign = v > 0 ? '+' : v < 0 ? '−' : ''; + const abs = Math.abs(v); + return `${sign}$${abs >= 10000 ? `${(abs / 1000).toFixed(1)}k` : abs.toFixed(0)}`; +} + +/** Round tick steps to a clean 1/2/5 x 10^n ladder. */ +function niceTicks(lo: number, hi: number, count = 4): number[] { + const span = hi - lo || 1; + const raw = span / count; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw; + const start = Math.ceil(lo / step) * step; + const out: number[] = []; + for (let v = start; v <= hi; v += step) out.push(v); + return out; +} + +/** Paper book vs the same dollars riding SPY — cumulative P&L since first trade. */ +export function PerfChart() { + const curve = useQuery({ queryKey: ['paper-trades', 'equity-curve'], queryFn: getEquityCurve }); + const [hover, setHover] = useState(null); + const svgRef = useRef(null); + + const data = curve.data ?? []; + + const geom = useMemo(() => { + if (data.length < 2) return null; + const values = data.flatMap((p) => [p.book_pnl, p.benchmark_pnl, 0]); + const lo = Math.min(...values); + const hi = Math.max(...values); + const pad = (hi - lo) * 0.08 || 1; + const yLo = lo - pad; + const yHi = hi + pad; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW; + const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH; + const line = (key: 'book_pnl' | 'benchmark_pnl') => + data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' '); + // Month boundaries for the x axis. + const xTicks: { i: number; label: string }[] = []; + let lastMonth = ''; + data.forEach((p, i) => { + const m = p.date.slice(0, 7); + if (m !== lastMonth) { + lastMonth = m; + xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) }); + } + }); + if (xTicks.length > 8) { + const keep = Math.ceil(xTicks.length / 8); + for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1); + } + return { yLo, yHi, plotH, px, py, line, yTicks: niceTicks(yLo, yHi), xTicks }; + }, [data]); + + if (!geom) return null; + const { px, py, line, yTicks, xTicks, plotH } = geom; + + const onMove = (e: React.MouseEvent) => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect) return; + const x = ((e.clientX - rect.left) / rect.width) * W; + const i = Math.round(((x - PAD.left) / (W - PAD.left - PAD.right)) * (data.length - 1)); + setHover(Math.max(0, Math.min(data.length - 1, i))); + }; + + const last = data.length - 1; + const hb = hover !== null ? data[hover] : null; + const fmtDate = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + + return ( +
+
+
+ Book + Same $ in SPY +
+ setHover(null)} + role="img" + aria-label={`Paper book cumulative P&L ${money(data[last].book_pnl)} versus ${money(data[last].benchmark_pnl)} for the same dollars in SPY`} + > + {yTicks.map((t) => ( + + + + {money(t)} + + + ))} + {/* zero baseline slightly stronger when it's inside the plot */} + + {xTicks.map(({ i, label }) => ( + + {label} + + ))} + + + {hover !== null && ( + + + + + + )} + + + + {money(data[last].book_pnl)} + + + {money(data[last].benchmark_pnl)} + + +

+ {hb + ? <>{fmtDate(hb.date)} — book {money(hb.book_pnl)} · SPY {money(hb.benchmark_pnl)} + : <>hover for daily values · realized + mark-to-market, since first paper trade} +

+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0815b3e..ed26e63 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { useTickerNames } from '../hooks/useTickers'; import { Callout } from '../components/ui/Callout'; import { Section } from '../components/ui/Section'; import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel'; +import { PerfChart } from '../components/dashboard/PerfChart'; import { PriceRail, RadarChart, radarAxesFromDimensions } from '../components/charts/horizon'; import type { RadarAxis } from '../components/charts/horizon'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; @@ -67,21 +68,37 @@ interface RadarRow { reason: string | null; } -/** One radar row — compact enough for the half-width column. */ -function RadarSetupRow({ setup, rank, reason, name }: RadarRow & { name?: string }) { +/** One radar row — compact enough for the half-width column; selecting it + * swaps the focus card to this setup. */ +function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarRow & { + name?: string; + selected?: boolean; + onSelect?: () => void; +}) { const qualified = reason === null; const prob = primaryTargetProbability(setup); return (
  • { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect?.(); + } + }} + className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${ + selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]' + } ${qualified ? '' : 'opacity-60'}`} + title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''} · click to focus`} > {rank} e.stopPropagation()} className="block font-medium text-blue-300 transition-colors hover:text-blue-200" > {setup.symbol} @@ -119,19 +136,27 @@ function convictionLabel(action: TradeSetup['recommended_action']): string { return '—'; } -/** The one focal card: today's top qualified setup as a spatial price rail. */ -function FocusCard({ setup, name, gateNote }: { +/** The focal card: a setup as a spatial price rail — the top pick by default, + * or whichever radar row is selected. */ +function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: { setup: TradeSetup; name: string | undefined; - gateNote: string; + badge: string; + badgeTone: 'ember' | 'muted'; + footNote: string; + onReset?: () => void; }) { const prob = primaryTargetProbability(setup); return ( -
    +
    - - top pick + + {badge}
    @@ -185,8 +210,16 @@ function FocusCard({ setup, name, gateNote }: { />
    - cleared the gate · {gateNote} - + {footNote} + + {onReset && ( + + )} (null); + const topWatchlist = useMemo( () => [...(watchlist.data ?? [])] @@ -318,6 +354,13 @@ export default function DashboardPage() { const topPick = topSetups[0]; + // What the focus card shows: the selected radar row, else the top pick. + const focusRow = focusId != null + ? [...radar.qualified, ...radar.below].find((r) => r.setup.id === focusId) ?? null + : null; + const focusSetup = focusRow?.setup ?? topPick; + const focusIsTop = focusRow == null || (topPick != null && focusRow.setup.id === topPick.id); + return (
    {/* Hero — the verdict */} @@ -370,24 +413,129 @@ export default function DashboardPage() { ) )} - {/* Focal setup */} - {(trades.isLoading || activation.isLoading) && } - {trades.isError && Failed to load setups} - {trades.data && activation.data && ( - topPick ? ( - - ) : ( - - No qualified actionable setups right now — the radar below shows what's close and why it doesn't qualify. - - ) - )} + {/* Setup in focus | Radar — the decision pair */} +
    +
    + {(trades.isLoading || activation.isLoading) && } + {trades.isError && Failed to load setups} + {trades.data && activation.data && ( + focusSetup ? ( + setFocusId(null)} + /> + ) : ( + + No qualified actionable setups right now — select a radar row to inspect what's close and why it doesn't qualify. + + ) + )} +
    - {/* Metric strip */} +
    +
    + {trades.isLoading && } + {trades.data && radar.qualified.length === 0 && radar.below.length === 0 && ( + No live setups right now. + )} + {(radar.qualified.length > 0 || radar.below.length > 0) && ( +
    + {/* Qualified fingerprints — same axes, shapes compare at a glance */} + {fingerprints.length > 0 && ( +
    + {fingerprints.map((f) => ( +
    + +
    + + {f.symbol} + + + {f.rank === 1 ? 'top pick' : `rank ${f.rank}`} + +
    +
    + ))} +

    + qualified fingerprints · hover a corner for scores +

    +
    + )} + + {/* Qualified rows — the actionable list */} + {radar.qualified.length > 0 ? ( +
      + {radar.qualified.map((row) => ( + setFocusId(row.setup.id)} + /> + ))} +
    + ) : ( +

    + None clear the gate today — the closest candidates are below. +

    + )} + + {/* Below the gate, collapsed by default when there are qualified setups */} + {radar.below.length > 0 && ( + <> + + {showBelow && ( +
      + {radar.below.map((row) => ( + setFocusId(row.setup.id)} + /> + ))} +
    + )} + + )} + +
    + + All setups → + +
    +
    + )} +
    +
    +
    + + {/* Metric strip — the account ribbons, right above the positions they describe */} {(trades.isLoading || openTrades.isLoading) ? (
    @@ -428,85 +576,11 @@ export default function DashboardPage() {
    )} - {/* Open positions | Radar — side by side like the mockup */} -
    - - -
    - {trades.isLoading && } - {trades.data && radar.qualified.length === 0 && radar.below.length === 0 && ( - No live setups right now. - )} - {(radar.qualified.length > 0 || radar.below.length > 0) && ( -
    - {/* Qualified fingerprints — same axes, shapes compare at a glance */} - {fingerprints.length > 0 && ( -
    - {fingerprints.map((f) => ( -
    - -
    - - {f.symbol} - - - {f.rank === 1 ? 'top pick' : `rank ${f.rank}`} - -
    -
    - ))} -

    - qualified fingerprints · hover a corner for scores -

    -
    - )} - - {/* Qualified rows — the actionable list */} - {radar.qualified.length > 0 ? ( -
      - {radar.qualified.map((row) => ( - - ))} -
    - ) : ( -

    - None clear the gate today — the closest candidates are below. -

    - )} - - {/* Below the gate, collapsed by default when there are qualified setups */} - {radar.below.length > 0 && ( - <> - - {showBelow && ( -
      - {radar.below.map((row) => ( - - ))} -
    - )} - - )} - -
    - - All setups → - -
    -
    - )} -
    -
    + {/* Open positions — full width, right under their ribbons */} + + {/* Performance — the paper book vs the same dollars in SPY */} +
    ); } diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 7db8ba3..40519dc 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/charts/horizon.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/tickersearch.tsx","./src/components/layout/topbar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/designhorizonpage.tsx","./src/pages/designmockupspage.tsx","./src/pages/designorbitpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/charts/horizon.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/dashboard/perfchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/tickersearch.tsx","./src/components/layout/topbar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/designhorizonpage.tsx","./src/pages/designmockupspage.tsx","./src/pages/designorbitpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/tests/unit/test_equity_curve.py b/tests/unit/test_equity_curve.py new file mode 100644 index 0000000..42c5422 --- /dev/null +++ b/tests/unit/test_equity_curve.py @@ -0,0 +1,76 @@ +"""Unit tests for the paper-book equity curve math (pure function).""" + +from datetime import date, datetime +from types import SimpleNamespace + +from app.services.paper_trade_service import build_equity_curve + + +def _trade(**kw): + defaults = dict( + ticker_id=1, + direction="long", + entry_price=100.0, + shares=10.0, + status="open", + opened_at=datetime(2026, 1, 5, 15, 0), + closed_at=None, + close_price=None, + ) + defaults.update(kw) + return SimpleNamespace(**defaults) + + +BENCH = { + date(2026, 1, 5): 500.0, + date(2026, 1, 6): 505.0, + date(2026, 1, 7): 510.0, +} + + +def test_open_long_marks_to_market_vs_benchmark(): + trades = [_trade()] + ticker_closes = {1: {date(2026, 1, 5): 100.0, date(2026, 1, 6): 104.0, date(2026, 1, 7): 106.0}} + curve = build_equity_curve(trades, ticker_closes, BENCH) + + assert [p["date"] for p in curve] == ["2026-01-05", "2026-01-06", "2026-01-07"] + # Day 3: book +6 * 10 shares; benchmark: 1000 basis * (510-500)/500 = +20 + assert curve[-1]["book_pnl"] == 60.0 + assert curve[-1]["benchmark_pnl"] == 20.0 + + +def test_closed_trade_freezes_both_legs_at_close_date(): + trades = [ + _trade( + status="closed", + closed_at=datetime(2026, 1, 6, 21, 0), + close_price=104.0, + ) + ] + # Ticker keeps rising after the close — must NOT affect the curve. + ticker_closes = {1: {date(2026, 1, 5): 100.0, date(2026, 1, 6): 104.0, date(2026, 1, 7): 999.0}} + curve = build_equity_curve(trades, ticker_closes, BENCH) + + # Realized +4 * 10 from close date onward. + assert curve[-1]["book_pnl"] == 40.0 + # Benchmark leg also freezes at the close date: (505-500)/500 * 1000 = +10. + assert curve[-1]["benchmark_pnl"] == 10.0 + + +def test_short_direction_and_missing_ticker_prices(): + trades = [ + _trade(direction="short"), + _trade(ticker_id=2), # no price history — contributes nothing + ] + ticker_closes = {1: {date(2026, 1, 5): 100.0, date(2026, 1, 6): 96.0, date(2026, 1, 7): 90.0}} + curve = build_equity_curve(trades, ticker_closes, BENCH) + + # Short: entry 100 → 90 = +10/share * 10 shares. + assert curve[-1]["book_pnl"] == 100.0 + # Benchmark counterfactual is long-SPY for the priced trade only. + assert curve[-1]["benchmark_pnl"] == 20.0 + + +def test_empty_without_trades_or_benchmark(): + assert build_equity_curve([], {}, BENCH) == [] + assert build_equity_curve([_trade()], {}, {}) == []