When the master changes an account type with "New accounts only", it now serves the type's old rules as kept rule sets (firm.keptAccountRules), each for accounts dated inside [openedAt, closedAt). This build applies them: - getAccountConfig, the one rules lookup trading reads, puts a kept set's rule fields over the live type when the account's rules date falls in its window. Id, label, prefix, size and the projection fields stay the live type's, so every report still names the live type. - account_assignments gains rules_date and rules_date_manual. A new row is dated when the machine first sees the account, on the master's clock when this one runs behind and never before the catalog's last change. Auto dates come down to the broker's evidence: the end of the first fund day in Chicago, or a DXtrade registration instant. Evidence is applied before an account's first trade. Rows from before the migration get its time; unpinning keeps the row and its date. Backups carry both columns. - An account on a kept set stays on its type while its prefix and cached size still match, so a tie-break flip can't move it off its old rules. - Kept entries parse through the same normalizer as live types (now normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the sync like a malformed type. The cached catalog records the parser that stored it, and a cache from an older parser is fetched in full rather than trusted with ?since= (this also brings in maxConcurrentAccounts). - Reports add keptRuleIds per firm and live counts per kept set per type. - The dashboard row and the account page show the rules date next to the account name, with an "old rules" chip saying what differs. Clicking the date edits it inline (PUT /api/accounts/:id/rules-date), previewing which rules the date lands on. Also: the master's per-firm cap on accounts traded at once (maxConcurrentAccounts), applied on top of the machine's own max concurrent when a batch is drawn and in copy trading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
885 lines
46 KiB
TypeScript
885 lines
46 KiB
TypeScript
'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';
|
||
import { RulesDate } from '@/app/components/RulesDate';
|
||
|
||
/** 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]));
|
||
|
||
// The firm's hard daily loss limit, and the least a day must make to count
|
||
// as a trading day — two different numbers; neither is set on every type.
|
||
const dailyLossLimit = (cfg?.dailyLossLimit ?? 0) > 0 ? cfg!.dailyLossLimit! : null;
|
||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||
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;
|
||
|
||
// 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(); }}
|
||
/>
|
||
)}
|
||
<RulesDate account={account} firm={firm} onSaved={() => { 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 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={dailyLossLimit !== null ? `$${fmt(dailyLossLimit)}` : 'None'} />
|
||
<PerfRow label="Min Day P&L" value={minDayPnL !== null ? `$${fmt(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}` : '—'}
|
||
/>
|
||
{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'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>
|
||
);
|
||
}
|