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
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { wipeAccountData } from '@/lib/db';
/**
@@ -29,7 +30,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
await client.fetchDaysTraded([accountId]);
const loaded = client.historyReady(accountId);
const loaded = client.historyReady(accountId) || !getAccountConfig(accountId).accountType;
return NextResponse.json({
ok: true,
entries: (client.dailyPnL[accountId] ?? []).length,
+4 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { wipeAccountData } from '@/lib/db';
/**
@@ -29,8 +30,9 @@ export async function POST() {
}
await client.fetchDaysTraded();
// Counted from the result, not the fetch's own tally: a client with
// no session fetches nothing and reports no failures.
failedAccounts += ids.filter((id) => !client.historyReady(id)).length;
// 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;
}));
return NextResponse.json({ ok: true, wiped, failedAccounts });
+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
+9 -5
View File
@@ -894,10 +894,11 @@ export class TopstepXClient implements PlatformClient {
console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`);
}
// A freshly fetched history is tradable once a fresh pass has
// actually checked the funds behind the balance (a skipped
// check — a position still open — waits for a later pass).
const ours = Boolean(getAccountConfig(acc.id).accountType);
if ((reconciled || !ours) && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
// actually checked the funds behind the balance. A skipped
// check waits for a later pass — a position still open, or no
// account type yet (not traded until one is assigned, and then
// only after a check).
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
try {
this.updateMllFloor(acc);
@@ -1290,8 +1291,11 @@ export class TopstepXClient implements PlatformClient {
// rather than leave them out for up to a minute, and before returning,
// so a wipe route reads the result. (The firm's first pass syncs from
// maybeFetchDaysTraded once readiness allows.)
// Only ours: an account with no type stays pending without costing
// a sync on every pass.
const waiting = () => this.accountList.some((a) => this.reconcilePendingIds.has(a.id)
&& (!onlyAccountIds || onlyAccountIds.includes(a.id)));
&& (!onlyAccountIds || onlyAccountIds.includes(a.id))
&& Boolean(getAccountConfig(a.id).accountType));
if (waiting() && this.historyLoaded && !this.intentionalDisconnect) {
await this.requestSync(); // a pass already running may have gone past them
if (waiting()) await this.requestSync();
+56 -14
View File
@@ -138,6 +138,7 @@ const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
* hold (so a restart onto a failing report keeps it). */
const BASELINE_META = 'cash_baseline';
const HOLD_META = 'history_hold';
const ROLL_BASE_META = 'roll_base';
function holdRetryMs(attempts: number): number {
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1));
@@ -149,6 +150,13 @@ function fundSignature(txns: { date: string; amount: number }[] | undefined): st
return (txns ?? []).map((f) => `${f.date}:${f.amount}`).join('|');
}
/** 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 {
const had = new Set(before ? before.split('|') : []);
return (now ? now.split('|') : []).some((row) => !had.has(row));
}
/** Whether cash is still what the baseline accounts for: the same start-of-day
* balance ('same'), or the daily reset having folded the day's realized P&L
* into the balance ('folded'). */
@@ -227,7 +235,7 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
get realizedPnL(): number {
if (!ended()) return recorded;
if (inRollWindow()) return 0;
entry.rollBase ??= recorded;
entry.rollBase ??= latchRollBase(cb.accountId, tradeDate!, recorded);
return Math.round((recorded - entry.rollBase) * 100) / 100;
},
get pendingRoll(): boolean {
@@ -237,6 +245,20 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
return entry;
}
/** The roll base for an unrolled record, kept on record per trade date: a
* restart later the same trade date must count from the same point, not
* from a value that already includes the day's fills. */
function latchRollBase(accountId: number, recordDate: string, recorded: number): number {
const day = currentTradeDateCT();
try {
const saved = JSON.parse(loadAccountMeta(accountId, ROLL_BASE_META) ?? 'null') as
{ record?: string; day?: string; base?: number } | null;
if (saved?.record === recordDate && saved.day === day && Number.isFinite(saved.base)) return saved.base!;
} catch { /* unreadable: latch afresh */ }
saveAccountMeta(accountId, ROLL_BASE_META, JSON.stringify({ record: recordDate, day, base: recorded }));
return recorded;
}
/**
* Store a cash record unless the one already held is newer. At the roll the
* snapshot and the socket deliver the ended day's record (archived) and the
@@ -335,9 +357,11 @@ export class TradovateClient {
* targeted pass doesn't request the same reports again (a wipe fetches
* outside maybeFetchDaysTraded). */
private fetchingIds = new Set<number>();
/** Accounts whose Cash History was refused outright on its last try (a
* 4xx or an errorText — a passed account's 404s), not merely failing. */
private cashHistoryUnavailable = new Set<number>();
/** Per account, since when every Cash History try has been refused
* outright (a 4xx other than auth/rate limits, or an answer without data
* — a passed account's 404s), as against merely failing. Any other
* outcome clears it. */
private cashHistoryRefusedSince = new Map<number, number>();
/** Interval handles tracked so they can be cleared on reconnect */
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
@@ -1383,7 +1407,7 @@ export class TradovateClient {
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
delete this.lastFetchErrors[account.name];
this.cashHistoryUnavailable.delete(account.id);
this.cashHistoryRefusedSince.delete(account.id);
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
continue;
} catch (err) {
@@ -1391,10 +1415,11 @@ export class TradovateClient {
// Cash History 404s for good — as against a server that may
// answer next time (5xx, a timeout, still queued).
const status = (err as { response?: { status?: number } } | null)?.response?.status;
const refused = (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429)
const refused = (typeof status === 'number' && status >= 400 && status < 500
&& ![401, 403, 408, 429].includes(status))
|| /without data/.test(errorMessage(err));
if (refused) this.cashHistoryUnavailable.add(account.id);
else this.cashHistoryUnavailable.delete(account.id);
if (!refused) this.cashHistoryRefusedSince.delete(account.id);
else if (!this.cashHistoryRefusedSince.has(account.id)) this.cashHistoryRefusedSince.set(account.id, Date.now());
// fall through to Fills fallback
}
@@ -1503,9 +1528,13 @@ export class TradovateClient {
* bring back. Kept on record too, so a restart doesn't forget it. */
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.hold(accountId, 'wipe', '');
// 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]);
this.hold(accountId, 'wipe', preWipe);
saveAccountMeta(accountId, HOLD_META, 'wipe');
delete this.balanceBaseline[accountId];
this.saveBaseline(accountId); // wipeAccountData dropped the saved one
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
@@ -1565,8 +1594,9 @@ export class TradovateClient {
hold.retryAt = now + holdRetryMs(hold.attempts);
// Only when Cash History is refused for good (a passed account):
// an outage that may end keeps the payout held for it.
if (hold.reason === 'balance' && this.cashHistoryUnavailable.has(accountId)
&& now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
const refusedSince = this.cashHistoryRefusedSince.get(accountId);
if (hold.reason === 'balance' && refusedSince !== undefined
&& now - refusedSince >= HOLD_UNCHECKED_RELEASE_MS && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
this.historyHolds.delete(accountId);
@@ -1600,9 +1630,21 @@ export class TradovateClient {
this.setBaseline(accountId, cashAfter ?? null);
return;
}
// Booked (balance), or rebuilt from Cash History (wipe).
if (hold.reason === 'wipe') {
saveAccountMeta(accountId, HOLD_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.
const known = knownBefore ?? this.balanceBaseline[accountId];
if (known && before && cashExplained(known, before) === null && !bookedNew(hold.fundSig, fundSigNow)) {
this.hold(accountId, 'balance', fundSigNow, now + 60_000);
console.warn(`${this.tag} ${this.accountName(accountId)}: rebuilt after the wipe, but its balance change`
+ ' is still unbooked — held for a later report');
return;
}
}
// Booked (balance), or rebuilt and explained (wipe).
this.historyHolds.delete(accountId);
if (hold.reason === 'wipe') saveAccountMeta(accountId, HOLD_META, '');
this.setBaseline(accountId, before);
return;
}