'use client'; import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, Dot, } from 'recharts'; import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; import { stagePillClasses } from '@/lib/stage-colors'; import { projectPerfectPath } from '@/lib/trading-logic'; import { RulesDate } from '@/app/components/RulesDate'; import { TradesTable } from '@/app/components/TradesTable'; /** Reassign this account to another of its firm's account types, or hand it * back to the auto-resolver. Mirrors the control on the dashboard. */ function AccountTypePicker({ account, firm, onAssigned }: { account: AccountState; firm: FirmConfig; onAssigned: () => void; }) { const [saving, setSaving] = useState(false); async function assign(accountTypeId: string) { setSaving(true); try { if (accountTypeId === '') { await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' }); } else { await fetch(`/api/accounts/${account.id}/account-type`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountTypeId }), }); } onAssigned(); } finally { setSaving(false); } } return ( {account.assignmentSource === 'manual' && ( )} {account.accountTypeAmbiguous && ( * )} {!account.accountTypeLabel && ( Unassigned )} ); } function fmt(value: number) { return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** Claim now: the account's whole profit above its size, to the firm's main * account (Vest's Claim Profit) — for payout accounts on a firm that takes * claims by API. Confirmed inline, since it moves money. */ function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) { type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean }; const [info, setInfo] = useState(null); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const check = useCallback(async () => { try { const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' }); if (res.ok) setInfo(await res.json()); } catch { /* keep the last answer */ } }, [accountId]); useEffect(() => { void check(); const t = setInterval(() => { void check(); }, 10_000); return () => clearInterval(t); }, [check]); async function claim(amount: number) { setBusy(true); setMessage(null); try { const res = await fetch(`/api/accounts/${accountId}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount }), }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); setMessage({ ok: true, text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`, }); onDone(); } catch (err) { setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' }); } finally { setBusy(false); setConfirming(false); void check(); } } if (!info || (info.claimable === null && info.unsupported)) return null; if (confirming && info.claimable !== null) { const amount = info.claimable; return (
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
); } return (
{message && {message.text}} {info.claimable !== null ? ( ) : ( Claim now — {info.reason} )}
); } /** Wipes the account's cached P&L / fund / meta rows and refetches from * Tradovate. Confirmed inline because rows older than the report's 28-day * window cannot come back. */ function WipeDataButton({ accountId, onDone }: { accountId: number; onDone: () => void }) { const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); async function wipe() { setBusy(true); setError(''); try { const res = await fetch(`/api/accounts/${accountId}/wipe`, { method: 'POST' }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error ?? 'Wipe failed'); if (body.fetchError) setError(`Wiped, but refetch: ${body.fetchError}`); setConfirming(false); onDone(); } catch (err) { setError(err instanceof Error ? err.message : 'Wipe failed'); } finally { setBusy(false); } } if (confirming) { return (
Wipe cached data and refetch? Days older than the 28-day report window are lost for good.
); } return (
{error && {error}}
); } function fmtDate(iso: string) { const [, m, d] = iso.split('-'); return `${parseInt(m)}/${parseInt(d)}`; } function PerfRow({ label, value, badge, positive }: { label: string; value: string | number; badge?: string; positive?: boolean }) { return (
{label}
{badge != null && ( {badge} )} {value}
); } function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; value: string }) { return (
{passed ? ✓ : ! } {label}
{value}
); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function EquityTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; // Always read from the raw data point so fill-only series don't interfere const equity: number = payload[0].payload.equity; const daily: number = payload[0].payload.pnl; return (

{label}

= 0 ? 'text-green-600' : 'text-red-500'}`}> {equity >= 0 ? '+' : ''}${fmt(equity)}

= 0 ? 'text-green-500' : 'text-red-400'}`}> {daily >= 0 ? '▲' : '▼'} ${fmt(Math.abs(daily))} day

); } const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function CalendarMonth({ year, month, pnlMap, fundMap, stageMap, projPnlMap, projFundMap, projStageMap, projProbMap, payoutDate, payoutLabel }: { year: number; month: number; pnlMap: Map; fundMap: Map; stageMap: Map; /** Perfect-path projection — rendered as dashed outlines on days with no real data. */ projPnlMap?: Map; projFundMap?: Map; projStageMap?: Map; /** Cumulative probability of reaching each projected day (fair-walk ruin model). */ projProbMap?: Map; payoutDate?: string; /** "Payout ✓" for payoutable types; "Passed ✓" for evals that just pass. */ payoutLabel?: string; }) { const daysInMonth = new Date(year, month, 0).getDate(); const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday // Monthly total from only the days that have data let monthTotal = 0; for (let d = 1; d <= daysInMonth; d++) { const key = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const pnl = pnlMap.get(key); if (pnl !== undefined) monthTotal += pnl; } monthTotal = Math.round(monthTotal * 100) / 100; // Build flat cell array: null = empty leading cell, number = day of month const cells: (number | null)[] = [ ...Array.from({ length: firstDow }, () => null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1), ]; return (
{/* Month header */}
{MONTH_NAMES[month - 1]} {year} {monthTotal !== 0 && ( = 0 ? 'text-green-600' : 'text-red-500'}`}> {monthTotal >= 0 ? '+' : '−'}${fmt(Math.abs(monthTotal))} )}
{/* Day-of-week headers */}
{DOW_LABELS.map((d) => (
{d}
))}
{/* Day cells */}
{cells.map((day, i) => { if (day === null) return
; const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const pnl = pnlMap.get(key); const fundAmt = fundMap.get(key); const hasData = pnl !== undefined; const positive = hasData && pnl! >= 0; // Projection only fills days with no real data. Potential // days render as dashed OUTLINES — no fill. const projPnl = !hasData && fundAmt === undefined ? projPnlMap?.get(key) : undefined; const projFund = !hasData && fundAmt === undefined ? projFundMap?.get(key) : undefined; const isPayout = !hasData && key === payoutDate; const isProj = projPnl !== undefined || projFund !== undefined || isPayout; const projProb = isProj ? projProbMap?.get(key) : undefined; const stage = stageMap.get(key) ?? (isProj ? projStageMap?.get(key) : undefined); return (
{day} {stage !== undefined && ( #{stage} )}
{hasData && ( {positive ? '+' : '−'}${fmt(Math.abs(pnl!))} )} {fundAmt !== undefined && ( W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))} )} {projPnl !== undefined && ( +${fmt(projPnl)} )} {projFund !== undefined && ( W/D −${fmt(Math.abs(projFund))} )} {isPayout && ( {payoutLabel ?? 'Payout ✓'} )} {projProb !== undefined && ( {Math.round(projProb * 100)}% )}
); })}
); } export default function AccountPage() { const { id } = useParams<{ id: string }>(); const accountId = Number(id); const [account, setAccount] = useState(null); const [cfg, setCfg] = useState(null); const [firmName, setFirmName] = useState(''); const [firm, setFirm] = useState(null); const load = useCallback(async () => { const [stateRes, firmsRes] = await Promise.all([ fetch('/api/state'), fetch('/api/firms'), ]); const states: FirmState[] = await stateRes.json(); const firms: FirmConfig[] = await firmsRes.json(); for (const firmState of states) { const acc = firmState.accounts.find((a) => a.id === accountId); if (acc) { // Join on the master's firm id — the name changes the // moment someone renames a firm there. const firmCfg = firms.find((f) => f.id === firmState.firmId); setAccount(acc); setFirmName(firmState.firm); setFirm(firmCfg ?? null); setCfg(acc.accountType); break; } } }, [accountId]); useEffect(() => { // The first load from a timer too, like the polls: not setState // from the effect's own body. const first = setTimeout(() => { void load(); }, 0); const interval = setInterval(() => { void load(); }, 5000); return () => { clearTimeout(first); clearInterval(interval); }; }, [load]); if (!account) { return (
← Back

Loading…

); } // dailyPnL = current cycle (for targets); fullDailyPnL = all history (for display) const dailyPnL = account.dailyPnL; const displayPnL = account.fullDailyPnL ?? account.dailyPnL; const fundTransactions = account.fundTransactions ?? []; const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount])); // The firm's hard daily loss limit, and the least a day must make to count // as a trading day — two different numbers; neither is set on every type. // The limit and the min days are the current stage's, which may set its own. const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0; const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null; const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null; const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0; const daysPassed = cfg != null && account.daysTraded >= minTradingDays; // Dead when balance hits the liquidation floor — Tradovate reports its // (positive) floor; TopstepX floors are reconstructed MLLs, negative or // +$0.01 once locked at breakeven. 0 = no floor known. const isDead = account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold; const liqFloor = account.autoLiqThreshold !== 0 ? account.autoLiqThreshold : null; // Ground-truth profit = balance minus funded account size. // Falls back to FIFO total when accountSize is unavailable. const fifoTotal = dailyPnL.reduce((s, d) => s + d.pnl, 0); const totalProfit = cfg?.accountSize ? Math.round((account.amount - cfg.accountSize) * 100) / 100 : Math.round(fifoTotal * 100) / 100; const profitPct = cfg?.accountSize ? (totalProfit / cfg.accountSize) * 100 : null; const profitPassed = cfg != null && totalProfit >= (account.effectiveProfitTarget ?? cfg.profitTarget); // dailyTarget is computed server-side in the state API — single source of truth. const dailyTarget = account.dailyTarget ?? null; // Consistency target: the total profit level at which the best day no longer // violates the consistency ratio. Only meaningful once a positive day exists. const maxDayPnL = displayPnL.length > 0 ? Math.max(...displayPnL.filter(d => d.pnl > 0).map(d => d.pnl)) : 0; const consistencyTarget = cfg && cfg.consistency > 0 && cfg.consistency < 1 && maxDayPnL > 0 ? Math.round(maxDayPnL / cfg.consistency * 100) / 100 : null; // Build equity curve: FIFO daily increments + withdrawal step-downs, origin at $0 // Merge daily P&L and fund transactions into a single sorted timeline type EquityEvent = { date: string; pnl?: number; fundAmt?: number }; const allDates = new Set([...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)]); const eventsByDate = new Map(); for (const d of displayPnL) eventsByDate.set(d.date, { date: d.date, pnl: d.pnl }); for (const f of fundTransactions) { const existing = eventsByDate.get(f.date); eventsByDate.set(f.date, { ...existing, date: f.date, fundAmt: f.amount }); } const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!); // Running equity after each event, summed here rather than inside the // map below: a render callback mustn't reassign an outer variable. const runningAfter: number[] = []; let running = 0; for (const ev of sortedEvents) { if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity if (ev.pnl !== undefined) running += ev.pnl; runningAfter.push(running); } const equityData = [ { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false }, ...sortedEvents.map((ev, i) => { const equity = Math.round(runningAfter[i] * 100) / 100; return { label: fmtDate(ev.date), equity, pnl: ev.pnl ?? 0, pos: Math.max(0, equity), neg: Math.min(0, equity), isWithdrawal: ev.fundAmt !== undefined, fundAmt: ev.fundAmt, date: ev.date, }; }), ]; // Dates with fund transactions that appear in the chart (for vertical reference lines) const withdrawalLabels = equityData.filter((d) => d.isWithdrawal).map((d) => d.label); const isPositive = totalProfit >= 0; // Equity range const equityValues = equityData.map((d) => d.equity); // Y-axis domain: include profit target so its reference line stays visible const rawMin = Math.min(0, ...equityValues); const rawMax = Math.max(0, ...equityValues, cfg?.profitTarget ?? 0, consistencyTarget ?? 0); // Stroke gradient split: use only the actual equity range, NOT the profit target. // The SVG gradient bounding box is the line's bbox, so inflating by profitTarget // would shift the green→red transition away from y=0. const gradMax = Math.max(0, ...equityValues); const gradMin = rawMin; // rawMin never includes profitTarget const gradRange = gradMax - gradMin; const zeroFraction = gradRange > 0 ? gradMax / gradRange : 0.5; const zeroPct = `${(Math.max(0, Math.min(1, zeroFraction)) * 100).toFixed(2)}%`; const strokeGradId = 'equityStroke'; // Perfect-path projection: what the future looks like if every trading day // books exactly its plan, cycles complete, and the configured withdrawal // fires the day a cycle finishes. Same planner the trade path runs, so the // numbers (bumps and drain amounts included) are what would actually be // aimed for. Shown up to TWO payout days ahead. const rawProjection = (cfg && !isDead) ? projectPerfectPath({ accountId: account.id, cfg, cycleDailyPnL: account.dailyPnL ?? [], fundTransactions, balance: account.amount, realizedPnL: account.realizedPnL, // A type whose stages reset each day: its state stage counts today's payouts. stageInputs: { stagesSameDay: cfg.stagesSameDay === true, sameDayWithdrawals: account.stage - 1 }, // Such a type pays out once per stage on each of those days. maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2, payout: { withdrawalPct: cfg.withdrawalPct, payoutCap: cfg.payoutCap, profitSplit: cfg.profitSplit, // Same default chain the reporter uses for reach probabilities. drawdown: (cfg.trailingDrawdown ?? 0) > 0 ? cfg.trailingDrawdown! : Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000, // The platform's real liquidation floor, when one is known — // reported by Tradovate, reconstructed for TopstepX. startThreshold: account.autoLiqThreshold !== 0 ? Math.round((account.autoLiqThreshold - cfg.accountSize) * 100) / 100 : undefined, }, }) : []; // Cut the projection after the second payout day — days beyond it are // noise here. The path's dates never go back, and a payoutReady entry is // always its last. const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))]; const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection; // A payout day is the cycle's FINAL trading day — it can carry both the // day's booked P&L and the withdrawal. A same-day type's day can hold // several cycles: their P&L and withdrawals add up, and the day shows // the last stage it traded. const projPnlMap = new Map(); const projFundMap = new Map(); for (const p of projection) { if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100); if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100); } const projStageMap = new Map(projection.map((p) => [p.date, p.stage])); /** Cumulative probability of the perfect path surviving through each projected day. */ const projProbMap = new Map(projection.map((p) => [p.date, p.prob])); const payoutDate = projection.find((p) => p.payoutReady)?.date; // Build calendar data const pnlMap = new Map(displayPnL.map((d) => [d.date, d.pnl])); const allCalendarDates = [ ...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date), ...projection.map((p) => p.date), ]; const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort(); // Build stage map: stage increments after each withdrawal (negative fund txn). // A type whose stages reset each day starts every day on stage 1 and // trades its next stage after a payout that day, as the projected days // show — one fund row a day holds all of that day's payouts, so a day // with one reads stage 2. const sortedWithdrawalDates = fundTransactions .filter((f) => f.amount < 0) .map((f) => f.date) .sort(); const stageMap = new Map(); for (const d of displayPnL) { const stage = cfg?.stagesSameDay === true ? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1 : sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1; stageMap.set(d.date, stage); } return (
← Back / {firmName} {firm && ( { void load(); }} /> )} { void load(); }} /> /

{account.name}

{isDead && ( ☠ DEAD )}
{ void load(); }} /> { void load(); }} />
{isDead && (
☠

Account Blown

Balance ${fmt(account.amount)} has breached the auto-liquidation floor {liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.

)}

Overall Performance

= 0 ? '+' : ''}$${fmt(totalProfit)}`} badge={profitPct != null ? `${profitPct >= 0 ? '↑' : '↓'} ${Math.abs(profitPct).toFixed(1)}%` : undefined} positive={profitPct != null && profitPct >= 0} />

Objectives

Profit Target Stage {account.stage}{account.stageDrain ? ' – Drain' : ''} } value={cfg ? `$${fmt(totalProfit)} of $${fmt(account.effectiveProfitTarget ?? cfg.profitTarget)}` : '—'} /> {dailyTarget != null && (
Next Trading Day Amount ${fmt(dailyTarget.amount)}
)}
{/* Equity Curve */}

