diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 2d3cd04..55f9ed8 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -158,19 +158,20 @@ 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]; + // Nothing is judged before a good sync that started after the window: + // until then a position read can predate the fill (or have failed) while + // the trades read after it already show the P&L it booked. + const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt; + if (syncedFrom < (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS || cash === undefined) return true; + if (client.hasWorkingOrders?.(accountId)) return true; // 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)) { + if (client.positions[accountId] || 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 - && !client.hasWorkingOrders?.(accountId); - if (!settled) return true; runtime().entryOutcomes?.delete(accountId); unrecordLeg(accountId, tradeDate, outcome.legAmount); console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`); diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index d34951c..c7a19e5 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -244,6 +244,10 @@ export class TopstepXClient implements PlatformClient { private reconcilePendingIds = new Set(); /** See PlatformClient: when the last good pass started reading. */ public lastSyncStartedAt = 0; + /** Accounts whose last order ended with its outcome unknown: each sync + * checks their open orders (Order/searchOpen) until it shows none. The + * value is the open count last seen (undefined until checked). */ + private suspectOrderAccounts = new Map(); /** Settles when the sync in flight ends (requestSync hands it to a caller * that arrives mid-pass). */ private syncRunning: Promise = Promise.resolve(); @@ -864,6 +868,19 @@ export class TopstepXClient implements PlatformClient { } if (superseded()) return; + if (this.suspectOrderAccounts.has(acc.id)) { + try { + const open = await this.post<{ orders?: unknown[] }>('/api/Order/searchOpen', { accountId: acc.id }); + if (superseded()) return; + const count = (open.orders ?? []).length; + if (count === 0) this.suspectOrderAccounts.delete(acc.id); + else this.suspectOrderAccounts.set(acc.id, count); + } catch (err) { + accountFresh = false; + console.error(`[topstepx] open-order check failed for ${acc.name}: ${errorMessage(err)}`); + } + } + try { const tradeRes = await this.post<{ trades: TsxTrade[] }>( '/api/Trade/search', { accountId: acc.id, startTimestamp: start, endTimestamp: end }); @@ -1460,8 +1477,16 @@ export class TopstepXClient implements PlatformClient { } } + /** See PlatformClient: an account whose last order's outcome is unknown + * counts as holding a working order until a sync's Order/searchOpen shows + * none. */ + public hasWorkingOrders(accountId: number): boolean { + return this.suspectOrderAccounts.has(accountId); + } + /** Place an order and wait for its fill (trade event, with a REST poll as - * backstop). Resolves with an object exposing the fill `price`. */ + * backstop). Resolves with an object exposing the fill `price`. An order + * whose outcome stays unknown marks the account for open-order checks. */ public async sendOrder( accountId: number, contractSymbol: string, @@ -1469,6 +1494,22 @@ export class TopstepXClient implements PlatformClient { action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number + ): Promise> { + try { + return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); + } catch (err) { + if (!isOrderNotPlaced(err)) this.suspectOrderAccounts.set(accountId, undefined); + throw err; + } + } + + private async sendOrderAndWait( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number ): Promise> { const contract = await this.contractForOrder(contractSymbol); const orderId = await this.placeOrder( diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 8efb3a2..0f1c74b 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -103,8 +103,9 @@ 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']); +/** Order states that can no longer fill (Tradovate ordStatus); any other — + * Working, PendingNew/Replace/Cancel, Suspended, Unknown — still might. */ +const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expired', 'Completed']); /** 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; @@ -1017,7 +1018,7 @@ export class TradovateClient { 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 ?? '')) { + if (order.accountId && !FINISHED_ORDER_STATUSES.has(order.ordStatus ?? '')) { working[order.accountId] = (working[order.accountId] ?? 0) + 1; } }