diff --git a/lib/topstep-fees.ts b/lib/topstep-fees.ts new file mode 100644 index 0000000..859fd99 --- /dev/null +++ b/lib/topstep-fees.ts @@ -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> = { + 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> = { + 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 | 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; +} diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 30fb0ca..0cb3b4d 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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 } = {}; - /** 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 } = {}; + /** Product codes already warned about having no Topstep commission. */ + private unknownCommissionCodes = new Set(); + /** Front-month contract per product symbol, plus name→contract for order * placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */ private contractByProduct = new Map(); @@ -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): 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 { + const learned = new Map(); + 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(); for (const e of cached) fees.set(e.date, e.fees ?? 0); for (const e of fresh) fees.set(e.date, e.fees); diff --git a/scripts/check-instruments.mjs b/scripts/check-instruments.mjs index 6492264..bd2a6a0 100644 --- a/scripts/check-instruments.mjs +++ b/scripts/check-instruments.mjs @@ -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); +});