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
+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;
}