Files
autofirmer-expanded/app/accounts/[id]/page.tsx
T
SenofyandClaude Sonnet 4.6 570a54fe60 Fix fetchDaysTraded to use reports API with bearer auth and correct daysTraded counting
- Replace fill/ldeps and fill/list approaches with Tradovate reports API
- Add bearer auth to getreport polling (root cause of prior 404s)
- Use endDate = tomorrow to ensure current-session fills are included
- Count all traded days when minDayPnL is 0, otherwise count days >= minDayPnL
- Add PATCH /api/debug endpoint for proxying raw Tradovate API calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:57:12 -05:00

519 lines
26 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';
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('');
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 (
<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>
);
}
// dailyPnL comes from state — same source as dailyTarget, no separate fetch needed.
const dailyPnL = account.dailyPnL;
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;
// 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 = dailyPnL.length > 0 ? Math.max(...dailyPnL.filter(d => d.pnl > 0).map(d => d.pnl)) : 0;
const consistencyTarget = cfg && cfg.consistency > 0 && maxDayPnL > 0
? Math.round(maxDayPnL / cfg.consistency * 100) / 100
: null;
// 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, 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(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)}`}
/>
)}
{dailyTarget != null && (
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
<span className="text-slate-500 text-sm">Next Trading Day Amount</span>
<span className="text-slate-800 font-bold tabular-nums text-sm">
${fmt(dailyTarget.amount)}
</span>
</div>
)}
</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',
}}
/>
)}
{consistencyTarget != null && (
<ReferenceLine
y={consistencyTarget}
stroke="#6366f1"
strokeWidth={1.5}
strokeDasharray="4 3"
label={{
value: `$${fmt(consistencyTarget)} consistency`,
position: 'insideTopLeft',
fontSize: 11,
fontWeight: 600,
fill: '#4f46e5',
}}
/>
)}
{/* 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>
);
}