Files
autofirmer-expanded/lib/account-type-resolver.ts
T
Brandon LiandClaude Fable 5 50a5eb4713 Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 16:25:52 -05:00

218 lines
8.7 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 { 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 };