'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import type { FirmConfig, FirmState, AccountState } from '@/types'; import { stagePillClasses } from '@/lib/stage-colors'; type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status'; type SortDir = 'asc' | 'desc'; function statusRank(account: AccountState): number { if (isAccountDead(account)) return 0; if (!account.active) return 1; if (!account.positionDirection) { if (account.targetHit) return 4; // Target Hit — most accomplished return 2; // Flat } return 3; // In Trade } function fmt(value: number) { return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */ function isAccountDead(account: AccountState): boolean { // 0 = no floor; negative floors are reconstructed TopstepX MLLs. return account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold; } function SettingsIcon() { return ( ); } function KeyOffIcon() { return ( ); } function ChevronIcon({ open }: { open: boolean }) { return ( ); } function DetailsIcon() { return ( ); } function EyeIcon() { return ( ); } function EyeOffIcon() { return ( ); } function maskName(name: string, privacy: boolean): string { if (!privacy) return name; const visible = name.slice(0, 5); const hidden = name.slice(5); return hidden.length > 0 ? visible + '•'.repeat(hidden.length) : visible; } function SortHeader({ label, col, sortKey, sortDir, onSort }: { label: string; col: SortKey; sortKey: SortKey | null; sortDir: SortDir; onSort: (col: SortKey) => void; }) { const active = sortKey === col; return ( onSort(col)} >
{label} {active ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
); } /** The account-type override. "Auto" hands the account back to the resolver; * anything else pins it, and a pin survives every later sync. */ function AccountTypeCell({ 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); } } // A pinned type that no longer exists on the master: keep the pin, but say // so, because the account has silently stopped trading. const danglingPin = account.assignmentSource === 'unassigned' && account.accountTypeId !== null; return (
{account.assignmentSource === 'manual' && ( )} {account.accountTypeAmbiguous && ( * )} {danglingPin && ( ! )} {!account.accountTypeLabel && !danglingPin && ( Unassigned )}
); } /** Inline credential entry, shown to the right of a firm that has none. * Tradovate wants username + password; TopstepX wants the API key (username * optional for now, though login needs it eventually — password unused). */ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { const isTopstepX = firm.platform === 'topstepx'; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [apiKey, setApiKey] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const canSave = isTopstepX ? apiKey.trim().length > 0 : username.trim().length > 0 && password.trim().length > 0; async function save() { if (!canSave) return; setSaving(true); setError(''); try { const res = await fetch(`/api/firms/${firm.id}/credentials`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(isTopstepX ? { username, apiKey } : { username, password }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed'); setUsername(''); setPassword(''); setApiKey(''); onSaved(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed'); } finally { setSaving(false); } } const cls = 'border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-32'; return (
e.stopPropagation()}> setUsername(e.target.value)} /> {isTopstepX ? ( setApiKey(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} /> ) : ( setPassword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} /> )} {error && {error}}
); } /** Bulk form of the detail page's wipe: drops every account's cached rows on * every firm and refetches them all. One report per account, so the busy state * can run for minutes on a big roster. Same 28-day-window caveat, so the * confirm step spells it out. */ function WipeAllButton() { const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [result, setResult] = useState(''); async function wipeAll() { setBusy(true); setResult(''); try { const res = await fetch('/api/accounts/wipe-all', { method: 'POST' }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error ?? 'Wipe failed'); setResult(body.failedAccounts > 0 ? `Rebuilt ${body.wiped}, ${body.failedAccounts} refetch failure(s)` : `Rebuilt ${body.wiped} account(s)`); // No refresh hook needed — the dashboard polls /api/state every 5s. setConfirming(false); } catch (err) { setResult(err instanceof Error ? err.message : 'Wipe failed'); } finally { setBusy(false); } } if (confirming) { return (
Wipe every account's cached data and refetch? Days older than the 28-day report window are lost for good.
); } return (
{result && {result}}
); } function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { account: AccountState; firm: FirmConfig; hideDead: boolean; privacy: boolean; onAssigned: () => void; }) { const cfg = account.accountType; const dead = isAccountDead(account); if (hideDead && dead) return null; const pnlColor = account.realizedPnL > 0 ? 'text-green-600 font-medium' : account.realizedPnL < 0 ? 'text-red-600 font-medium' : 'text-slate-400'; return (
{maskName(account.name, privacy)}
${fmt(account.amount)} {account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)} {account.daysTraded} / {cfg?.minTradingDays ?? '—'}
${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
{account.dailyTarget && ( ${fmt(account.dailyTarget.amount)} today {account.nextStop !== null && ( · stop ${fmt(account.nextStop)} )} )}
{dead ? Dead : !account.active ? Hit DLL : account.positionDirection ? {account.positionDirection === 'long' ? 'Long' : 'Short'} : account.personalDll ? Personal DLL : account.targetHit ? Target Hit : Flat} e.stopPropagation()} >
); } function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: { state: FirmState; firm: FirmConfig; hideDead: boolean; privacy: boolean; sortKey: SortKey | null; sortDir: SortDir; tradeSymbol: string; onChanged: () => void; }) { const [open, setOpen] = useState(true); const [confirmCreds, setConfirmCreds] = useState(false); const [removingCreds, setRemovingCreds] = useState(false); const hasCredentials = firm.hasCredentials; // Clearing the login drops the Tradovate client, so the firm's accounts go // with it until someone enters one again — worth a confirm step. async function removeCredentials() { setRemovingCreds(true); try { await fetch(`/api/firms/${firm.id}/credentials`, { method: 'DELETE' }); onChanged(); } catch { /* the row simply stays as it was */ } finally { setRemovingCreds(false); setConfirmCreds(false); } } const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => { const dir = sortDir === 'asc' ? 1 : -1; switch (sortKey) { case 'name': return dir * a.name.localeCompare(b.name); case 'balance': return dir * (a.amount - b.amount); case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL); case 'daysTraded': return dir * (a.daysTraded - b.daysTraded); case 'target': { const at = a.effectiveProfitTarget ?? a.accountType?.profitTarget ?? 0; const bt = b.effectiveProfitTarget ?? b.accountType?.profitTarget ?? 0; return dir * (at - bt); } case 'status': return dir * (statusRank(a) - statusRank(b)); default: return 0; } }); return ( <> { if (hasCredentials) setOpen((o) => !o); }} >
{firm.firm} {firm.bannedSymbols?.includes(tradeSymbol) && ( {tradeSymbol} BANNED )} {firm.platform === 'topstepx' && ( TopstepX )} {firm.platform === 'topstepx' && hasCredentials && !firm.username && ( Username missing )} {hasCredentials && !state.connected && ( Connecting… )} {hasCredentials && state.connected && state.accounts.length === 0 && ( No accounts )}
{!hasCredentials ? ( ) : confirmCreds ? (
e.stopPropagation()}> Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
) : ( <> )}
{hasCredentials && !confirmCreds && ( )} e.stopPropagation()} title="Account types" > {hasCredentials && }
{open && hasCredentials && ( state.accounts.length === 0 ? ( {state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'} ) : ( sortedAccounts.map((acc) => ( )) ) )} ); } interface SchedulerStatus { running: boolean; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; lastRun: string | null; intervalSeconds: number; stopAfterAll: boolean; } export default function Home() { const [config, setConfig] = useState([]); const [firms, setFirms] = useState([]); const [catalog, setCatalog] = useState<{ lastSyncAt: string | null; lastError: string | null; masterUrl: string | null } | null>(null); const [hideDead, setHideDead] = useState(false); const [privacy, setPrivacy] = useState(false); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState('asc'); // Trade controls const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false }); const [enabledSymbols, setEnabledSymbols] = useState([]); const [tradeSymbol, setTradeSymbol] = useState(''); const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy'); const [tickInterval, setTickInterval] = useState('60'); const [tradeLoading, setTradeLoading] = useState(false); const [stopAfterAll, setStopAfterAll] = useState(false); const handleSort = (col: SortKey) => { if (sortKey === col) { setSortDir((d) => d === 'asc' ? 'desc' : 'asc'); } else { setSortKey(col); setSortDir('asc'); } }; const fetchConfig = async () => { try { const res = await fetch('/api/firms'); if (res.ok) { const data: FirmConfig[] = await res.json(); setConfig(data); } } catch { // server not running yet } }; const fetchCatalog = async () => { try { const res = await fetch('/api/firm-catalog'); if (res.ok) setCatalog(await res.json()); } catch { /* server not running yet */ } }; const syncCatalogNow = async () => { try { await fetch('/api/firm-catalog', { method: 'POST' }); } catch { /* ignore */ } fetchCatalog(); fetchConfig(); }; const fetchScheduler = async () => { try { const res = await fetch('/api/auto-trade'); if (res.ok) setScheduler(await res.json()); } catch { /* ignore */ } }; useEffect(() => { fetch('/api/instruments') .then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>) .then((instruments) => { const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol); setEnabledSymbols(syms); setTradeSymbol((prev) => prev || syms[0] || 'NQ'); }) .catch(() => {}); fetch('/api/settings') .then((r) => r.json()) .then((s: { tick_interval_seconds?: string | null }) => { if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds); }) .catch(() => {}); fetchConfig(); fetchCatalog(); fetchScheduler(); const fetchState = async () => { try { const res = await fetch('/api/state'); if (res.ok) { const data: FirmState[] = await res.json(); setFirms(data); } } catch { // server not running yet } }; fetchState(); const stateInterval = setInterval(fetchState, 5000); const schedulerInterval = setInterval(fetchScheduler, 5000); return () => { clearInterval(stateInterval); clearInterval(schedulerInterval); }; }, []); const handleStart = async () => { setTradeLoading(true); try { await fetch('/api/trade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol, stopAfterAll }), }); await fetchScheduler(); } finally { setTradeLoading(false); } }; const handleStop = async () => { await fetch('/api/auto-trade', { method: 'DELETE' }); setScheduler((s) => ({ ...s, running: false, lastRun: null })); }; const hasAnyPosition = firms.some(f => f.accounts.some(a => a.positionDirection !== null)); const [copyLoading, setCopyLoading] = useState(false); const handleCopy = async () => { setCopyLoading(true); try { await fetch('/api/copy-trade', { method: 'POST' }); } finally { setCopyLoading(false); } }; // A firm with no stored login can do nothing until someone enters one, so it // sits below the ones that are actually running rather than interleaving by // name. Within each group, catalog order (by name) is preserved. const orderedConfig = [...config].sort((a, b) => Number(b.hasCredentials) - Number(a.hasCredentials) || a.firm.localeCompare(b.firm)); // Count dead accounts across all firms for the toggle button label const deadCount = firms.reduce((total, firmState) => { const firmCfg = config.find((c) => c.id === firmState.firmId); if (!firmCfg) return total; return total + firmState.accounts.filter((acc) => isAccountDead(acc)).length; }, 0); return (

AutoTrader

{deadCount > 0 && ( )}
{/* ── Trade Controls ── */}
{scheduler.running ? ( <> {scheduler.symbol} · {scheduler.action} every {scheduler.intervalSeconds}s {scheduler.lastRun && ( · last run {new Date(scheduler.lastRun).toLocaleTimeString()} )}
{hasAnyPosition && ( )}
) : ( <> Idle
setTickInterval(e.target.value)} onBlur={() => { const secs = Math.max(5, parseInt(tickInterval, 10) || 60); setTickInterval(String(secs)); fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tick_interval_seconds: secs }), }).catch(() => {}); }} className="w-16 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" /> s
{hasAnyPosition && ( )}
)}
{config.length === 0 ? ( ) : ( orderedConfig.map((cfg) => { const state = firms.find((f) => f.firmId === cfg.id) ?? { firmId: cfg.id, firm: cfg.firm, connected: false, accounts: [] }; return ( ); }) )}
Account Type
{catalog && catalog.lastSyncAt === null ? ( <> Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'} {catalog.lastError && <>
{catalog.lastError}}
) : ( <>No firms yet — add them on the master dashboard )}
); }