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:
Brandon Li
2026-10-18 19:24:29 -05:00
co-authored by Claude Opus 5.5
parent aab225a19c
commit d7d1e2cdae
4 changed files with 288 additions and 32 deletions
+111
View File
@@ -771,3 +771,114 @@ test('tick reasons fold their amounts, so like reasons count together', () => {
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
});
// ── fund events from the balance (TopstepX reconcileFunds) ──────────────────
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
/** reconcileFunds' bookkeeping for one trade date, pass by pass: `balance`
* and `trades` (all trade P&L, today's included) as each pass read them.
* `existing` is a row already on the day that reconcileFunds didn't book. */
function reconcileDay(passes, existing = null) {
let row = existing;
let undoable = false; // the day's first booking saves what it replaces
let sighting = null;
const steps = [];
for (const { at, balance, trades } of passes) {
const delta = Math.round((balance - trades - (row ?? 0)) * 100) / 100;
const step = fundReconcileStep({ delta, sighting, now: at * 1000, existingToday: row, undoable });
steps.push(step);
if (step.kind === 'unconfirmed') { sighting = step.sighting; continue; }
sighting = null;
if (step.kind === 'book') { if (row === null) undoable = true; row = step.amount; }
if (step.kind === 'drop') { row = null; undoable = false; }
}
return { row, kinds: steps.map((s) => s.kind), steps };
}
test('fund reconcile: a balance read before a fill the trades already show books nothing', () => {
// 2026-10-05, EXPRESS-V2-CT-DLL-39029-23275136: the pass read the balance
// (−2.16), then the trades with the −3,001.16 stop-out in them. One pass
// later the balance had it (−3,004.32, $1.00 of commission off-book).
// Booked as it came, that was +3,001.16, then −3,002.16, merged to −1.00.
const express = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(express.kinds, ['unconfirmed', 'settled']);
assert.equal(express.steps[0].sighting.delta, 3001.16);
assert.equal(express.row, null);
// 2026-10-08, 150KTC-SKU-V2-DLL-39029-99745612 (balance less the $150K
// start): +2,474.01 then −2,492.01, which left a −18.00 row.
const combine = reconcileDay([
{ at: 0, balance: 2413.43, trades: -60.58 },
{ at: 60, balance: -78.58, trades: -60.58 },
]);
assert.deepEqual(combine.kinds, ['unconfirmed', 'settled']);
assert.equal(combine.row, null);
});
test('fund reconcile: a real withdrawal books once a later pass still sees it', () => {
// 2026-10-14, EXPRESS-V2-CT-DLL-39029-43788281: a $4,620 payout.
const payout = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 10, balance: 4601.42, trades: 9221.42 }, // too soon to confirm
{ at: 60, balance: 4601.42, trades: 9221.42 },
]);
assert.deepEqual(payout.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(payout.steps[1].fresh, false);
assert.equal(payout.steps[1].sighting.since, 0); // the clock runs from the first sighting
assert.equal(payout.row, -4620);
// Fee noise between the passes still confirms; the later amount books.
assert.equal(reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: FUND_CONFIRM_MS / 1000, balance: 4598.42, trades: 9221.42 },
]).row, -4623);
// An amount that moves starts over: the second sighting is the new one.
const moved = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 60, balance: 4901.42, trades: 9221.42 },
{ at: 120, balance: 4901.42, trades: 9221.42 },
]);
assert.deepEqual(moved.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(moved.steps[1].fresh, true);
assert.equal(moved.row, -4320);
});
test('fund reconcile: a transient that outlasts the confirmation is taken back whole', () => {
// The balance lags for two passes, so +3,001.16 books; when it catches up,
// the reversal nets the day to −1.00 and the day is dropped, not left as
// a −1.00 "withdrawal" that splits the cycle.
const lagging = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -3004.32, trades: -3003.32 },
{ at: 180, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(lagging.kinds, ['unconfirmed', 'book', 'unconfirmed', 'drop']);
assert.equal(lagging.steps[1].amount, 3001.16);
assert.equal(lagging.steps[3].net, -1);
assert.equal(lagging.row, null);
// A payout landing with the reversal is real: the day keeps its row.
const withPayout = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -7624.32, trades: -3003.32 },
{ at: 180, balance: -7624.32, trades: -3003.32 },
]);
assert.deepEqual(withPayout.kinds, ['unconfirmed', 'book', 'unconfirmed', 'book']);
assert.equal(withPayout.row, -4621);
// Only a day reconcileFunds booked itself is taken back: a row it found
// there (an import) merges as before.
const imported = reconcileDay([
{ at: 0, balance: -1, trades: 0 },
{ at: 60, balance: -1, trades: 0 },
], -4620);
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
assert.equal(imported.row, -1);
});