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:
co-authored by
Claude Opus 5.5
parent
8e66631b0e
commit
57835c01f0
+18
-8
@@ -132,12 +132,14 @@ interface EntryOutcome {
|
|||||||
at: number;
|
at: number;
|
||||||
realizedBefore: number;
|
realizedBefore: number;
|
||||||
legAmount: 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
|
/** An unknown entry counts as never filled only once a good sync that
|
||||||
* this long after it shows the account flat with realized P&L unchanged — a
|
* STARTED at least this long after it was declared unknown shows the account
|
||||||
* market order that could fill has done so by then (and a Tradovate order
|
* flat, with realized P&L unchanged and no order still working — a market
|
||||||
* that timed out waiting for its fill was cancelled). */
|
* order that could fill has done so by then. */
|
||||||
const UNKNOWN_ENTRY_SETTLE_MS = 90_000;
|
const UNKNOWN_ENTRY_SETTLE_MS = 90_000;
|
||||||
|
|
||||||
function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null {
|
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);
|
const outcome = entryOutcomeToday(accountId, tradeDate);
|
||||||
if (!outcome || outcome.state !== 'unknown') return false;
|
if (!outcome || outcome.state !== 'unknown') return false;
|
||||||
const cash = client.accountCashBalances[accountId];
|
const cash = client.accountCashBalances[accountId];
|
||||||
const settled = client.lastSyncAt >= outcome.at + UNKNOWN_ENTRY_SETTLE_MS
|
// The broker shows the fill (the position, or the P&L it booked): it
|
||||||
&& !client.positions[accountId]
|
// 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
|
&& cash !== undefined
|
||||||
&& Math.abs(cash.realizedPnL - outcome.realizedBefore) < 0.005;
|
&& !client.hasWorkingOrders?.(accountId);
|
||||||
if (!settled) return true;
|
if (!settled) return true;
|
||||||
runtime().entryOutcomes?.delete(accountId);
|
runtime().entryOutcomes?.delete(accountId);
|
||||||
unrecordLeg(accountId, tradeDate, outcome.legAmount);
|
unrecordLeg(accountId, tradeDate, outcome.legAmount);
|
||||||
@@ -226,7 +236,7 @@ async function sendEntry(
|
|||||||
unrecordLeg(acc.id, tradeDate, legAmount);
|
unrecordLeg(acc.id, tradeDate, legAmount);
|
||||||
console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`);
|
console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`);
|
||||||
} else {
|
} 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`);
|
console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export interface PlatformClient {
|
|||||||
syncComplete: boolean;
|
syncComplete: boolean;
|
||||||
/** Epoch ms of the last sync that came back good (0 = never). */
|
/** Epoch ms of the last sync that came back good (0 = never). */
|
||||||
lastSyncAt: number;
|
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<string, string>;
|
lastFetchErrors: Record<string, string>;
|
||||||
lastFetchRaw: Record<string, string>;
|
lastFetchRaw: Record<string, string>;
|
||||||
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
|
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
* fetched without it (after a wipe took the fund rows) would plan the
|
* fetched without it (after a wipe took the fund rows) would plan the
|
||||||
* account as stage 1 until the next sync. */
|
* account as stage 1 until the next sync. */
|
||||||
private reconcilePendingIds = new Set<number>();
|
private reconcilePendingIds = new Set<number>();
|
||||||
|
/** 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
|
/** Settles when the sync in flight ends (requestSync hands it to a caller
|
||||||
* that arrives mid-pass). */
|
* that arrives mid-pass). */
|
||||||
private syncRunning: Promise<void> = Promise.resolve();
|
private syncRunning: Promise<void> = 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`);
|
console.warn(`[topstepx] sync incomplete — not refreshed: ${staleAccounts.join(', ')}; not counted as a good sync`);
|
||||||
} else if (this.historyLoaded) {
|
} else if (this.historyLoaded) {
|
||||||
this.lastSyncAt = Date.now();
|
this.lastSyncAt = Date.now();
|
||||||
|
this.lastSyncStartedAt = startedAt;
|
||||||
}
|
}
|
||||||
// The resolver runs off this; its failure is not a sync failure.
|
// The resolver runs off this; its failure is not a sync failure.
|
||||||
try {
|
try {
|
||||||
|
|||||||
+34
-2
@@ -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). */
|
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
|
||||||
const ACK_TIMEOUT_MS = 15_000;
|
const ACK_TIMEOUT_MS = 15_000;
|
||||||
const FILL_TIMEOUT_MS = 30_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
|
/** How far the start-of-day balance may move without a fill before the
|
||||||
* account's history is treated as overtaken (balanceDrifted). Fills move
|
* account's history is treated as overtaken (balanceDrifted). Fills move
|
||||||
* amount and realizedPnL together, so this only has to absorb rounding;
|
* amount and realizedPnL together, so this only has to absorb rounding;
|
||||||
@@ -416,6 +418,10 @@ export class TradovateClient {
|
|||||||
private closingSeenAt = 0;
|
private closingSeenAt = 0;
|
||||||
/** When the oldest still-unanswered syncrequest was sent (0 = none). */
|
/** When the oldest still-unanswered syncrequest was sent (0 = none). */
|
||||||
private syncRequestedAt = 0;
|
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 livenessInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private renewInFlight = false;
|
private renewInFlight = false;
|
||||||
/** The current token's expirationTime was (nearly) past on arrival: this
|
/** The current token's expirationTime was (nearly) past on arrival: this
|
||||||
@@ -970,6 +976,7 @@ export class TradovateClient {
|
|||||||
private requestSync(): void {
|
private requestSync(): void {
|
||||||
if (this.socketProblem(this.ws)) return;
|
if (this.socketProblem(this.ws)) return;
|
||||||
this.directEventCallbacks[3] = (response: any, status?: number) => {
|
this.directEventCallbacks[3] = (response: any, status?: number) => {
|
||||||
|
const requestedAt = this.syncRequestedAt;
|
||||||
this.syncRequestedAt = 0;
|
this.syncRequestedAt = 0;
|
||||||
// A refused or empty answer must not wipe the accounts, positions
|
// A refused or empty answer must not wipe the accounts, positions
|
||||||
// and balances we have — nor pass for a fresh sync.
|
// and balances we have — nor pass for a fresh sync.
|
||||||
@@ -1008,6 +1015,14 @@ export class TradovateClient {
|
|||||||
return account;
|
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'] = {};
|
const balances: TradovateClient['accountCashBalances'] = {};
|
||||||
for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) {
|
for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) {
|
||||||
storeCash(balances, item, this.accountCashBalances[item.accountId]);
|
storeCash(balances, item, this.accountCashBalances[item.accountId]);
|
||||||
@@ -1033,6 +1048,7 @@ export class TradovateClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.lastSyncAt = Date.now();
|
this.lastSyncAt = Date.now();
|
||||||
|
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
||||||
// A session counts as working once it has synced (abandonSocket).
|
// A session counts as working once it has synced (abandonSocket).
|
||||||
this.consecutiveSocketFailures = 0;
|
this.consecutiveSocketFailures = 0;
|
||||||
console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`);
|
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.
|
// so a payout the rebuilt report hasn't booked yet stays held.
|
||||||
// Called before wipeAccountData, so the stored rows and baseline are
|
// Called before wipeAccountData, so the stored rows and baseline are
|
||||||
// still there when this process hasn't loaded them yet.
|
// 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
|
const preWipe = this.historyHolds.get(accountId)?.fundSig
|
||||||
|
?? pending
|
||||||
?? fundSignature(this.fundTransactions[accountId] ?? loadFundTransactions(accountId));
|
?? fundSignature(this.fundTransactions[accountId] ?? loadFundTransactions(accountId));
|
||||||
this.hold(accountId, 'wipe', preWipe);
|
this.hold(accountId, 'wipe', preWipe);
|
||||||
saveAccountMeta(accountId, HOLD_META, 'wipe');
|
saveAccountMeta(accountId, HOLD_META, 'wipe');
|
||||||
@@ -2015,13 +2037,23 @@ export class TradovateClient {
|
|||||||
/** Resolves with the fill event for `orderId` (it carries the real
|
/** Resolves with the fill event for `orderId` (it carries the real
|
||||||
* execution price). Registered synchronously from the ack handler, so a
|
* execution price). Registered synchronously from the ack handler, so a
|
||||||
* fill later in the same frame as the ack is not missed. */
|
* 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> {
|
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));
|
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>> {
|
private waitForFill(orderId: number | undefined): Promise<Record<string, any>> {
|
||||||
// Fills are pushed on the event stream, independently of the ack: one
|
// 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)
|
// that got ahead of it (an earlier frame, or earlier in the same one)
|
||||||
|
|||||||
Reference in New Issue
Block a user