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:
co-authored by
Claude Opus 5
parent
8b2e78d766
commit
8bdc85080c
+78
-14
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -17,10 +17,63 @@ import {
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
|
||||
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
|
||||
return [...firm.accounts]
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
.find((a) => name.startsWith(a.prefix));
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
function AccountTypePicker({ 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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
|
||||
disabled={saving}
|
||||
onChange={(e) => assign(e.target.value)}
|
||||
className="border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
>
|
||||
<option value="">
|
||||
{account.accountTypeLabel ? `Auto — ${account.accountTypeLabel}` : 'Auto — 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" title="Pinned by hand" />
|
||||
)}
|
||||
{account.accountTypeAmbiguous && (
|
||||
<span className="text-amber-500 text-xs font-bold" title="Several account types matched — using the most recently updated one">*</span>
|
||||
)}
|
||||
{!account.accountTypeLabel && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700">
|
||||
Unassigned
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number) {
|
||||
@@ -189,9 +242,9 @@ export default function AccountPage() {
|
||||
const [account, setAccount] = useState<AccountState | null>(null);
|
||||
const [cfg, setCfg] = useState<AccountConfig | null>(null);
|
||||
const [firmName, setFirmName] = useState('');
|
||||
const [firm, setFirm] = useState<FirmConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const load = useCallback(async () => {
|
||||
const [stateRes, firmsRes] = await Promise.all([
|
||||
fetch('/api/state'),
|
||||
fetch('/api/firms'),
|
||||
@@ -202,20 +255,24 @@ export default function AccountPage() {
|
||||
for (const firmState of states) {
|
||||
const acc = firmState.accounts.find((a) => a.id === accountId);
|
||||
if (acc) {
|
||||
const firmCfg = firms.find((f) => f.firm === firmState.firm);
|
||||
// Join on the master's firm id — the name changes the
|
||||
// moment someone renames a firm there.
|
||||
const firmCfg = firms.find((f) => f.id === firmState.firmId);
|
||||
setAccount(acc);
|
||||
setFirmName(firmState.firm);
|
||||
if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null);
|
||||
setFirm(firmCfg ?? null);
|
||||
setCfg(acc.accountType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
@@ -339,6 +396,13 @@ export default function AccountPage() {
|
||||
</Link>
|
||||
<span className="text-slate-300">/</span>
|
||||
<span className="text-slate-400 text-sm">{firmName}</span>
|
||||
{firm && (
|
||||
<AccountTypePicker
|
||||
account={account}
|
||||
firm={firm}
|
||||
onAssigned={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
|
||||
Reference in New Issue
Block a user