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>
484 lines
23 KiB
TypeScript
484 lines
23 KiB
TypeScript
/**
|
||
* Pure helpers for the DXTrade client (lib/dxtrade-class.ts): account ids,
|
||
* contract names, the firm-day clock, parsing the API's loosely typed
|
||
* answers, and the instrument selector's volatility and cost scoring.
|
||
*
|
||
* No imports beyond node built-ins, so scripts/check-trading-logic.mjs can
|
||
* test them directly.
|
||
*/
|
||
|
||
import { createHash } from 'crypto';
|
||
|
||
// ── account ids ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A stable numeric id for a DXTrade account code ("clearing:account"). Every
|
||
* account table, route and backup keys on a number, and DXTrade codes are
|
||
* strings, so the code is hashed — deterministically, which is what lets a
|
||
* backup restored onto a new machine find its manual assignments again (a
|
||
* per-machine id table would renumber them).
|
||
*
|
||
* 52 bits of SHA-256 folded into [2^50, 2^53): safe integers, far above any
|
||
* Tradovate or TopstepX id. The host is part of the key, so the same code on
|
||
* two DXTrade servers never collides — and changing a firm's server URL
|
||
* re-keys its accounts.
|
||
*/
|
||
export function dxAccountId(host: string, code: string): number {
|
||
const digest = createHash('sha256').update(`dxtrade|${host.toLowerCase()}|${code}`).digest();
|
||
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
|
||
}
|
||
|
||
/** "default:TFY-50K-123" → "TFY-50K-123": what the dashboard shows and what
|
||
* account-type prefixes match against. */
|
||
export function accountDisplayName(code: string): string {
|
||
const i = code.indexOf(':');
|
||
return i >= 0 && i < code.length - 1 ? code.slice(i + 1) : code;
|
||
}
|
||
|
||
/** DXTrade answers carry account codes either as "clearing:account" or as an
|
||
* {clearing, account} object, depending on the endpoint's serializer. */
|
||
export function accountCodeOf(raw: unknown): string | null {
|
||
if (typeof raw === 'string' && raw) return raw;
|
||
if (raw && typeof raw === 'object') {
|
||
const r = raw as { clearing?: unknown; account?: unknown };
|
||
if (typeof r.account === 'string') return typeof r.clearing === 'string' ? `${r.clearing}:${r.account}` : r.account;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── contract names ───────────────────────────────────────────────────────────
|
||
|
||
/** An engine "contract" on DXTrade is one quantity step of a symbol; the
|
||
* step rides in the name the engine hands back to sendOrder, which keeps
|
||
* order placement stateless. Symbols never contain '|' (the master refuses
|
||
* them). */
|
||
export function encodeContractName(symbol: string, step: number): string {
|
||
return `${symbol}|${step}`;
|
||
}
|
||
|
||
export function decodeContractName(name: string): { symbol: string; step: number } | null {
|
||
const i = name.lastIndexOf('|');
|
||
if (i <= 0) return null;
|
||
const step = Number(name.slice(i + 1));
|
||
if (!Number.isFinite(step) || step <= 0) return null;
|
||
return { symbol: name.slice(0, i), step };
|
||
}
|
||
|
||
/** Decimal places of a step like 0.001 (or 1e-7), for exact rounding. */
|
||
export function decimalsOf(step: number): number {
|
||
if (!Number.isFinite(step) || step <= 0) return 0;
|
||
const s = step.toString();
|
||
const exp = s.match(/e-(\d+)$/);
|
||
if (exp) return Number(exp[1]) + (s.split('e')[0].split('.')[1]?.length ?? 0);
|
||
return s.split('.')[1]?.length ?? 0;
|
||
}
|
||
|
||
/** A multiple of `step` without float fuzz (0.1 + 0.2 style). */
|
||
export function roundToStep(value: number, step: number, mode: 'nearest' | 'down' | 'up' = 'nearest'): number {
|
||
if (!(step > 0)) return value;
|
||
const n = value / step;
|
||
const k = mode === 'down' ? Math.floor(n + 1e-9) : mode === 'up' ? Math.ceil(n - 1e-9) : Math.round(n);
|
||
return Number((k * step).toFixed(decimalsOf(step)));
|
||
}
|
||
|
||
// ── clocks ───────────────────────────────────────────────────────────────────
|
||
|
||
/** The most recent 22:00 UTC at or before `now` — when Tradeify (and DXtrade
|
||
* brokers generally) snapshot the closed balance and start a new day. */
|
||
export function lastUtcReset(now: Date, hourUtc = 22): Date {
|
||
const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hourUtc));
|
||
if (reset.getTime() > now.getTime()) reset.setUTCDate(reset.getUTCDate() - 1);
|
||
return reset;
|
||
}
|
||
|
||
/** The trading day the firm books a moment to: the day that began at the
|
||
* reset before it, labelled by the calendar date that follows — the label
|
||
* currentTradeDateCT gives the same day. In summer the two roll together
|
||
* (22:00 UTC = 5 PM CDT); in winter the firm's rolls an hour earlier, and a
|
||
* close in that hour belongs to the new day, as the firm counts it. */
|
||
export function firmDayOf(at: Date, hourUtc = 22): string {
|
||
return new Date(at.getTime() + (24 - hourUtc) * 3_600_000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** When the current Central trade date began (5 PM CT) — the window today's
|
||
* realized P&L covers, matching currentTradeDateCT. */
|
||
export function ctTradeDayStart(now: Date): Date {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||
}).formatToParts(now);
|
||
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0');
|
||
const secondsIntoDay = (get('hour') % 24) * 3600 + get('minute') * 60 + get('second');
|
||
const sinceRoll = (secondsIntoDay - 17 * 3600 + 86_400) % 86_400;
|
||
return new Date(Math.floor(now.getTime() / 1000) * 1000 - sinceRoll * 1000);
|
||
}
|
||
|
||
/** DXTrade timestamps: ISO strings on the wire (the schema calls them
|
||
* objects); epoch ms tolerated. NaN when unreadable. */
|
||
export function timeOf(raw: unknown): number {
|
||
if (typeof raw === 'number') return raw;
|
||
if (typeof raw === 'string') return Date.parse(raw);
|
||
return NaN;
|
||
}
|
||
|
||
/** The login answer's inactivity timeout, in ms: "PT30M", "01:05:08", a
|
||
* {seconds} object or a number of seconds. Falls back to 30 minutes. */
|
||
export function parseDurationMs(raw: unknown): number {
|
||
const fallback = 30 * 60_000;
|
||
if (typeof raw === 'number' && raw > 0) return raw * 1000;
|
||
if (raw && typeof raw === 'object') {
|
||
const s = (raw as { seconds?: unknown }).seconds;
|
||
if (typeof s === 'number' && s > 0) return s * 1000;
|
||
}
|
||
if (typeof raw === 'string') {
|
||
const iso = raw.match(/^P(?:T)?(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/i);
|
||
if (iso && (iso[1] || iso[2] || iso[3])) {
|
||
return ((Number(iso[1] ?? 0) * 60 + Number(iso[2] ?? 0)) * 60 + Number(iso[3] ?? 0)) * 1000;
|
||
}
|
||
const hms = raw.match(/^(\d+):(\d{2}):(\d{2})$/);
|
||
if (hms) return ((Number(hms[1]) * 60 + Number(hms[2])) * 60 + Number(hms[3])) * 1000;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
// ── loose parsing ────────────────────────────────────────────────────────────
|
||
|
||
/** A number field read case-insensitively — the live server says openPL
|
||
* where the docs say openPl. */
|
||
export function ciNum(obj: unknown, key: string): number | undefined {
|
||
if (!obj || typeof obj !== 'object') return undefined;
|
||
const want = key.toLowerCase();
|
||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||
if (k.toLowerCase() === want && typeof v === 'number' && Number.isFinite(v)) return v;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Deposits and withdrawals move money in or out; every other cash
|
||
* transaction (SETTLEMENT, COMMISSION, FINANCING, ADJUSTMENT…) is trading. */
|
||
export function isFundType(type: unknown): boolean {
|
||
return type === 'DEPOSIT' || type === 'WITHDRAWAL';
|
||
}
|
||
|
||
/** A withdrawal always takes money out, whatever sign the server files it with. */
|
||
export function signedFundValue(type: string, value: number): number {
|
||
return type === 'WITHDRAWAL' ? -Math.abs(value) : Math.abs(value);
|
||
}
|
||
|
||
export interface DxTxn {
|
||
key: string;
|
||
account: string;
|
||
type: string;
|
||
value: number;
|
||
time: number;
|
||
orderCode: string | undefined;
|
||
}
|
||
|
||
/**
|
||
* The cash transactions in a /transfers answer, flattened. A close books its
|
||
* SETTLEMENT (the trade's P&L) and its COMMISSION under one transaction code,
|
||
* so the key carries the type — and a running count, should a transfer ever
|
||
* hold two of one type — or one would overwrite the other.
|
||
*/
|
||
export function parseTransfers(body: unknown): DxTxn[] {
|
||
const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? [];
|
||
const out: DxTxn[] = [];
|
||
for (const t of transfers) {
|
||
const transfer = t as Record<string, unknown>;
|
||
const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : [];
|
||
const seen = new Map<string, number>();
|
||
for (const x of txns) {
|
||
const tx = x as Record<string, unknown>;
|
||
const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account);
|
||
const value = ciNum(tx, 'value');
|
||
const time = timeOf(tx.transactionTime ?? transfer.transactionTime);
|
||
if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue;
|
||
const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : '';
|
||
const base = code
|
||
? `${code}|${tx.type}`
|
||
: `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`;
|
||
const n = seen.get(base) ?? 0;
|
||
seen.set(base, n + 1);
|
||
out.push({
|
||
key: n === 0 ? base : `${base}|${n}`,
|
||
account,
|
||
type: tx.type,
|
||
value,
|
||
time,
|
||
orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined,
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The margin an account posts per dollar of notional on an instrument — its
|
||
* leverage cap, inverted (0.2 = 5:1). A tiered rate reads as its steepest
|
||
* tier. undefined when the details don't say.
|
||
*/
|
||
export function marginRateOf(details: unknown): number | undefined {
|
||
const rate = (details as { marginRate?: { tiers?: { value?: unknown }[]; value?: unknown } } | undefined)?.marginRate;
|
||
const values = [...(rate?.tiers ?? []).map((t) => Number(t?.value)), Number(rate?.value)]
|
||
.filter((v) => Number.isFinite(v) && v > 0 && v <= 1);
|
||
return values.length > 0 ? Math.max(...values) : undefined;
|
||
}
|
||
|
||
/** A client order code: unique, ≤ 64 chars, and recognisably ours. */
|
||
export function newOrderCode(tag: string): string {
|
||
const rand = Math.random().toString(36).slice(2, 8);
|
||
return `af-${Date.now().toString(36)}-${rand}-${tag}`.slice(0, 64);
|
||
}
|
||
|
||
// ── trading hours ────────────────────────────────────────────────────────────
|
||
|
||
const WEEKDAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||
|
||
/**
|
||
* Whether an instrument's sessions have it open at `now`. Sessions are
|
||
* SESSION_OPEN / SESSION_CLOSE events on a UTC week; the latest event at or
|
||
* before now (wrapping around the week) decides. No sessions, or any it
|
||
* can't read, means always open — 24/7 crypto lists none.
|
||
*/
|
||
export function inTradingHours(sessions: unknown, now: Date): boolean {
|
||
if (!Array.isArray(sessions) || sessions.length === 0) return true;
|
||
const events: { at: number; open: boolean }[] = [];
|
||
for (const s of sessions) {
|
||
const e = s as { eventType?: unknown; weekDay?: { dayOfWeek?: unknown; time?: { hour?: unknown; minute?: unknown } } };
|
||
const day = WEEKDAYS.indexOf(String(e.weekDay?.dayOfWeek ?? ''));
|
||
const hour = Number(e.weekDay?.time?.hour ?? NaN);
|
||
const minute = Number(e.weekDay?.time?.minute ?? 0);
|
||
if (day < 0 || !Number.isFinite(hour) || (e.eventType !== 'SESSION_OPEN' && e.eventType !== 'SESSION_CLOSE')) return true;
|
||
events.push({ at: day * 1440 + hour * 60 + minute, open: e.eventType === 'SESSION_OPEN' });
|
||
}
|
||
events.sort((a, b) => a.at - b.at);
|
||
const t = now.getUTCDay() * 1440 + now.getUTCHours() * 60 + now.getUTCMinutes();
|
||
const past = events.filter((e) => e.at <= t);
|
||
const latest = past.length > 0 ? past[past.length - 1] : events[events.length - 1];
|
||
return latest.open;
|
||
}
|
||
|
||
// ── instrument selector ──────────────────────────────────────────────────────
|
||
|
||
/** Fewest hourly candles a volatility estimate is trusted on. */
|
||
export const MIN_VOL_CANDLES = 24;
|
||
|
||
/**
|
||
* σ of hourly log returns (per √hour) from hourly closes, oldest first, in
|
||
* wall-clock time — flat hours count, so an instrument that sits still all
|
||
* weekend really does read as quiet on a weekend. null on too few candles.
|
||
*/
|
||
export function realizedVolPerHour(closes: number[]): number | null {
|
||
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
|
||
if (clean.length < MIN_VOL_CANDLES + 1) return null;
|
||
const returns: number[] = [];
|
||
for (let i = 1; i < clean.length; i++) returns.push(Math.log(clean[i] / clean[i - 1]));
|
||
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
|
||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
|
||
return Math.sqrt(variance);
|
||
}
|
||
|
||
/**
|
||
* A candidate's cost score — lower is better.
|
||
*
|
||
* A leg sized for a hold of T hours needs notional ∝ 1/σ, so its cost —
|
||
* spread plus both commissions plus financing over the hold, all
|
||
* proportional to notional — is proportional to (spread + 2c + f·T/24) / σ.
|
||
* A volatile instrument reaches the take-profit with less notional, which is
|
||
* what makes a tight spread alone not enough.
|
||
*
|
||
* null = can't be scored (no σ).
|
||
*/
|
||
export function scoreCandidate(o: {
|
||
spreadFraction: number;
|
||
commissionPct: number;
|
||
financingPctPerDay: number;
|
||
targetHoldHours: number;
|
||
volatilityPerHour: number | null;
|
||
}): number | null {
|
||
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
|
||
const financing = (o.financingPctPerDay / 100) * (Math.max(0, o.targetHoldHours) / 24);
|
||
return (o.spreadFraction + 2 * (o.commissionPct / 100) + financing) / o.volatilityPerHour;
|
||
}
|
||
|
||
/** Most a spread may be, as a share of the mid price, for a candidate to trade. */
|
||
export const MAX_SPREAD_FRACTION = 0.005;
|
||
/** A quote older than this doesn't count as live. */
|
||
export const MAX_QUOTE_AGE_MS = 5 * 60_000;
|
||
|
||
/** Why a candidate can't trade right now, or null when it can. */
|
||
export function candidateRejection(o: {
|
||
tradingStatus: string | undefined;
|
||
bid: number | undefined;
|
||
ask: number | undefined;
|
||
quoteAgeMs: number | null;
|
||
inHours: boolean;
|
||
candles: number;
|
||
minContractsOk: boolean;
|
||
currency: string | undefined;
|
||
accountCurrency: string;
|
||
}): string | null {
|
||
if (o.tradingStatus !== undefined && o.tradingStatus !== 'FULL') return `trading status ${o.tradingStatus}`;
|
||
if (!o.inHours) return 'outside trading hours';
|
||
if (!(typeof o.bid === 'number' && o.bid > 0 && typeof o.ask === 'number' && o.ask >= o.bid)) return 'no live quote';
|
||
if (o.quoteAgeMs !== null && o.quoteAgeMs > MAX_QUOTE_AGE_MS) return `quote ${Math.round(o.quoteAgeMs / 60_000)}m old`;
|
||
const mid = (o.bid + o.ask) / 2;
|
||
if ((o.ask - o.bid) / mid > MAX_SPREAD_FRACTION) return `spread ${(((o.ask - o.bid) / mid) * 100).toFixed(2)}%`;
|
||
if (o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`;
|
||
if (!o.minContractsOk) return 'minimum order size out of reach';
|
||
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
|
||
return null;
|
||
}
|
||
|
||
// ── trades ───────────────────────────────────────────────────────────────────
|
||
|
||
/** A filled order off /orders/history, as the trades record needs it. */
|
||
export interface DxHistoryOrder {
|
||
orderCode: string;
|
||
orderId: number | undefined;
|
||
/** MARKET, LIMIT, STOP. */
|
||
type: string;
|
||
instrument: string;
|
||
positionCode: string | undefined;
|
||
effect: 'OPEN' | 'CLOSE' | undefined;
|
||
side: 'BUY' | 'SELL' | undefined;
|
||
/** Filled. */
|
||
qty: number;
|
||
/** Average fill price. */
|
||
price: number | undefined;
|
||
/** The last fill, else the order's own time. */
|
||
at: number;
|
||
}
|
||
|
||
/** null for an order that never filled. */
|
||
export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null {
|
||
const o = raw as Record<string, unknown>;
|
||
if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null;
|
||
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
|
||
const leg = legs[0] ?? {};
|
||
const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0);
|
||
if (!(qty > 0)) return null;
|
||
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
|
||
const fillTimes = executions
|
||
.filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0)
|
||
.map((e) => timeOf(e.transactionTime))
|
||
.filter(Number.isFinite);
|
||
const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN;
|
||
if (!Number.isFinite(at)) return null;
|
||
const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined;
|
||
const sideRaw = leg.side ?? o.side;
|
||
return {
|
||
orderCode: o.orderCode,
|
||
orderId: ciNum(o, 'orderId'),
|
||
type: String(o.type ?? ''),
|
||
instrument: String(leg.instrument ?? o.instrument ?? ''),
|
||
positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined,
|
||
effect,
|
||
side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined,
|
||
qty,
|
||
price: ciNum(leg, 'averagePrice')
|
||
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
|
||
at,
|
||
};
|
||
}
|
||
|
||
/** One position, from the orders that opened and closed it. */
|
||
export interface DxTrade {
|
||
positionCode: string;
|
||
symbol: string;
|
||
side: 'long' | 'short';
|
||
quantity: number;
|
||
openPrice: number | null;
|
||
closePrice: number | null;
|
||
/** The SETTLEMENT its closes booked; null while open or unseen. */
|
||
pnl: number | null;
|
||
/** Its COMMISSION, opens and closes; null when none was seen. */
|
||
fee: number | null;
|
||
openedAt: number;
|
||
closedAt: number | null;
|
||
/** The opening order's code; null when it opened before the history. */
|
||
openCode: string | null;
|
||
/** The order that closed it, and its type. */
|
||
closeCode: string | null;
|
||
closeType: string | null;
|
||
}
|
||
|
||
/** What the record knows of a position already: the open one on the
|
||
* account now, or one stored from an earlier pass. */
|
||
export interface DxKnownPosition {
|
||
symbol: string;
|
||
side: 'long' | 'short';
|
||
quantity: number;
|
||
openPrice: number | null;
|
||
openedAt: number;
|
||
}
|
||
|
||
const vwap = (orders: DxHistoryOrder[]): number | null => {
|
||
const priced = orders.filter((o) => o.price !== undefined);
|
||
const qty = priced.reduce((s, o) => s + o.qty, 0);
|
||
return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null;
|
||
};
|
||
|
||
/**
|
||
* The account's positions, out of its filled orders (one account) and its
|
||
* cash transactions. On a position-based account an opening order's position
|
||
* code is its order id, so an OPEN leg without one takes that. A position the
|
||
* account still holds (`open`) stays open whatever closed part of it; one
|
||
* whose opening is older than the orders at hand is completed from `known`,
|
||
* and left out when nothing knows it.
|
||
*/
|
||
export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map<string, DxKnownPosition>, known: Map<string, DxKnownPosition>): DxTrade[] {
|
||
const groups = new Map<string, DxHistoryOrder[]>();
|
||
for (const o of orders) {
|
||
const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined);
|
||
if (!code) continue;
|
||
groups.set(code, [...(groups.get(code) ?? []), o]);
|
||
}
|
||
for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []);
|
||
|
||
const settled = new Map<string, number>();
|
||
const commission = new Map<string, number>();
|
||
for (const t of txns) {
|
||
if (!t.orderCode) continue;
|
||
if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value);
|
||
else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value));
|
||
}
|
||
|
||
const out: DxTrade[] = [];
|
||
for (const [code, list] of groups) {
|
||
const sorted = [...list].sort((a, b) => a.at - b.at);
|
||
const now = open.get(code);
|
||
const before = now ?? known.get(code);
|
||
// An OPEN leg, or one on the same side as the opening.
|
||
const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined));
|
||
const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined);
|
||
if (!openSide) continue;
|
||
const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide));
|
||
const closes = sorted.filter((o) => !opens.includes(o));
|
||
if (opens.length === 0 && !before) continue;
|
||
|
||
const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice;
|
||
const closeQty = closes.reduce((s, o) => s + o.qty, 0);
|
||
const closed = !now && closeQty > 0;
|
||
const last = closed ? closes[closes.length - 1] : undefined;
|
||
const codes = sorted.map((o) => o.orderCode);
|
||
const fees = codes.filter((c) => commission.has(c));
|
||
const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c));
|
||
out.push({
|
||
positionCode: code,
|
||
symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '',
|
||
side: openSide === 'BUY' ? 'long' : 'short',
|
||
quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0),
|
||
openPrice: openPrice ?? now?.openPrice ?? null,
|
||
closePrice: closed ? vwap(closes) : null,
|
||
pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null,
|
||
fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null,
|
||
openedAt: opens.length > 0 ? opens[0].at : before!.openedAt,
|
||
closedAt: last ? last.at : null,
|
||
openCode: opens.length > 0 ? opens[0].orderCode : null,
|
||
closeCode: last?.orderCode ?? null,
|
||
closeType: last?.type ?? null,
|
||
});
|
||
}
|
||
return out;
|
||
}
|