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:
co-authored by
Claude Opus 5.5
parent
c0261f42ae
commit
aab225a19c
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Topstep's own commission on TopstepX — the part of a fill's cost the
|
||||||
|
* gateway never reports.
|
||||||
|
*
|
||||||
|
* Trade/search and the user hub carry `fees` on every half-turn, but only the
|
||||||
|
* exchange and NFA fees (NQ $1.39 a side, GC $1.66, CL $1.51, MNQ $0.36).
|
||||||
|
* Topstep's commission — $0.50 a side on a full-size contract, $0.25 on a
|
||||||
|
* micro — comes off the balance with no trade carrying it. Topstep's
|
||||||
|
* "TopstepX Commissions and Fees" page totals both: NQ and ES $3.78 a round
|
||||||
|
* turn, MNQ and MES $1.22, GC $4.32, CL $4.02. Left out, the books ran $1.00
|
||||||
|
* a full-size round turn above the balance (2026-10-18: an XFA $27.50 off
|
||||||
|
* after 26 NQ/GC round turns and 3 MNQ, each combine $1.00 per round turn).
|
||||||
|
*
|
||||||
|
* No imports, so scripts/check-instruments.mjs can test it directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Per contract and side, by CME root. */
|
||||||
|
export const TOPSTEP_COMMISSION_PER_SIDE: Readonly<Record<string, number>> = {
|
||||||
|
NQ: 0.5, MNQ: 0.25,
|
||||||
|
ES: 0.5, MES: 0.25,
|
||||||
|
YM: 0.5, MYM: 0.25,
|
||||||
|
RTY: 0.5, M2K: 0.25,
|
||||||
|
GC: 0.5, MGC: 0.25,
|
||||||
|
SI: 0.5,
|
||||||
|
CL: 0.5, MCL: 0.25,
|
||||||
|
NG: 0.5,
|
||||||
|
ZB: 0.5, ZN: 0.5, ZF: 0.5,
|
||||||
|
'6E': 0.5, '6J': 0.5, '6B': 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A product missing from the table pays the full-size rate, so the books
|
||||||
|
* err toward the lower balance. */
|
||||||
|
export const TOPSTEP_COMMISSION_FALLBACK = 0.5;
|
||||||
|
|
||||||
|
/** TopstepX's product code in a contract id → the CME root, where the two
|
||||||
|
* differ. ENQ, GCE and CLE are seen on real fills; the rest follow the same
|
||||||
|
* naming, and contracts the client resolves itself override them. */
|
||||||
|
const PRODUCT_ROOTS: Readonly<Record<string, string>> = {
|
||||||
|
ENQ: 'NQ', EP: 'ES', GCE: 'GC', SIE: 'SI', CLE: 'CL', MCLE: 'MCL', NGE: 'NG',
|
||||||
|
USA: 'ZB', TYA: 'ZN', FVA: 'ZF', EU6: '6E', JY6: '6J', BP6: '6B',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "CON.F.US.ENQ.Z26" → "ENQ"; null for anything else. */
|
||||||
|
export function topstepxProductCode(contractId: unknown): string | null {
|
||||||
|
const parts = String(contractId ?? '').split('.');
|
||||||
|
return parts.length === 5 && parts[0] === 'CON' && parts[3] ? parts[3] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CME root a TopstepX contract id trades, or null when it's none this
|
||||||
|
* table knows. `learned` maps product codes to roots off the contracts the
|
||||||
|
* client resolved itself; the table covers the rest of the history. */
|
||||||
|
export function topstepxRoot(contractId: unknown, learned?: ReadonlyMap<string, string>): string | null {
|
||||||
|
const code = topstepxProductCode(contractId);
|
||||||
|
if (!code) return null;
|
||||||
|
const known = (root: string | undefined) => (root !== undefined && root in TOPSTEP_COMMISSION_PER_SIDE ? root : null);
|
||||||
|
return known(learned?.get(code)) ?? known(PRODUCT_ROOTS[code]) ?? known(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topstep's commission on one fill of `size` contracts (one side). */
|
||||||
|
export function topstepCommission(root: string | null, size: number): number {
|
||||||
|
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
||||||
|
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
|
||||||
|
}
|
||||||
+47
-11
@@ -11,7 +11,9 @@
|
|||||||
* - There is no cash-history / fund-transaction report. Daily P&L comes from
|
* - 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
|
* POST /api/Trade/search (each half-turn carries fees; closing halves carry
|
||||||
* profitAndLoss), grouped into Central-time trade dates with the same 5 PM
|
* 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.
|
* fund transactions only ever come from whatever the local DB already holds.
|
||||||
* - There is no OCO order. placeOcoExit places the limit and stop separately
|
* - 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
|
* 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 { signalrWebSocket } from './outbound-proxy';
|
||||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
|
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
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 type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } 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. */
|
* recomputed from these on every REST sync and every live trade event. */
|
||||||
private todayTrades: { [accountId: number]: Map<number, TsxTrade> } = {};
|
private todayTrades: { [accountId: number]: Map<number, TsxTrade> } = {};
|
||||||
|
|
||||||
/** Reported fees per trade date, per account — weights for spreading the
|
/** Fees per trade date, per account (reported plus Topstep's commission) —
|
||||||
* off-book fee drift when reconstructing the MLL floor. */
|
* weights for spreading the off-book fee drift when reconstructing the
|
||||||
|
* MLL floor. */
|
||||||
private dailyFees: { [accountId: number]: Map<string, number> } = {};
|
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
|
/** Front-month contract per product symbol, plus name→contract for order
|
||||||
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
|
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
|
||||||
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
|
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
|
||||||
@@ -811,15 +818,41 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
const map = this.todayTrades[accountId];
|
const map = this.todayTrades[accountId];
|
||||||
let realized = 0;
|
let realized = 0;
|
||||||
if (map) {
|
if (map) {
|
||||||
|
const learned = this.learnedProductRoots();
|
||||||
for (const t of map.values()) {
|
for (const t of map.values()) {
|
||||||
if (t.voided) continue;
|
if (t.voided) continue;
|
||||||
if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) 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;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── sync ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1106,9 +1139,10 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
* floor = min(0, eodBalanceHigh − trailingDrawdown)
|
* floor = min(0, eodBalanceHigh − trailingDrawdown)
|
||||||
*
|
*
|
||||||
* The EOD balance curve is rebuilt from daily P&L + recorded fund events.
|
* 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
|
* Balance the books don't explain (sub-threshold off-book fees — mostly
|
||||||
* across days proportionally to each day's reported fees — validated
|
* Topstep's commission, until the books carried it) is spread across days
|
||||||
* against a platform-reported MLL to within $0.11.
|
* 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
|
* Stored in autoLiqThresholds (negative, or +$0.01 once locked at
|
||||||
* breakeven — 0 must keep meaning "no floor"), which lights up the same
|
* 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)';
|
this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)';
|
||||||
|
|
||||||
const dailyMap: { [date: string]: { pnl: number; fees: number } } = {};
|
const dailyMap: { [date: string]: { pnl: number; fees: number } } = {};
|
||||||
|
const learned = this.learnedProductRoots();
|
||||||
for (const t of trades) {
|
for (const t of trades) {
|
||||||
const date = currentTradeDateCT(new Date(t.creationTimestamp));
|
const date = currentTradeDateCT(new Date(t.creationTimestamp));
|
||||||
const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 });
|
const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 });
|
||||||
entry.pnl += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
|
const fees = this.tradeFees(t, learned);
|
||||||
entry.fees += t.fees ?? 0;
|
entry.pnl += (t.profitAndLoss ?? 0) - fees;
|
||||||
|
entry.fees += fees;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fresh = Object.entries(dailyMap)
|
const fresh = Object.entries(dailyMap)
|
||||||
@@ -1313,8 +1349,8 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
|
|
||||||
const merged = mergePnL(cached, fresh);
|
const merged = mergePnL(cached, fresh);
|
||||||
|
|
||||||
// Reported fees per day — the weights the MLL reconstruction
|
// Fees per day — the weights the MLL reconstruction uses to
|
||||||
// uses to place off-book fee drift. Fresh overwrites cached.
|
// place off-book fee drift. Fresh overwrites cached.
|
||||||
const fees = new Map<string, number>();
|
const fees = new Map<string, number>();
|
||||||
for (const e of cached) fees.set(e.date, e.fees ?? 0);
|
for (const e of cached) fees.set(e.date, e.fees ?? 0);
|
||||||
for (const e of fresh) fees.set(e.date, e.fees);
|
for (const e of fresh) fees.set(e.date, e.fees);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
|
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
|
||||||
} from '../lib/instrument-specs.ts';
|
} from '../lib/instrument-specs.ts';
|
||||||
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts';
|
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts';
|
||||||
|
import { TOPSTEP_COMMISSION_PER_SIDE, topstepxProductCode, topstepxRoot, topstepCommission } from '../lib/topstep-fees.ts';
|
||||||
|
|
||||||
// Deterministic PRNG so a failure reproduces.
|
// Deterministic PRNG so a failure reproduces.
|
||||||
function rng(seed) {
|
function rng(seed) {
|
||||||
@@ -256,3 +257,41 @@ test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () =>
|
|||||||
assert.deepEqual(sanitizeInstrumentLimits(null), {});
|
assert.deepEqual(sanitizeInstrumentLimits(null), {});
|
||||||
assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {});
|
assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Topstep commission: every root, a micro at half the full-size rate', () => {
|
||||||
|
assert.deepEqual(Object.keys(TOPSTEP_COMMISSION_PER_SIDE).sort(), [...CME_ROOTS].sort());
|
||||||
|
for (const root of CME_ROOTS) assert.equal(TOPSTEP_COMMISSION_PER_SIDE[root], CME_SPECS[root].micro ? 0.25 : 0.5, root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Topstep commission: TopstepX contract ids to roots', () => {
|
||||||
|
// Codes seen on real fills.
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26'), 'NQ');
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.MNQ.Z26'), 'MNQ');
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.GCE.Z26'), 'GC');
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.CLE.X26'), 'CL');
|
||||||
|
assert.equal(topstepxProductCode('CON.F.US.ENQ.Z26'), 'ENQ');
|
||||||
|
// Not a contract id, or a product with no entry.
|
||||||
|
assert.equal(topstepxRoot('NQZ6'), null);
|
||||||
|
assert.equal(topstepxRoot(undefined), null);
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.MBT.Z26'), null);
|
||||||
|
// A contract the client resolved names its own code; a stray key doesn't.
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.MBT.Z26', new Map([['MBT', 'MES']])), 'MES');
|
||||||
|
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26', new Map([['ENQ', '*']])), 'NQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Topstep commission: reported fees plus commission make the published round turn', () => {
|
||||||
|
// TopstepX reports exchange + NFA a side; Topstep's page totals a round turn.
|
||||||
|
const roundTurn = (root, reportedPerSide, contracts) =>
|
||||||
|
Math.round((2 * reportedPerSide * contracts + 2 * topstepCommission(root, contracts)) * 100) / 100;
|
||||||
|
assert.equal(roundTurn('NQ', 1.39, 1), 3.78);
|
||||||
|
assert.equal(roundTurn('MNQ', 0.36, 1), 1.22);
|
||||||
|
assert.equal(roundTurn('GC', 1.66, 1), 4.32);
|
||||||
|
assert.equal(roundTurn('CL', 1.51, 1), 4.02);
|
||||||
|
assert.equal(roundTurn('NQ', 1.39, 3), 11.34);
|
||||||
|
// A sell fill's size is still a cost; an unknown product pays the full-size rate.
|
||||||
|
assert.equal(topstepCommission('NQ', -3), 1.5);
|
||||||
|
assert.equal(topstepCommission(null, 2), 1);
|
||||||
|
// 2026-10-18, an XFA $27.50 under its books: 26 full-size round turns and 3 MNQ.
|
||||||
|
const missing = 26 * 2 * topstepCommission('NQ', 1) + 3 * 2 * topstepCommission('MNQ', 1);
|
||||||
|
assert.equal(missing, 27.5);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user