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>
This commit is contained in:
Brandon Li
2026-09-12 16:25:52 -05:00
co-authored by Claude Fable 5
parent 1bca441247
commit 50a5eb4713
21 changed files with 1854 additions and 142 deletions
+14 -7
View File
@@ -12,7 +12,7 @@
*/
import type { CatalogAccountType } from './master-protocol';
import type { TradovateClient } from './tradovate-class';
import type { PlatformClient } from './platform-client';
import {
getAssignment,
setAssignment,
@@ -69,10 +69,14 @@ export function matchAccountType(
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);
// 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 };
@@ -117,7 +121,7 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
/** 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 {
export function getInferredSize(accountId: number, client: PlatformClient): number | null {
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
if (cached !== null) {
const value = Number(cached);
@@ -130,7 +134,7 @@ export function getInferredSize(accountId: number, client: TradovateClient): num
* Called after each Tradovate sync and after the catalog changes. */
export function resolveFirmAssignments(
firmId: string,
client: TradovateClient,
client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? [];
@@ -184,6 +188,9 @@ export function resolveFirmAssignments(
}
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));
}