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
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Binds a live Tradovate account to one of its firm's account types.
|
||||
*
|
||||
* Matching uses the account's name prefix AND its size together, because two
|
||||
* account types at one firm can share a prefix and differ only by size. The
|
||||
* size comes from the account's initial funding transaction, which does not
|
||||
* arrive with the first sync — until it does the account is Unassigned and the
|
||||
* resolver simply tries again on the next one.
|
||||
*
|
||||
* An Unassigned account does not trade: every caller already guards on a
|
||||
* missing config, which is the behaviour we want.
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType } from './master-protocol';
|
||||
import type { TradovateClient } from './tradovate-class';
|
||||
import {
|
||||
getAssignment,
|
||||
setAssignment,
|
||||
setAutoAssignment,
|
||||
clearAssignment,
|
||||
listAssignments,
|
||||
loadAccountMeta,
|
||||
saveAccountMeta,
|
||||
} from './db';
|
||||
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
|
||||
|
||||
const INFERRED_SIZE_KEY = 'inferred_account_size';
|
||||
|
||||
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
|
||||
|
||||
export interface ResolvedAssignment {
|
||||
accountTypeId: string | null;
|
||||
accountType: CatalogAccountType | null;
|
||||
source: AssignmentSource;
|
||||
/** True when more than one account type matched and the tie-break decided. */
|
||||
ambiguous: boolean;
|
||||
}
|
||||
|
||||
const UNASSIGNED: ResolvedAssignment = {
|
||||
accountTypeId: null,
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
};
|
||||
|
||||
/** The earliest positive fund transaction — the account's opening balance.
|
||||
* Returns null while the reports fetch hasn't produced one yet, which is the
|
||||
* "wait until it is findable" case rather than an error. */
|
||||
export function inferAccountSize(fundTxns: { date: string; amount: number }[]): number | null {
|
||||
const deposits = fundTxns.filter((t) => t.amount > 0);
|
||||
if (deposits.length === 0) return null;
|
||||
return [...deposits].sort((a, b) => a.date.localeCompare(b.date))[0].amount;
|
||||
}
|
||||
|
||||
function sizesToTry(fundTxns: { date: string; amount: number }[]): number[] {
|
||||
// Earliest deposit first, then every other deposit: an account that was
|
||||
// reset or refunded can carry more than one, and the first is not always
|
||||
// the one that equals the account size.
|
||||
const deposits = [...fundTxns.filter((t) => t.amount > 0)].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return [...new Set(deposits.map((t) => t.amount))];
|
||||
}
|
||||
|
||||
/** Pure. Exported for the UI's candidate list and for reasoning about ties. */
|
||||
export function matchAccountType(
|
||||
accountName: string,
|
||||
accountSize: number | null,
|
||||
types: CatalogAccountType[],
|
||||
): { type: CatalogAccountType | null; ambiguous: boolean } {
|
||||
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
|
||||
if (byPrefix.length === 0) return { type: null, ambiguous: false };
|
||||
|
||||
// No size yet — stay Unassigned and try again next sync.
|
||||
if (accountSize === null) return { type: null, ambiguous: false };
|
||||
|
||||
const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01);
|
||||
if (candidates.length === 0) return { type: null, ambiguous: false };
|
||||
if (candidates.length === 1) return { type: candidates[0], ambiguous: false };
|
||||
|
||||
// More than one match. Most recently updated wins; the rest of the ordering
|
||||
// exists so the outcome is stable across restarts rather than incidental.
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const byUpdated = b.updatedAt.localeCompare(a.updatedAt);
|
||||
if (byUpdated !== 0) return byUpdated;
|
||||
const byPrefixLength = b.prefix.length - a.prefix.length;
|
||||
if (byPrefixLength !== 0) return byPrefixLength;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return { type: sorted[0], ambiguous: true };
|
||||
}
|
||||
|
||||
/** THE account→config lookup. Reads the assignment table; never writes.
|
||||
* Replaces the longest-prefix helper that used to be copied into five files. */
|
||||
export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
const row = getAssignment(accountId);
|
||||
if (!row?.account_type_id) return UNASSIGNED;
|
||||
|
||||
const accountType = getAccountTypeById(row.account_type_id);
|
||||
if (!accountType) {
|
||||
// The account type was deleted on the master. A manual pin is kept so
|
||||
// that a master flap which recreates it restores the user's intent, but
|
||||
// it reports as unassigned until then, so the account stops trading.
|
||||
return {
|
||||
accountTypeId: row.account_type_id,
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountTypeId: accountType.id,
|
||||
accountType,
|
||||
source: row.manual === 1 ? 'manual' : 'auto',
|
||||
ambiguous: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The size this account resolved to, cached once found so a later reset
|
||||
* deposit can't move it. */
|
||||
export function getInferredSize(accountId: number, client: TradovateClient): number | null {
|
||||
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
|
||||
if (cached !== null) {
|
||||
const value = Number(cached);
|
||||
if (Number.isFinite(value)) return value;
|
||||
}
|
||||
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides.
|
||||
* Called after each Tradovate sync and after the catalog changes. */
|
||||
export function resolveFirmAssignments(
|
||||
firmId: string,
|
||||
client: TradovateClient,
|
||||
): { assigned: number; unassigned: number; manual: number } {
|
||||
const firm = getCachedFirm(firmId);
|
||||
const types = firm?.accountTypes ?? [];
|
||||
|
||||
let assigned = 0;
|
||||
let unassigned = 0;
|
||||
let manual = 0;
|
||||
|
||||
for (const account of client.accountList) {
|
||||
if (getAssignment(account.id)?.manual === 1) {
|
||||
manual++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fundTxns = client.fundTransactions?.[account.id] ?? [];
|
||||
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
|
||||
|
||||
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
|
||||
let matchedSize: number | null = null;
|
||||
|
||||
if (cached !== null && Number.isFinite(Number(cached))) {
|
||||
matchedSize = Number(cached);
|
||||
matched = matchAccountType(account.name, matchedSize, types);
|
||||
} else {
|
||||
for (const size of sizesToTry(fundTxns)) {
|
||||
const attempt = matchAccountType(account.name, size, types);
|
||||
if (attempt.type) {
|
||||
matched = attempt;
|
||||
matchedSize = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.type) {
|
||||
if (matchedSize !== null && cached === null) {
|
||||
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
||||
}
|
||||
setAutoAssignment(account.id, firmId, matched.type.id);
|
||||
assigned++;
|
||||
} else {
|
||||
setAutoAssignment(account.id, firmId, null);
|
||||
unassigned++;
|
||||
}
|
||||
}
|
||||
|
||||
return { assigned, unassigned, manual };
|
||||
}
|
||||
|
||||
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true);
|
||||
}
|
||||
|
||||
/** Drops the override so the auto-resolver takes the account back over. */
|
||||
export function clearManualAssignment(accountId: number): boolean {
|
||||
return clearAssignment(accountId);
|
||||
}
|
||||
|
||||
export { listAssignments };
|
||||
+19
-52
@@ -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;
|
||||
|
||||
+8
-1
@@ -72,7 +72,11 @@ export type AutomationStep =
|
||||
// unrolled before the runner ever sees it — see resolveSteps.
|
||||
| { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string };
|
||||
|
||||
/** A number the user supplies on the dashboard before starting a run. */
|
||||
/**
|
||||
* NOTE: the `FIRMS` array below is the AutoBuyer's list of prop-firm *websites*
|
||||
* to click through. It is unrelated to the firm catalog in lib/firm-catalog.ts,
|
||||
* which is the Tradovate side. Linking the two is future work.
|
||||
* A number the user supplies on the dashboard before starting a run. */
|
||||
export interface AutomationInput {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -141,6 +145,9 @@ export interface Firm {
|
||||
automations: Automation[];
|
||||
}
|
||||
|
||||
// The AutoBuyer's list of prop-firm *websites* to click through. Unrelated to
|
||||
// the firm catalog in lib/firm-catalog.ts, which is the Tradovate side — the
|
||||
// names overlap but nothing joins them. Linking the two is future work.
|
||||
export const FIRMS: Firm[] = [
|
||||
{
|
||||
id: 'tradeify',
|
||||
|
||||
Binary file not shown.
@@ -6,167 +6,138 @@ const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS firms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
|
||||
prefix TEXT NOT NULL,
|
||||
profit_target REAL NOT NULL DEFAULT 3000,
|
||||
consistency REAL NOT NULL DEFAULT 0.5,
|
||||
min_day_pnl REAL NOT NULL DEFAULT -999,
|
||||
min_trading_days INTEGER NOT NULL DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instruments (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: add account_size column if it doesn't exist yet
|
||||
try {
|
||||
db.exec('ALTER TABLE account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// ── Machine-owned firm state ─────────────────────────────────────────────────
|
||||
//
|
||||
// Firms and account types themselves come from the master catalog (see
|
||||
// lib/firm-catalog.ts) and are cached as one JSON blob in `settings`. These two
|
||||
// tables hold the parts that are genuinely per-machine and must survive the
|
||||
// catalog changing underneath them.
|
||||
//
|
||||
// Both reference firm ids as plain TEXT with NO foreign key on purpose. This
|
||||
// file sets `foreign_keys = ON`, so an ON DELETE CASCADE here would wipe the
|
||||
// Tradovate credentials the moment a firm was removed on the master — and
|
||||
// mirroring master's deletions is routine, not exceptional. They must be
|
||||
// orphan-tolerant: a firm that disappears and comes back finds its credentials
|
||||
// and assignments still here.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS firm_credentials (
|
||||
firm_id TEXT PRIMARY KEY,
|
||||
firm_name TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
// Migration: add max_loss column (0 = no max loss limit)
|
||||
try {
|
||||
db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
CREATE TABLE IF NOT EXISTS account_assignments (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: add max_position_size column (0 = no limit)
|
||||
try {
|
||||
db.exec('ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// The legacy `firms`, `account_configs` and `firm_banned_symbols` tables are
|
||||
// dead: firms now come from the master catalog. They are left on disk rather
|
||||
// than dropped because `firms` is the only table that ever held plaintext
|
||||
// Tradovate credentials, and a silent DROP would be unrecoverable if this
|
||||
// change had to be rolled back. Safe to drop by hand once it has stuck.
|
||||
|
||||
// Migration: add target_same_equity flag
|
||||
try {
|
||||
db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// ── Firm credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
// Migration: add withdrawal_stages JSON array
|
||||
try {
|
||||
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Interfaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccountConfigRow {
|
||||
id: number;
|
||||
firm_id: number;
|
||||
prefix: string;
|
||||
profit_target: number;
|
||||
consistency: number;
|
||||
min_day_pnl: number;
|
||||
min_trading_days: number;
|
||||
account_size: number;
|
||||
max_loss: number;
|
||||
max_position_size: number;
|
||||
target_same_equity: number; // 0 | 1
|
||||
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
|
||||
}
|
||||
|
||||
export interface FirmRow {
|
||||
id: number;
|
||||
name: string;
|
||||
export interface FirmCredentialRow {
|
||||
firm_id: string;
|
||||
firm_name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FirmWithAccounts extends FirmRow {
|
||||
accounts: AccountConfigRow[];
|
||||
export function getCredentials(firmId: string): { username: string; password: string } | undefined {
|
||||
const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as
|
||||
{ username: string; password: string } | undefined;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Firms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getFirms(): FirmWithAccounts[] {
|
||||
const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[];
|
||||
const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id');
|
||||
return firms.map((firm) => ({
|
||||
...firm,
|
||||
accounts: getAccounts.all(firm.id) as AccountConfigRow[],
|
||||
}));
|
||||
export function listCredentials(): FirmCredentialRow[] {
|
||||
return db.prepare('SELECT * FROM firm_credentials').all() as FirmCredentialRow[];
|
||||
}
|
||||
|
||||
export function getFirmById(id: number): FirmWithAccounts | undefined {
|
||||
const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined;
|
||||
if (!firm) return undefined;
|
||||
const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[];
|
||||
return { ...firm, accounts };
|
||||
/** firm_name is stored alongside the id so credentials can be re-keyed by name
|
||||
* if the master's database is ever rebuilt and the firms come back with new
|
||||
* ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */
|
||||
export function setCredentials(firmId: string, firmName: string, username: string, password: string): void {
|
||||
db.prepare(`
|
||||
INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(firm_id) DO UPDATE SET
|
||||
firm_name = excluded.firm_name,
|
||||
username = excluded.username,
|
||||
password = excluded.password,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(firmId, firmName, username, password, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function createFirm(name: string, username: string, password: string): FirmRow {
|
||||
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
||||
const result = stmt.run(name, username, password);
|
||||
return db.prepare('SELECT * FROM firms WHERE id = ?').get(result.lastInsertRowid) as FirmRow;
|
||||
export function clearCredentials(firmId: string): boolean {
|
||||
return db.prepare('DELETE FROM firm_credentials WHERE firm_id = ?').run(firmId).changes > 0;
|
||||
}
|
||||
|
||||
export function deleteFirm(id: number): boolean {
|
||||
const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
export function rekeyCredentials(fromFirmId: string, toFirmId: string): void {
|
||||
db.prepare('UPDATE firm_credentials SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
|
||||
db.prepare('UPDATE account_assignments SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
|
||||
}
|
||||
|
||||
// ── Account Configs ─────────────────────────────────────────────────────────
|
||||
// ── Account type assignments ─────────────────────────────────────────────────
|
||||
|
||||
export function createAccountConfig(firmId: number, data: {
|
||||
prefix: string;
|
||||
profitTarget: number;
|
||||
consistency: number;
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
accountSize: number;
|
||||
maxLoss: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
}): AccountConfigRow {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
|
||||
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
|
||||
export interface AssignmentRow {
|
||||
account_id: number;
|
||||
firm_id: string;
|
||||
account_type_id: string | null;
|
||||
manual: number; // 0 | 1
|
||||
resolved_at: string;
|
||||
}
|
||||
|
||||
export function deleteAccountConfig(id: number): boolean {
|
||||
const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
export function getAssignment(accountId: number): AssignmentRow | undefined {
|
||||
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
|
||||
}
|
||||
|
||||
export function updateAccountConfig(id: number, data: {
|
||||
prefix: string;
|
||||
profitTarget: number;
|
||||
consistency: number;
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
accountSize: number;
|
||||
maxLoss: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
}): boolean {
|
||||
const result = db.prepare(`
|
||||
UPDATE account_configs
|
||||
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
|
||||
WHERE id = ?
|
||||
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
|
||||
return result.changes > 0;
|
||||
export function listAssignments(firmId: string): AssignmentRow[] {
|
||||
return db.prepare('SELECT * FROM account_assignments WHERE firm_id = ?').all(firmId) as AssignmentRow[];
|
||||
}
|
||||
|
||||
export function setAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
manual: boolean,
|
||||
): void {
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Writes an auto-resolved assignment, leaving any manual override alone.
|
||||
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
|
||||
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
const existing = getAssignment(accountId);
|
||||
if (existing?.manual === 1) return;
|
||||
setAssignment(accountId, firmId, accountTypeId, false);
|
||||
}
|
||||
|
||||
export function clearAssignment(accountId: number): boolean {
|
||||
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
@@ -307,33 +278,6 @@ export function loadAccountMeta(accountId: number, key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
// ── Firm banned symbols ───────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS firm_banned_symbols (
|
||||
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
|
||||
symbol TEXT NOT NULL,
|
||||
PRIMARY KEY (firm_id, symbol)
|
||||
);
|
||||
`);
|
||||
|
||||
export function getBannedSymbols(firmId: number): string[] {
|
||||
return (db.prepare('SELECT symbol FROM firm_banned_symbols WHERE firm_id = ? ORDER BY symbol').all(firmId) as { symbol: string }[])
|
||||
.map((r) => r.symbol);
|
||||
}
|
||||
|
||||
export function isSymbolBanned(firmId: number, symbol: string): boolean {
|
||||
return !!db.prepare('SELECT 1 FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').get(firmId, symbol);
|
||||
}
|
||||
|
||||
export function setBannedSymbol(firmId: number, symbol: string, banned: boolean): void {
|
||||
if (banned) {
|
||||
db.prepare('INSERT OR IGNORE INTO firm_banned_symbols (firm_id, symbol) VALUES (?, ?)').run(firmId, symbol);
|
||||
} else {
|
||||
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* The firm catalog — firms and their account types, owned by the master
|
||||
* dashboard and pulled from its public GET /api/firms/catalog.
|
||||
*
|
||||
* This machine holds a cache of the last complete, valid response. That cache
|
||||
* is the whole point: lib/auto-trade.ts needs every account's profit target and
|
||||
* size on each 60s tick, so if those numbers only ever came over the network, a
|
||||
* master reboot or a flaky link would stop trading with positions open. The
|
||||
* cache is written only by a successful sync and never edited locally, so it
|
||||
* cannot drift the way the old per-machine account_configs did.
|
||||
*
|
||||
* Stored as one JSON blob in `settings` rather than mirrored into tables: the
|
||||
* catalog is only ever read whole, or asked "give me firm X's account types".
|
||||
*
|
||||
* (Unrelated to the `FIRMS` array in lib/automations.ts, which describes
|
||||
* prop-firm websites the AutoBuyer clicks through. Different notion of "firm";
|
||||
* the two are not linked yet.)
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
|
||||
import {
|
||||
getSetting,
|
||||
setSetting,
|
||||
listCredentials,
|
||||
getCredentials,
|
||||
rekeyCredentials,
|
||||
} from './db';
|
||||
|
||||
const CATALOG_PATH = '/api/firms/catalog';
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Until the very first successful sync there is nothing to trade from, so back
|
||||
// off quickly rather than sitting on the full poll interval.
|
||||
const COLD_RETRY_MIN_MS = 10_000;
|
||||
const COLD_RETRY_MAX_MS = 60_000;
|
||||
|
||||
interface CatalogCache {
|
||||
payload: FirmCatalogPayload | null;
|
||||
/** Index over every firm's account types, for the id lookup. */
|
||||
typesById: Map<string, CatalogAccountType>;
|
||||
}
|
||||
|
||||
const g = global as typeof globalThis & {
|
||||
__firmCatalog?: CatalogCache;
|
||||
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
|
||||
__firmCatalogColdDelay?: number;
|
||||
};
|
||||
|
||||
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
|
||||
const typesById = new Map<string, CatalogAccountType>();
|
||||
for (const firm of payload?.firms ?? []) {
|
||||
for (const type of firm.accountTypes) typesById.set(type.id, type);
|
||||
}
|
||||
return { payload, typesById };
|
||||
}
|
||||
|
||||
function cache(): CatalogCache {
|
||||
if (g.__firmCatalog) return g.__firmCatalog;
|
||||
|
||||
let payload: FirmCatalogPayload | null = null;
|
||||
const raw = getSetting('catalog_payload');
|
||||
if (raw) {
|
||||
try {
|
||||
payload = validatePayload(JSON.parse(raw));
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
}
|
||||
g.__firmCatalog = indexPayload(payload);
|
||||
return g.__firmCatalog;
|
||||
}
|
||||
|
||||
export function getCachedCatalog(): CatalogFirm[] {
|
||||
return cache().payload?.firms ?? [];
|
||||
}
|
||||
|
||||
export function getCachedFirm(firmId: string): CatalogFirm | undefined {
|
||||
return getCachedCatalog().find((f) => f.id === firmId);
|
||||
}
|
||||
|
||||
export function getAccountTypeById(typeId: string): CatalogAccountType | undefined {
|
||||
return cache().typesById.get(typeId);
|
||||
}
|
||||
|
||||
export function getCatalogMeta(): {
|
||||
version: number;
|
||||
updatedAt: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastError: string | null;
|
||||
firmCount: number;
|
||||
} {
|
||||
return {
|
||||
version: Number(getSetting('catalog_version') ?? 0),
|
||||
updatedAt: getSetting('catalog_updated_at'),
|
||||
lastSyncAt: getSetting('catalog_last_sync_at'),
|
||||
lastError: getSetting('catalog_last_error'),
|
||||
firmCount: getCachedCatalog().length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Firms this machine can actually trade: in the catalog AND holding
|
||||
* credentials. Everything that starts a Tradovate client goes through here. */
|
||||
export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] {
|
||||
const result: (CatalogFirm & { username: string; password: string })[] = [];
|
||||
for (const firm of getCachedCatalog()) {
|
||||
const creds = getCredentials(firm.id);
|
||||
if (creds) result.push({ ...firm, ...creds });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof (s as Record<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
|
||||
}
|
||||
|
||||
/** Rejects anything that isn't a complete catalog. A half-parsed payload would
|
||||
* silently unassign accounts, so it is better to keep the old cache. */
|
||||
function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (typeof raw !== 'object' || raw === null) throw new Error('payload is not an object');
|
||||
const body = raw as Record<string, unknown>;
|
||||
if (typeof body.version !== 'number') throw new Error('missing version');
|
||||
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
||||
throw new Error(`firm ${i} is missing id or name`);
|
||||
}
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t as Record<string, unknown>;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
return type as unknown as CatalogAccountType;
|
||||
});
|
||||
|
||||
return {
|
||||
id: firm.id,
|
||||
name: firm.name,
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return { version: body.version, updatedAt: body.updatedAt, firms };
|
||||
}
|
||||
|
||||
function catalogUrl(): URL | null {
|
||||
const base = getSetting('master_dashboard_url');
|
||||
if (!base) return null;
|
||||
try {
|
||||
// new URL, never string concatenation: the stored value routinely has a
|
||||
// trailing slash, which would produce //api/firms/catalog.
|
||||
return new URL(CATALOG_PATH, base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function recordError(message: string): void {
|
||||
setSetting('catalog_last_error', message);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
}
|
||||
|
||||
/** If the master's database was rebuilt, its firms come back with fresh ids and
|
||||
* every credential row orphans. Match the survivors up by name (once) rather
|
||||
* than making someone retype every password. */
|
||||
function rekeyCredentialsByName(firms: CatalogFirm[]): void {
|
||||
const byName = new Map(firms.map((f) => [f.name.toLowerCase(), f.id]));
|
||||
const knownIds = new Set(firms.map((f) => f.id));
|
||||
|
||||
for (const row of listCredentials()) {
|
||||
if (knownIds.has(row.firm_id)) continue;
|
||||
const match = byName.get(row.firm_name.toLowerCase());
|
||||
if (!match) continue;
|
||||
console.warn(`[catalog] re-keying credentials for "${row.firm_name}": ${row.firm_id} -> ${match}`);
|
||||
rekeyCredentials(row.firm_id, match);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; version: number; error?: string }> {
|
||||
const version = Number(getSetting('catalog_version') ?? 0);
|
||||
const url = catalogUrl();
|
||||
if (!url) {
|
||||
const error = 'master_dashboard_url is not set';
|
||||
recordError(error);
|
||||
return { ok: false, changed: false, version, error };
|
||||
}
|
||||
|
||||
// Only the URL is required — deliberately not instance_name, unlike the
|
||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||
// before anyone has got round to naming it.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
if (hasCache) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`master returned ${res.status}`);
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', '');
|
||||
return { ok: true, changed: false, version };
|
||||
}
|
||||
|
||||
const payload = validatePayload(body);
|
||||
|
||||
// A master pointed at the wrong FIRMS_DB, or restored from an empty
|
||||
// one, serves {"firms":[]} as a perfectly valid 200. Mirroring that
|
||||
// would stop trading on every machine at once, so keep what we have.
|
||||
if (payload.firms.length === 0 && hasCache) {
|
||||
const error = 'master returned an empty catalog — ignored';
|
||||
console.error(`[catalog] ${error}`);
|
||||
recordError(error);
|
||||
return { ok: false, changed: false, version, error };
|
||||
}
|
||||
|
||||
// Same reasoning one level down: a firm losing every account type
|
||||
// unassigns all of its accounts. Keep the previous types and flag it.
|
||||
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
|
||||
const suspicious: string[] = [];
|
||||
for (const firm of payload.firms) {
|
||||
const before = previous.get(firm.id);
|
||||
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
|
||||
firm.accountTypes = before.accountTypes;
|
||||
suspicious.push(firm.name);
|
||||
}
|
||||
}
|
||||
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_updated_at', payload.updatedAt);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', suspicious.length > 0
|
||||
? `kept cached account types for ${suspicious.join(', ')} — master sent none`
|
||||
: '');
|
||||
g.__firmCatalog = indexPayload(payload);
|
||||
|
||||
rekeyCredentialsByName(payload.firms);
|
||||
|
||||
console.log(`[catalog] synced v${payload.version} — ${payload.firms.length} firm(s)`);
|
||||
return { ok: true, changed: true, version: payload.version };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error('[catalog] sync failed:', error);
|
||||
recordError(error);
|
||||
return { ok: false, changed: false, version, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function startCatalogSync(onChange?: () => void): void {
|
||||
stopCatalogSync();
|
||||
|
||||
const tick = async () => {
|
||||
const result = await syncCatalog();
|
||||
if (result.changed) {
|
||||
try {
|
||||
onChange?.();
|
||||
} catch (err) {
|
||||
console.error('[catalog] onChange handler failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
let delay = POLL_INTERVAL_MS;
|
||||
if (getCachedCatalog().length === 0) {
|
||||
// Never synced: retry hard, with backoff, until something lands.
|
||||
const current = g.__firmCatalogColdDelay ?? COLD_RETRY_MIN_MS;
|
||||
delay = current;
|
||||
g.__firmCatalogColdDelay = Math.min(current * 2, COLD_RETRY_MAX_MS);
|
||||
} else {
|
||||
g.__firmCatalogColdDelay = COLD_RETRY_MIN_MS;
|
||||
// Jitter so a fleet restarting together doesn't poll in lockstep.
|
||||
delay = POLL_INTERVAL_MS + Math.floor(Math.random() * 10_000);
|
||||
}
|
||||
|
||||
g.__firmCatalogTimer = setTimeout(() => void tick(), delay);
|
||||
};
|
||||
|
||||
void tick();
|
||||
}
|
||||
|
||||
export function stopCatalogSync(): void {
|
||||
if (g.__firmCatalogTimer) clearTimeout(g.__firmCatalogTimer);
|
||||
g.__firmCatalogTimer = undefined;
|
||||
}
|
||||
@@ -7,11 +7,23 @@
|
||||
* running different versions of this file at the same time.
|
||||
*/
|
||||
|
||||
export interface AccountTypeStats {
|
||||
accountTypeId: string | null;
|
||||
label: string;
|
||||
accountSize: number;
|
||||
totalAccounts: number;
|
||||
accountsTraded: number;
|
||||
inTrade: number;
|
||||
}
|
||||
|
||||
export interface FirmStats {
|
||||
firm: string;
|
||||
totalAccounts: number;
|
||||
accountsTraded: number;
|
||||
inTrade: number;
|
||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||
* on an older build simply ignores it. */
|
||||
accountTypes?: AccountTypeStats[];
|
||||
}
|
||||
|
||||
export interface SchedulerState {
|
||||
@@ -36,6 +48,53 @@ export interface InstanceCapabilities {
|
||||
runnerOnline: boolean;
|
||||
}
|
||||
|
||||
// ── Firm catalog ─────────────────────────────────────────────────────────────
|
||||
// Served by the master's public GET /api/firms/catalog and cached locally by
|
||||
// lib/firm-catalog.ts. Credentials are per-machine and absent from all of it.
|
||||
|
||||
/** One account type as published by the master.
|
||||
*
|
||||
* The field names match `AccountConfig` in types.ts on purpose — that is what
|
||||
* lets the trading code consume a catalog account type wherever it used to
|
||||
* read a local account_configs row. */
|
||||
export interface CatalogAccountType {
|
||||
id: string;
|
||||
firmId: string;
|
||||
label: string;
|
||||
prefix: string;
|
||||
accountSize: number;
|
||||
profitTarget: number;
|
||||
consistency: number;
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
maxLoss: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity: boolean;
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
/** ISO. Ties between two matching account types are broken by this. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogFirm {
|
||||
id: string;
|
||||
name: string;
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FirmCatalogPayload {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
firms: CatalogFirm[];
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
unchanged: true;
|
||||
}
|
||||
|
||||
export type Command =
|
||||
| { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }
|
||||
| { kind: 'stop-trading' }
|
||||
|
||||
+49
-16
@@ -13,63 +13,96 @@
|
||||
|
||||
import { getSetting } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getFirms } from './db';
|
||||
import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { computeDailyTarget } from './trading-logic';
|
||||
import type { AccountConfigRow } from './db';
|
||||
|
||||
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
|
||||
return [...accounts]
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
.find((a) => name.startsWith(a.prefix));
|
||||
}
|
||||
import type { AccountTypeStats } from './master-protocol';
|
||||
|
||||
function collectFirmStats() {
|
||||
const firms = getFirms();
|
||||
// Only firms this machine has credentials for. A firm it cannot log into
|
||||
// has nothing to say about, and reporting it as zero accounts would fill
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
|
||||
return firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0 };
|
||||
return { firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0, accountTypes: [] };
|
||||
}
|
||||
|
||||
let totalAccounts = 0;
|
||||
let accountsTraded = 0;
|
||||
let inTrade = 0;
|
||||
|
||||
// Per account type, so the master can say what the accounts still to
|
||||
// trade actually are. Keyed by account type id; unassigned accounts
|
||||
// share the null bucket — they inflate "left to trade" indefinitely,
|
||||
// which is precisely the thing worth being able to see.
|
||||
const byType = new Map<string | null, AccountTypeStats>();
|
||||
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
|
||||
const key = cfg?.id ?? null;
|
||||
let entry = byType.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
accountTypeId: key,
|
||||
label: cfg?.label ?? 'Unassigned',
|
||||
accountSize: cfg?.accountSize ?? 0,
|
||||
totalAccounts: 0,
|
||||
accountsTraded: 0,
|
||||
inTrade: 0,
|
||||
};
|
||||
byType.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
for (const acc of client.accountList) {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
||||
if (isDead) continue;
|
||||
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const slice = bucket(cfg);
|
||||
|
||||
totalAccounts++;
|
||||
slice.totalAccounts++;
|
||||
|
||||
if (client.positions[acc.id]) {
|
||||
inTrade++;
|
||||
accountsTraded++;
|
||||
slice.inTrade++;
|
||||
slice.accountsTraded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if target was hit today
|
||||
const cfg = getAccountConfig(acc.name, f.accounts);
|
||||
if (cfg) {
|
||||
const dailyPnL = client.dailyPnL[acc.id] ?? [];
|
||||
const equityProfit = cash.amount - cfg.account_size;
|
||||
const target = computeDailyTarget(cfg.profit_target, cfg.consistency, dailyPnL, cfg.min_day_pnl, cfg.min_trading_days, equityProfit);
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit);
|
||||
const targetHit = !target ? false :
|
||||
((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.min_trading_days) ||
|
||||
((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
|
||||
(cash.realizedPnL >= target.amount));
|
||||
|
||||
if (targetHit || cash.realizedPnL !== 0) {
|
||||
accountsTraded++;
|
||||
slice.accountsTraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { firm: f.name, totalAccounts, accountsTraded, inTrade };
|
||||
// Biggest shortfall first — the account types with the most left to do
|
||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||
// when it is genuinely small.
|
||||
const accountTypes = [...byType.values()].sort((a, b) =>
|
||||
(b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded)
|
||||
|| a.label.localeCompare(b.label));
|
||||
|
||||
return { firm: f.name, totalAccounts, accountsTraded, inTrade, accountTypes };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +126,7 @@ export async function sendReport(): Promise<void> {
|
||||
|
||||
if (pushReport(firms, scheduler)) return;
|
||||
|
||||
await fetch(`${url}/api/report`, {
|
||||
await fetch(new URL('/api/report', url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instance: instanceName, firms, scheduler }),
|
||||
|
||||
Reference in New Issue
Block a user