One-and-done counts confirmed fills; holds need a persistent refusal; wipes keep their yardstick

Fourth review round (52ed9c7):

- The one-and-done guard counts only entries the broker confirmed filled,
  so an entry that timed out or was rejected unfilled no longer benches the
  account for the day.
- TopstepX: an account is promoted only by a sync that actually checked its
  funds; one with no account type stays out (not traded) without costing a
  sync per pass, and once typed waits for that check. Wipe routes don't
  count untyped accounts as failures.
- Tradovate: an unchecked balance-hold release needs Cash History refused
  for a full hour straight (a 4xx other than 401/403/408/429, or an answer
  without data); any other outcome resets it, so one stray reply during a
  5xx outage can't release a payout.
- A wipe keeps the pre-wipe baseline and fund rows as its yardstick: the
  rebuild must explain today's balance against them, so a payout the
  rebuilt report hasn't booked yet stays held.
- The roll base of an unrolled record is kept per trade date in
  account_meta, so a restart later the same trade date counts from the same
  point instead of from a value that already includes the day's fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 20:46:35 -05:00
co-authored by Claude Opus 5.5
parent 52ed9c75e4
commit 1eb0736fbe
5 changed files with 88 additions and 27 deletions
+17 -5
View File
@@ -37,6 +37,10 @@ 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>;
/** 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
@@ -120,6 +124,10 @@ 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;
}
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
runtime().legCounts.set(accountId, {
date: tradeDate,
@@ -168,7 +176,9 @@ async function sendEntry(
legAmount: number,
) {
try {
return 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);
return fill;
} catch (err) {
if (isOrderNotPlaced(err)) {
unrecordLeg(acc.id, tradeDate, legAmount);
@@ -479,10 +489,12 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
// And any leg this process fired today, whatever the broker's
// realized P&L says (a record replaced under it, a fill not booked yet).
if (legsToday(acc.id, tradeDate) > 0) {
return { ...evaluated, eligible: false, reason: 'already traded today (leg fired)' };
// 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)) {
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
}
if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0