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
+39
View File
@@ -13,6 +13,7 @@ import {
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
} from '../lib/instrument-specs.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.
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(['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);
});