TopstepX: book Topstep's commission with each fill

TopstepX's gateway reports a fill's exchange and NFA fees only (NQ $1.39 a
side, GC $1.66, CL $1.51, MNQ $0.36). Topstep's own commission, $0.50 a side
on a full-size contract and $0.25 on a micro, comes off the balance with no
trade row carrying it, so our P&L ran $1.00 per full-size round turn above
Topstep's. On 2026-10-18 the combines sat $6 to $8 under their books and an
XFA $27.50 under, and one combine had its $18 booked as a "withdrawal".
Topstep's published round turns (NQ $3.78, MNQ $1.22, GC $4.32, CL $4.02)
are the reported fees plus this commission.

- lib/topstep-fees.ts: the per-product commission table, and TopstepX
  contract ids mapped to roots (ENQ, GCE and CLE as seen on real fills,
  overridden by contracts the client resolves itself; an unknown product
  pays the full-size rate and is logged once).
- Daily P&L, today's realized, and so the measured entry fee a take-profit
  is placed from, all net the commission. The 90-day refetch rewrites the
  cached days.
- npm run check: the table against the micro flags, the id mapping, and
  the published round turns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-18 18:39:52 -05:00
co-authored by Claude Opus 5.5
parent c0261f42ae
commit aab225a19c
3 changed files with 149 additions and 11 deletions
+47 -11
View File
@@ -11,7 +11,9 @@
* - There is no cash-history / fund-transaction report. Daily P&L comes from
* POST /api/Trade/search (each half-turn carries fees; closing halves carry
* profitAndLoss), grouped into Central-time trade dates with the same 5 PM
* roll Tradovate uses. Deposits/withdrawals are invisible to the API, so
* roll Tradovate uses. Those fees are exchange and NFA only — Topstep's
* commission is added from lib/topstep-fees.ts. Deposits/withdrawals are
* invisible to the API, so
* fund transactions only ever come from whatever the local DB already holds.
* - There is no OCO order. placeOcoExit places the limit and stop separately
* and cancels the sibling the moment one fills (order events, plus a safety
@@ -27,6 +29,7 @@ import { getCachedContract } from './contract-resolver';
import { signalrWebSocket } from './outbound-proxy';
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } 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';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
@@ -266,10 +269,14 @@ export class TopstepXClient implements PlatformClient {
* recomputed from these on every REST sync and every live trade event. */
private todayTrades: { [accountId: number]: Map<number, TsxTrade> } = {};
/** Reported fees per trade date, per account — weights for spreading the
* off-book fee drift when reconstructing the MLL floor. */
/** Fees per trade date, per account (reported plus Topstep's commission) —
* weights for spreading the off-book fee drift when reconstructing the
* MLL floor. */
private dailyFees: { [accountId: number]: Map<string, number> } = {};
/** Product codes already warned about having no Topstep commission. */
private unknownCommissionCodes = new Set<string>();
/** Front-month contract per product symbol, plus name→contract for order
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
@@ -811,15 +818,41 @@ export class TopstepXClient implements PlatformClient {
const map = this.todayTrades[accountId];
let realized = 0;
if (map) {
const learned = this.learnedProductRoots();
for (const t of map.values()) {
if (t.voided) continue;
if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) continue;
realized += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
realized += (t.profitAndLoss ?? 0) - this.tradeFees(t, learned);
}
}
return Math.round(realized * 100) / 100;
}
/** What a fill cost: the exchange and NFA fees TopstepX reports plus
* Topstep's commission, which it doesn't (lib/topstep-fees.ts). */
private tradeFees(t: TsxTrade, learned: ReadonlyMap<string, string>): number {
const root = topstepxRoot(t.contractId, learned);
if (root === null) {
const code = topstepxProductCode(t.contractId) ?? String(t.contractId);
if (!this.unknownCommissionCodes.has(code)) {
this.unknownCommissionCodes.add(code);
console.warn(`[topstepx] no Topstep commission known for ${code} — charging $${TOPSTEP_COMMISSION_FALLBACK} a side`);
}
}
return (t.fees ?? 0) + topstepCommission(root, t.size);
}
/** Product code → root, off the contracts resolved this run
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
private learnedProductRoots(): Map<string, string> {
const learned = new Map<string, string>();
for (const [root, { contract }] of this.contractByProduct) {
const code = topstepxProductCode(contract.id);
if (code) learned.set(code, root);
}
return learned;
}
// ── sync ─────────────────────────────────────────────────────────────────
/**
@@ -1106,9 +1139,10 @@ export class TopstepXClient implements PlatformClient {
* floor = min(0, eodBalanceHigh − trailingDrawdown)
*
* The EOD balance curve is rebuilt from daily P&L + recorded fund events.
* Balance the books don't explain (sub-threshold off-book fees) is spread
* across days proportionally to each day's reported fees — validated
* against a platform-reported MLL to within $0.11.
* Balance the books don't explain (sub-threshold off-book fees — mostly
* Topstep's commission, until the books carried it) is spread across days
* proportionally to each day's fees — validated against a
* platform-reported MLL to within $0.11.
*
* Stored in autoLiqThresholds (negative, or +$0.01 once locked at
* breakeven — 0 must keep meaning "no floor"), which lights up the same
@@ -1296,11 +1330,13 @@ export class TopstepXClient implements PlatformClient {
this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)';
const dailyMap: { [date: string]: { pnl: number; fees: number } } = {};
const learned = this.learnedProductRoots();
for (const t of trades) {
const date = currentTradeDateCT(new Date(t.creationTimestamp));
const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 });
entry.pnl += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
entry.fees += t.fees ?? 0;
const fees = this.tradeFees(t, learned);
entry.pnl += (t.profitAndLoss ?? 0) - fees;
entry.fees += fees;
}
const fresh = Object.entries(dailyMap)
@@ -1313,8 +1349,8 @@ export class TopstepXClient implements PlatformClient {
const merged = mergePnL(cached, fresh);
// Reported fees per day — the weights the MLL reconstruction
// uses to place off-book fee drift. Fresh overwrites cached.
// Fees per day — the weights the MLL reconstruction uses to
// place off-book fee drift. Fresh overwrites cached.
const fees = new Map<string, number>();
for (const e of cached) fees.set(e.date, e.fees ?? 0);
for (const e of fresh) fees.set(e.date, e.fees);