From 8e66631b0ec3953d60ca383a37229046055bf842 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Thu, 24 Sep 2026 21:12:51 -0500 Subject: [PATCH] Entries of unknown outcome hold the account until settled; wipes keep their guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 of 52ed9c7 and the stacked re-entries of 1eb0736. - 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 --- app/api/accounts/[id]/wipe/route.ts | 9 ++-- app/api/accounts/wipe-all/route.ts | 14 +++--- lib/auto-trade.ts | 70 ++++++++++++++++++++++++----- lib/db.ts | 10 ++++- lib/tradovate-class.ts | 36 +++++++++++++-- 5 files changed, 114 insertions(+), 25 deletions(-) diff --git a/app/api/accounts/[id]/wipe/route.ts b/app/api/accounts/[id]/wipe/route.ts index f12e10c..cd255af 100644 --- a/app/api/accounts/[id]/wipe/route.ts +++ b/app/api/accounts/[id]/wipe/route.ts @@ -21,16 +21,19 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st const account = client.accountList.find((a) => a.id === accountId); if (!account) continue; - wipeAccountData(accountId); + // Read before the wipe: the resolver may un-assign it mid-rebuild. + const ours = Boolean(getAccountConfig(accountId).accountType); // Clear the in-memory view too — a failed refetch must leave the // account visibly empty, not showing the data that was just wiped — - // and out of trading until a fetch rebuilds it. + // and out of trading until a fetch rebuilds it. First, so the client + // reads the stored rows it keeps as the rebuild's yardstick. client.forgetHistory(accountId, account.name); + wipeAccountData(accountId); await client.fetchDaysTraded([accountId]); - const loaded = client.historyReady(accountId) || !getAccountConfig(accountId).accountType; + const loaded = client.historyReady(accountId) || !ours; return NextResponse.json({ ok: true, entries: (client.dailyPnL[accountId] ?? []).length, diff --git a/app/api/accounts/wipe-all/route.ts b/app/api/accounts/wipe-all/route.ts index 5bfb16b..f3c4baa 100644 --- a/app/api/accounts/wipe-all/route.ts +++ b/app/api/accounts/wipe-all/route.ts @@ -22,17 +22,21 @@ export async function POST() { await Promise.all(clients.map(async (client) => { // Forgotten, the accounts read unloaded and stay out of trading // until the refetch below (or, if it fails, a later one) lands. - const ids = client.accountList.map((account) => account.id); + // Taken before the wipe: the resolver may un-assign an account while + // the rebuild runs, and it still counts. Accounts with no type aren't + // traded, so they aren't failures. + const ours = client.accountList.filter((account) => getAccountConfig(account.id).accountType).map((a) => a.id); for (const account of client.accountList) { - wipeAccountData(account.id); + // History first: the client reads the stored rows it keeps as the + // rebuild's yardstick before they go. client.forgetHistory(account.id, account.name); + wipeAccountData(account.id); wiped++; } await client.fetchDaysTraded(); // Counted from the result, not the fetch's own tally: a client with - // no session fetches nothing and reports no failures. Accounts with - // no type aren't traded, so they aren't failures either. - failedAccounts += ids.filter((id) => getAccountConfig(id).accountType && !client.historyReady(id)).length; + // no session fetches nothing and reports no failures. + failedAccounts += ours.filter((id) => !client.historyReady(id)).length; })); return NextResponse.json({ ok: true, wiped, failedAccounts }); diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 2483af9..142ba1a 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -37,10 +37,11 @@ interface TradeRuntime { * how a stop-out is detected. Resets on restart, which is fine — day * completion is derived from realized PnL, not from this counter. */ legCounts: Map; - /** Per account, the trade date of its last entry the broker confirmed as - * filled — the one-and-done guard's "already traded", which an entry - * whose outcome is unknown or that never filled must not trip. */ - filledEntries?: Map; + /** Per account, how today's last entry ended: 'filled' (confirmed), or + * 'unknown' (an ack or fill that never came back — it may have filled, + * with no exit placed). An entry that never reached the broker leaves + * nothing here. */ + entryOutcomes?: Map; /** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */ sessionOpenJitter: Map; /** Accounts whose orders are being placed right now. Their position only @@ -124,8 +125,46 @@ function legSumToday(accountId: number, tradeDate: string): number { return entry && entry.date === tradeDate ? entry.sum : 0; } -function filledEntryToday(accountId: number, tradeDate: string): boolean { - return runtime().filledEntries?.get(accountId) === tradeDate; +interface EntryOutcome { + tradeDate: string; + state: 'filled' | 'unknown'; + /** When the entry went out, and the realized P&L just before it. */ + at: number; + realizedBefore: number; + legAmount: 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). */ +const UNKNOWN_ENTRY_SETTLE_MS = 90_000; + +function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null { + const outcome = runtime().entryOutcomes?.get(accountId); + return outcome && outcome.tradeDate === tradeDate ? outcome : null; +} + +/** + * An entry whose outcome is unknown holds the account — it may be in a + * position no exit protects, and a retry would stack another on it — until + * broker state shows it never filled; then the leg is taken back and the + * account is free again. True while it still holds. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function unknownEntryHolds(client: any, accountId: number, tradeDate: string): boolean { + 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] + && cash !== undefined + && Math.abs(cash.realizedPnL - outcome.realizedBefore) < 0.005; + 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`); + return false; } function recordLeg(accountId: number, tradeDate: string, legAmount: number): void { @@ -175,16 +214,20 @@ async function sendEntry( tradeDate: string, legAmount: number, ) { + const outcomes = (runtime().entryOutcomes ??= new Map()); + const at = Date.now(); + const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0; try { const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market'); - (runtime().filledEntries ??= new Map()).set(acc.id, tradeDate); + outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount }); return fill; } catch (err) { if (isOrderNotPlaced(err)) { unrecordLeg(acc.id, tradeDate, legAmount); console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`); } else { - console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept (it may have filled)`); + outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount }); + console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`); } throw err; } @@ -394,6 +437,8 @@ const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch'; /** Likewise for an account whose broker hasn't rolled its cash record to the * new trade date, when this process didn't see the old day end. */ const ROLL_PENDING = "waiting for the broker to roll the account's day"; +/** An entry whose outcome is unknown, waiting for the broker to show it. */ +const ENTRY_UNKNOWN = 'entry outcome unknown — waiting for the broker to show it'; interface AccountEvaluation { eligible: boolean; @@ -445,6 +490,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // fresh stage-1 first day: an oversized leg, or a re-opened finished cycle. if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING }; if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; + if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; // Obfuscation stops: a stop-out shows as realized P&L falling short of the // legs fired today — the day is booked; the make-back ride is tomorrow. @@ -491,9 +537,8 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a } // And any entry of today's the broker confirmed filled, whatever its // realized P&L says (a record replaced under it, a fill not booked - // yet). Only a confirmed fill: an entry that timed out or was - // rejected unfilled leaves the account free to retry. - if (filledEntryToday(acc.id, tradeDate)) { + // yet). One of unknown outcome is held above until settled. + if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') { return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' }; } if (!isExtraDay && plan.remainingToday <= 0) { @@ -1235,7 +1280,8 @@ function hasRemainingConfiguredAccounts(): boolean { // during the session and before it opens alike (a restart between // the 5 PM roll and the open loads histories right then). Only // once the session has closed is a still-loading account let go. - if ((ev?.reason === HISTORY_PENDING || ev?.reason === ROLL_PENDING) && !isAfterSessionClose()) return true; + if ((ev?.reason === HISTORY_PENDING || ev?.reason === ROLL_PENDING || ev?.reason === ENTRY_UNKNOWN) + && !isAfterSessionClose()) return true; } } diff --git a/lib/db.ts b/lib/db.ts index fb5f265..763184b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -377,11 +377,19 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl * meta — so the next report fetch rebuilds it from scratch. Rows older than * the report's 28-day window are gone for good; that is the point (stale * pre-roll dates), but it is why callers confirm first. */ +/** account_meta keys a wipe leaves in place (see wipeAccountData). */ +const WIPE_KEEPS = ['cash_baseline', 'roll_base', 'history_hold', 'wipe_fund_sig', 'inferred_account_size']; + export function wipeAccountData(accountId: number): void { const txn = db.transaction(() => { db.prepare('DELETE FROM daily_pnl WHERE account_id = ?').run(accountId); db.prepare('DELETE FROM fund_transactions WHERE account_id = ?').run(accountId); - db.prepare('DELETE FROM account_meta WHERE account_id = ?').run(accountId); + // History goes; what guards the rebuild stays — the balance baseline + // and roll base a Cash History vouched for, the wipe's own hold and + // pre-wipe fund signature — and the resolver's inferred size, which + // nothing in the rebuilt history can infer again. + db.prepare(`DELETE FROM account_meta WHERE account_id = ? AND key NOT IN (${WIPE_KEEPS.map(() => '?').join(', ')})`) + .run(accountId, ...WIPE_KEEPS); }); txn(); } diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 99b23e9..65631c1 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -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 { + 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> { // 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); });