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
+58 -12
View File
@@ -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<number, { date: string; count: number; sum: number }>;
/** 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<number, string>;
/** 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<number, EntryOutcome>;
/** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */
sessionOpenJitter: Map<string, number>;
/** 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;
}
}