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
@@ -1107,6 +1107,74 @@ export function unbookedPayoutDrop(
|
||||
return drop >= PAYOUT_HOLD_MIN ? drop : null;
|
||||
}
|
||||
|
||||
// ── fund events from the balance (TopstepX) ──────────────────────────────────
|
||||
|
||||
/** Balance the books don't explain past this is a fund event (TopstepX's
|
||||
* reconcileFunds books it); under it, fee noise and rounding. The smallest
|
||||
* real withdrawal in the drain flow is well above it. */
|
||||
export const FUND_EVENT_MIN = 200;
|
||||
/** A sighting is booked only once a later pass, at least this much later,
|
||||
* sees the same amount... */
|
||||
export const FUND_CONFIRM_MS = 30_000;
|
||||
/** ...to within this (fee noise between the two passes). */
|
||||
export const FUND_CONFIRM_TOLERANCE = 5;
|
||||
|
||||
/** An unexplained balance seen, not yet booked: the amount and when it was
|
||||
* first seen at about that amount. */
|
||||
export interface FundSighting { delta: number; since: number }
|
||||
|
||||
export type FundStep =
|
||||
/** Under FUND_EVENT_MIN: nothing to book. */
|
||||
| { kind: 'settled' }
|
||||
/** Not yet seen twice far enough apart. `sighting` is what to keep for
|
||||
* the next pass; `fresh` when it is new (or the amount moved). */
|
||||
| { kind: 'unconfirmed'; sighting: FundSighting; fresh: boolean }
|
||||
/** Book today's row as `amount` (today's existing row merged in). */
|
||||
| { kind: 'book'; amount: number }
|
||||
/** Today's bookings net under FUND_EVENT_MIN: a transient and its
|
||||
* reversal. Take today's row back and leave `net` unbooked. */
|
||||
| { kind: 'drop'; net: number };
|
||||
|
||||
/**
|
||||
* One fresh pass's decision on the balance the books don't explain.
|
||||
*
|
||||
* The pass reads the balance first and the positions and trades after it, so
|
||||
* a fill landing in between shows in the trades but not yet in the balance
|
||||
* (or, the other way, in the balance before its trade row). For one pass the
|
||||
* books are off by the whole fill. On 2026-10-05 two Express accounts booked a
|
||||
* $3,001.16 "deposit" that way, and the next pass booked the $3,002.16
|
||||
* "withdrawal" that undid it. The two merged into a −$1.00 row, the
|
||||
* commission drift of the day. A negative row is a withdrawal: it started a
|
||||
* new cycle, dropped the day's −$3,003.32 out of it and moved both accounts
|
||||
* to their drain stage. A real fund event stays put, so an amount is booked
|
||||
* only when a pass at least FUND_CONFIRM_MS after the first sighting still
|
||||
* sees it.
|
||||
*
|
||||
* `undoable`: today's row was booked by this reconcile alone, with what it
|
||||
* replaced saved (the first booking of a day saves it), so it can be taken
|
||||
* back whole. A transient that outlasts the confirmation and is then
|
||||
* reversed nets under FUND_EVENT_MIN, and the day is dropped, not left as a
|
||||
* tiny row.
|
||||
*/
|
||||
export function fundReconcileStep(opts: {
|
||||
delta: number;
|
||||
sighting: FundSighting | null;
|
||||
now: number;
|
||||
/** Today's fund row, null when there is none. */
|
||||
existingToday: number | null;
|
||||
undoable: boolean;
|
||||
}): FundStep {
|
||||
const { delta, sighting, now, existingToday } = opts;
|
||||
if (Math.abs(delta) < FUND_EVENT_MIN) return { kind: 'settled' };
|
||||
if (sighting === null || Math.abs(sighting.delta - delta) > FUND_CONFIRM_TOLERANCE) {
|
||||
return { kind: 'unconfirmed', sighting: { delta, since: now }, fresh: true };
|
||||
}
|
||||
if (now - sighting.since < FUND_CONFIRM_MS) return { kind: 'unconfirmed', sighting, fresh: false };
|
||||
const amount = round2((existingToday ?? 0) + delta);
|
||||
if (existingToday !== null && opts.undoable && Math.abs(amount) < FUND_EVENT_MIN) return { kind: 'drop', net: amount };
|
||||
return { kind: 'book', amount };
|
||||
}
|
||||
|
||||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** An entry's fee when the balance update hasn't shown it yet — at least one
|
||||
|
||||
Reference in New Issue
Block a user