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
+19 -52
View File
@@ -7,12 +7,12 @@
* signal but have since exited and are now eligible.
*/
import { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db';
import { getInstruments } from './db';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
import { getSetting, setSetting } from './db';
import type { FirmConfig, AccountConfig } from '@/types';
import type { FirmWithAccounts } from './db';
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -20,34 +20,6 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
return autoLiqThreshold > 0 && amount <= autoLiqThreshold;
}
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));
}
/** Map DB row (snake_case) → FirmConfig (camelCase) to fix field-name mismatch. */
function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
return {
id: firm.id,
firm: firm.name,
username: firm.username,
password: firm.password,
bannedSymbols: getBannedSymbols(firm.id),
accounts: firm.accounts.map((a) => ({
prefix: a.prefix,
profitTarget: a.profit_target,
consistency: a.consistency,
minDayPnL: a.min_day_pnl,
minTradingDays: a.min_trading_days,
accountSize: a.account_size,
maxLoss: a.max_loss,
maxPositionSize: a.max_position_size,
targetSameEquity: a.target_same_equity === 1,
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
})),
};
}
@@ -116,7 +88,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
// Block if any account still has an open position (same gate as the scheduler tick)
const allClients = getClients();
const allFirms = getFirms();
const allFirms = getTradableFirms();
const openPositions = allFirms.reduce((count, firm) => {
const c = allClients.get(firm.id);
if (!c) return count;
@@ -142,7 +114,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
const firms = getFirms();
const firms = getTradableFirms();
const clients = getClients();
// ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ──
@@ -152,7 +124,6 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
firmConfig: FirmConfig;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
@@ -165,12 +136,11 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
if (!client || client.accountList.length === 0) return;
// Skip this firm entirely if the symbol is banned for it
if (isSymbolBanned(firm.id, resolvedSymbol)) {
if (firm.bannedSymbols.includes(resolvedSymbol)) {
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
return;
}
const firmConfig = mapFirmConfig(firm);
const contract = await client.findFrontMonthContract(resolvedSymbol);
if (!contract) return;
@@ -185,7 +155,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
if (!acc.active) continue;
if (client.positions[acc.id]) continue;
const cfg = getAccountConfig(acc.name, firmConfig);
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
@@ -211,7 +181,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
if (target.amount <= 0 && !isMnqExtraDay) continue;
allEligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded });
allEligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded });
}
}));
@@ -227,8 +197,8 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
// ── Phase 2: fire the batch simultaneously ──
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract, firmConfig, cash, dailyPnL, daysTraded } = item;
const cfg = getAccountConfig(acc.name, firmConfig)!;
const { client, acc, contract, cash, dailyPnL, daysTraded } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
@@ -340,7 +310,7 @@ export async function copyTrade() {
return [];
}
const firms = getFirms();
const firms = getTradableFirms();
const clients = getClients();
const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10));
@@ -413,7 +383,6 @@ export async function copyTrade() {
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
firmConfig: FirmConfig;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
@@ -426,12 +395,11 @@ export async function copyTrade() {
console.log(`[copy-trade] ${firm.name}: no client or empty account list — skip firm`);
return;
}
if (isSymbolBanned(firm.id, resolvedSymbol!)) {
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
return;
}
const firmConfig = mapFirmConfig(firm);
const contract = await client.findFrontMonthContract(resolvedSymbol!);
if (!contract) {
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
@@ -444,7 +412,7 @@ export async function copyTrade() {
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
if (isAccountDead(cash.amount, autoLiqThreshold)) { console.log(`[copy-trade] ${acc.name}: dead — skip`); continue; }
if (!acc.active) { console.log(`[copy-trade] ${acc.name}: inactive (DLL) — skip`); continue; }
const cfg = getAccountConfig(acc.name, firmConfig);
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (cash.realizedPnL !== 0) { console.log(`[copy-trade] ${acc.name}: already traded today (realizedPnL=${cash.realizedPnL}) — skip`); continue; }
@@ -463,7 +431,7 @@ export async function copyTrade() {
if (!target) { console.log(`[copy-trade] ${acc.name}: target=null (invalid balance) — skip`); continue; }
if (target.amount <= 0) { console.log(`[copy-trade] ${acc.name}: target=$${target.amount} (challenge complete) — skip`); continue; }
eligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded });
eligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded });
}
}));
@@ -477,8 +445,8 @@ export async function copyTrade() {
// Fire orders (same as Phase 2 of runTrade)
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract, firmConfig, cash, dailyPnL } = item;
const cfg = getAccountConfig(acc.name, firmConfig)!;
const { client, acc, contract, cash, dailyPnL } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
@@ -542,17 +510,16 @@ export async function copyTrade() {
/** Returns true if any configured account could still trade today (not dead, not inactive, hasn't traded, target > 0 or extra-day, or has open position). */
function hasRemainingConfiguredAccounts(): boolean {
const firms = getFirms();
const firms = getTradableFirms();
const clients = getClients();
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) continue;
const firmConfig = mapFirmConfig(firm);
for (const acc of client.accountList) {
const cfg = getAccountConfig(acc.name, firmConfig);
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue; // no config = not our account
// Account with open position = still in play
@@ -636,7 +603,7 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
// Skip this tick until every client has completed its initial sync (positions are populated)
const clients = getClients();
const firms = getFirms();
const firms = getTradableFirms();
const notReady = firms.filter(f => {
const c = clients.get(f.id);
return c && !c.syncComplete;