Entries of unknown outcome hold the account until settled; wipes keep their guards
Fifth review round (1eb0736): - An entry now has three outcomes. Filled (confirmed): one-and-done is done for the day. Never placed: leg taken back, free to retry. Unknown (an ack or fill that never came back): the account — any mode — is held until a good sync at least 90s later shows it flat with realized P&L unchanged, then the leg is taken back and it may retry. Stop-after-all counts it in play meanwhile. This closes both the day-long bench of52ed9c7and the stacked re-entries of1eb0736. - Tradovate cancels an acked order whose fill didn't come within 30s (as TopstepX already does), so a retry can't sit beside a still-working one. - A wipe keeps the rebuild's guards: wipeAccountData leaves the Cash History baseline, roll base, wipe hold, pre-wipe fund signature and the resolver's inferred size in account_meta; the routes forget history before wiping (so the stored rows are still there to read), and a wipe hold restored after a restart keeps its pre-wipe yardstick. - Wipe routes take the set of typed accounts before wiping, so an account the resolver un-assigns mid-rebuild still counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1eb0736fbe
commit
8e66631b0e
+32
-4
@@ -139,6 +139,9 @@ const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
||||
const BASELINE_META = 'cash_baseline';
|
||||
const HOLD_META = 'history_hold';
|
||||
const ROLL_BASE_META = 'roll_base';
|
||||
const WIPE_SIG_META = 'wipe_fund_sig';
|
||||
/** A pre-wipe fund signature that wasn't kept: no rebuilt row counts as new. */
|
||||
const UNKNOWN_SIG = '?';
|
||||
|
||||
function holdRetryMs(attempts: number): number {
|
||||
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1));
|
||||
@@ -153,6 +156,7 @@ function fundSignature(txns: { date: string; amount: number }[] | undefined): st
|
||||
/** Whether `now` holds a fund transaction `before` didn't (a rebuild after a
|
||||
* wipe can lose rows older than the report's window without booking any). */
|
||||
function bookedNew(before: string, now: string): boolean {
|
||||
if (before === UNKNOWN_SIG) return false;
|
||||
const had = new Set(before ? before.split('|') : []);
|
||||
return (now ? now.split('|') : []).some((row) => !had.has(row));
|
||||
}
|
||||
@@ -1531,10 +1535,17 @@ export class TradovateClient {
|
||||
// The fund transactions and baseline from before the wipe stay as the
|
||||
// yardstick: the rebuild must explain today's balance against them,
|
||||
// so a payout the rebuilt report hasn't booked yet stays held.
|
||||
const preWipe = this.historyHolds.get(accountId)?.fundSig ?? fundSignature(this.fundTransactions[accountId]);
|
||||
// Called before wipeAccountData, so the stored rows and baseline are
|
||||
// still there when this process hasn't loaded them yet.
|
||||
const preWipe = this.historyHolds.get(accountId)?.fundSig
|
||||
?? fundSignature(this.fundTransactions[accountId] ?? loadFundTransactions(accountId));
|
||||
this.hold(accountId, 'wipe', preWipe);
|
||||
saveAccountMeta(accountId, HOLD_META, 'wipe');
|
||||
this.saveBaseline(accountId); // wipeAccountData dropped the saved one
|
||||
saveAccountMeta(accountId, WIPE_SIG_META, preWipe);
|
||||
if (!this.balanceBaseline[accountId]) {
|
||||
const saved = this.savedBaseline(accountId);
|
||||
if (saved) this.balanceBaseline[accountId] = saved;
|
||||
}
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
@@ -1580,7 +1591,8 @@ export class TradovateClient {
|
||||
this.historyLoadedIds.add(accountId);
|
||||
const now = Date.now();
|
||||
if (!this.historyHolds.has(accountId) && loadAccountMeta(accountId, HOLD_META) === 'wipe') {
|
||||
this.hold(accountId, 'wipe', ''); // a wipe from before a restart
|
||||
// A wipe from before a restart, with its pre-wipe yardstick.
|
||||
this.hold(accountId, 'wipe', loadAccountMeta(accountId, WIPE_SIG_META) ?? UNKNOWN_SIG);
|
||||
}
|
||||
const hold = this.historyHolds.get(accountId);
|
||||
if (!this.balanceBaseline[accountId]) {
|
||||
@@ -1632,6 +1644,7 @@ export class TradovateClient {
|
||||
}
|
||||
if (hold.reason === 'wipe') {
|
||||
saveAccountMeta(accountId, HOLD_META, '');
|
||||
saveAccountMeta(accountId, WIPE_SIG_META, '');
|
||||
// Rebuilt — but the balance must still be explained against
|
||||
// the pre-wipe baseline, unless the rebuild booked a fund
|
||||
// transaction the pre-wipe rows didn't have.
|
||||
@@ -2002,6 +2015,13 @@ 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). */
|
||||
private cancelOrder(orderId: number): Promise<void> {
|
||||
return this.sendRequest('order/cancelorder', `\n${JSON.stringify({ orderId })}`, ACK_TIMEOUT_MS, (response) => {
|
||||
console.log(`${this.tag} cancelorder ${orderId}:`, JSON.stringify(response));
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -2030,7 +2050,15 @@ export class TradovateClient {
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
this.indirectEventCallbacks = this.indirectEventCallbacks.filter((info) => info !== watcher);
|
||||
reject(new Error(`Order ${orderId} acknowledged but no fill within 30s (market may be closed)`));
|
||||
// The caller gives up on it and places no exit, so a late fill
|
||||
// would open a position nothing manages — and a retry would
|
||||
// add a second order beside it. Cancel it, best effort (a
|
||||
// no-op if it filled after all), like TopstepX does.
|
||||
if (orderId != null) {
|
||||
this.cancelOrder(orderId).catch((err) =>
|
||||
console.error(`${this.tag} failed to cancel unfilled order ${orderId}: ${errorMessage(err)}`));
|
||||
}
|
||||
reject(new Error(`Order ${orderId} acknowledged but no fill within 30s (market may be closed) — cancel requested`));
|
||||
}, FILL_TIMEOUT_MS);
|
||||
this.indirectEventCallbacks.push(watcher);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user