/** * 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. * * Which of its type's rule sets an account trades on is the other half (see * lib/kept-rules.ts): each assignment row carries a rules date, and an * account dated inside one of the type's kept rule sets trades that set's * rules — getAccountConfig applies it, so every trading read sees it. */ import type { CatalogAccountType, KeptAccountRules } from './master-protocol'; import type { PlatformClient } from './platform-client'; import { firstFundDate } from './platform-client'; import { getAssignment, setAssignment, setAutoAssignment, unpinAssignment, lowerAutoRulesDate, stampMissingRulesDate, listAssignments, loadAccountMeta, saveAccountMeta, } from './db'; import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog'; import { identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate, type EffectiveAccountType, type KeptRulesWindow, } from './kept-rules'; const INFERRED_SIZE_KEY = 'inferred_account_size'; /** Accounts this process has run the evidence step on with their fund * history loaded (see ensureRulesEvidence). On `global` like the clients, * so the trade path and the resolver share it whichever bundle loads them. */ const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set }; function evidenceApplied(): Set { return (ge.__rulesEvidenceApplied ??= new Set()); } export type AssignmentSource = 'manual' | 'auto' | 'unassigned'; export interface ResolvedAssignment { /** Always the live account type's id — a kept rule set's never is one. */ accountTypeId: string | null; /** The rules the account trades on: its live type, or the live type with * a kept rule set's rules on top (then keptRules says which). */ accountType: EffectiveAccountType | null; source: AssignmentSource; /** True when more than one account type matched and the tie-break decided. */ ambiguous: boolean; /** The account's rules date (ISO); null = none recorded, current rules. */ rulesDate: string | null; /** The kept rule set the account trades on; null = its type's current rules. */ keptRules: KeptRulesWindow | null; } const UNASSIGNED: ResolvedAssignment = { accountTypeId: null, accountType: null, source: 'unassigned', ambiguous: false, rulesDate: null, keptRules: null, }; /** One overlay per kept set and live type, so a tick's many lookups share one * object the way they share the live type. Both come from the same catalog * index, so a sync replaces them together. */ const overlays = new WeakMap(); function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType { const hit = overlays.get(kept); if (hit && hit.live === live) return hit.effective; const effective = overlayKept(live, kept); overlays.set(kept, { live, effective }); return effective; } /** 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. * Every trading read of an account's rules comes through here, so this is * where an account dated inside a kept rule set gets that set's rules — * manual pins and auto assignments alike. One row read, the rest from the * in-memory catalog: it runs per account per tick. */ export function getAccountConfig(accountId: number): ResolvedAssignment { const row = getAssignment(accountId); if (!row) return UNASSIGNED; // `?? null`: a row read before the rules-date migration ran has no column. const rulesDate = row.rules_date ?? null; if (!row.account_type_id) return { ...UNASSIGNED, rulesDate }; const live = getAccountTypeById(row.account_type_id); if (!live) { // 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, rulesDate, keptRules: null, }; } const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate); const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live; return { accountTypeId: live.id, accountType, source: row.manual === 1 ? 'manual' : 'auto', ambiguous: false, rulesDate, keptRules: accountType.keptRules ?? null, }; } /** 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] ?? []); } /** The rules date a row first written now gets — on the master's clock when * this one runs behind, and never before the catalog's last change. */ function freshRulesDate(): string { return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt()); } /** The broker's evidence of when the account opened (the report's * firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined * until its fund history has loaded, null when that history doesn't say. */ function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined { const funds = client.fundTransactions?.[accountId]; if (!funds) return undefined; return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime); } /** * The evidence step for one account, for the trade path: once its fund * history has loaded, lower an auto rules date to the broker's evidence * before anything reads the account's rules. The resolver does the same on * every pass, but only after a sync — and history lands between syncs, when * an account turns tradable (historyReady needs the fund rows loaded, on * every platform) and could trade on its first-seen date for up to a sync. * Once per account per process; a no-op until the history has loaded and the * account has an assignment row. */ export function ensureRulesEvidence(client: PlatformClient, accountId: number): void { if (evidenceApplied().has(accountId)) return; const evidence = brokerEvidence(client, accountId); if (evidence === undefined) return; const row = getAssignment(accountId); if (!row) return; if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence); evidenceApplied().add(accountId); } /** Re-resolves every account on one firm's client. Skips manual overrides, * and auto ones on a kept rule set (see below). Lowers rules dates to the * broker's evidence on the way. Called after each 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 ?? []; const stamp = freshRulesDate(); let assigned = 0; let unassigned = 0; let manual = 0; for (const account of client.accountList) { let row = getAssignment(account.id); // A row an older build wrote without a rules date (between a build's // migration and its restart, or after a rollback): dated now, as the // migration dates rows, before the evidence below can lower it. if (row && !row.rules_date) { stampMissingRulesDate(account.id, stamp); row = { ...row, rules_date: stamp }; } // Evidence next, for every account, pinned or not: once its fund // history has loaded, the broker shows when it opened (the report's // firstFundDate), and an auto rules date later than that comes down // to it. Ahead of the kept-set check below, which reads the date. const loadedFunds = client.fundTransactions?.[account.id]; const evidence = brokerEvidence(client, account.id) ?? null; if (row && evidence && lowersRulesDate(row, evidence)) { lowerAutoRulesDate(account.id, evidence); row = { ...row, rules_date: evidence }; } if (row && loadedFunds) evidenceApplied().add(account.id); if (row?.manual === 1) { manual++; continue; } const fundTxns = loadedFunds ?? []; const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY); // Sticky: an account on one of its type's kept rule sets stays bound // to that type, so a tie-break flip to another matching type can never // move it off its old rules. Only while it still matches the type's // prefix and size, though: a kept set takes both from the live type, // so after a size edit it would trade on a size it doesn't have. // Unstuck, it re-matches as it did before kept rules existed — // usually to Unassigned, which stops it trading. if (row?.account_type_id) { const live = getAccountTypeById(row.account_type_id); if ( live && live.firmId === firmId && identityStillMatches(account.name, cached, live) && pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null) ) { assigned++; continue; } } 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, stamp); assigned++; } else { setAutoAssignment(account.id, firmId, null, stamp); unassigned++; } // A row this pass created is dated "first seen now"; the evidence may // already date it earlier. if (!row && evidence) lowerAutoRulesDate(account.id, evidence); if (!row && loadedFunds) evidenceApplied().add(account.id); } return { assigned, unassigned, manual }; } /** insertRulesDate dates the row when the pin creates it (default: now, as * stampRulesDate gives it); re-pinning keeps the account's rules date. */ export function setManualAssignment( accountId: number, firmId: string, accountTypeId: string | null, insertRulesDate: string = freshRulesDate(), ): void { setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate); } /** Drops the override so the auto-resolver takes the account back over. The * row and its rules date stay: unpinning must not make an old account look * new. */ export function clearManualAssignment(accountId: number): boolean { return unpinAssignment(accountId); } export { listAssignments };