Files
autofirmer-expanded/app/page.tsx
T
Brandon LiandClaude Opus 5.5 5eb0ea7760 Broker sessions rest while nothing needs them
A firm's client used to stay logged in around the clock, renewing on a fixed
timer overnight and at weekends. Now, once a firm has nothing open, nothing
pending and nothing left to trade before its next session for a random
15–45 minutes, its client disconnects and the dashboard shows it Resting.
It logs back in a random 20–50 minutes before the next session opens, at a
check-in every 3–7 Central daytime hours, or at once when trading starts, a
manual leg, copy trade, manual order or AutoBuyer run needs it. Token
renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX
every 45–150 min). Settings → Trading → Rest idle broker sessions
(session_rest) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:28:47 -05:00

995 lines
51 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';
import { RulesDate } from '@/app/components/RulesDate';
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
type SortDir = 'asc' | 'desc';
function statusRank(account: AccountState): number {
if (isAccountDead(account)) return 0;
if (account.completed) return 5; // Completed — finished its cycle today
if (!account.active) return 1;
if (!account.positionDirection) {
if (account.targetHit) return 4; // Target Hit — next to Completed
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 (
<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>
);
}
/** What a firm's saved login is called in the dashboard's prompts. */
function credentialNoun(platform: FirmConfig['platform']): string {
switch (platform) {
case 'topstepx': return 'TopstepX API key';
case 'dxtrade': return 'DXtrade login';
case 'tradovate': return 'Tradovate login';
}
}
/** Inline credential entry, shown to the right of a firm that has none.
* Tradovate and DXtrade want 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)' : firm.platform === 'dxtrade' ? 'DXtrade username' : '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, onStateChanged }: {
account: AccountState;
firm: FirmConfig;
hideDead: boolean;
privacy: boolean;
onAssigned: () => void;
/** Refetches /api/state — after a rules date is saved. */
onStateChanged: () => 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 pl-4">
<span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
</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 of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
)}
{account.lastStand && (
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</span>
)}
{typeof account.dllRoom === 'number' && (
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
)}
{account.holdEstimate && (
<span
className="text-slate-500"
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional, ${account.holdEstimate.multiplier}× the account`
+ (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}× max)` : '')
+ ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week`
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')
+ (account.holdEstimate.winProbabilityNoFees !== null ? ` (~${Math.round(account.holdEstimate.winProbabilityNoFees * 100)}% with no commission, spread or swap)` : '')
+ (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')
+ `. Expected cost ~$${fmt(account.holdEstimate.cost)} (spread, commissions, swap) — the cheapest candidate that reaches the target hold`}
>
{' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`}
</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.completed
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-700" title="Finished its cycle today — target, consistency and min days met">Completed</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 (obfuscation or daily-loss) — trading resumes 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, onStateChanged }: {
state: FirmState;
firm: FirmConfig;
hideDead: boolean;
privacy: boolean;
sortKey: SortKey | null;
sortDir: SortDir;
tradeSymbol: string;
onChanged: () => void;
onStateChanged: () => 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.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
{tradeSymbol} not listed
</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 === 'dxtrade' && (
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
DXtrade
</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.resting && (
<span
className="text-xs bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded font-semibold"
title="Logged out while nothing can trade here; the accounts show their last sync"
>
{state.resting.wakeAt !== null
? `Resting · back ${new Date(state.resting.wakeAt).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}`
: 'Resting until trading starts'}
</span>
)}
{hasCredentials && !state.connected && !state.resting && (
<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 {credentialNoun(firm.platform)} 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={`Remove saved ${credentialNoun(firm.platform)}`}
>
<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 || state.resting ? 'No accounts at this firm' : 'Connecting…'}
</td>
</tr>
) : (
sortedAccounts.map((acc) => (
<AccountRow
key={acc.id}
account={acc}
firm={firm}
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
onStateChanged={onStateChanged}
/>
))
)
)}
</>
);
}
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: 'Auto', 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 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
}
};
const fetchScheduler = async () => {
try {
const res = await fetch('/api/auto-trade');
if (res.ok) setScheduler(await res.json());
} catch { /* ignore */ }
};
useEffect(() => {
// The roots this machine's futures firms list; 'Auto' draws among
// each batch firm's list and leads the dropdown.
fetch('/api/instruments')
.then((r) => r.json() as Promise<{ roots: string[] }>)
.then(({ roots }) => {
setEnabledSymbols(roots ?? []);
setTradeSymbol((prev) => prev || 'Auto');
})
.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();
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"
>
<option value="Auto">Auto</option>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</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}
onStateChanged={fetchState}
/>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}