- SQLite DB (better-sqlite3) with firms, account_configs, firm_fees, instruments tables - REST API routes: firms CRUD, account configs CRUD, state, accounts, instruments - Live Tradovate WebSocket client: login, sync, positions, auto-liq thresholds - Dashboard (app/page.tsx): per-firm account list with balance, day P&L, days traded, target progress, and Dead/Inactive/Flat status based on Tradovate auto-liq floors - Account detail page: objectives progress, daily P&L chart, consistency tracking - Per-firm settings page: account configs and instrument fee management - Dead detection uses trailingMaxDrawdownLimit - trailingMaxDrawdown from userAccountAutoLiqs; filters Tradovate sentinel value (999999999 = no limit) - FIFO P&L engine with commission accounting for daily P&L history - Removed manual maxLoss fallback in favour of live Tradovate auto-liq data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
494 lines
24 KiB
TypeScript
494 lines
24 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } 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';
|
||
|
||
interface DailyPnL {
|
||
date: string;
|
||
pnl: number;
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||
<span className="text-slate-500 text-sm">{label}</span>
|
||
<div className="flex items-center gap-2">
|
||
{badge != null && (
|
||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${positive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||
{badge}
|
||
</span>
|
||
)}
|
||
<span className="text-slate-800 font-bold tabular-nums">{value}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ObjRow({ passed, label, value }: { passed: boolean; label: string; value: string }) {
|
||
return (
|
||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||
<div className="flex items-center gap-2.5">
|
||
{passed
|
||
? <span className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">✓</span>
|
||
: <span className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">!</span>
|
||
}
|
||
<span className="text-slate-700 text-sm">{label}</span>
|
||
</div>
|
||
<span className="text-slate-800 font-bold tabular-nums text-sm">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="bg-white border border-slate-200 rounded-lg shadow-sm px-3 py-2.5 text-sm min-w-[120px]">
|
||
<p className="text-slate-400 text-xs mb-1">{label}</p>
|
||
<p className={`font-bold tabular-nums ${equity >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||
{equity >= 0 ? '+' : ''}${fmt(equity)}
|
||
</p>
|
||
<p className={`text-xs tabular-nums mt-0.5 ${daily >= 0 ? 'text-green-500' : 'text-red-400'}`}>
|
||
{daily >= 0 ? '▲' : '▼'} ${fmt(Math.abs(daily))} day
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 }: { year: number; month: number; pnlMap: Map<string, number> }) {
|
||
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 (
|
||
<div>
|
||
{/* Month header */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-slate-700 font-semibold text-sm">
|
||
{MONTH_NAMES[month - 1]} {year}
|
||
</span>
|
||
{monthTotal !== 0 && (
|
||
<span className={`text-sm font-bold tabular-nums ${monthTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||
{monthTotal >= 0 ? '+' : '−'}${fmt(Math.abs(monthTotal))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Day-of-week headers */}
|
||
<div className="grid grid-cols-7 mb-1">
|
||
{DOW_LABELS.map((d) => (
|
||
<div key={d} className="text-center text-[11px] font-medium text-slate-400 py-1">{d}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day cells */}
|
||
<div className="grid grid-cols-7 gap-1">
|
||
{cells.map((day, i) => {
|
||
if (day === null) return <div key={`empty-${i}`} />;
|
||
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
const pnl = pnlMap.get(key);
|
||
const hasData = pnl !== undefined;
|
||
const positive = hasData && pnl! >= 0;
|
||
return (
|
||
<div
|
||
key={key}
|
||
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col ${
|
||
hasData
|
||
? positive
|
||
? 'bg-green-50 border border-green-100'
|
||
: 'bg-red-50 border border-red-100'
|
||
: 'bg-slate-50 border border-transparent'
|
||
}`}
|
||
>
|
||
<span className={`text-[11px] font-medium leading-none mb-1.5 ${
|
||
hasData ? (positive ? 'text-green-700' : 'text-red-600') : 'text-slate-400'
|
||
}`}>
|
||
{day}
|
||
</span>
|
||
{hasData && (
|
||
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
|
||
positive ? 'text-green-700' : 'text-red-600'
|
||
}`}>
|
||
{positive ? '+' : '−'}${fmt(Math.abs(pnl!))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AccountPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const accountId = Number(id);
|
||
|
||
const [account, setAccount] = useState<AccountState | null>(null);
|
||
const [cfg, setCfg] = useState<AccountConfig | null>(null);
|
||
const [firmName, setFirmName] = useState('');
|
||
const [dailyPnL, setDailyPnL] = useState<DailyPnL[]>([]);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const [stateRes, firmsRes, dailyRes] = await Promise.all([
|
||
fetch('/api/state'),
|
||
fetch('/api/firms'),
|
||
fetch(`/api/accounts/${accountId}/daily-pnl`),
|
||
]);
|
||
const states: FirmState[] = await stateRes.json();
|
||
const firms: FirmConfig[] = await firmsRes.json();
|
||
const daily: DailyPnL[] = await dailyRes.json();
|
||
|
||
setDailyPnL(daily);
|
||
|
||
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 (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">← Back</Link>
|
||
</div>
|
||
<p className="text-slate-400 italic text-sm">Loading…</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 >= cfg.profitTarget;
|
||
const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL);
|
||
|
||
// Build equity curve: FIFO daily increments, origin at $0
|
||
let running = 0;
|
||
const equityData = [
|
||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0 },
|
||
...dailyPnL.map((d) => {
|
||
running += d.pnl;
|
||
const equity = Math.round(running * 100) / 100;
|
||
return {
|
||
label: fmtDate(d.date),
|
||
equity,
|
||
pnl: d.pnl,
|
||
pos: Math.max(0, equity), // above-zero portion for green fill
|
||
neg: Math.min(0, equity), // below-zero portion for red fill
|
||
};
|
||
}),
|
||
];
|
||
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);
|
||
|
||
// 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(dailyPnL.map((d) => [d.date, d.pnl]));
|
||
const calendarMonths = [...new Set(dailyPnL.map((d) => d.date.slice(0, 7)))].sort();
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">
|
||
← Back
|
||
</Link>
|
||
<span className="text-slate-300">/</span>
|
||
<span className="text-slate-400 text-sm">{firmName}</span>
|
||
<span className="text-slate-300">/</span>
|
||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||
{isDead && (
|
||
<span className="ml-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-slate-900 text-white tracking-wide">
|
||
☠ DEAD
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{isDead && (
|
||
<div className="mb-5 flex items-center gap-3 bg-slate-900 text-white rounded-xl px-5 py-4">
|
||
<span className="text-2xl leading-none">☠</span>
|
||
<div>
|
||
<p className="font-bold text-sm">Account Blown</p>
|
||
<p className="text-slate-300 text-xs mt-0.5">
|
||
Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor
|
||
{liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-5 mb-5">
|
||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||
<h2 className="text-slate-900 font-bold text-base mb-1">Overall Performance</h2>
|
||
<PerfRow label="Account Balance" value={`$${fmt(account.amount)}`} />
|
||
<PerfRow
|
||
label="Total Profit"
|
||
value={`${totalProfit >= 0 ? '+' : ''}$${fmt(totalProfit)}`}
|
||
badge={profitPct != null ? `${profitPct >= 0 ? '↑' : '↓'} ${Math.abs(profitPct).toFixed(1)}%` : undefined}
|
||
positive={profitPct != null && profitPct >= 0}
|
||
/>
|
||
<PerfRow label="Trading Days" value={account.daysTraded} />
|
||
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
|
||
</div>
|
||
|
||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||
<h2 className="text-slate-900 font-bold text-base mb-1">Objectives</h2>
|
||
<ObjRow
|
||
passed={profitPassed}
|
||
label="Profit Target"
|
||
value={cfg ? `$${fmt(totalProfit)} of $${fmt(cfg.profitTarget)}` : '—'}
|
||
/>
|
||
<ObjRow
|
||
passed={daysPassed}
|
||
label="Trading Days"
|
||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||
/>
|
||
{hasLossLimit && (
|
||
<ObjRow
|
||
passed={lossPassed}
|
||
label="Daily Loss Limit"
|
||
value={`$${fmt(cfg!.minDayPnL)}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Equity Curve */}
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mb-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-slate-900 font-bold text-base">Equity Curve</h2>
|
||
{dailyPnL.length > 0 && (
|
||
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
|
||
{isPositive ? '+' : ''}${fmt(totalProfit)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{dailyPnL.length === 0 ? (
|
||
<p className="text-slate-400 italic text-sm text-center py-8">No trading history available</p>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={260}>
|
||
<AreaChart data={equityData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||
<defs>
|
||
{/* Positive fill: opaque at peak, fades to transparent at zero */}
|
||
<linearGradient id="greenFill" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.20} />
|
||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.02} />
|
||
</linearGradient>
|
||
{/* Negative fill: transparent at zero, opaque at trough */}
|
||
<linearGradient id="redFill" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.02} />
|
||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.20} />
|
||
</linearGradient>
|
||
{/* Stroke: green above zero, red below */}
|
||
<linearGradient id={strokeGradId} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#22c55e" />
|
||
<stop offset={zeroPct} stopColor="#22c55e" />
|
||
<stop offset={zeroPct} stopColor="#ef4444" />
|
||
<stop offset="100%" stopColor="#ef4444" />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||
<XAxis
|
||
dataKey="label"
|
||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
/>
|
||
<YAxis
|
||
domain={[
|
||
() => 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}
|
||
/>
|
||
<Tooltip
|
||
content={<EquityTooltip />}
|
||
cursor={{ stroke: '#e2e8f0', strokeWidth: 1 }}
|
||
/>
|
||
<ReferenceLine y={0} stroke="#e2e8f0" strokeWidth={1.5} />
|
||
{cfg?.profitTarget != null && (
|
||
<ReferenceLine
|
||
y={cfg.profitTarget}
|
||
stroke="#f59e0b"
|
||
strokeWidth={1.5}
|
||
strokeDasharray="6 3"
|
||
label={{
|
||
value: `$${fmt(cfg.profitTarget)} target`,
|
||
position: 'insideTopRight',
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
fill: '#d97706',
|
||
}}
|
||
/>
|
||
)}
|
||
{/* Green fill: positive equity only, fills down to y=0 */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="pos"
|
||
baseValue={0}
|
||
stroke="none"
|
||
fill="url(#greenFill)"
|
||
dot={false}
|
||
activeDot={false}
|
||
legendType="none"
|
||
tooltipType="none"
|
||
/>
|
||
{/* Red fill: negative equity only, fills up to y=0 */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="neg"
|
||
baseValue={0}
|
||
stroke="none"
|
||
fill="url(#redFill)"
|
||
dot={false}
|
||
activeDot={false}
|
||
legendType="none"
|
||
tooltipType="none"
|
||
/>
|
||
{/* Equity line: stroke-only, green above zero / red below */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="equity"
|
||
baseValue={0}
|
||
stroke={`url(#${strokeGradId})`}
|
||
strokeWidth={2.5}
|
||
fill="none"
|
||
dot={(props: any) => {
|
||
if (props.payload?.origin) return <g key={props.key} />;
|
||
return (
|
||
<Dot
|
||
key={props.key}
|
||
cx={props.cx}
|
||
cy={props.cy}
|
||
r={3.5}
|
||
fill={props.payload.pnl >= 0 ? '#22c55e' : '#ef4444'}
|
||
stroke="white"
|
||
strokeWidth={1.5}
|
||
/>
|
||
);
|
||
}}
|
||
activeDot={{ r: 5, fill: '#64748b', stroke: 'white', strokeWidth: 2 }}
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
|
||
{/* Daily P&L Calendar */}
|
||
{calendarMonths.length > 0 && (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||
<h2 className="text-slate-900 font-bold text-base mb-6">Daily P&L</h2>
|
||
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
||
{calendarMonths.map((ym) => {
|
||
const [y, m] = ym.split('-').map(Number);
|
||
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} />;
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|