Unknown entries settle only on a sync started after them; filled-after-all and working orders seen

Sixth review round (8e66631):

- An entry of unknown outcome is judged against a good sync that STARTED
  at least 90s after it was declared unknown (lastSyncStartedAt: TopstepX's
  pass start, Tradovate's request time), not one that merely ended then —
  a sequential pass can read the account before a late fill.
- Once the broker shows its fill (a position, or realized P&L moved), the
  entry becomes an ordinary filled one instead of holding the account for
  the rest of the day.
- It never settles while the broker still shows a working order on the
  account: Tradovate now reads working orders from the sync snapshot, and
  its fill-timeout cancel checks Tradovate's answer instead of assuming it.
- A second wipe (after a restart) keeps the pending wipe's pre-wipe fund
  signature instead of overwriting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 21:38:57 -05:00
co-authored by Claude Opus 5.5
parent 8e66631b0e
commit 57835c01f0
4 changed files with 61 additions and 10 deletions
+34 -2
View File
@@ -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<typeof setInterval> | 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<void> {
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<Record<string, any>> {
// 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)