/** * 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 { PlatformClient } from './platform-client'; 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 }; // Exact size match first. An account type with size 0 means the account's // balance starts at $0 (TopstepX Express style) — there is no funding // transaction to infer a size from, so it matches on prefix alone, but // only once every sized candidate has had its chance. const sized = accountSize !== null ? byPrefix.filter((t) => t.accountSize > 0 && Math.abs(t.accountSize - accountSize) < 0.01) : []; const candidates = sized.length > 0 ? sized : byPrefix.filter((t) => t.accountSize === 0); 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: PlatformClient): 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: PlatformClient, ): { 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; } } // Fallback while the throttled report fetch hasn't produced fund // transactions yet: an account that has never traded still sits at // exactly its opening balance, and balances arrive with the first // sync. Only a cent-exact match counts — a traded account's balance // won't equal any type size, so it falls through and waits for the // reports as before. The matched size is persisted below, so the // assignment survives once the balance starts moving. if (!matched.type) { const balance = client.accountCashBalances[account.id]?.amount; if (typeof balance === 'number') { const attempt = matchAccountType(account.name, balance, types); if (attempt.type) { matched = attempt; matchedSize = balance; } } } } if (matched.type) { // A $0-start type matched on prefix alone — whatever number rode // along (usually the live balance) is not an inferred size. if (matched.type.accountSize === 0) matchedSize = 0; 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 };