diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 142ba1a..2d3cd04 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -132,12 +132,14 @@ interface EntryOutcome { at: number; realizedBefore: number; legAmount: number; + /** When its outcome was declared unknown (the send's error). */ + unknownAt?: number; } -/** An unknown entry counts as never filled only once a good sync at least - * this long after it shows the account flat with realized P&L unchanged — a - * market order that could fill has done so by then (and a Tradovate order - * that timed out waiting for its fill was cancelled). */ +/** An unknown entry counts as never filled only once a good sync that + * STARTED at least this long after it was declared unknown shows the account + * flat, with realized P&L unchanged and no order still working — a market + * order that could fill has done so by then. */ const UNKNOWN_ENTRY_SETTLE_MS = 90_000; function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null { @@ -156,10 +158,18 @@ function unknownEntryHolds(client: any, accountId: number, tradeDate: string): b const outcome = entryOutcomeToday(accountId, tradeDate); if (!outcome || outcome.state !== 'unknown') return false; const cash = client.accountCashBalances[accountId]; - const settled = client.lastSyncAt >= outcome.at + UNKNOWN_ENTRY_SETTLE_MS - && !client.positions[accountId] + // The broker shows the fill (the position, or the P&L it booked): it + // filled after all. From here it's an ordinary filled entry — the + // position gate holds it while open, one-and-done is done for the day. + if (client.positions[accountId] || (cash && Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005)) { + outcome.state = 'filled'; + console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`); + return false; + } + const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt; + const settled = syncedFrom >= (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS && cash !== undefined - && Math.abs(cash.realizedPnL - outcome.realizedBefore) < 0.005; + && !client.hasWorkingOrders?.(accountId); if (!settled) return true; runtime().entryOutcomes?.delete(accountId); unrecordLeg(accountId, tradeDate, outcome.legAmount); @@ -226,7 +236,7 @@ async function sendEntry( unrecordLeg(acc.id, tradeDate, legAmount); console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`); } else { - outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount }); + outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount, unknownAt: Date.now() }); console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`); } throw err; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 5309d64..6a8fd63 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -48,6 +48,12 @@ export interface PlatformClient { syncComplete: boolean; /** Epoch ms of the last sync that came back good (0 = never). */ lastSyncAt: number; + /** When that good sync started reading: everything it reported is at + * least this recent (a sequential pass can read an account long before + * it ends). */ + lastSyncStartedAt?: number; + /** Whether the broker still shows a working order on the account. */ + hasWorkingOrders?(accountId: number): boolean; lastFetchErrors: Record; lastFetchRaw: Record; recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[]; diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 6fe7213..d34951c 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -242,6 +242,8 @@ export class TopstepXClient implements PlatformClient { * fetched without it (after a wipe took the fund rows) would plan the * account as stage 1 until the next sync. */ private reconcilePendingIds = new Set(); + /** See PlatformClient: when the last good pass started reading. */ + public lastSyncStartedAt = 0; /** Settles when the sync in flight ends (requestSync hands it to a caller * that arrives mid-pass). */ private syncRunning: Promise = Promise.resolve(); @@ -925,6 +927,7 @@ export class TopstepXClient implements PlatformClient { console.warn(`[topstepx] sync incomplete — not refreshed: ${staleAccounts.join(', ')}; not counted as a good sync`); } else if (this.historyLoaded) { this.lastSyncAt = Date.now(); + this.lastSyncStartedAt = startedAt; } // The resolver runs off this; its failure is not a sync failure. try { diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 65631c1..8efb3a2 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -103,6 +103,8 @@ const TOKEN_REUSE_MIN_MS = 5 * 60_000; /** An order ack later than this is treated as lost (ambiguous — it may have been placed). */ const ACK_TIMEOUT_MS = 15_000; const FILL_TIMEOUT_MS = 30_000; +/** Order states that can still fill (Tradovate ordStatus). */ +const WORKING_ORDER_STATUSES = new Set(['Working', 'PendingNew', 'PendingReplace', 'Suspended']); /** How far the start-of-day balance may move without a fill before the * account's history is treated as overtaken (balanceDrifted). Fills move * amount and realizedPnL together, so this only has to absorb rounding; @@ -416,6 +418,10 @@ export class TradovateClient { private closingSeenAt = 0; /** When the oldest still-unanswered syncrequest was sent (0 = none). */ private syncRequestedAt = 0; + /** See PlatformClient. */ + public lastSyncStartedAt = 0; + /** Per account, orders the last sync showed still working. */ + private workingOrders: { [accountId: number]: number } = {}; private livenessInterval: ReturnType | null = null; private renewInFlight = false; /** The current token's expirationTime was (nearly) past on arrival: this @@ -970,6 +976,7 @@ export class TradovateClient { private requestSync(): void { if (this.socketProblem(this.ws)) return; this.directEventCallbacks[3] = (response: any, status?: number) => { + const requestedAt = this.syncRequestedAt; this.syncRequestedAt = 0; // A refused or empty answer must not wipe the accounts, positions // and balances we have — nor pass for a fresh sync. @@ -1008,6 +1015,14 @@ export class TradovateClient { return account; }); + const working: { [accountId: number]: number } = {}; + for (const order of (response.orders ?? []) as { accountId?: number; ordStatus?: string }[]) { + if (order.accountId && WORKING_ORDER_STATUSES.has(order.ordStatus ?? '')) { + working[order.accountId] = (working[order.accountId] ?? 0) + 1; + } + } + this.workingOrders = working; + const balances: TradovateClient['accountCashBalances'] = {}; for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) { storeCash(balances, item, this.accountCashBalances[item.accountId]); @@ -1033,6 +1048,7 @@ export class TradovateClient { ); this.lastSyncAt = Date.now(); + this.lastSyncStartedAt = requestedAt || this.lastSyncAt; // A session counts as working once it has synced (abandonSocket). this.consecutiveSocketFailures = 0; console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`); @@ -1537,7 +1553,13 @@ export class TradovateClient { // so a payout the rebuilt report hasn't booked yet stays held. // Called before wipeAccountData, so the stored rows and baseline are // still there when this process hasn't loaded them yet. + // A wipe already pending (this process's, or one from before a + // restart) keeps its yardstick: the rows it left are no guide. + const pending = loadAccountMeta(accountId, HOLD_META) === 'wipe' + ? loadAccountMeta(accountId, WIPE_SIG_META) ?? UNKNOWN_SIG + : undefined; const preWipe = this.historyHolds.get(accountId)?.fundSig + ?? pending ?? fundSignature(this.fundTransactions[accountId] ?? loadFundTransactions(accountId)); this.hold(accountId, 'wipe', preWipe); saveAccountMeta(accountId, HOLD_META, 'wipe'); @@ -2015,13 +2037,23 @@ export class TradovateClient { /** Resolves with the fill event for `orderId` (it carries the real * execution price). Registered synchronously from the ack handler, so a * fill later in the same frame as the ack is not missed. */ - /** Cancel a working order (order/cancelorder). */ + /** Cancel a working order (order/cancelorder); rejects unless Tradovate + * accepted the cancel. */ private cancelOrder(orderId: number): Promise { - return this.sendRequest('order/cancelorder', `\n${JSON.stringify({ orderId })}`, ACK_TIMEOUT_MS, (response) => { + return this.sendRequest('order/cancelorder', `\n${JSON.stringify({ orderId })}`, ACK_TIMEOUT_MS, (response, status) => { console.log(`${this.tag} cancelorder ${orderId}:`, JSON.stringify(response)); + const failure = (response as { failureReason?: string; failureText?: string } | null)?.failureReason; + if ((typeof status === 'number' && status !== 200) || typeof response === 'string' || (failure && failure !== 'Success')) { + throw new Error(`cancel of order ${orderId} refused (status ${status ?? '?'}): ${typeof response === 'string' ? response : failure ?? ''}`); + } }); } + /** See PlatformClient: the last sync's view of the account's working orders. */ + public hasWorkingOrders(accountId: number): boolean { + return (this.workingOrders[accountId] ?? 0) > 0; + } + private waitForFill(orderId: number | undefined): Promise> { // Fills are pushed on the event stream, independently of the ack: one // that got ahead of it (an earlier frame, or earlier in the same one)