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
+20 -13
View File
@@ -881,14 +881,16 @@ export class TopstepXClient implements PlatformClient {
// Withdrawal/deposit detection — the books must explain the balance.
// Never off a failed fetch: a fresh balance against stale trades
// would book the unseen P&L as a (permanent) fund event.
let reconciled = false;
try {
if (accountFresh) this.reconcileFunds(acc);
if (accountFresh) reconciled = this.reconcileFunds(acc);
} catch (err) {
console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`);
}
// A freshly fetched history is tradable once a fresh pass has
// had its go at booking the funds behind the balance.
if (accountFresh && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
// actually checked the funds behind the balance (a skipped
// check — a position still open — waits for a later pass).
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
try {
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
* in flight) or while the trade history is known-stale.
*/
private reconcileFunds(account: PlatformAccount): void {
if (this.positions[account.id]) return;
if (this.lastFetchErrors[account.name]) return;
/** Books a withdrawal/deposit the trades can't explain. True once it has
* actually checked the books against the balance (false when it had to
* 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];
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];
if (!cash) return;
if (!cash) return false;
// The account's starting balance ($0 for Express, the funded size
// otherwise) is part of the balance the books must explain — without
// 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
// rather than guess $0 and misfire on a funded account.
const acctType = getAccountConfig(account.id).accountType;
if (!acctType) return;
if (!acctType) return false;
const startBal = acctType.accountSize;
const today = currentTradeDateCT();
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.
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
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}`);
// 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.lastFundDates[account.id] = today;
this.applyCycleSlice(account.id);
return true;
}
/**
@@ -1270,10 +1276,11 @@ export class TopstepXClient implements PlatformClient {
}
this.fetchDaysComplete = true;
// 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
// pass syncs from maybeFetchDaysTraded once readiness allows.)
// 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.)
if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) {
void this.requestSync();
await this.requestSync();
}
return { failedAccounts };
}