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>
244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
/**
|
|
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
|
|
* trade runs from flat back to flat: adds and partial closes stay inside it,
|
|
* and a fill that crosses zero closes one trade and opens the next. Vest and
|
|
* DXtrade report whole positions themselves.
|
|
*
|
|
* No imports, so scripts/check-fill-trades.mjs can test it directly.
|
|
*/
|
|
|
|
export interface Fill {
|
|
id: string;
|
|
orderId: string;
|
|
at: number;
|
|
side: 'buy' | 'sell';
|
|
qty: number;
|
|
price: number;
|
|
/** What the fill cost (positive); null when unknown. */
|
|
fee: number | null;
|
|
/** The P&L the platform booked on this fill, before fees (TopstepX's
|
|
* closing half-turns); null to work it out from the prices. */
|
|
pnl: number | null;
|
|
}
|
|
|
|
/** A trade already stored as open, from before the fills at hand begin —
|
|
* continued when they start part-way through a position. */
|
|
export interface CarriedTrade {
|
|
id: string;
|
|
side: 'long' | 'short';
|
|
quantity: number;
|
|
openPrice: number | null;
|
|
fee: number | null;
|
|
openedAt: number;
|
|
}
|
|
|
|
export interface FillTrade {
|
|
/** The first fill's id, or the carried trade's. */
|
|
id: string;
|
|
side: 'long' | 'short';
|
|
/** The most held at once. */
|
|
quantity: number;
|
|
/** Average of the fills that opened or added to it. */
|
|
openPrice: number | null;
|
|
/** Average of the fills that reduced it; null while open. */
|
|
closePrice: number | null;
|
|
/** Before fees; null while open, or when it can't be told. */
|
|
pnl: number | null;
|
|
fee: number | null;
|
|
openedAt: number;
|
|
closedAt: number | null;
|
|
/** The order whose fill opened it; null for a carried trade. */
|
|
openOrderId: string | null;
|
|
/** The order whose fill took it flat; null while open. */
|
|
closeOrderId: string | null;
|
|
carried: boolean;
|
|
}
|
|
|
|
const EPS = 1e-9;
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
|
|
|
|
/** Oldest first; ties by id, numerically when both are numbers. */
|
|
export function byFillTime(a: Fill, b: Fill): number {
|
|
if (a.at !== b.at) return a.at - b.at;
|
|
const an = Number(a.id);
|
|
const bn = Number(b.id);
|
|
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
}
|
|
|
|
/** The position before the first of `fills`, given the one after the last. */
|
|
export function startingNet(fills: Fill[], currentNet: number): number {
|
|
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
|
|
return Math.abs(before) < EPS ? 0 : before;
|
|
}
|
|
|
|
interface Building {
|
|
id: string;
|
|
dir: 1 | -1;
|
|
net: number;
|
|
maxAbs: number;
|
|
openQty: number;
|
|
openNotional: number | null;
|
|
closeQty: number;
|
|
closeNotional: number;
|
|
platformPnl: number;
|
|
pnlFromPlatform: boolean;
|
|
fee: number | null;
|
|
openedAt: number;
|
|
openOrderId: string | null;
|
|
carried: boolean;
|
|
}
|
|
|
|
/**
|
|
* The trades `fills` (one account, one contract) make. `startNet` is the
|
|
* position before the first fill (startingNet): when it isn't flat, the
|
|
* fills start inside a position — `carry` continues the stored trade when it
|
|
* is on the same side, and without one those fills are passed over until the
|
|
* account is flat. `pointValue` (dollars per point per contract) works out
|
|
* the P&L of a fill the platform gave none for.
|
|
*/
|
|
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
|
|
const out: FillTrade[] = [];
|
|
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
|
|
let cur: Building | null = null;
|
|
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
|
|
const qty = Math.abs(net);
|
|
cur = {
|
|
id: o.carry.id,
|
|
dir: net > 0 ? 1 : -1,
|
|
net,
|
|
maxAbs: Math.max(qty, o.carry.quantity),
|
|
openQty: qty,
|
|
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
|
|
closeQty: 0,
|
|
closeNotional: 0,
|
|
platformPnl: 0,
|
|
pnlFromPlatform: true,
|
|
fee: o.carry.fee,
|
|
openedAt: o.carry.openedAt,
|
|
openOrderId: null,
|
|
carried: true,
|
|
};
|
|
}
|
|
|
|
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
|
|
id: f.id,
|
|
dir: f.side === 'buy' ? 1 : -1,
|
|
net: f.side === 'buy' ? qty : -qty,
|
|
maxAbs: qty,
|
|
openQty: qty,
|
|
openNotional: f.price * qty,
|
|
closeQty: 0,
|
|
closeNotional: 0,
|
|
platformPnl: 0,
|
|
pnlFromPlatform: true,
|
|
fee: feeShare,
|
|
openedAt: f.at,
|
|
openOrderId: f.orderId,
|
|
carried: false,
|
|
});
|
|
const addFee = (b: Building, fee: number | null) => {
|
|
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
|
|
};
|
|
|
|
for (const f of [...fills].sort(byFillTime)) {
|
|
if (!(f.qty > 0)) continue;
|
|
const s = signed(f);
|
|
if (cur === null && net !== 0) {
|
|
// Inside a position whose start isn't on record: wait for flat.
|
|
if (Math.sign(s) === Math.sign(net)) {
|
|
net += s;
|
|
continue;
|
|
}
|
|
const closing = Math.min(Math.abs(s), Math.abs(net));
|
|
net += Math.sign(s) * closing;
|
|
if (Math.abs(net) < EPS) net = 0;
|
|
const rest = f.qty - closing;
|
|
if (net === 0 && rest > EPS) {
|
|
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
|
net = cur.net;
|
|
}
|
|
continue;
|
|
}
|
|
if (cur === null) {
|
|
cur = open(f, f.qty, f.fee);
|
|
net = cur.net;
|
|
continue;
|
|
}
|
|
if (Math.sign(s) === cur.dir) {
|
|
cur.net += s;
|
|
net = cur.net;
|
|
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
|
|
cur.openQty += f.qty;
|
|
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
|
|
addFee(cur, f.fee);
|
|
continue;
|
|
}
|
|
const closing = Math.min(f.qty, Math.abs(cur.net));
|
|
const rest = f.qty - closing;
|
|
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
|
|
cur.closeQty += closing;
|
|
cur.closeNotional += f.price * closing;
|
|
if (f.pnl === null) cur.pnlFromPlatform = false;
|
|
else cur.platformPnl += f.pnl;
|
|
cur.net += Math.sign(s) * closing;
|
|
if (Math.abs(cur.net) < EPS) cur.net = 0;
|
|
net = cur.net;
|
|
if (cur.net !== 0) continue;
|
|
|
|
out.push(finish(cur, f, o.pointValue));
|
|
cur = null;
|
|
if (rest > EPS) {
|
|
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
|
net = cur.net;
|
|
}
|
|
}
|
|
if (cur !== null) {
|
|
out.push({
|
|
id: cur.id,
|
|
side: cur.dir === 1 ? 'long' : 'short',
|
|
quantity: cur.maxAbs,
|
|
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
|
|
closePrice: null,
|
|
pnl: null,
|
|
fee: cur.fee === null ? null : round2(cur.fee),
|
|
openedAt: cur.openedAt,
|
|
closedAt: null,
|
|
openOrderId: cur.openOrderId,
|
|
closeOrderId: null,
|
|
carried: cur.carried,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
|
|
// Flat to flat, so as much was bought as sold: the P&L is the difference
|
|
// in what each side came to.
|
|
const computed = b.openNotional === null || pointValue === null
|
|
? null
|
|
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
|
|
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
|
|
return {
|
|
id: b.id,
|
|
side: b.dir === 1 ? 'long' : 'short',
|
|
quantity: b.maxAbs,
|
|
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
|
|
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
|
|
pnl: pnl === null ? null : round2(pnl),
|
|
fee: b.fee === null ? null : round2(b.fee),
|
|
openedAt: b.openedAt,
|
|
closedAt: last.at,
|
|
openOrderId: b.openOrderId,
|
|
closeOrderId: last.orderId,
|
|
carried: b.carried,
|
|
};
|
|
}
|
|
|
|
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
|
|
export function contractRoot(name: string): string {
|
|
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
|
|
return m ? m[1] : name.trim().toUpperCase();
|
|
}
|