Roll window instead of a roll-time base; transient outages keep payout holds
Third review round (088886e):
- Tradovate: a record still on the ended trade date waits only inside
17:00-17:45 CT (every account rolled 17:17-17:21 on 2026-09-24); nothing
trades on it there, so no pre-roll fill can be lost or double-counted by
the broker's roll. Past the window an unrolled record (an idle account)
counts from its value when first read, so nothing waits indefinitely —
across restarts, stale bases or accounts the broker rolls only on a fill.
- One-and-done accounts also stop once this process fired a leg today.
- A balance hold is released unchecked only when Cash History is refused
outright (4xx / errorText, e.g. a passed account's 404s); a 5xx, timeout
or queue outage keeps the payout held until it recovers.
- A Cash History is judged against the baseline as it stood when requested,
so a fold observed while it was in flight is not taken for a payout.
- TopstepX: a cache fallback for an account not loaded before waits for a
funds check; accounts with no account type (not ours) aren't left
pending; a fetch awaits a sync already running and syncs again if its
accounts still wait, so wipe routes report the real outcome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
088886ed73
commit
52ed9c75e4
+62
-27
@@ -131,8 +131,8 @@ const HOLD_RETRY_MAX_MS = 60 * 60_000;
|
||||
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
|
||||
const HOLD_MAX_CHECKS = 3;
|
||||
const HOLD_RELEASE_MS = 15 * 60_000;
|
||||
/** One no Cash History can ever check (the account's report fails for good —
|
||||
* a passed account's 404s) is let go after this long, with a warning. */
|
||||
/** One no Cash History can ever check (the account's report is refused for
|
||||
* good — a passed account's 404s) is let go after this long, with a warning. */
|
||||
const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
||||
/** account_meta keys: the baseline a Cash History vouched for, and a wipe's
|
||||
* hold (so a restart onto a failing report keeps it). */
|
||||
@@ -174,26 +174,43 @@ interface StoredCash {
|
||||
realizedPnL: number;
|
||||
tradeDate: string | null;
|
||||
archived: boolean;
|
||||
/** The record's realizedPnL as last seen while its trade date was the
|
||||
* current one — the ended day's final figure once the 5 PM roll passes. */
|
||||
/** For a record the broker hasn't rolled past its trade date: its
|
||||
* realizedPnL when first read after the roll window — what later fills
|
||||
* on the same record are counted from. */
|
||||
rollBase: number | undefined;
|
||||
/** Past the roll with no rollBase seen (the process started after it):
|
||||
* today's share of the record can't be told apart, so the account waits
|
||||
* for Tradovate to roll it. */
|
||||
/** Inside the roll window with the ended day's record still in place:
|
||||
* the account waits for the broker to roll it. */
|
||||
pendingRoll: boolean;
|
||||
}
|
||||
|
||||
/** Tradovate rolled every account seen on 2026-09-24 at 17:17–17:21 CT. Until
|
||||
* this long after 5 PM, an account still on the ended day's record waits. */
|
||||
const ROLL_WINDOW_END_MIN = 17 * 60 + 45;
|
||||
|
||||
function inRollWindow(now: Date = new Date()): boolean {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||||
const minutes = (get('hour') % 24) * 60 + get('minute');
|
||||
return minutes >= 17 * 60 && minutes < ROLL_WINDOW_END_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account's cash as the plan must read it. Tradovate rolls each account's
|
||||
* cash record to the new trade date on its own schedule — archiving the old
|
||||
* record and opening one with realizedPnL 0 — well after the 17:00 CT roll
|
||||
* (17-21 minutes on 2026-09-24). Until then the latest record still carries
|
||||
* the ended day's realizedPnL, which the plan would read as today's: legs
|
||||
* sized off yesterday, one-and-done accounts read as already traded, the
|
||||
* entry-commission delta swallowing a whole day's P&L. And tonight's own
|
||||
* fills before the roll may be booked onto that same old record. So past the
|
||||
* roll an old-dated record's realized P&L counts only what it gained since
|
||||
* the roll (recorded - rollBase); its amount already holds the ended day.
|
||||
* (17-21 minutes on 2026-09-24), and an idle account perhaps not until it
|
||||
* trades. Until then the latest record still carries the ended day's
|
||||
* realizedPnL, which the plan would read as today's: legs sized off
|
||||
* yesterday, one-and-done accounts read as already traded, the
|
||||
* entry-commission delta swallowing a whole day's P&L.
|
||||
*
|
||||
* So inside the roll window an account on the ended day's record waits
|
||||
* (pendingRoll) — nothing is traded on it, so nothing is booked onto it that
|
||||
* the roll could lose. Past the window, a record still not rolled counts from
|
||||
* its value when first read then: 0 now, and any fill booked onto it later
|
||||
* shows. Its amount already holds the ended day.
|
||||
*/
|
||||
function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
|
||||
const t = cb.tradeDate;
|
||||
@@ -201,24 +218,23 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
|
||||
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
|
||||
: null;
|
||||
const recorded = cb.realizedPnL;
|
||||
// Seen while current: this is the base. Seen past the roll: keep the base
|
||||
// the same record carried before it, if this process saw one.
|
||||
const current = tradeDate === null || tradeDate >= currentTradeDateCT();
|
||||
const rollBase = current ? recorded : prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined;
|
||||
const ended = () => tradeDate !== null && tradeDate < currentTradeDateCT();
|
||||
return {
|
||||
const entry: StoredCash = {
|
||||
amount: cb.amount,
|
||||
tradeDate,
|
||||
archived: cb.archived === true,
|
||||
rollBase,
|
||||
rollBase: prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined,
|
||||
get realizedPnL(): number {
|
||||
if (!ended()) return recorded;
|
||||
return rollBase === undefined ? 0 : Math.round((recorded - rollBase) * 100) / 100;
|
||||
if (inRollWindow()) return 0;
|
||||
entry.rollBase ??= recorded;
|
||||
return Math.round((recorded - entry.rollBase) * 100) / 100;
|
||||
},
|
||||
get pendingRoll(): boolean {
|
||||
return ended() && rollBase === undefined;
|
||||
return ended() && inRollWindow();
|
||||
},
|
||||
};
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,6 +335,9 @@ 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>();
|
||||
/** Interval handles tracked so they can be cleared on reconnect */
|
||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -1278,6 +1297,7 @@ export class TradovateClient {
|
||||
const cashNow = this.accountCashBalances[account.id];
|
||||
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
|
||||
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
|
||||
const knownBefore = this.balanceBaseline[account.id] ? { ...this.balanceBaseline[account.id] } : null;
|
||||
|
||||
// --- Try Cash History first (true after-fee daily P&L) ---
|
||||
try {
|
||||
@@ -1363,9 +1383,18 @@ 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.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore);
|
||||
this.cashHistoryUnavailable.delete(account.id);
|
||||
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
|
||||
continue;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Refused outright (a 4xx, an errorText) — a passed account's
|
||||
// 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)
|
||||
|| /without data/.test(errorMessage(err));
|
||||
if (refused) this.cashHistoryUnavailable.add(account.id);
|
||||
else this.cashHistoryUnavailable.delete(account.id);
|
||||
// fall through to Fills fallback
|
||||
}
|
||||
|
||||
@@ -1513,6 +1542,7 @@ export class TradovateClient {
|
||||
fromCashHistory: boolean,
|
||||
cashBefore: { amount: number; realizedPnL: number } | null = null,
|
||||
fundSigBefore = '',
|
||||
knownBefore: CashBaseline | null = null,
|
||||
): void {
|
||||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
@@ -1533,9 +1563,12 @@ export class TradovateClient {
|
||||
if (hold) {
|
||||
hold.attempts++;
|
||||
hold.retryAt = now + holdRetryMs(hold.attempts);
|
||||
if (hold.reason === 'balance' && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
|
||||
// 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) {
|
||||
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
|
||||
+ ` ${Math.round((now - hold.since) / 60_000)}m — Cash History never came back; releasing it`);
|
||||
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
|
||||
this.historyHolds.delete(accountId);
|
||||
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
|
||||
}
|
||||
@@ -1576,7 +1609,9 @@ export class TradovateClient {
|
||||
|
||||
// A balance the last vouched-for baseline can't explain, with nothing
|
||||
// booked by this report: a payout it hasn't caught up with yet.
|
||||
const known = this.balanceBaseline[accountId];
|
||||
// Judged against the baseline as it stood when the report was asked
|
||||
// for: a fold the sync saw while it was in flight is not a payout.
|
||||
const known = knownBefore ?? this.balanceBaseline[accountId];
|
||||
if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
|
||||
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
|
||||
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`
|
||||
|
||||
Reference in New Issue
Block a user