'use client'; import { 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'; function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined { return [...firm.accounts] .sort((a, b) => b.prefix.length - a.prefix.length) .find((a) => name.startsWith(a.prefix)); } function fmt(value: number) { return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } 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}
); } 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 }: { year: number; month: number; pnlMap: Map; fundMap: Map; stageMap: Map }) { 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 stage = stageMap.get(key); const hasData = pnl !== undefined; const positive = hasData && pnl! >= 0; return (
{day} {stage !== undefined && ( #{stage} )}
{hasData && ( {positive ? '+' : '−'}${fmt(Math.abs(pnl!))} )} {fundAmt !== undefined && ( W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))} )}
); })}
); } 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(''); useEffect(() => { async function load() { 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) { const firmCfg = firms.find((f) => f.firm === firmState.firm); setAccount(acc); setFirmName(firmState.firm); if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null); break; } } } load(); const interval = setInterval(load, 5000); return () => clearInterval(interval); }, [accountId]); 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])); const hasLossLimit = cfg != null && cfg.minDayPnL !== -999; const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays; // Dead when balance hits Tradovate's auto-liquidation floor 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; const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL); // 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)!); let running = 0; const equityData = [ { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false }, ...sortedEvents.map((ev) => { if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity if (ev.pnl !== undefined) running += ev.pnl; const equity = Math.round(running * 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'; // 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)]; const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort(); // Build stage map: stage increments after each withdrawal (negative fund txn) const sortedWithdrawalDates = fundTransactions .filter((f) => f.amount < 0) .map((f) => f.date) .sort(); const stageMap = new Map(); for (const d of displayPnL) { const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1; stageMap.set(d.date, stage); } return (
← Back / {firmName} /

{account.name}

{isDead && ( ☠ DEAD )}
{isDead && (

Account Blown

Balance ${fmt(account.amount)} has breached the Tradovate 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} } value={cfg ? `$${fmt(totalProfit)} of $${fmt(account.effectiveProfitTarget ?? cfg.profitTarget)}` : '—'} /> {hasLossLimit && ( )} {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

{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))}
)}
); }