A stop-enabled account whose day booked red via its own stop shows an amber "Personal DLL" badge on the dashboard (the firm's lockout keeps its red "Hit DLL") and reports as personalDll in the day outcomes instead of loss, per account type and per firm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
901 lines
44 KiB
TypeScript
901 lines
44 KiB
TypeScript
'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. */
|
|
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
async function save() {
|
|
if (!username.trim() || !password.trim()) return;
|
|
setSaving(true);
|
|
setError('');
|
|
try {
|
|
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
|
setUsername('');
|
|
setPassword('');
|
|
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="Tradovate username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
<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 || !username.trim() || !password.trim()}
|
|
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'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}
|
|
</span>
|
|
</div>
|
|
{account.dailyTarget && (
|
|
<span className="text-xs text-slate-400">${fmt(account.dailyTarget.amount)} today</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>
|
|
)}
|
|
{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 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="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>
|
|
);
|
|
}
|