Pre-roll fills stay visible; holds survive restarts and judge fund amounts

Second review round (e496404, 9b2d677):

- Tradovate: past the 5 PM roll an old-dated cash record counts only what
  it gained since the roll (its realizedPnL as last seen while current is
  kept as the roll base), so tonight's fills booked onto it before
  Tradovate rolls the account stay visible — no re-trading a one-and-done
  account. A record this process never saw before the roll leaves the
  account waiting for the broker's roll (ROLL_PENDING, counted in play for
  stop-after-all). The 60s snapshot carries the roll base and can't undo a
  newer record the socket delivered.
- A wipe's hold is kept in account_meta, so a restart onto a failing Cash
  History still holds the account.
- A Cash History whose pre-request balance the last vouched-for baseline
  can't explain, and which booked nothing, holds the account instead of
  rebasing onto an unbooked payout.
- Holds compare fund transactions by date and amount (a second payout on a
  date that already has one), and an in-flight hold is judged against the
  rows from before its report.
- The saved baseline tracks the day's realized P&L and folds, so a restart
  judges against current figures; held refetches back off to an hour, and
  a balance hold no Cash History can check is released after an hour.
- TopstepX: a fetched account is promoted only by a sync whose
  reconcileFunds actually checked the books (not one skipped for an open
  position), and a direct fetch awaits that sync, so wipe routes report
  the real outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 18:45:45 -05:00
