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 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 21:12:51 -05:00
co-authored by Claude Opus 5.5
parent 1eb0736fbe
commit 8e66631b0e
5 changed files with 114 additions and 25 deletions
+6 -3
View File
@@ -21,16 +21,19 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
const account = client.accountList.find((a) => a.id === accountId); const account = client.accountList.find((a) => a.id === accountId);
if (!account) continue; 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 // Clear the in-memory view too — a failed refetch must leave the
// account visibly empty, not showing the data that was just wiped — // 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); client.forgetHistory(accountId, account.name);
wipeAccountData(accountId);
await client.fetchDaysTraded([accountId]); await client.fetchDaysTraded([accountId]);
const loaded = client.historyReady(accountId) || !getAccountConfig(accountId).accountType; const loaded = client.historyReady(accountId) || !ours;
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
entries: (client.dailyPnL[accountId] ?? []).length, entries: (client.dailyPnL[accountId] ?? []).length,
+9 -5
View File
@@ -22,17 +22,21 @@ export async function POST() {
await Promise.all(clients.map(async (client) => { await Promise.all(clients.map(async (client) => {
// Forgotten, the accounts read unloaded and stay out of trading // Forgotten, the accounts read unloaded and stay out of trading
// until the refetch below (or, if it fails, a later one) lands. // 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) { 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); client.forgetHistory(account.id, account.name);
wipeAccountData(account.id);
wiped++; wiped++;
} }
await client.fetchDaysTraded(); await client.fetchDaysTraded();
// Counted from the result, not the fetch's own tally: a client with // Counted from the result, not the fetch's own tally: a client with
// no session fetches nothing and reports no failures. Accounts with // no session fetches nothing and reports no failures.
// no type aren't traded, so they aren't failures either. failedAccounts += ours.filter((id) => !client.historyReady(id)).length;
failedAccounts += ids.filter((id) => getAccountConfig(id).accountType && !client.historyReady(id)).length;
})); }));
return NextResponse.json({ ok: true, wiped, failedAccounts }); return NextResponse.json({ ok: true, wiped, failedAccounts });
+58 -12
View File
@@ -37,10 +37,11 @@ interface TradeRuntime {
* how a stop-out is detected. Resets on restart, which is fine — day * how a stop-out is detected. Resets on restart, which is fine — day
* completion is derived from realized PnL, not from this counter. */ * completion is derived from realized PnL, not from this counter. */
legCounts: Map<number, { date: string; count: number; sum: number }>; legCounts: Map<number, { date: string; count: number; sum: number }>;
/** Per account, the trade date of its last entry the broker confirmed as /** Per account, how today's last entry ended: 'filled' (confirmed), or
* filled — the one-and-done guard's "already traded", which an entry * 'unknown' (an ack or fill that never came back — it may have filled,
* whose outcome is unknown or that never filled must not trip. */ * with no exit placed). An entry that never reached the broker leaves
filledEntries?: Map<number, string>; * nothing here. */
entryOutcomes?: Map<number, EntryOutcome>;
/** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */ /** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */
sessionOpenJitter: Map<string, number>; sessionOpenJitter: Map<string, number>;
/** Accounts whose orders are being placed right now. Their position only /** 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; return entry && entry.date === tradeDate ? entry.sum : 0;
} }
function filledEntryToday(accountId: number, tradeDate: string): boolean { interface EntryOutcome {
return runtime().filledEntries?.get(accountId) === tradeDate; 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 { function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
@@ -175,16 +214,20 @@ async function sendEntry(
tradeDate: string, tradeDate: string,
legAmount: number, legAmount: number,
) { ) {
const outcomes = (runtime().entryOutcomes ??= new Map());
const at = Date.now();
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
try { try {
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market'); 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; return fill;
} catch (err) { } catch (err) {
if (isOrderNotPlaced(err)) { if (isOrderNotPlaced(err)) {
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 {
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; 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 /** 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. */ * 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"; 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 { interface AccountEvaluation {
eligible: boolean; 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. // 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 (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_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 // 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. // 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 // 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 // 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 // yet). One of unknown outcome is held above until settled.
// rejected unfilled leaves the account free to retry. if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') {
if (filledEntryToday(acc.id, tradeDate)) {
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' }; return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
} }
if (!isExtraDay && plan.remainingToday <= 0) { if (!isExtraDay && plan.remainingToday <= 0) {
@@ -1235,7 +1280,8 @@ function hasRemainingConfiguredAccounts(): boolean {
// during the session and before it opens alike (a restart between // during the session and before it opens alike (a restart between
// the 5 PM roll and the open loads histories right then). Only // the 5 PM roll and the open loads histories right then). Only
// once the session has closed is a still-loading account let go. // 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;
} }
} }
+9 -1
View File
@@ -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 * 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 * 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. */ * 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 { export function wipeAccountData(accountId: number): void {
const txn = db.transaction(() => { const txn = db.transaction(() => {
db.prepare('DELETE FROM daily_pnl WHERE account_id = ?').run(accountId); 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 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(); txn();
} }
+32 -4
View File
@@ -139,6 +139,9 @@ const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
const BASELINE_META = 'cash_baseline'; const BASELINE_META = 'cash_baseline';
const HOLD_META = 'history_hold'; const HOLD_META = 'history_hold';
const ROLL_BASE_META = 'roll_base'; 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 { function holdRetryMs(attempts: number): number {
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1)); 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 /** 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). */ * wipe can lose rows older than the report's window without booking any). */
function bookedNew(before: string, now: string): boolean { function bookedNew(before: string, now: string): boolean {
if (before === UNKNOWN_SIG) return false;
const had = new Set(before ? before.split('|') : []); const had = new Set(before ? before.split('|') : []);
return (now ? now.split('|') : []).some((row) => !had.has(row)); 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 // The fund transactions and baseline from before the wipe stay as the
// yardstick: the rebuild must explain today's balance against them, // yardstick: the rebuild must explain today's balance against them,
// so a payout the rebuilt report hasn't booked yet stays held. // 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); this.hold(accountId, 'wipe', preWipe);
saveAccountMeta(accountId, HOLD_META, 'wipe'); 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.dailyPnL[accountId];
delete this.fullDailyPnL[accountId]; delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId]; delete this.daysTraded[accountId];
@@ -1580,7 +1591,8 @@ export class TradovateClient {
this.historyLoadedIds.add(accountId); this.historyLoadedIds.add(accountId);
const now = Date.now(); const now = Date.now();
if (!this.historyHolds.has(accountId) && loadAccountMeta(accountId, HOLD_META) === 'wipe') { 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); const hold = this.historyHolds.get(accountId);
if (!this.balanceBaseline[accountId]) { if (!this.balanceBaseline[accountId]) {
@@ -1632,6 +1644,7 @@ export class TradovateClient {
} }
if (hold.reason === 'wipe') { if (hold.reason === 'wipe') {
saveAccountMeta(accountId, HOLD_META, ''); saveAccountMeta(accountId, HOLD_META, '');
saveAccountMeta(accountId, WIPE_SIG_META, '');
// Rebuilt — but the balance must still be explained against // Rebuilt — but the balance must still be explained against
// the pre-wipe baseline, unless the rebuild booked a fund // the pre-wipe baseline, unless the rebuild booked a fund
// transaction the pre-wipe rows didn't have. // 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 /** 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). */
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>> { 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)
@@ -2030,7 +2050,15 @@ export class TradovateClient {
}; };
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.indirectEventCallbacks = this.indirectEventCallbacks.filter((info) => info !== watcher); 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); }, FILL_TIMEOUT_MS);
this.indirectEventCallbacks.push(watcher); this.indirectEventCallbacks.push(watcher);
}); });