Take firms from the master instead of defining them per machine

This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.

The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.

Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.

firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.

The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 22:17:23 -05:00
co-authored by Claude Opus 5
parent 8b2e78d766
commit 8bdc85080c
27 changed files with 1489 additions and 1311 deletions
+74 -563
View File
@@ -1,226 +1,50 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import type { FirmConfig } from '@/types';
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
interface AccountConfig {
id: number;
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxPositionSize: number;
targetSameEquity: boolean;
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
}
interface FirmConfig {
id: number;
firm: string;
accounts: AccountConfig[];
}
interface RowState extends AccountConfig {
dirty: boolean;
saving: boolean;
error: string;
useCustomSize: boolean;
confirmDelete: boolean;
}
function initRow(acc: AccountConfig): RowState {
return {
...acc,
dirty: false,
saving: false,
error: '',
useCustomSize: !SIZE_PRESETS.includes(acc.accountSize),
confirmDelete: false,
};
}
function newRow(): RowState {
return {
id: -(Date.now()),
prefix: '',
profitTarget: 3000,
consistency: 0.5,
minDayPnL: -999,
minTradingDays: 5,
accountSize: 50_000,
maxPositionSize: 0,
targetSameEquity: true,
withdrawalStages: [],
dirty: true,
saving: false,
error: '',
useCustomSize: false,
confirmDelete: false,
};
}
/**
* Read-only view of one firm's account types and banned symbols.
*
* Both are owned by the master dashboard now. This page renders from the local
* catalog cache rather than linking straight out to the master, because the
* moment you most want to know what an account type says is the moment the
* master is unreachable.
*/
function fmtSize(n: number) {
return n >= 1000 ? `${n / 1000}k` : String(n);
}
function TrashIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
);
function fmtMoney(n: number) {
return `$${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
}
const inputCls =
'border border-slate-200 rounded-md px-2 py-1.5 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white w-full';
export default function FirmSettingsPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [firmName, setFirmName] = useState('');
const [rows, setRows] = useState<RowState[]>([]);
const [firm, setFirm] = useState<FirmConfig | null>(null);
const [masterUrl, setMasterUrl] = useState<string | null>(null);
const [loadError, setLoadError] = useState('');
// Instruments (globally enabled only) and per-firm bans
const [instruments, setInstruments] = useState<string[]>([]);
const [bannedSymbols, setBannedSymbols] = useState<Set<string>>(new Set());
useEffect(() => {
fetch(`/api/firms/${id}`)
.then((r) => {
if (!r.ok) throw new Error('Not found');
return r.json() as Promise<FirmConfig>;
})
.then((firm) => {
setFirmName(firm.firm);
setRows(firm.accounts.map(initRow));
})
.then(setFirm)
.catch(() => setLoadError('Failed to load firm'));
// Load globally-enabled instruments
fetch('/api/instruments')
fetch('/api/firm-catalog')
.then((r) => r.json())
.then((data: { symbol: string; enabled: boolean }[]) =>
setInstruments(data.filter((i) => i.enabled).map((i) => i.symbol))
);
// Load this firm's banned symbols
fetch(`/api/firms/${id}/banned-symbols`)
.then((r) => r.json())
.then((syms: string[]) => setBannedSymbols(new Set(syms)));
.then((meta: { masterUrl?: string | null }) => setMasterUrl(meta.masterUrl ?? null))
.catch(() => {});
}, [id]);
async function toggleBan(symbol: string) {
const nowBanned = !bannedSymbols.has(symbol);
setBannedSymbols((prev) => {
const next = new Set(prev);
if (nowBanned) next.add(symbol); else next.delete(symbol);
return next;
});
await fetch(`/api/firms/${id}/banned-symbols`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol, banned: nowBanned }),
});
}
const updateRow = (rowId: number, patch: Partial<RowState>) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r))
);
};
const saveRow = async (row: RowState) => {
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, saving: true, error: '' } : r))
);
try {
const body = {
prefix: row.prefix,
profitTarget: row.profitTarget,
consistency: row.consistency,
minDayPnL: row.minDayPnL,
minTradingDays: row.minTradingDays,
accountSize: row.accountSize,
maxPositionSize: row.maxPositionSize,
targetSameEquity: row.targetSameEquity,
withdrawalStages: row.withdrawalStages,
};
if (row.id < 0) {
const res = await fetch(`/api/firms/${id}/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed');
const created = await res.json() as AccountConfig;
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...initRow(created), dirty: false } : r
)
);
} else {
const res = await fetch(`/api/account-configs/${row.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed');
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, saving: false, dirty: false, error: '' } : r
)
);
}
} catch {
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, saving: false, error: 'Save failed' } : r
)
);
}
};
const confirmDeleteRow = (rowId: number) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: true } : r))
);
};
const cancelDeleteRow = (rowId: number) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: false } : r))
);
};
const deleteRow = async (row: RowState) => {
if (row.id < 0) {
setRows((prev) => prev.filter((r) => r.id !== row.id));
return;
}
try {
const res = await fetch(`/api/account-configs/${row.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed');
setRows((prev) => prev.filter((r) => r.id !== row.id));
} catch {
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, confirmDelete: false, error: 'Delete failed' } : r
)
);
}
};
if (loadError) {
return (
<div className="min-h-screen bg-slate-50 p-8">
@@ -231,11 +55,12 @@ export default function FirmSettingsPage() {
);
}
const firmsPageUrl = masterUrl ? new URL('/firms', masterUrl).toString() : null;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.push('/')}
@@ -243,402 +68,88 @@ export default function FirmSettingsPage() {
>
← Back
</button>
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
<h1 className="text-2xl font-bold text-slate-900">{firm?.firm ?? '…'}</h1>
<span className="text-slate-300 text-2xl font-light">/</span>
<span className="text-2xl font-bold text-slate-400">Settings</span>
<span className="text-2xl font-bold text-slate-400">Account Types</span>
</div>
<div className="mb-4 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
Account types and banned symbols are managed on the master dashboard and pulled from it — this is a
read-only copy of the last successful sync.
{firmsPageUrl && (
<>
{' '}
<a
href={firmsPageUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Edit on master →
</a>
</>
)}
</div>
{/* Account Types */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Account Types
</h2>
<div className="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">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Prefix</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
<th className="px-3 py-3 w-32" />
{['Label', 'Prefix', 'Account Size', 'Profit Target', 'Consistency', 'Min Day P&L', 'Min Trading Days', 'Max Contracts', 'After First W/D'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && firmName && (
{firm && firm.accounts.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
No account types yet
No account types — add them on the master dashboard
</td>
</tr>
)}
{rows.map((row) => (
<React.Fragment key={row.id}>
<tr className="border-b border-slate-100 last:border-0">
{/* Prefix */}
<td className="px-4 py-2.5">
<input
className={`${inputCls} font-mono w-32`}
value={row.prefix}
placeholder="e.g. MYACCT"
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
/>
{firm?.accounts.map((acc) => (
<tr key={acc.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-medium text-slate-800">{acc.label}</td>
<td className="px-4 py-2.5 font-mono text-slate-700">{acc.prefix}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtSize(acc.accountSize)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtMoney(acc.profitTarget)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{Math.round(acc.consistency * 100)}%</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.minDayPnL <= -999 ? '—' : fmtMoney(acc.minDayPnL)}
</td>
{/* Account Size */}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<select
className={`${inputCls} w-24`}
value={row.useCustomSize ? 'custom' : row.accountSize}
onChange={(e) => {
if (e.target.value === 'custom') {
updateRow(row.id, { useCustomSize: true });
} else {
updateRow(row.id, {
accountSize: Number(e.target.value),
useCustomSize: false,
});
}
}}
>
{SIZE_PRESETS.map((s) => (
<option key={s} value={s}>{fmtSize(s)}</option>
))}
<option value="custom">Custom</option>
</select>
{row.useCustomSize && (
<input
type="number"
className={`${inputCls} w-28`}
value={row.accountSize || ''}
min={0}
step={1000}
placeholder="e.g. 35000"
onChange={(e) =>
updateRow(row.id, { accountSize: Number(e.target.value) })
}
/>
)}
</div>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{acc.minTradingDays}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.maxPositionSize === 0 ? 'No limit' : acc.maxPositionSize}
</td>
{/* Profit Target */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.profitTarget}
min={0}
step={100}
onChange={(e) =>
updateRow(row.id, { profitTarget: Number(e.target.value) })
}
/>
</div>
</td>
{/* Consistency */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<input
type="number"
className={`${inputCls} pr-6 w-24`}
value={Math.round(row.consistency * 100)}
min={0}
max={100}
step={1}
onChange={(e) =>
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
}
/>
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
</div>
</td>
{/* Min Day P&L */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
placeholder="None"
step={100}
onChange={(e) =>
updateRow(row.id, {
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
})
}
/>
</div>
</td>
{/* Min Trading Days */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.minTradingDays}
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, { minTradingDays: Number(e.target.value) })
}
/>
</td>
{/* Max Contracts */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.maxPositionSize === 0 ? '' : row.maxPositionSize}
placeholder="No limit"
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, {
maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value),
})
}
/>
</td>
{/* After Withdrawal */}
<td className="px-4 py-2.5">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={row.targetSameEquity}
onChange={(e) =>
updateRow(row.id, { targetSameEquity: e.target.checked })
}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
/>
<span className="text-xs text-slate-600">Same equity</span>
</label>
</td>
{/* Actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{row.error && (
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
)}
{row.confirmDelete ? (
<>
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
<button
onClick={() => deleteRow(row)}
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
Yes
</button>
<button
onClick={() => cancelDeleteRow(row.id)}
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
No
</button>
</>
) : (
<>
<button
onClick={() => saveRow(row)}
disabled={row.saving || !row.dirty}
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
>
{row.saving ? '…' : 'Save'}
</button>
<button
onClick={() => confirmDeleteRow(row.id)}
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
title="Delete account type"
>
<TrashIcon />
</button>
</>
)}
</div>
<td className="px-4 py-2.5 text-slate-600">
{acc.targetSameEquity
? 'Same equity'
: acc.withdrawalStages.length === 0
? 'Base values'
: `${acc.withdrawalStages.length} stage${acc.withdrawalStages.length === 1 ? '' : 's'}`}
</td>
</tr>
{/* Stage sub-rows — shown when targetSameEquity is OFF */}
{!row.targetSameEquity && (
<>
{row.withdrawalStages.map((stage, idx) => {
const isLast = idx === row.withdrawalStages.length - 1;
const showPlus = isLast;
return (
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
<td colSpan={9} className="pl-10 pr-4 py-1.5">
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400 w-16 shrink-0">
Stage {idx + 2}{showPlus ? '+' : ''}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
value={stage.profit}
min={0}
step={100}
placeholder="Profit"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, profit: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
</div>
{row.consistency > 0 && (
<div className="relative flex items-center">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
value={Math.round(stage.consistency * 100)}
min={0}
max={100}
step={1}
placeholder="Cons%"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
</div>
)}
<div className="flex items-center gap-1">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
value={stage.minTradingDays}
min={0}
step={1}
placeholder="Days"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="text-slate-400 text-[10px] shrink-0">days</span>
</div>
<button
onClick={() => {
const next = row.withdrawalStages.filter((_, i) => i !== idx);
updateRow(row.id, { withdrawalStages: next });
}}
className="text-slate-300 hover:text-red-400 text-sm leading-none"
title="Remove stage"
>
×
</button>
</div>
</td>
</tr>
);
})}
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
<td colSpan={9} className="pl-10 pr-4 py-1">
<button
onClick={() =>
updateRow(row.id, {
withdrawalStages: [
...row.withdrawalStages,
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
],
})
}
className="text-xs text-blue-400 hover:text-blue-600"
>
+ Add stage
</button>
</td>
</tr>
</>
)}
</React.Fragment>
))}
{/* Add row */}
<tr className="border-t border-dashed border-slate-200">
<td colSpan={9} className="px-2 py-2">
<button
onClick={() => setRows((prev) => [...prev, newRow()])}
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
>
+ Add account type
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Instruments
Banned Symbols
</h2>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">Loading…</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.bannedSymbols.length === 0 ? (
<span className="text-sm text-slate-400 italic">None</span>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Banned</th>
</tr>
</thead>
<tbody>
{instruments.map((sym) => {
const banned = bannedSymbols.has(sym);
return (
<tr key={sym} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 flex items-center gap-2">
<span className="font-mono font-semibold text-slate-800">{sym}</span>
{banned && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{sym} BANNED
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggleBan(sym)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
banned ? 'bg-red-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
banned ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex flex-wrap gap-1.5">
{firm.bannedSymbols.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
{sym}
</span>
))}
</div>
)}
</div>