211 lines
8.2 KiB
TypeScript
211 lines
8.2 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;
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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 };
|