Files
autofirmer-expanded/app/accounts/[id]/page.tsx
T
Brandon LiandClaude Fable 5 fc65b4340d Reconstruct TopstepX MLL floors from the EOD balance curve
TopstepX never reports the Maximum Loss Limit, but it's deterministic:
floor = min(0, eodBalanceHigh − trailingDrawdown), locking at the $0
starting balance. The EOD curve rebuilds from daily P&L + fund events,
with off-book fee drift spread across days proportionally to reported
fees (validated against a platform-reported MLL to ~$0.10). Stored in
autoLiqThresholds — negative, or +$0.01 once locked — so dead detection,
dashboards, and the ruin model's startThreshold all use the real room.
Guards move from '> 0' to '!== 0'; 0 still means no floor. canTrade=false
accounts are skipped. daily_pnl gains a fees column for the weighting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 13:57:06 -05:00

887 lines
45 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 { useCallback, 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';
import { stagePillClasses } from '@/lib/stage-colors';
import { projectPerfectPath } from '@/lib/trading-logic';
/** Reassign this account to another of its firm's account types, or hand it
* back to the auto-resolver. Mirrors the control on the dashboard. */
function AccountTypePicker({ account, firm, onAssigned }: {
account: AccountState;
firm: FirmConfig;
onAssigned: () => void;
}) {
const [saving, setSaving] = useState(false);
async function assign(accountTypeId: string) {
setSaving(true);
try {
if (accountTypeId === '') {
await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' });
} else {
await fetch(`/api/accounts/${account.id}/account-type`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountTypeId }),
});
}
onAssigned();
} finally {
setSaving(false);
}
}
return (
<span className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onChange={(e) => assign(e.target.value)}
className="border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
>
<option value="">
{account.accountTypeLabel ?? 'Unassigned'}
</option>
{firm.accounts.map((type) => (
<option key={type.id} value={type.id}>
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
</option>
))}
</select>
{account.assignmentSource === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold" title="Several account types matched — using the most recently updated one">*</span>
)}
{!account.accountTypeLabel && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700">
Unassigned
</span>
)}
</span>
);
}
function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Wipes the account's cached P&L / fund / meta rows and refetches from
* Tradovate. Confirmed inline because rows older than the report's 28-day
* window cannot come back. */
function WipeDataButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function wipe() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/accounts/${accountId}/wipe`, { method: 'POST' });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? 'Wipe failed');
if (body.fetchError) setError(`Wiped, but refetch: ${body.fetchError}`);
setConfirming(false);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : 'Wipe failed');
} finally {
setBusy(false);
}
}
if (confirming) {
return (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-slate-500">
Wipe cached data and refetch? Days older than the 28-day report window are lost for good.
</span>
<button
onClick={wipe}
disabled={busy}
className="px-2.5 py-1 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
{busy ? 'Refetching…' : 'Wipe & recalculate'}
</button>
<button
onClick={() => { setConfirming(false); setError(''); }}
disabled={busy}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="ml-auto flex items-center gap-2">
{error && <span className="text-xs text-red-500">{error}</span>}
<button
onClick={() => setConfirming(true)}
className="px-2.5 py-1 text-xs font-semibold text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Delete this account's cached daily P&L, fund transactions and meta, then rebuild them from a fresh Tradovate report"
>
Wipe & recalculate
</button>
</div>
);
}
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: ReactNode; 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, fundMap, stageMap, projPnlMap, projFundMap, projStageMap, projProbMap, payoutDate, payoutLabel }: {
year: number;
month: number;
pnlMap: Map<string, number>;
fundMap: Map<string, number>;
stageMap: Map<string, number>;
/** Perfect-path projection — rendered as dashed outlines on days with no real data. */
projPnlMap?: Map<string, number>;
projFundMap?: Map<string, number>;
projStageMap?: Map<string, number>;
/** Cumulative probability of reaching each projected day (fair-walk ruin model). */
projProbMap?: Map<string, number>;
payoutDate?: string;
/** "Payout ✓" for payoutable types; "Passed ✓" for evals that just pass. */
payoutLabel?: string;
}) {
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 fundAmt = fundMap.get(key);
const hasData = pnl !== undefined;
const positive = hasData && pnl! >= 0;
// Projection only fills days with no real data. Potential
// days render as dashed OUTLINES — no fill.
const projPnl = !hasData && fundAmt === undefined ? projPnlMap?.get(key) : undefined;
const projFund = !hasData && fundAmt === undefined ? projFundMap?.get(key) : undefined;
const isPayout = !hasData && key === payoutDate;
const isProj = projPnl !== undefined || projFund !== undefined || isPayout;
const projProb = isProj ? projProbMap?.get(key) : undefined;
const stage = stageMap.get(key) ?? (isProj ? projStageMap?.get(key) : undefined);
return (
<div
key={key}
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col relative ${
hasData
? positive
? 'bg-green-50 border border-green-100'
: 'bg-red-50 border border-red-100'
: fundAmt !== undefined
? 'bg-amber-50 border border-amber-100'
: isPayout
? 'border border-dashed border-blue-300'
: projFund !== undefined
? 'border border-dashed border-amber-300'
: projPnl !== undefined
? 'border border-dashed border-green-300'
: 'bg-slate-50 border border-transparent'
}`}
>
<div className="flex items-start justify-between">
<span className={`text-[11px] font-medium leading-none ${
hasData ? (positive ? 'text-green-700' : 'text-red-600') : fundAmt !== undefined ? 'text-amber-700' : 'text-slate-400'
}`}>
{day}
</span>
{stage !== undefined && (
<span className={`text-[8px] font-semibold leading-none ${isProj ? 'text-slate-300' : 'text-slate-400'}`}>
#{stage}
</span>
)}
</div>
{hasData && (
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
positive ? 'text-green-700' : 'text-red-600'
}`}>
{positive ? '+' : '−'}${fmt(Math.abs(pnl!))}
</span>
)}
{fundAmt !== undefined && (
<span className="text-[10px] font-semibold tabular-nums leading-tight mt-0.5 text-amber-600">
W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))}
</span>
)}
{projPnl !== undefined && (
<span className="text-[11px] font-semibold tabular-nums leading-tight text-green-600/70">
+${fmt(projPnl)}
</span>
)}
{projFund !== undefined && (
<span className="text-[10px] font-semibold tabular-nums leading-tight mt-0.5 text-amber-500/80">
W/D −${fmt(Math.abs(projFund))}
</span>
)}
{isPayout && (
<span className="text-[10px] font-bold leading-tight mt-0.5 text-blue-600/80">
{payoutLabel ?? 'Payout ✓'}
</span>
)}
{projProb !== undefined && (
<span
className="text-[8px] font-medium tabular-nums leading-tight mt-0.5 text-slate-400"
title="Probability of the perfect path reaching this day (fair-walk ruin model, cumulative)"
>
{Math.round(projProb * 100)}%
</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 [firm, setFirm] = useState<FirmConfig | null>(null);
const load = useCallback(async () => {
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) {
// Join on the master's firm id — the name changes the
// moment someone renames a firm there.
const firmCfg = firms.find((f) => f.id === firmState.firmId);
setAccount(acc);
setFirmName(firmState.firm);
setFirm(firmCfg ?? null);
setCfg(acc.accountType);
break;
}
}
}, [accountId]);
useEffect(() => {
void load();
const interval = setInterval(() => { void load(); }, 5000);
return () => clearInterval(interval);
}, [load]);
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 = 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 the liquidation floor — Tradovate reports its
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
// +$0.01 once locked at breakeven. 0 = no floor known.
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<string, EquityEvent>();
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';
// Perfect-path projection: what the future looks like if every trading day
// books exactly its plan, cycles complete, and the configured withdrawal
// fires the day a cycle finishes. Same planner the trade path runs, so the
// numbers (bumps and drain amounts included) are what would actually be
// aimed for. Shown up to TWO payouts ahead.
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
accountId: account.id,
cfg,
cycleDailyPnL: account.dailyPnL ?? [],
fundTransactions,
balance: account.amount,
realizedPnL: account.realizedPnL,
maxWithdrawals: 2,
payout: {
withdrawalPct: cfg.withdrawalPct,
payoutCap: cfg.payoutCap,
profitSplit: cfg.profitSplit,
// Same default chain the reporter uses for reach probabilities.
drawdown: (cfg.trailingDrawdown ?? 0) > 0
? cfg.trailingDrawdown!
: Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000,
// The platform's real liquidation floor, when one is known —
// reported by Tradovate, reconstructed for TopstepX.
startThreshold: account.autoLiqThreshold !== 0
? Math.round((account.autoLiqThreshold - cfg.accountSize) * 100) / 100
: undefined,
},
}) : [];
// Cut the projection at the second payout — days beyond it are noise here.
const secondPayoutIdx = (() => {
let seen = 0;
for (let i = 0; i < rawProjection.length; i++) {
const p = rawProjection[i];
if (p.withdrawal !== undefined || p.payoutReady) {
seen++;
if (seen >= 2 || p.payoutReady) return i;
}
}
return -1;
})();
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
// A payout day is the cycle's FINAL trading day — it can carry both the
// day's booked P&L and the withdrawal.
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
/** Cumulative probability of the perfect path surviving through each projected day. */
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
const payoutDate = projection.find((p) => p.payoutReady)?.date;
// 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),
...projection.map((p) => p.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<string, number>();
for (const d of displayPnL) {
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
stageMap.set(d.date, stage);
}
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>
{firm && (
<AccountTypePicker
account={account}
firm={firm}
onAssigned={() => { void load(); }}
/>
)}
<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>
)}
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
</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={
<span className="inline-flex items-center gap-1.5">
Profit Target
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
</span>
</span>
}
value={cfg ? `$${fmt(totalProfit)} of $${fmt(account.effectiveProfitTarget ?? 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>
{displayPnL.length > 0 && (
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
{isPositive ? '+' : ''}${fmt(totalProfit)}
</span>
)}
</div>
{displayPnL.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',
}}
/>
)}
{/* Vertical lines at fund transaction dates */}
{withdrawalLabels.map((lbl) => (
<ReferenceLine
key={`wd-${lbl}`}
x={lbl}
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="4 3"
label={{
value: 'W/D',
position: 'insideBottomRight',
fontSize: 10,
fontWeight: 700,
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">
<div className="flex items-baseline justify-between mb-6">
<h2 className="text-slate-900 font-bold text-base">Daily P&L</h2>
{projection.length > 0 && (
<span className="text-xs text-slate-400">
Dashed outlines: the perfect path, two payouts ahead — planned targets daily, the projected withdrawal on each cycle&apos;s final day, % = odds of reaching that day
</span>
)}
</div>
<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} fundMap={fundMap} stageMap={stageMap}
projPnlMap={projPnlMap} projFundMap={projFundMap}
projStageMap={projStageMap} projProbMap={projProbMap}
payoutDate={payoutDate}
payoutLabel={cfg?.payoutable === true ? 'Payout ✓' : 'Passed ✓'}
/>
);
})}
</div>
</div>
)}
{/* Cash History Table */}
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
<h2 className="text-slate-900 font-bold text-base mb-4">Cash History</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left text-slate-400 font-medium py-2 pr-4">Date</th>
<th className="text-left text-slate-400 font-medium py-2 pr-4">Type</th>
<th className="text-right text-slate-400 font-medium py-2">Amount</th>
</tr>
</thead>
<tbody>
{[
...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) => (
<tr key={i} className="border-b border-slate-50 last:border-0">
<td className="py-2.5 pr-4 text-slate-500 tabular-nums">{row.date}</td>
<td className="py-2.5 pr-4">
{row.type === 'W/D' ? (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-amber-100 text-amber-700">W/D</span>
) : (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-slate-100 text-slate-500">Trade</span>
)}
</td>
<td className={`py-2.5 text-right font-bold tabular-nums ${row.amount >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{row.amount >= 0 ? '+' : '−'}${fmt(Math.abs(row.amount))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}