TopstepX: book a fund event only once a later pass still sees it
A sync reads the balance first and each account's positions and trades after it, so a fill landing in between shows in the trades but not yet in the balance, and for that one pass the books are off by the whole fill. reconcileFunds booked that as a fund event, and the next pass booked the reversal. Same-day rows merge, so the two netted to the commission drift of the day and left a tiny row: - 2026-10-05, Express 43788281 and 23275136: +3,001.16 "deposit" (balance -2.16 against the -3,001.16 stop-out already in the trades), then -3,002.16, merged to -1.00. - 2026-10-08, combine 99745612: +2,474.01, then -2,492.01, left -18.00. - 2026-09-24, five 150K combines on another instance: +3,049.45, then -3,059.45, left -10.00 each. A negative row is a withdrawal. It started a new cycle, dropped its day out of the cycle (the -3,003.32 on 10-05) and counted toward the stage: both Express accounts traded drain days from 10-06 on a $1 "payout". - fundReconcileStep (trading-logic): an unexplained balance is booked only when a check at least 30s after the first sighting still sees it, to within $5. A transient is gone by the next pass and books nothing. - A transient that outlasts that, and so books, is taken back whole once its reversal nets the day under FUND_EVENT_MIN. The day's first booking saves what it replaced (fund_day_before), and dropFundTransaction deletes the row and puts last_fund_date and ended_cycle_start back in one transaction. A row reconcileFunds didn't book (an import) merges as before. - funds_reconciled is set only once a check settles, so a first-check lump that waits on a second pass is still booked as the first check. Existing tiny rows are left as they are; cleaning them up is a separate, one-time step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aab225a19c
commit
d7d1e2cdae
+93
-32
@@ -24,10 +24,10 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
import { signalrWebSocket } from './outbound-proxy';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction } from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||
@@ -73,10 +73,10 @@ const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
* minutes after its caller gave up on it. Longer than the idle timeout, so
|
||||
* it only bites where that one can't. */
|
||||
const REST_DEADLINE_MS = 90_000;
|
||||
/** Balance the books don't explain past this is a fund event (reconcileFunds
|
||||
* books it); under it, fee noise and rounding. The smallest real withdrawal
|
||||
* in the drain flow is well above it. */
|
||||
const FUND_EVENT_MIN = 200;
|
||||
/** account_meta: what the day's first fund booking replaced (its date, and
|
||||
* last_fund_date and ended_cycle_start as they stood), so a reversal that
|
||||
* nets the day to fee noise can put them back. */
|
||||
const FUND_DAY_BEFORE_META = 'fund_day_before';
|
||||
|
||||
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation
|
||||
* keeps running (callers discard whatever it belonged to); Promise.race
|
||||
@@ -252,6 +252,9 @@ export class TopstepXClient implements PlatformClient {
|
||||
/** Accounts unbookedBalanceChange has warned about, until their books
|
||||
* settle — one line per episode, not one per tick. */
|
||||
private unbookedWarned = new Set<number>();
|
||||
/** Unexplained balances reconcileFunds has seen but not booked yet: one
|
||||
* is booked only once a later check still sees it (fundReconcileStep). */
|
||||
private fundSightings = new Map<number, FundSighting>();
|
||||
/** See PlatformClient: when the last good pass started reading. */
|
||||
public lastSyncStartedAt = 0;
|
||||
/** Accounts whose last order ended with its outcome unknown: each sync
|
||||
@@ -1039,51 +1042,108 @@ export class TopstepXClient implements PlatformClient {
|
||||
* and starts a new cycle; the very first check also books any historical
|
||||
* net withdrawal in one lump (fine — the last stage repeats).
|
||||
*
|
||||
* FUND_EVENT_MIN keeps fee noise and rounding out. Skipped while a
|
||||
* position is open (fills in flight) or while the trade history is
|
||||
* known-stale; until it books, unbookedBalanceChange holds the account.
|
||||
* FUND_EVENT_MIN keeps fee noise and rounding out. An amount is booked
|
||||
* only once a later check still sees it: for one pass, a fill landing
|
||||
* between the balance read and the trade read looks just like a fund
|
||||
* event (fundReconcileStep). Skipped while a position is open (fills in
|
||||
* flight) or while the trade history is known-stale; until it books,
|
||||
* unbookedBalanceChange holds the account.
|
||||
*/
|
||||
/** 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). */
|
||||
* skip: a position open, no history or balance yet, no account type; or
|
||||
* when what it saw waits on a later check to be booked). */
|
||||
private reconcileFunds(account: PlatformAccount): boolean {
|
||||
if (this.positions[account.id]) return false;
|
||||
if (this.lastFetchErrors[account.name]) return false;
|
||||
const delta = this.unbookedBalance(account.id);
|
||||
if (delta === null) return false;
|
||||
const delta = this.positions[account.id] || this.lastFetchErrors[account.name]
|
||||
? null
|
||||
: this.unbookedBalance(account.id);
|
||||
if (delta === null) {
|
||||
// A sighting is confirmed by the next check, not one after a skip.
|
||||
this.fundSightings.delete(account.id);
|
||||
return false;
|
||||
}
|
||||
const cash = this.accountCashBalances[account.id]!;
|
||||
const startBal = getAccountConfig(account.id).accountType!.accountSize;
|
||||
const today = currentTradeDateCT();
|
||||
const txns = this.fundTransactions[account.id] ?? [];
|
||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? null;
|
||||
const before = this.fundDayBefore(account.id, today);
|
||||
const step = fundReconcileStep({
|
||||
delta,
|
||||
sighting: this.fundSightings.get(account.id) ?? null,
|
||||
now: Date.now(),
|
||||
existingToday,
|
||||
undoable: before !== null,
|
||||
});
|
||||
const books = Math.round((cash.amount - startBal - delta) * 100) / 100;
|
||||
if (step.kind === 'unconfirmed') {
|
||||
this.fundSightings.set(account.id, step.sighting);
|
||||
if (step.fresh) console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — ${delta} unexplained; booked if a later pass still sees it`);
|
||||
return false;
|
||||
}
|
||||
this.fundSightings.delete(account.id);
|
||||
// The first check on this instance (or since a wipe) only has the
|
||||
// fetch window to explain the balance with: a lump it books is unseen
|
||||
// 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) < FUND_EVENT_MIN) return true;
|
||||
if (step.kind === 'settled') return true;
|
||||
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((cash.amount - startBal - delta) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
||||
// A new cycle starts today: note where the one it ends began, by
|
||||
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||
// catch-up. A second event today doesn't start another cycle.
|
||||
const prevFundDate = this.lastFundDates[account.id] ?? null;
|
||||
if (!firstCheck && prevFundDate !== today) {
|
||||
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
|
||||
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||
if (step.kind === 'drop') {
|
||||
// A transient booked today and then reversed: the cycle it started
|
||||
// never happened, so the markers go back to what it replaced.
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — today's fund bookings net to ${step.net}, a transient and its reversal; taking today's row back`);
|
||||
dropFundTransaction(account.id, today, {
|
||||
last_fund_date: before!.lastFundDate,
|
||||
ended_cycle_start: before!.endedCycleStart,
|
||||
[FUND_DAY_BEFORE_META]: null,
|
||||
});
|
||||
this.lastFundDates[account.id] = before!.lastFundDate;
|
||||
} else {
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
||||
// The day's first booking: keep what it replaces.
|
||||
if (existingToday === null) {
|
||||
saveAccountMeta(account.id, FUND_DAY_BEFORE_META, JSON.stringify({
|
||||
date: today,
|
||||
lastFundDate: loadAccountMeta(account.id, 'last_fund_date'),
|
||||
endedCycleStart: loadAccountMeta(account.id, 'ended_cycle_start'),
|
||||
}));
|
||||
}
|
||||
// A new cycle starts today: note where the one it ends began, by
|
||||
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||
// catch-up. A second event today doesn't start another cycle.
|
||||
const prevFundDate = this.lastFundDates[account.id] ?? null;
|
||||
if (!firstCheck && prevFundDate !== today) {
|
||||
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
|
||||
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||
}
|
||||
// Same-day events merge into one row (the table keys on
|
||||
// account+date): step.amount is today's delta on top of whatever
|
||||
// is already booked for today.
|
||||
saveFundTransactions(account.id, account.name, [{ date: today, amount: step.amount }]);
|
||||
saveAccountMeta(account.id, 'last_fund_date', today);
|
||||
this.lastFundDates[account.id] = today;
|
||||
}
|
||||
// Same-day events merge into one row (the table keys on account+date),
|
||||
// so add today's delta onto whatever is already booked for today.
|
||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
|
||||
saveFundTransactions(account.id, account.name, [
|
||||
{ date: today, amount: Math.round((existingToday + delta) * 100) / 100 },
|
||||
]);
|
||||
saveAccountMeta(account.id, 'last_fund_date', today);
|
||||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||||
this.lastFundDates[account.id] = today;
|
||||
this.applyCycleSlice(account.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What today's first fund booking replaced — null when today has none
|
||||
* that reconcileFunds made (none yet, or a row it didn't book). */
|
||||
private fundDayBefore(accountId: number, today: string): { lastFundDate: string | null; endedCycleStart: string | null } | null {
|
||||
const raw = loadAccountMeta(accountId, FUND_DAY_BEFORE_META);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
const saved = JSON.parse(raw) as { date?: unknown; lastFundDate?: unknown; endedCycleStart?: unknown };
|
||||
const strOrNull = (v: unknown) => v === null || typeof v === 'string';
|
||||
if (saved.date !== today || !strOrNull(saved.lastFundDate) || !strOrNull(saved.endedCycleStart)) return null;
|
||||
return { lastFundDate: saved.lastFundDate as string | null, endedCycleStart: saved.endedCycleStart as string | null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance the books don't explain: balance less the starting balance
|
||||
* ($0 for Express, the funded size otherwise), all trade P&L (today's off
|
||||
@@ -1107,7 +1167,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
|
||||
/**
|
||||
* See PlatformClient. The hub moves the balance the moment a payout
|
||||
* lands, but reconcileFunds books it only in the next sync — and not
|
||||
* lands, but reconcileFunds books it only a sync or two later — and not
|
||||
* while a position is open. Planned on in between, the payout reads as
|
||||
* lost profit: on 2026-10-06 two Express accounts that had finished
|
||||
* their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds
|
||||
@@ -1421,6 +1481,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
this.reconcilePendingIds.delete(accountId);
|
||||
this.fundSightings.delete(accountId);
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
|
||||
Reference in New Issue
Block a user