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>
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
/**
|
|
* 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 };
|