Files
autofirmer-expanded/app/accounts/[id]/page.tsx
T
SenofyandClaude Sonnet 4.6 dd18f91584 Add full Next.js autotrader app with SQLite persistence and live Tradovate data
- 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>
2026-03-08 15:03:21 -05:00

494 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}