Files
autofirmer-expanded/app/page.tsx
T
Brandon LiandClaude Fable 5 50a5eb4713 Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 16:25:52 -05:00

939 lines
46 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 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 {
return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
}
function SettingsIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
function KeyOffIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3 21 2" />
<path d="m17 6 3 3" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`chevron${open ? ' open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
function DetailsIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
);
}
function EyeIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
);
}
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 (
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer select-none transition-colors group"
onClick={() => onSort(col)}
>
<div className="flex items-center gap-1">
<span className={active ? 'text-slate-600' : 'text-slate-400 group-hover:text-slate-500'}>
{label}
</span>
<span className={`text-[10px] ${active ? 'text-slate-500' : 'text-slate-300 group-hover:text-slate-400'}`}>
{active ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
</span>
</div>
</th>
);
}
/** 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 (
<div className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onClick={(e) => e.stopPropagation()}
onChange={(e) => assign(e.target.value)}
className={`border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 max-w-[10rem] ${
account.accountTypeLabel ? 'text-slate-700' : 'text-slate-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 shrink-0" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold shrink-0" title="Several account types matched — using the most recently updated one">*</span>
)}
{danglingPin && (
<span className="text-amber-600 text-xs shrink-0" title="The pinned account type no longer exists on the master — this account will not trade">!</span>
)}
{!account.accountTypeLabel && !danglingPin && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0">
Unassigned
</span>
)}
</div>
);
}
/** 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 (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{isTopstepX ? (
<input
className={`${cls} w-52`}
type="password"
placeholder="TopstepX API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
) : (
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
)}
<button
onClick={save}
disabled={saving || !canSave}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
</button>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
);
}
/** 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 (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 max-w-64 text-right">
Wipe every account&apos;s cached data and refetch? Days older than the 28-day report window are lost for good.
</span>
<button
onClick={wipeAll}
disabled={busy}
className="px-4 py-2 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-sm font-semibold rounded-lg transition-colors whitespace-nowrap"
>
{busy ? 'Refetching…' : 'Wipe all'}
</button>
<button
onClick={() => setConfirming(false)}
disabled={busy}
className="px-4 py-2 bg-white hover:bg-slate-100 disabled:opacity-50 text-slate-600 text-sm font-semibold rounded-lg border border-slate-200 transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{result && <span className="text-xs text-slate-400">{result}</span>}
<button
onClick={() => setConfirming(true)}
className="px-4 py-2 bg-white text-slate-600 border border-slate-200 hover:text-red-600 hover:bg-red-50 hover:border-red-200 text-sm font-semibold rounded-lg transition-colors whitespace-nowrap"
title="Delete every account's cached daily P&L, fund transactions and meta, then rebuild them from fresh Tradovate reports"
>
Wipe & recalc all
</button>
</div>
);
}
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 (
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
{maskName(account.name, privacy)}
</div>
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
<td className={`px-4 py-3 tabular-nums ${pnlColor}`}>
{account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)}
</td>
<td className="px-4 py-3">
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<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>
</div>
{account.dailyTarget && (
<span className="text-xs text-slate-400">
${fmt(account.dailyTarget.amount)} today
{account.nextStop !== null && (
<span className="text-amber-600" title="The next trade's stop — dollars it may lose before booking the day"> · stop ${fmt(account.nextStop)}</span>
)}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-between">
{dead
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
: !account.active
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.personalDll
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
<Link
href={`/accounts/${account.id}`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
title="View details"
onClick={(e) => e.stopPropagation()}
>
<DetailsIcon />
</Link>
</div>
</td>
</tr>
);
}
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 (
<>
<tr
className={`border-t border-slate-200 bg-slate-50 transition-colors ${
hasCredentials ? 'hover:bg-slate-100 cursor-pointer select-none' : 'opacity-50'
}`}
onClick={() => { if (hasCredentials) setOpen((o) => !o); }}
>
<td className="px-4 py-3.5">
<div className="flex items-center gap-2.5">
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.bannedSymbols?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{tradeSymbol} BANNED
</span>
)}
{firm.platform === 'topstepx' && (
<span className="text-xs bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-semibold">
TopstepX
</span>
)}
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
<span
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
title="TopstepX login needs the platform username the API key was generated for — remove and re-save the credentials with it"
>
Username missing
</span>
)}
{hasCredentials && !state.connected && (
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
Connecting…
</span>
)}
{hasCredentials && state.connected && state.accounts.length === 0 && (
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-semibold">
No accounts
</span>
)}
</div>
</td>
{!hasCredentials ? (
<td colSpan={5} className="px-4 py-2.5">
<CredentialForm firm={firm} onSaved={onChanged} />
</td>
) : confirmCreds ? (
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
disabled={removingCreds}
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"
>
{removingCreds ? '…' : 'Remove'}
</button>
<button
onClick={() => setConfirmCreds(false)}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
</td>
) : (
<><td /><td /><td /><td /><td /></>
)}
<td className="px-4 py-3.5">
<div className="flex items-center justify-end gap-1">
{hasCredentials && !confirmCreds && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
>
<KeyOffIcon />
</button>
)}
<Link
href={`/firms/${firm.id}/settings`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
onClick={(e) => e.stopPropagation()}
title="Account types"
>
<SettingsIcon />
</Link>
{hasCredentials && <ChevronIcon open={open} />}
</div>
</td>
</tr>
{open && hasCredentials && (
state.accounts.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
</td>
</tr>
) : (
sortedAccounts.map((acc) => (
<AccountRow
key={acc.id}
account={acc}
firm={firm}
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
/>
))
)
)}
</>
);
}
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<FirmConfig[]>([]);
const [firms, setFirms] = useState<FirmState[]>([]);
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<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
// Trade controls
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
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 (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-slate-900">AutoTrader</h1>
<div className="flex items-center gap-2">
<WipeAllButton />
{deadCount > 0 && (
<button
onClick={() => setHideDead((h) => !h)}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors border ${
hideDead
? 'bg-slate-800 text-white border-slate-800 hover:bg-slate-700'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100'
}`}
>
{hideDead ? `Show Dead (${deadCount})` : `Hide Dead (${deadCount})`}
</button>
)}
<button
onClick={() => setPrivacy((p) => !p)}
className={`px-3 py-2 rounded-lg transition-colors inline-flex items-center ${privacy ? 'text-blue-600 bg-blue-50 hover:bg-blue-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title={privacy ? 'Show account names' : 'Hide account names'}
>
{privacy ? <EyeOffIcon /> : <EyeIcon />}
</button>
<Link
href="/settings"
className="px-3 py-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors inline-flex items-center"
title="Global Settings"
>
<SettingsIcon />
</Link>
</div>
</div>
{/* ── Trade Controls ── */}
<div className="bg-white border border-slate-200 rounded-xl px-4 py-3 mb-4 shadow-sm flex items-center gap-3">
{scheduler.running ? (
<>
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse flex-shrink-0" />
<span className="text-sm font-semibold text-green-700">
{scheduler.symbol} · {scheduler.action}
</span>
<span className="text-xs text-slate-400">every {scheduler.intervalSeconds}s</span>
{scheduler.lastRun && (
<span className="text-xs text-slate-400">
· last run {new Date(scheduler.lastRun).toLocaleTimeString()}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStop}
className="px-4 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
>
■ Stop
</button>
</div>
</>
) : (
<>
<span className="w-2 h-2 rounded-full bg-slate-300 flex-shrink-0" />
<span className="text-sm text-slate-400 font-medium">Idle</span>
<div className="flex items-center gap-2 ml-3">
<select
value={tradeSymbol}
onChange={(e) => setTradeSymbol(e.target.value)}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="Auto">Auto</option>
</select>
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
<button
onClick={() => setTradeAction('Buy')}
className={`px-3 py-1.5 transition-colors ${tradeAction === 'Buy' ? 'bg-green-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Buy
</button>
<button
onClick={() => setTradeAction('Sell')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Sell' ? 'bg-red-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Sell
</button>
<button
onClick={() => setTradeAction('Auto')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Auto' ? 'bg-purple-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Auto
</button>
</div>
<div className="flex items-center gap-1.5 ml-1">
<label className="text-xs text-slate-400 whitespace-nowrap">Every</label>
<input
type="number"
min={5}
max={3600}
value={tickInterval}
onChange={(e) => 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"
/>
<span className="text-xs text-slate-400">s</span>
</div>
</div>
<label className="flex items-center gap-1.5 ml-2 cursor-pointer select-none">
<input
type="checkbox"
checked={stopAfterAll}
onChange={(e) => setStopAfterAll(e.target.checked)}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStart}
disabled={tradeLoading}
className="px-4 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
>
{tradeLoading ? 'Starting…' : '▶ Start'}
</button>
</div>
</>
)}
</div>
<div className="w-full bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<SortHeader label="Account" col="name" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Balance" col="balance" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Day P&L" col="dayPnL" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Type</th>
<SortHeader label="Days Traded" col="daysTraded" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Target" col="target" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Status" col="status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{config.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-slate-400 italic">
{catalog && catalog.lastSyncAt === null ? (
<>
Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'}
{catalog.lastError && <><br />{catalog.lastError}</>}
<br />
<button
onClick={syncCatalogNow}
className="mt-2 px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md not-italic transition-colors"
>
Retry
</button>
</>
) : (
<>No firms yet — add them on the master dashboard</>
)}
</td>
</tr>
) : (
orderedConfig.map((cfg) => {
const state = firms.find((f) => f.firmId === cfg.id)
?? { firmId: cfg.id, firm: cfg.firm, connected: false, accounts: [] };
return (
<FirmRows
key={cfg.id}
state={state}
firm={cfg}
hideDead={hideDead}
privacy={privacy}
sortKey={sortKey}
sortDir={sortDir}
tradeSymbol={tradeSymbol}
onChanged={fetchConfig}
/>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}