Equity Curve

{displayPnL.length > 0 && ( {isPositive ? '+' : ''}${fmt(totalProfit)} )}
{displayPnL.length === 0 ? (

No trading history available

) : ( {/* Positive fill: opaque at peak, fades to transparent at zero */} {/* Negative fill: transparent at zero, opaque at trough */} {/* Stroke: green above zero, red below */} rawMin, () => rawMax, ]} tickFormatter={(v) => { const abs = Math.abs(v); const sign = v < 0 ? '-' : ''; return abs >= 1000 ? `${sign}$${(abs / 1000).toFixed(1)}k` : `${sign}$${v}`; }} tick={{ fontSize: 11, fill: '#94a3b8' }} axisLine={false} tickLine={false} width={60} /> } cursor={{ stroke: '#e2e8f0', strokeWidth: 1 }} /> {cfg?.profitTarget != null && ( )} {consistencyTarget != null && ( )} {/* Vertical lines at fund transaction dates */} {withdrawalLabels.map((lbl) => ( ))} {/* Green fill: positive equity only, fills down to y=0 */} {/* Red fill: negative equity only, fills up to y=0 */} {/* Equity line: stroke-only, green above zero / red below */} { if (props.payload?.origin) return ; return ( = 0 ? '#22c55e' : '#ef4444'} stroke="white" strokeWidth={1.5} /> ); }} activeDot={{ r: 5, fill: '#64748b', stroke: 'white', strokeWidth: 2 }} /> )}
{/* Daily P&L Calendar */} {calendarMonths.length > 0 && (

Daily P&L

{projection.length > 0 && ( Dashed outlines: the perfect path, two payouts ahead — planned targets daily, the projected withdrawal on each cycle's final day, % = odds of reaching that day )}
{calendarMonths.map((ym) => { const [y, m] = ym.split('-').map(Number); return ( ); })}
)} {/* Cash History Table */} {(displayPnL.length > 0 || fundTransactions.length > 0) && (

Cash History

{[ ...displayPnL.map((d) => ({ date: d.date, type: 'Trade' as const, amount: d.pnl })), ...fundTransactions.map((f) => ({ date: f.date, type: 'W/D' as const, amount: f.amount })), ] .sort((a, b) => b.date.localeCompare(a.date)) .map((row, i) => ( ))}
Date Type Amount
{row.date} {row.type === 'W/D' ? ( W/D ) : ( Trade )} = 0 ? 'text-green-600' : 'text-red-500'}`}> {row.amount >= 0 ? '+' : '−'}${fmt(Math.abs(row.amount))}
)}
); }