TopstepX and Tradovate report fills, so a trade is built from them, flat to flat (lib/fill-trades.ts), off the window each sync already reads: TopstepX's two days of Trade/search, Tradovate's sync snapshot. Neither says who placed an order, so every order the bot places is noted in own_orders, and a trade is the bot's when its opening fill's order is one; its stop or take-profit closing it reads as such. A contract is written only when the position before its window comes out the same two passes running, so a fill landing between the position and history reads can't build the wrong trades. DXtrade reports positions: its record groups the last two days' filled orders by position code, with each close's SETTLEMENT and COMMISSION. The bot's own orders carry af- codes, so nothing is kept locally. Only accounts with something going on are read, at most every 5 minutes. The Trades button shows on every firm with a login, and the account page lists any account's trades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
3.5 KiB
TypeScript
75 lines
3.5 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/** How a contract reads on the dashboard: "CON.F.US.ENQ.Z26" → "NQZ6" (the
|
|
* root where it's known, else the product code); anything else as it is. */
|
|
export function topstepxSymbol(contractId: unknown, learned?: ReadonlyMap<string, string>): string {
|
|
const parts = String(contractId ?? '').split('.');
|
|
const code = topstepxProductCode(contractId);
|
|
if (!code) return String(contractId ?? '');
|
|
const expiry = /^([FGHJKMNQUVXZ])(\d{1,2})$/.exec(parts[4] ?? '');
|
|
const root = topstepxRoot(contractId, learned) ?? code;
|
|
return expiry ? `${root}${expiry[1]}${expiry[2].slice(-1)}` : root;
|
|
}
|