Files
autofirmer-expanded/app/settings/page.tsx
T
Brandon LiandClaude Opus 5 8bdc85080c 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>
2026-08-31 22:17:23 -05:00

334 lines
16 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';
interface Instrument {
symbol: string;
enabled: boolean;
}
interface ResolvedContract {
name: string;
alternative?: string;
}
interface AppSettings {
max_concurrent_accounts: string | null;
master_dashboard_url: string | null;
instance_name: string | null;
trading_hours: string | null;
}
export default function SettingsPage() {
const [instruments, setInstruments] = useState<Instrument[]>([]);
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
const [masterUrl, setMasterUrl] = useState<string>('');
const [catalog, setCatalog] = useState<{
version: number;
firmCount: number;
lastSyncAt: string | null;
lastError: string | null;
} | null>(null);
const [syncing, setSyncing] = useState(false);
const [instanceName, setInstanceName] = useState<string>('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const fetchCatalog = () => {
fetch('/api/firm-catalog')
.then((r) => r.json())
.then(setCatalog)
.catch(() => {});
};
const syncCatalogNow = async () => {
setSyncing(true);
try {
await fetch('/api/firm-catalog', { method: 'POST' });
} catch { /* the error lands in lastError */ }
fetchCatalog();
setSyncing(false);
};
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
fetchCatalog();
fetch('/api/settings')
.then((r) => r.json())
.then((s: AppSettings) => {
if (s.max_concurrent_accounts != null) {
setMaxConcurrent(s.max_concurrent_accounts);
}
if (s.trading_hours != null) {
setTradingHours(s.trading_hours);
}
if (s.master_dashboard_url != null) {
setMasterUrl(s.master_dashboard_url);
}
if (s.instance_name != null) {
setInstanceName(s.instance_name);
}
});
// Load cached contracts; if cache is empty, auto-resolve
fetch('/api/instruments/contracts')
.then((r) => r.json())
.then((data: Record<string, ResolvedContract | null>) => {
if (data.error) return;
const hasData = Object.values(data).some((v) => v !== null);
if (hasData) {
setContracts(data);
} else {
// Cache empty — trigger a fresh resolve automatically
fetch('/api/instruments/contracts', { method: 'POST' })
.then((r) => r.json())
.then((fresh) => { if (!fresh.error) setContracts(fresh); })
.catch(() => {});
}
})
.catch(() => {});
}, []);
async function toggle(symbol: string, enabled: boolean) {
setInstruments((prev) =>
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
);
await fetch(`/api/instruments/${symbol}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
// When enabling a symbol, auto-resolve its active contract
if (enabled) {
fetch('/api/instruments/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: [symbol] }),
})
.then((r) => r.json())
.then((fresh: Record<string, ResolvedContract | null>) => {
if (!fresh.error) {
setContracts((prev) => ({ ...prev, ...fresh }));
}
})
.catch(() => {});
}
}
async function saveSettings() {
setSaving(true);
setSaved(false);
await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName }),
});
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Link
href="/"
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
>
← Back
</Link>
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
</div>
{/* ── Trading ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Trading
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Max concurrent accounts</p>
<p className="text-xs text-slate-400 mt-0.5">
How many accounts trade in parallel per /api/trade call
</p>
</div>
<div className="flex items-center gap-3">
<input
type="number"
min={1}
max={500}
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
className="w-20 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={saveSettings}
disabled={saving}
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
saved
? 'bg-green-500 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50'
}`}
>
{saved ? 'Saved ✓' : saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Allowed trading hours</p>
<p className="text-xs text-slate-400 mt-0.5">
5 min buffer
</p>
</div>
<select
value={tradingHours}
onChange={(e) => setTradingHours(e.target.value)}
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="full_cme">Full CME (5:00 PM – 3:00 PM CT)</option>
<option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option>
</select>
</div>
</div>
{/* ── Master Dashboard ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Master Dashboard
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Instance Name</p>
<p className="text-xs text-slate-400 mt-0.5">
Identifier for this autotrader on the master dashboard
</p>
</div>
<input
type="text"
placeholder="e.g. VPS-1"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
className="w-48 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
<p className="text-xs text-slate-400 mt-0.5">
Reports state here every 30 seconds, and holds a control
socket open so the dashboard can trigger trades and buys
</p>
</div>
<input
type="text"
placeholder="e.g. http://master-ip:4000"
value={masterUrl}
onChange={(e) => setMasterUrl(e.target.value)}
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
<p className="text-xs text-slate-400 mt-0.5">
{catalog
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
catalog.lastSyncAt
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
: 'never synced'
}`
: 'Loading…'}
</p>
{catalog?.lastError && (
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
)}
</div>
<button
onClick={syncCatalogNow}
disabled={syncing}
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
>
{syncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Instruments
</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>
) : (
<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-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
</tr>
</thead>
<tbody>
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
return (
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
{instr.symbol}
</td>
<td className="px-4 py-2.5">
{instr.enabled && c ? (
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
{c.name}
</span>
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled from {c.alternative}
</span>
)}
</div>
) : null}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggle(instr.symbol, !instr.enabled)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
instr.enabled ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}