co-authored by Claude Opus 5.5
parent 9b2d677662
commit 088886ed73
4 changed files with 204 additions and 107 deletions
+5 -1
View File
@@ -381,6 +381,9 @@ function entryCommissionFrom(before: number, after: number, contracts: number):
/** The ineligible reason for an account whose history isn't loaded yet — still /** The ineligible reason for an account whose history isn't loaded yet — still
* in play for stop-after-all, since it trades once the fetch lands. */ * in play for stop-after-all, since it trades once the fetch lands. */
const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch'; 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";
interface AccountEvaluation { interface AccountEvaluation {
eligible: boolean; eligible: boolean;
@@ -431,6 +434,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// account) or that a payout has overtaken would plan the account as a // account) or that a payout has overtaken would plan the account as a
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle. // 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 (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
// Obfuscation stops: a stop-out shows as realized P&L falling short of the // 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. // legs fired today — the day is booked; the make-back ride is tomorrow.
@@ -1214,7 +1218,7 @@ function hasRemainingConfiguredAccounts(): boolean {
// during the session and before it opens alike (a restart between // during the session and before it opens alike (a restart between
// the 5 PM roll and the open loads histories right then). Only // the 5 PM roll and the open loads histories right then). Only
// once the session has closed is a still-loading account let go. // once the session has closed is a still-loading account let go.
if (ev?.reason === HISTORY_PENDING && !isAfterSessionClose()) return true; if ((ev?.reason === HISTORY_PENDING || ev?.reason === ROLL_PENDING) && !isAfterSessionClose()) return true;
} }
} }
+4 -1
View File
@@ -23,7 +23,10 @@ export interface PlatformContract {
export interface PlatformClient { export interface PlatformClient {
accountList: PlatformAccount[]; accountList: PlatformAccount[];
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } }; /** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
* the ended day's record and today's share of it can't be told apart —
* the account waits for the broker to roll it. */
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
positions: { positions: {
[accountId: number]: { [accountId: number]: {
contractId: number | string; contractId: number | string;
+20 -13
View File
@@ -881,14 +881,16 @@ export class TopstepXClient implements PlatformClient {
// Withdrawal/deposit detection — the books must explain the balance. // Withdrawal/deposit detection — the books must explain the balance.
// Never off a failed fetch: a fresh balance against stale trades // Never off a failed fetch: a fresh balance against stale trades
// would book the unseen P&L as a (permanent) fund event. // would book the unseen P&L as a (permanent) fund event.
let reconciled = false;
try { try {
if (accountFresh) this.reconcileFunds(acc); if (accountFresh) reconciled = this.reconcileFunds(acc);
} catch (err) { } catch (err) {
console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`); console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`);
} }
// A freshly fetched history is tradable once a fresh pass has // A freshly fetched history is tradable once a fresh pass has
// had its go at booking the funds behind the balance. // actually checked the funds behind the balance (a skipped
if (accountFresh && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id); // check — a position still open — waits for a later pass).
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
try { try {
this.updateMllFloor(acc); this.updateMllFloor(acc);
@@ -965,21 +967,24 @@ export class TopstepXClient implements PlatformClient {
* the drain flow is well above it. Skipped while a position is open (fills * the drain flow is well above it. Skipped while a position is open (fills
* in flight) or while the trade history is known-stale. * in flight) or while the trade history is known-stale.
*/ */
private reconcileFunds(account: PlatformAccount): void { /** Books a withdrawal/deposit the trades can't explain. True once it has
if (this.positions[account.id]) return; * actually checked the books against the balance (false when it had to
if (this.lastFetchErrors[account.name]) return; * skip: a position open, no history or balance yet, no account type). */
private reconcileFunds(account: PlatformAccount): boolean {
if (this.positions[account.id]) return false;
if (this.lastFetchErrors[account.name]) return false;
const history = this.fullDailyPnL[account.id]; const history = this.fullDailyPnL[account.id];
if (!history) return; // first daily-P&L fetch hasn't landed yet if (!history) return false; // first daily-P&L fetch hasn't landed yet
const cash = this.accountCashBalances[account.id]; const cash = this.accountCashBalances[account.id];
if (!cash) return; if (!cash) return false;
// The account's starting balance ($0 for Express, the funded size // The account's starting balance ($0 for Express, the funded size
// otherwise) is part of the balance the books must explain — without // otherwise) is part of the balance the books must explain — without
// it a funded account books a phantom deposit for its whole size. Until // it a funded account books a phantom deposit for its whole size. Until
// the account type is resolved that number is unknown, so hold off // the account type is resolved that number is unknown, so hold off
// rather than guess $0 and misfire on a funded account. // rather than guess $0 and misfire on a funded account.
const acctType = getAccountConfig(account.id).accountType; const acctType = getAccountConfig(account.id).accountType;
if (!acctType) return; if (!acctType) return false;
const startBal = acctType.accountSize; const startBal = acctType.accountSize;
const today = currentTradeDateCT(); const today = currentTradeDateCT();
const tradePnL = history const tradePnL = history
@@ -993,7 +998,7 @@ export class TopstepXClient implements PlatformClient {
// P&L and past payouts, not the end of a cycle this instance watched. // P&L and past payouts, not the end of a cycle this instance watched.
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null; const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today); if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
if (Math.abs(delta) < 200) return; if (Math.abs(delta) < 200) return true;
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
// A new cycle starts today: note where the one it ends began, by // A new cycle starts today: note where the one it ends began, by
@@ -1014,6 +1019,7 @@ export class TopstepXClient implements PlatformClient {
this.fundTransactions[account.id] = loadFundTransactions(account.id); this.fundTransactions[account.id] = loadFundTransactions(account.id);
this.lastFundDates[account.id] = today; this.lastFundDates[account.id] = today;
this.applyCycleSlice(account.id); this.applyCycleSlice(account.id);
return true;
} }
/** /**
@@ -1270,10 +1276,11 @@ export class TopstepXClient implements PlatformClient {
} }
this.fetchDaysComplete = true; this.fetchDaysComplete = true;
// The accounts just fetched wait on a reconciling sync: run one now // The accounts just fetched wait on a reconciling sync: run one now
// rather than leave them out for up to a minute. (The firm's first // rather than leave them out for up to a minute, and before returning,
// pass syncs from maybeFetchDaysTraded once readiness allows.) // so a wipe route reads the result. (The firm's first pass syncs from
// maybeFetchDaysTraded once readiness allows.)
if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) { if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) {
void this.requestSync(); await this.requestSync();
} }
return { failedAccounts }; return { failedAccounts };
} }
+175 -92
View File
@@ -118,15 +118,36 @@ function reconnectBackoffMs(failures: number): number {
} }
interface CashBaseline { dayStart: number; realized: number } interface CashBaseline { dayStart: number; realized: number }
interface HistoryHold { reason: 'balance' | 'wipe'; fundCount: number; since: number; checks: number; retryAt: number } /** fundSig: the fund transactions (date:amount) held when the hold began — a
* Cash History "books" the change when they differ. checks counts Cash
* History reports, attempts every refetch. */
interface HistoryHold { reason: 'balance' | 'wipe'; fundSig: string; since: number; checks: number; attempts: number; retryAt: number }
/** A held account's refetch pace. */ /** A held account's first refetch comes after this, then doubling to an hour. */
const HOLD_RETRY_MS = 5 * 60_000; const HOLD_RETRY_MS = 5 * 60_000;
const HOLD_RETRY_MAX_MS = 60 * 60_000;
/** A balance hold no Cash History ever explains with a fund transaction — a /** A balance hold no Cash History ever explains with a fund transaction — a
* fee, or an adjustment the report files as trading — is let go after this * fee, or an adjustment the report files as trading — is let go after this
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */ * many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
const HOLD_MAX_CHECKS = 3; const HOLD_MAX_CHECKS = 3;
const HOLD_RELEASE_MS = 15 * 60_000; 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. */
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). */
const BASELINE_META = 'cash_baseline';
const HOLD_META = 'history_hold';
function holdRetryMs(attempts: number): number {
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1));
}
/** Fund transactions as one comparable value: a second payout on a date that
* already has one changes the amount, not the row count. */
function fundSignature(txns: { date: string; amount: number }[] | undefined): string {
return (txns ?? []).map((f) => `${f.date}:${f.amount}`).join('|');
}
/** Whether cash is still what the baseline accounts for: the same start-of-day /** 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 * balance ('same'), or the daily reset having folded the day's realized P&L
@@ -147,31 +168,55 @@ interface CashBalanceEntity {
archived?: boolean; archived?: boolean;
} }
/** A stored cash record. realizedPnL and pendingRoll are judged when read. */
interface StoredCash {
amount: number;
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. */
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. */
pendingRoll: boolean;
}
/** /**
* An account's cash as the plan must read it. Tradovate rolls each account's * 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 * 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 * record and opening one with realizedPnL 0 — well after the 17:00 CT roll
* (18 minutes on 2026-09-24), or not until the account first trades. Until * (17-21 minutes on 2026-09-24). Until then the latest record still carries
* then the latest record still carries the ended day's realizedPnL, which the * the ended day's realizedPnL, which the plan would read as today's: legs
* plan would read as today's: legs sized off yesterday, one-and-done accounts * sized off yesterday, one-and-done accounts read as already traded, the
* read as already traded, the entry-commission delta swallowing a whole day's * entry-commission delta swallowing a whole day's P&L. And tonight's own
* P&L. That day is over, and its P&L is already in amount: nothing is * fills before the roll may be booked onto that same old record. So past the
* realized today yet. * 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.
*/ */
function todaysCash(cb: CashBalanceEntity): { amount: number; realizedPnL: number; tradeDate: string | null; archived: boolean } { function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
const t = cb.tradeDate; const t = cb.tradeDate;
const tradeDate = t?.year const tradeDate = t?.year
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}` ? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
: null; : null;
const recorded = cb.realizedPnL; 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 { return {
amount: cb.amount, amount: cb.amount,
tradeDate, tradeDate,
archived: cb.archived === true, archived: cb.archived === true,
// Judged when read, not when stored: a record stored at 16:59 has rollBase,
// ended by 17:01, whether or not a sync has come in since.
get realizedPnL(): number { get realizedPnL(): number {
return tradeDate !== null && tradeDate < currentTradeDateCT() ? 0 : recorded; if (!ended()) return recorded;
return rollBase === undefined ? 0 : Math.round((recorded - rollBase) * 100) / 100;
},
get pendingRoll(): boolean {
return ended() && rollBase === undefined;
}, },
}; };
} }
@@ -179,17 +224,21 @@ function todaysCash(cb: CashBalanceEntity): { amount: number; realizedPnL: numbe
/** /**
* Store a cash record unless the one already held is newer. At the roll the * 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 * snapshot and the socket deliver the ended day's record (archived) and the
* new one together, in either order; the older must never win back. * new one together, in either order; the older must never win back. carried
* is the entry a fresh snapshot map replaces: it hands on the roll base, and
* a snapshot requested before the socket delivered a newer record can't
* undo it.
*/ */
function storeCash( function storeCash(into: { [accountId: number]: StoredCash }, cb: CashBalanceEntity, carried?: StoredCash): void {
into: { [accountId: number]: { amount: number; realizedPnL: number; tradeDate?: string | null; archived?: boolean } }, const held = into[cb.accountId] ?? carried;
cb: CashBalanceEntity, const next = todaysCash(cb, held);
): void { if (held?.tradeDate && next.tradeDate) {
const next = todaysCash(cb); const older = next.tradeDate < held.tradeDate
const prev = into[cb.accountId]; || (next.tradeDate === held.tradeDate && next.archived && !held.archived);
if (prev?.tradeDate && next.tradeDate) { if (older) {
if (next.tradeDate < prev.tradeDate) return; into[cb.accountId] ??= held;
if (next.tradeDate === prev.tradeDate && next.archived && !prev.archived) return; return;
}
} }
into[cb.accountId] = next; into[cb.accountId] = next;
} }
@@ -220,17 +269,9 @@ export class TradovateClient {
private deviceId = randomUUIDV4(); private deviceId = randomUUIDV4();
public accountList: AccountItem[] = []; public accountList: AccountItem[] = [];
public accountCashBalances: { /** Per account, its latest cash record — today's realized P&L judged at
[accountId: number]: { * read time (see todaysCash). */
amount: number; public accountCashBalances: { [accountId: number]: StoredCash } = {};
/** Today's realized P&L — see todaysCash. */
realizedPnL: number;
/** The trade date of the Tradovate record it came from (YYYY-MM-DD). */
tradeDate?: string | null;
/** That record was superseded at a roll. */
archived?: boolean;
};
} = {};
public positions: { public positions: {
[accountId: number]: { [accountId: number]: {
contractId: number; contractId: number;
@@ -921,7 +962,9 @@ export class TradovateClient {
}); });
const balances: TradovateClient['accountCashBalances'] = {}; const balances: TradovateClient['accountCashBalances'] = {};
for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) storeCash(balances, item); for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) {
storeCash(balances, item, this.accountCashBalances[item.accountId]);
}
this.accountCashBalances = balances; this.accountCashBalances = balances;
this.positions = (response.positions ?? []) this.positions = (response.positions ?? [])
@@ -1234,6 +1277,7 @@ export class TradovateClient {
// while the report is in flight isn't absorbed unexplained. // while the report is in flight isn't absorbed unexplained.
const cashNow = this.accountCashBalances[account.id]; const cashNow = this.accountCashBalances[account.id];
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null; const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
// --- Try Cash History first (true after-fee daily P&L) --- // --- Try Cash History first (true after-fee daily P&L) ---
try { try {
@@ -1319,7 +1363,7 @@ export class TradovateClient {
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length; 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 saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
delete this.lastFetchErrors[account.name]; delete this.lastFetchErrors[account.name];
this.markHistoryLoaded(account.id, true, cashBefore); this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore);
continue; continue;
} catch { } catch {
// fall through to Fills fallback // fall through to Fills fallback
@@ -1427,10 +1471,11 @@ export class TradovateClient {
/** Drop the account's history from memory (a wipe): it reads unloaded, and /** Drop the account's history from memory (a wipe): it reads unloaded, and
* stays out of trading, until a Cash History rebuilds it — the wipe took * stays out of trading, until a Cash History rebuilds it — the wipe took
* its fund transactions, which neither the Fills report nor the cache can * its fund transactions, which neither the Fills report nor the cache can
* bring back. */ * bring back. Kept on record too, so a restart doesn't forget it. */
public forgetHistory(accountId: number, accountName: string): void { public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId); this.historyLoadedIds.delete(accountId);
this.historyHolds.set(accountId, { reason: 'wipe', fundCount: 0, since: Date.now(), checks: 0, retryAt: 0 }); this.hold(accountId, 'wipe', '');
saveAccountMeta(accountId, HOLD_META, 'wipe');
delete this.balanceBaseline[accountId]; delete this.balanceBaseline[accountId];
delete this.dailyPnL[accountId]; delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId]; delete this.fullDailyPnL[accountId];
@@ -1442,22 +1487,32 @@ export class TradovateClient {
delete this.lastFetchRaw[accountName]; delete this.lastFetchRaw[accountName];
} }
private hold(accountId: number, reason: HistoryHold['reason'], fundSig: string, retryAt = 0): void {
this.historyHolds.set(accountId, { reason, fundSig, since: Date.now(), checks: 0, attempts: 0, retryAt });
}
private accountName(accountId: number): string | number {
return this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
}
/** /**
* A fetch pass reached the account. Cash History is the only source that * A fetch pass reached the account. Cash History is the only source that
* books fund transactions, so only it can lift a hold, and a balance hold * books fund transactions, so only it can lift a hold — a balance hold
* only once it books a fund transaction the account didn't have when the * only once its fund transactions differ from when the hold began. The
* hold began. The Fills fallback and the DB cache still count as loaded. * Fills fallback and the DB cache still count as loaded.
* *
* cashBefore is the balance when the Cash History was requested: the * cashBefore / fundSigBefore are the balance and fund transactions when
* baseline comes from it, not from the balance when the report lands, so * the Cash History was requested. The balance is judged against the
* a payout posting in between is still caught — and if the balance did * baseline this history last vouched for: if it moved and this report
* move in between, this report may not have seen it, so the account is * booked nothing, the move is held for a later report rather than taken
* held for the next one. * as the new baseline; if it moved while the report was in flight, this
* report may not have seen it, so it is held for the next one.
*/ */
private markHistoryLoaded( private markHistoryLoaded(
accountId: number, accountId: number,
fromCashHistory: boolean, fromCashHistory: boolean,
cashBefore: { amount: number; realizedPnL: number } | null = null, cashBefore: { amount: number; realizedPnL: number } | null = null,
fundSigBefore = '',
): void { ): void {
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) { if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
this.historyLoadedIds.delete(accountId); this.historyLoadedIds.delete(accountId);
@@ -1465,80 +1520,108 @@ export class TradovateClient {
} }
this.historyLoadedIds.add(accountId); this.historyLoadedIds.add(accountId);
const now = Date.now(); const now = Date.now();
if (!this.historyHolds.has(accountId) && loadAccountMeta(accountId, HOLD_META) === 'wipe') {
this.hold(accountId, 'wipe', ''); // a wipe from before a restart
}
const hold = this.historyHolds.get(accountId); const hold = this.historyHolds.get(accountId);
if (!this.balanceBaseline[accountId]) {
const saved = this.savedBaseline(accountId);
if (saved) this.balanceBaseline[accountId] = saved;
}
if (!fromCashHistory) { if (!fromCashHistory) {
if (hold) { if (hold) {
hold.retryAt = now + HOLD_RETRY_MS; hold.attempts++;
hold.retryAt = now + holdRetryMs(hold.attempts);
if (hold.reason === 'balance' && 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`);
this.historyHolds.delete(accountId);
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
}
return; return;
} }
// Keep the baseline a Cash History set — this process's, or the // Keep the baseline a Cash History set — this process's, or the
// last one on record (a restart onto a failing report server) — // last one on record — rather than take today's balance, which may
// rather than take today's balance, which may hold an unbooked // hold an unbooked payout; the drift check then judges it.
// payout; the drift check then judges it.
if (!this.balanceBaseline[accountId]) {
const saved = loadAccountMeta(accountId, 'cash_baseline');
try {
const parsed = saved ? JSON.parse(saved) as CashBaseline : null;
if (parsed && Number.isFinite(parsed.dayStart) && Number.isFinite(parsed.realized)) {
this.balanceBaseline[accountId] = parsed;
}
} catch { /* unreadable: judged from the live balance below */ }
}
this.balanceDrifted(accountId); this.balanceDrifted(accountId);
return; return;
} }
const fundSigNow = fundSignature(this.fundTransactions[accountId]);
const cashAfter = this.accountCashBalances[accountId]; const cashAfter = this.accountCashBalances[accountId];
const before = cashBefore ?? cashAfter ?? null; const before = cashBefore ?? cashAfter ?? null;
const base: CashBaseline | null = before
? { dayStart: before.amount - before.realizedPnL, realized: before.realizedPnL }
: null;
const movedDuringFetch = base !== null && cashAfter !== undefined && cashExplained(base, cashAfter) === null;
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
if (hold) { if (hold) {
hold.checks++; hold.checks++;
const booked = (this.fundTransactions[accountId]?.length ?? 0) > hold.fundCount; hold.attempts++;
if (hold.reason === 'balance' && !booked) { if (hold.reason === 'balance' && fundSigNow === hold.fundSig) {
if (hold.checks < HOLD_MAX_CHECKS || now - hold.since < HOLD_RELEASE_MS) { if (hold.checks < HOLD_MAX_CHECKS || now - hold.since < HOLD_RELEASE_MS) {
hold.retryAt = now + HOLD_RETRY_MS; hold.retryAt = now + holdRetryMs(hold.attempts);
return; return;
} }
console.warn(`${this.tag} ${name}: ${hold.checks} Cash History reports over` console.warn(`${this.tag} ${this.accountName(accountId)}: ${hold.checks} Cash History reports over`
+ ` ${Math.round((now - hold.since) / 60_000)}m booked no fund transaction for its balance change —` + ` ${Math.round((now - hold.since) / 60_000)}m booked no fund transaction for its balance change —`
+ ' releasing it (a fee, or an adjustment the report files as trading)'); + ' releasing it (a fee, or an adjustment the report files as trading)');
// Nothing will explain it: take the balance as it now stands.
this.historyHolds.delete(accountId); this.historyHolds.delete(accountId);
this.setBaseline(accountId, cashAfter ?? null); this.setBaseline(accountId, cashAfter ?? null);
return; return;
} }
// Booked (balance), or rebuilt from Cash History (wipe).
this.historyHolds.delete(accountId); this.historyHolds.delete(accountId);
if (hold.reason === 'wipe') saveAccountMeta(accountId, HOLD_META, '');
this.setBaseline(accountId, before);
return;
} }
if (movedDuringFetch) { // A balance the last vouched-for baseline can't explain, with nothing
this.historyHolds.set(accountId, { // booked by this report: a payout it hasn't caught up with yet.
reason: 'balance', const known = this.balanceBaseline[accountId];
fundCount: this.fundTransactions[accountId]?.length ?? 0, if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
since: now, checks: 0, retryAt: now + 60_000, this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
}); console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`
this.balanceBaseline[accountId] = base!; + `${Math.round((before.amount - before.realizedPnL - known.dayStart) * 100) / 100}`
console.warn(`${this.tag} ${name}: balance moved while its Cash History was in flight — held for the next report`); + ' and its Cash History booked nothing yet — held for a later report');
return;
}
const base = before ? { dayStart: before.amount - before.realizedPnL, realized: before.realizedPnL } : null;
if (base && cashAfter && cashExplained(base, cashAfter) === null) {
// Held against the fund transactions from before this report, so
// the next report lifts it if this one already booked the change.
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
this.balanceBaseline[accountId] = base;
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved while its Cash History was in flight — held for the next report`);
return; return;
} }
this.setBaseline(accountId, before); this.setBaseline(accountId, before);
} }
/** Set (and keep on record, for a restart that can't reach Cash History) /** The baseline last vouched for, as kept on record across restarts. */
* the baseline a Cash History vouched for. */ private savedBaseline(accountId: number): CashBaseline | null {
try {
const saved = loadAccountMeta(accountId, BASELINE_META);
const parsed = saved ? JSON.parse(saved) as Partial<CashBaseline> : null;
if (parsed && Number.isFinite(parsed.dayStart) && Number.isFinite(parsed.realized)) {
return { dayStart: parsed.dayStart!, realized: parsed.realized! };
}
} catch { /* unreadable: none */ }
return null;
}
/** Set the baseline and keep it on record, so a restart that can't reach
* Cash History judges the balance against it. */
private setBaseline(accountId: number, cash: { amount: number; realizedPnL: number } | null): void { private setBaseline(accountId: number, cash: { amount: number; realizedPnL: number } | null): void {
if (!cash) { if (!cash) {
delete this.balanceBaseline[accountId]; delete this.balanceBaseline[accountId];
return; return;
} }
const base = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL };
this.balanceBaseline[accountId] = base; this.saveBaseline(accountId);
saveAccountMeta(accountId, 'cash_baseline', JSON.stringify(base)); }
private saveBaseline(accountId: number): void {
const base = this.balanceBaseline[accountId];
if (base) saveAccountMeta(accountId, BASELINE_META, JSON.stringify({ ...base, tradeDate: currentTradeDateCT() }));
} }
/** /**
@@ -1549,33 +1632,33 @@ export class TradovateClient {
* amount and realizedPnL together, and the daily reset folds the day's * amount and realizedPnL together, and the daily reset folds the day's
* realized P&L into the balance; any other move past a few dollars holds * realized P&L into the balance; any other move past a few dollars holds
* the account until a Cash History books it (maybeFetchDaysTraded fetches * the account until a Cash History books it (maybeFetchDaysTraded fetches
* it on the next sync). * it on the next sync). What the baseline learns is kept on record, so a
* restart judges against the day's last realized P&L, not a stale one.
*/ */
private balanceDrifted(accountId: number): boolean { private balanceDrifted(accountId: number): boolean {
const cash = this.accountCashBalances[accountId]; const cash = this.accountCashBalances[accountId];
if (!cash) return false; if (!cash) return false;
const base = this.balanceBaseline[accountId]; const base = this.balanceBaseline[accountId];
if (!base) { if (!base) {
this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; this.setBaseline(accountId, cash);
return false; return false;
} }
const how = cashExplained(base, cash); const how = cashExplained(base, cash);
if (how === 'same') { if (how === 'same') {
base.realized = cash.realizedPnL; if (Math.abs(cash.realizedPnL - base.realized) >= 1) {
base.realized = cash.realizedPnL;
this.saveBaseline(accountId);
}
return false; return false;
} }
if (how === 'folded') { if (how === 'folded') {
this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; this.setBaseline(accountId, cash);
return false; return false;
} }
if (!this.historyHolds.has(accountId)) { if (!this.historyHolds.has(accountId)) {
this.historyHolds.set(accountId, { this.hold(accountId, 'balance', fundSignature(this.fundTransactions[accountId]));
reason: 'balance', console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved`
fundCount: this.fundTransactions[accountId]?.length ?? 0, + ` $${Math.round((cash.amount - cash.realizedPnL - base.dayStart) * 100) / 100}`
since: Date.now(), checks: 0, retryAt: 0,
});
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
console.warn(`${this.tag} ${name}: balance moved $${Math.round((cash.amount - cash.realizedPnL - base.dayStart) * 100) / 100}`
+ ' that its history does not explain — held out of trading until Cash History books it'); + ' that its history does not explain — held out of trading until Cash History books it');
} }
return true; return true;