'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 (
);
}
// 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
Date
Type
Amount
{[
...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) => (
{row.date}
{row.type === 'W/D' ? (
W/D
) : (
Trade
)}
= 0 ? 'text-green-600' : 'text-red-500'}`}>
{row.amount >= 0 ? '+' : '−'}${fmt(Math.abs(row.amount))}
))}
)}
);
}