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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user