src/frames/frames.ts
v0.1.0 · 28.5 KB
import { type Dec, decCmp, decMul, decSign, decToString, parseDec, stepsOf } from '../numbers/decimal.js';
import { parseLossless } from '../numbers/lossless.js';
export type QfexSide = 'BUY' | 'SELL';
export interface QfexOrderEvent {
reduceOnly?: boolean;
orderOrigin?: string;
orderId: string;
symbol: string;
status: string;
side: QfexSide;
type: string;
tif: string;
qty: string;
price: string;
remaining: string;
cloid: string | null;
updateTimeMs: number | null;
tradeId: string | null;
userId: string | null;
}
export interface QfexFill {
orderOrigin?: string;
tradeId: string;
orderId: string;
cloid: string | null;
symbol: string;
side: QfexSide;
price: string;
qty: string;
remaining: string | null;
fee: string | null;
execType: string | null;
tsMs: number | null;
aggressorSide: QfexSide | null;
orderType: string | null;
tif: string | null;
orderPrice: string | null;
realisedPnl: string | null;
userId: string | null;
}
export interface QfexLeverageRow {
id: string | null;
symbol: string;
leverage: string;
initialMargin: string | null;
maintenanceMargin: string | null;
maxNotional: string | null;
}
export interface QfexPositionRow {
symbol: string;
position: string;
id: string | null;
userId: string | null;
marginAlloc: string | null;
realisedPnl: string | null;
unrealisedPnl: string | null;
netFunding: string | null;
openOrders: string | null;
openQuantity: string | null;
leverage: string | null;
initialMargin: string | null;
maintenanceMargin: string | null;
averagePrice: string | null;
timestamp: string | null;
}
export interface QfexBalanceRow {
id: string | null;
userId: string | null;
availableBalance: string;
deposit: string | null;
realisedPnl: string | null;
orderMargin: string | null;
positionMargin: string | null;
unrealisedPnl: string | null;
netFunding: string | null;
referralRewards: string | null;
builderRewards: string | null;
fees: string | null;
}
export interface QfexTradeRow {
tradeId: string;
orderId: string;
symbol: string;
side: QfexSide;
price: string;
qty: string;
fee: string | null;
orderType: string | null;
tif: string | null;
}
export interface QfexEnvelopeMeta {
type: string | null;
channel: string | null;
connectionId: string | null;
messageId: string | null;
id: string | null;
}
export type QfexIncoming =
| {
kind: 'none';
}
| {
kind: 'request';
type: string;
params: Record<string, unknown>;
cloid: string | null;
orderId: string | null;
symbol: string | null;
cancelIdType: string | null;
}
| {
kind: 'opaque';
raw: unknown;
};
export type QfexErrForm = 'nested' | 'flat_Err' | 'flat_error' | 'flat_bare';
export type TradeFrame =
| {
k: 'auth';
ok: boolean;
form: 'authenticated' | 'type_result';
detail: string | null;
}
| {
k: 'subscribed';
channel: string;
}
| {
k: 'unsubscribed';
channel: string;
}
| {
k: 'order';
o: QfexOrderEvent;
}
| {
k: 'order_partial';
status: string;
orderId: string | null;
cloid: string | null;
symbol: string | null;
why: string;
raw: unknown;
}
| {
k: 'fill';
f: QfexFill;
}
| {
k: 'orders';
orders: QfexOrderEvent[];
twaps: unknown[];
stopOrders: unknown[];
malformed: string[];
}
| {
k: 'leverage';
rows: QfexLeverageRow[];
malformed: string[];
}
| {
k: 'leverage_levels';
rows: QfexLeverageRow[];
malformed: string[];
}
| {
k: 'user_trades';
form: 'user_trades_response' | 'user_trades';
trades: QfexTradeRow[];
count: number | null;
malformed: string[];
}
| {
k: 'ack';
ok: boolean;
form: 'ack' | 'ack_response';
incoming: QfexIncoming;
}
| {
k: 'err';
code: string;
message: string | null;
incoming: QfexIncoming;
form: QfexErrForm;
}
| {
k: 'positions';
form: 'object' | 'envelope';
rows: QfexPositionRow[];
malformed: string[];
envelope: QfexEnvelopeMeta | null;
raw: unknown;
}
| {
k: 'balances';
form: 'object' | 'envelope';
rows: QfexBalanceRow[];
malformed: string[];
envelope: QfexEnvelopeMeta | null;
raw: unknown;
}
| {
k: 'twap';
raw: unknown;
}
| {
k: 'stop_order';
raw: unknown;
}
| {
k: 'ping';
pong: boolean;
form: 'text' | 'json';
}
| {
k: 'unknown';
why: string;
detail: string | null;
raw: unknown;
};
const LIVE_STATUSES: ReadonlySet<string> = new Set(['ACK', 'MODIFIED']);
const FILL_STATUSES: ReadonlySet<string> = new Set(['FILLED']);
const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
'CANCELLED',
'CANCELLED_STP',
'IOC_CANCELLED',
'IOC_PARTIALLY_FILLED',
'EXPIRED',
]);
const REJECT_STATUSES: ReadonlySet<string> = new Set([
'REJECTED',
'INVALID_PRICE',
'INVALID_QUANTITY',
'INVALID_ORDER_TYPE',
'INVALID_TIME_IN_FORCE',
'BAD_SYMBOL',
'PRICE_LESS_THAN_MIN_PRICE',
'PRICE_GREATER_THAN_MAX_PRICE',
'REJECTED_LESS_THAN_MIN_PRICE_BAND',
'REJECTED_GREATER_THAN_MAX_PRICE_BAND',
'INVALID_TICK_SIZE_PRECISION_PRICE',
'INVALID_TICK_SIZE_PRECISION_QUANTITY',
'QUANTITY_LESS_THAN_MIN_QUANTITY',
'QUANTITY_GREATER_THAN_MAX_QUANTITY',
'REJECTED_LESS_THAN_MIN_NOTIONAL',
'FAILED_MARGIN_CHECK',
'REJECTED_WOULD_BREACH_MAX_POSITION',
'REJECTED_WOULD_BREACH_MAX_NOTIONAL',
'REJECTED_OPEN_INTEREST_LIMIT',
'REJECTED_TOTAL_OPEN_INTEREST_LIMIT',
'REJECTED_TOO_MANY_OPEN_ORDERS',
'REJECTED_MARKET_CLOSED',
'USER_IN_LIQUIDATION',
'REJECTED_FAILED_TO_PROCESS',
'USER_NOT_FOUND',
'PERMISSION_DENIED',
'RATE_LIMITED',
'INVALID_TAKE_PROFIT_PRICE',
'INVALID_STOP_LOSS_PRICE',
'INVALID_CLIENT_ORDER_ID',
'INVALID_TAKEPROFIT_PRICE',
'INVALID_STOPLOSS_PRICE',
]);
const CANCEL_REPLY_STATUSES: ReadonlySet<string> = new Set([
'NO_SUCH_ORDER',
'NOT_FOUND',
'CANNOT_MODIFY_NO_SUCH_ORDER',
'CANNOT_MODIFY_PARTIAL_FILL',
'CANNOT_MODIFY_ALO_WOULD_CROSS',
'REJECTED_MUST_CHANGE_VALUE_MODIFY',
'NO_CHANGE_NO_UPDATE',
]);
const STATUS_ALIASES: Readonly<Record<string, string>> = {
INVALID_TAKEPROFIT_PRICE: 'INVALID_TAKE_PROFIT_PRICE',
INVALID_STOPLOSS_PRICE: 'INVALID_STOP_LOSS_PRICE',
};
export const QFEX_WS_ERROR_CODES: ReadonlySet<string> = new Set([
'RateLimited',
'InvalidJSONFormat',
'AlreadyAuthenticated',
'InvalidParameter',
'PermissionDenied',
'ServerError',
'InvalidOrder',
'InvalidOrderId',
'KycRequired',
'TncRequired',
]);
export type StatusClass = 'live' | 'fill' | 'terminal' | 'reject' | 'cancel_reply' | 'unknown';
export function statusClass(s: string): StatusClass {
if (typeof s !== 'string') return 'unknown';
if (LIVE_STATUSES.has(s)) return 'live';
if (FILL_STATUSES.has(s)) return 'fill';
if (TERMINAL_STATUSES.has(s)) return 'terminal';
if (REJECT_STATUSES.has(s)) return 'reject';
if (CANCEL_REPLY_STATUSES.has(s)) return 'cancel_reply';
return 'unknown';
}
export function canonicalStatus(s: string): string {
return Object.hasOwn(STATUS_ALIASES, s) ? STATUS_ALIASES[s]! : s;
}
export function remainingIsZero(o: Pick<QfexOrderEvent, 'remaining'>): boolean {
const d = parseDec(o.remaining);
return d !== null && decSign(d) === 0;
}
export function isFinalOrderEvent(o: Pick<QfexOrderEvent, 'status' | 'remaining'>): boolean {
const c = statusClass(o.status);
if (c === 'terminal' || c === 'reject') return true;
if (c === 'fill') return remainingIsZero(o);
return false;
}
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
type Sign = 'any' | 'nonneg' | 'pos';
const ONE: Dec = { mant: 1n, exp: 0 };
const MS_PER_S: Dec = { mant: 1000n, exp: 0 };
const SEC_MIN: Dec = { mant: 1000000000n, exp: 0 };
const SEC_MAX: Dec = { mant: 10000000000n, exp: 0 };
function decOf(v: unknown): Dec | null {
return typeof v === 'string' ? parseDec(v) : null;
}
function secToMs(v: unknown): number | null {
const d = decOf(v);
if (d === null || decCmp(d, SEC_MIN) < 0 || decCmp(d, SEC_MAX) >= 0) return null;
return Number(stepsOf(decMul(d, MS_PER_S), ONE, 'floor'));
}
function strOrNull(v: unknown): string | null {
return typeof v === 'string' && v !== '' ? v : null;
}
function signOk(d: Dec, sign: Sign): boolean {
const s = decSign(d);
return sign === 'any' || (sign === 'nonneg' ? s >= 0 : s > 0);
}
const SIGN_TEXT: Record<Sign, string> = { any: '', nonneg: ' (< 0)', pos: ' (≤ 0)' };
class FieldReader {
readonly issues: string[] = [];
constructor(private readonly o: Obj) {}
private bad(key: string, what: string): null {
this.issues.push(`${key}: ${what}`);
return null;
}
reqStr(key: string): string | null {
const v = this.o[key];
if (typeof v === 'string' && v !== '') return v;
return this.bad(key, v === undefined ? 'missing required field' : 'expected a nonempty string');
}
optStr(key: string): string | null {
const v = this.o[key];
if (v === undefined || v === null || v === '') return null;
if (typeof v === 'string') return v;
return this.bad(key, 'expected a string');
}
reqDec(key: string, sign: Sign = 'any'): string | null {
const v = this.o[key];
if (v === undefined) return this.bad(key, 'missing required decimal');
const d = decOf(v);
if (d === null) return this.bad(key, 'invalid decimal');
if (!signOk(d, sign)) return this.bad(key, `decimal has invalid sign${SIGN_TEXT[sign]}`);
return decToString(d);
}
optDec(key: string, sign: Sign = 'any'): string | null {
const v = this.o[key];
if (v === undefined || v === null) return null;
const d = decOf(v);
if (d === null) return this.bad(key, 'invalid decimal');
if (!signOk(d, sign)) return this.bad(key, `decimal has invalid sign${SIGN_TEXT[sign]}`);
return decToString(d);
}
reqSide(key: string): QfexSide | null {
const v = this.o[key];
if (v === 'BUY' || v === 'SELL') return v;
return this.bad(key, v === undefined ? 'missing required side' : 'expected BUY or SELL');
}
optSide(key: string): QfexSide | null {
const v = this.o[key];
if (v === undefined || v === null || v === '') return null;
if (v === 'BUY' || v === 'SELL') return v;
return this.bad(key, 'expected BUY or SELL');
}
timeMs(key: string): number | null {
return secToMs(this.o[key]);
}
}
export type QfexOrderDecode =
| {
ok: true;
o: QfexOrderEvent;
}
| {
ok: false;
why: string;
};
export function decodeQfexOrder(x: unknown): QfexOrderDecode {
if (!isObj(x)) return { ok: false, why: 'Order row must be an object' };
const r = new FieldReader(x);
const orderId = r.reqStr('order_id');
const symbol = r.reqStr('symbol');
const status = r.reqStr('status');
const side = r.reqSide('side');
const type = r.reqStr('type');
const tif = r.reqStr('time_in_force');
const qty = r.reqDec('quantity', 'nonneg');
const price = r.reqDec('price');
const remaining = r.reqDec('quantity_remaining', 'nonneg');
const cloid = r.optStr('client_order_id');
const tradeId = r.optStr('trade_id');
const userId = r.optStr('user_id');
if (
orderId === null ||
symbol === null ||
status === null ||
side === null ||
type === null ||
tif === null ||
qty === null ||
price === null ||
remaining === null ||
r.issues.length > 0
) {
return { ok: false, why: r.issues.join('; ') };
}
return {
ok: true,
o: {
orderId,
symbol,
status,
side,
type,
tif,
qty,
price,
remaining,
cloid,
updateTimeMs: r.timeMs('update_time'),
tradeId,
userId,
...(typeof x.reduce_only === 'boolean' ? { reduceOnly: x.reduce_only } : {}),
...(typeof x.order_origin === 'string' ? { orderOrigin: x.order_origin } : {}),
},
};
}
export type QfexFillDecode =
| {
ok: true;
f: QfexFill;
}
| {
ok: false;
why: string;
};
export function decodeQfexFill(x: unknown): QfexFillDecode {
if (!isObj(x)) return { ok: false, why: 'Fill row must be an object' };
const r = new FieldReader(x);
const tradeId = r.reqStr('trade_id');
const orderId = r.reqStr('order_id');
const symbol = r.reqStr('symbol');
const side = r.reqSide('side');
const price = r.reqDec('price', 'nonneg');
const qty = r.reqDec('quantity', 'pos');
const cloid = r.optStr('client_order_id');
const remaining = r.optDec('remaining_quantity', 'nonneg');
const fee = r.optDec('fee');
const execType = r.optStr('execution_type');
const aggressorSide = r.optSide('aggressor_side');
const orderType = r.optStr('order_type');
const tif = r.optStr('tif');
const orderPrice = r.optDec('order_price');
const realisedPnl = r.optDec('realised_pnl');
const userId = r.optStr('user_id');
if (
tradeId === null ||
orderId === null ||
symbol === null ||
side === null ||
price === null ||
qty === null ||
r.issues.length > 0
) {
return { ok: false, why: r.issues.join('; ') };
}
return {
ok: true,
f: {
tradeId,
orderId,
cloid,
symbol,
side,
price,
qty,
remaining,
fee,
execType,
tsMs: r.timeMs('timestamp'),
aggressorSide,
orderType,
tif,
orderPrice,
realisedPnl,
userId,
...(typeof x.order_origin === 'string' ? { orderOrigin: x.order_origin } : {}),
},
};
}
function decodeLeverageRow(x: unknown):
| {
ok: true;
row: QfexLeverageRow;
}
| {
ok: false;
why: string;
} {
if (!isObj(x)) return { ok: false, why: 'Leverage row must be an object' };
const r = new FieldReader(x);
const symbol = r.reqStr('symbol');
const leverage = r.reqDec('leverage', 'pos');
const id = r.optStr('id');
const initialMargin = r.optDec('initial_margin', 'nonneg');
const maintenanceMargin = r.optDec('maintenance_margin', 'nonneg');
const maxNotional = r.optDec('max_notional', 'nonneg');
if (symbol === null || leverage === null || r.issues.length > 0) return { ok: false, why: r.issues.join('; ') };
return { ok: true, row: { id, symbol, leverage, initialMargin, maintenanceMargin, maxNotional } };
}
function decodePositionRow(x: unknown):
| {
ok: true;
row: QfexPositionRow;
}
| {
ok: false;
why: string;
} {
if (!isObj(x)) return { ok: false, why: 'Position row must be an object' };
const r = new FieldReader(x);
const symbol = r.reqStr('symbol');
const position = r.reqDec('position');
const row = {
id: r.optStr('id'),
userId: r.optStr('user_id'),
marginAlloc: r.optDec('margin_alloc'),
realisedPnl: r.optDec('realised_pnl'),
unrealisedPnl: r.optDec('unrealised_pnl'),
netFunding: r.optDec('net_funding'),
openOrders: r.optDec('open_orders', 'nonneg'),
openQuantity: r.optDec('open_quantity', 'nonneg'),
leverage: r.optDec('leverage', 'nonneg'),
initialMargin: r.optDec('initial_margin', 'nonneg'),
maintenanceMargin: r.optDec('maintenance_margin', 'nonneg'),
averagePrice: r.optDec('average_price'),
timestamp: r.optStr('timestamp'),
};
if (symbol === null || position === null || r.issues.length > 0) return { ok: false, why: r.issues.join('; ') };
return { ok: true, row: { symbol, position, ...row } };
}
function decodeBalanceRow(x: unknown):
| {
ok: true;
row: QfexBalanceRow;
}
| {
ok: false;
why: string;
} {
if (!isObj(x)) return { ok: false, why: 'Balance row must be an object' };
const r = new FieldReader(x);
const availableBalance = r.reqDec('available_balance');
const row = {
id: r.optStr('id'),
userId: r.optStr('user_id'),
deposit: r.optDec('deposit'),
realisedPnl: r.optDec('realised_pnl'),
orderMargin: r.optDec('order_margin'),
positionMargin: r.optDec('position_margin'),
unrealisedPnl: r.optDec('unrealised_pnl'),
netFunding: r.optDec('net_funding'),
referralRewards: r.optDec('referral_rewards'),
builderRewards: r.optDec('builder_rewards'),
fees: r.optDec('fees'),
};
if (availableBalance === null || r.issues.length > 0) return { ok: false, why: r.issues.join('; ') };
return { ok: true, row: { availableBalance, ...row } };
}
function decodeTradeRow(x: unknown):
| {
ok: true;
row: QfexTradeRow;
}
| {
ok: false;
why: string;
} {
if (!isObj(x)) return { ok: false, why: 'Trade row must be an object' };
const r = new FieldReader(x);
const tradeId = r.reqStr('trade_id');
const orderId = r.reqStr('order_id');
const symbol = r.reqStr('symbol');
const side = r.reqSide('side');
const price = r.reqDec('price', 'nonneg');
const qty = r.reqDec('quantity', 'pos');
const fee = r.optDec('fee');
const orderType = r.optStr('order_type');
const tif = r.optStr('tif');
if (
tradeId === null ||
orderId === null ||
symbol === null ||
side === null ||
price === null ||
qty === null ||
r.issues.length > 0
) {
return { ok: false, why: r.issues.join('; ') };
}
return { ok: true, row: { tradeId, orderId, symbol, side, price, qty, fee, orderType, tif } };
}
function decodeRows<T>(
v: unknown,
what: string,
one: (x: unknown) =>
| {
ok: true;
row: T;
}
| {
ok: false;
why: string;
},
): {
rows: T[];
malformed: string[];
} {
const rows: T[] = [];
const malformed: string[] = [];
if (v === undefined) malformed.push(`${what}: missing array`);
else if (v === null) malformed.push(`${what}: null is not a complete array`);
else if (!Array.isArray(v)) malformed.push(`${what}: expected an array`);
else {
v.forEach((x, i) => {
const d = one(x);
if (d.ok) rows.push(d.row);
else malformed.push(`${what}[${i}]: ${d.why}`);
});
}
return { rows, malformed };
}
function looseList(v: unknown, what: string, malformed: string[]): unknown[] {
if (v === undefined || v === null) return [];
if (!Array.isArray(v)) {
malformed.push(`${what}: expected an array`);
return [];
}
const out: unknown[] = [];
v.forEach((x, i) => {
if (isObj(x)) out.push(x);
else malformed.push(`${what}[${i}]: expected an object`);
});
return out;
}
const INCOMING_NONE: QfexIncoming = { kind: 'none' };
function incomingFromValue(p: unknown, raw: unknown): QfexIncoming {
if (p === undefined || p === null) return INCOMING_NONE;
if (!isObj(p)) return { kind: 'opaque', raw };
if (Object.keys(p).length === 0) return INCOMING_NONE;
const type = strOrNull(p.type);
if (type === null) return { kind: 'opaque', raw };
const params: Obj = isObj(p.params) ? p.params : {};
const cancelIdType = strOrNull(params.cancel_order_id_type);
return {
kind: 'request',
type,
params,
cloid: strOrNull(params.client_order_id),
orderId: cancelIdType === null || cancelIdType === 'order_id' ? strOrNull(params.order_id) : null,
symbol: strOrNull(params.symbol),
cancelIdType,
};
}
export function decodeQfexIncoming(v: unknown): QfexIncoming {
if (typeof v === 'string') {
const t = v.trim();
if (t === '') return INCOMING_NONE;
let parsed: unknown;
try {
parsed = parseLossless(t);
} catch {
return { kind: 'opaque', raw: v };
}
if (!isObj(parsed)) return { kind: 'opaque', raw: v };
return incomingFromValue(parsed, v);
}
return incomingFromValue(v, v);
}
const unknownFrame = (why: string, raw: unknown, detail: string | null = null): TradeFrame => ({
k: 'unknown',
why,
detail,
raw,
});
const PAYLOAD_KEYS = [
'authenticated',
'err',
'ack',
'ack_response',
'subscribed',
'unsubscribed',
'order_response',
'fill_response',
'all_orders_response',
'user_trades_response',
'user_trades',
'user_leverage_response',
'available_leverage_levels_response',
'position_response',
'balance_response',
'twap_response',
'stop_order_response',
] as const;
const MARKER_KEYS: readonly string[] = [...PAYLOAD_KEYS, 'contents', 'error_code'];
function envelopeMeta(o: Obj): QfexEnvelopeMeta {
return {
type: strOrNull(o.type),
channel: strOrNull(o.channel),
connectionId: strOrNull(o.connection_id),
messageId: strOrNull(o.message_id),
id: strOrNull(o.id),
};
}
function decodeErr(e: Obj, form: QfexErrForm, raw: unknown): TradeFrame {
const code = strOrNull(e.error_code);
if (code === null) return unknownFrame('Error frame is missing error_code', raw);
const message = typeof e.message === 'string' ? e.message : null;
return { k: 'err', code, message, incoming: decodeQfexIncoming(e.incoming_message), form };
}
function decodeOrderFrame(v: unknown): TradeFrame {
if (!isObj(v)) return unknownFrame('Order response must be an object', v);
const d = decodeQfexOrder(v);
if (d.ok) return { k: 'order', o: d.o };
const status = strOrNull(v.status);
if (status === null) return unknownFrame('Order response is missing status', v, d.why);
return {
k: 'order_partial',
status,
orderId: strOrNull(v.order_id),
cloid: strOrNull(v.client_order_id),
symbol: strOrNull(v.symbol),
why: d.why,
raw: v,
};
}
function decodeAllOrders(v: unknown): TradeFrame {
if (!isObj(v)) return unknownFrame('All-orders response must be an object', v);
const { rows, malformed } = decodeRows(v.orders, 'orders', (x) => {
const d = decodeQfexOrder(x);
return d.ok ? { ok: true as const, row: d.o } : d;
});
const twaps = looseList(v.twaps, 'twaps', malformed);
const stopOrders = looseList(v.stop_orders, 'stop_orders', malformed);
return { k: 'orders', orders: rows, twaps, stopOrders, malformed };
}
function decodeUserTrades(v: unknown, o: Obj, form: 'user_trades_response' | 'user_trades'): TradeFrame {
const { rows, malformed } = decodeRows(v, form, decodeTradeRow);
let count: number | null = null;
if (form === 'user_trades' && o.count !== undefined && o.count !== null) {
const c = typeof o.count === 'string' && /^\d{1,15}$/.test(o.count) ? Number(o.count) : null;
if (c === null) malformed.push('Trade count is not an unsigned integer');
count = c;
}
return { k: 'user_trades', form, trades: rows, count, malformed };
}
function decodePositionsObject(v: unknown, raw: unknown): TradeFrame {
const { rows, malformed } = decodeRows([v], 'position_response', decodePositionRow);
return { k: 'positions', form: 'object', rows, malformed, envelope: null, raw };
}
function decodeBalancesObject(v: unknown, raw: unknown): TradeFrame {
const { rows, malformed } = decodeRows([v], 'balance_response', decodeBalanceRow);
return { k: 'balances', form: 'object', rows, malformed, envelope: null, raw };
}
const ENVELOPE_BY_CHANNEL: Readonly<Record<string, 'positions' | 'balances'>> = {
v4_positions: 'positions',
v4_balances: 'balances',
};
const ENVELOPE_BY_TYPE: Readonly<Record<string, 'positions' | 'balances'>> = {
position_update: 'positions',
balance_update: 'balances',
v4_positions: 'positions',
v4_balances: 'balances',
};
function decodeEnvelope(o: Obj): TradeFrame {
const meta = envelopeMeta(o);
const byChannel = meta.channel !== null ? ENVELOPE_BY_CHANNEL[meta.channel] : undefined;
if (meta.channel !== null && byChannel === undefined)
return unknownFrame('Unknown envelope channel', o, meta.channel);
const byType =
meta.type !== null && Object.hasOwn(ENVELOPE_BY_TYPE, meta.type) ? ENVELOPE_BY_TYPE[meta.type] : undefined;
if (byChannel !== undefined && byType !== undefined && byChannel !== byType) {
return unknownFrame('Envelope type and channel disagree', o, `${meta.type}/${meta.channel}`);
}
const what = byChannel ?? byType;
if (what === undefined) return unknownFrame('Unknown envelope type', o, meta.type);
if (what === 'positions') {
const { rows, malformed } = decodeRows(o.contents, 'contents', decodePositionRow);
return { k: 'positions', form: 'envelope', rows, malformed, envelope: meta, raw: o };
}
const { rows, malformed } = decodeRows(o.contents, 'contents', decodeBalanceRow);
return { k: 'balances', form: 'envelope', rows, malformed, envelope: meta, raw: o };
}
function decodeByPayloadKey(key: (typeof PAYLOAD_KEYS)[number], o: Obj): TradeFrame {
const v = o[key];
switch (key) {
case 'authenticated':
if (typeof v !== 'boolean') return unknownFrame('authenticated must be boolean', o);
return { k: 'auth', ok: v, form: 'authenticated', detail: null };
case 'err':
if (!isObj(v)) return unknownFrame('err must be an object', o);
return decodeErr(v, 'nested', o);
case 'ack':
if (v === true) return { k: 'ack', ok: true, form: 'ack', incoming: INCOMING_NONE };
if (!isObj(v)) return unknownFrame('ack must be true or an object', o);
return { k: 'ack', ok: true, form: 'ack', incoming: decodeQfexIncoming(v.incoming_message) };
case 'ack_response':
if (typeof v !== 'boolean') return unknownFrame('ack_response must be boolean', o);
return { k: 'ack', ok: v, form: 'ack_response', incoming: INCOMING_NONE };
case 'subscribed':
case 'unsubscribed': {
const ch = strOrNull(v);
if (ch === null) return unknownFrame(`${key} must be a nonempty channel string`, o);
return key === 'subscribed' ? { k: 'subscribed', channel: ch } : { k: 'unsubscribed', channel: ch };
}
case 'order_response':
return decodeOrderFrame(v);
case 'fill_response': {
const d = decodeQfexFill(v);
return d.ok ? { k: 'fill', f: d.f } : unknownFrame('Malformed fill response', o, d.why);
}
case 'all_orders_response':
return decodeAllOrders(v);
case 'user_trades_response':
case 'user_trades':
return decodeUserTrades(v, o, key);
case 'user_leverage_response': {
const { rows, malformed } = decodeRows(v, key, decodeLeverageRow);
return { k: 'leverage', rows, malformed };
}
case 'available_leverage_levels_response': {
const { rows, malformed } = decodeRows(v, key, decodeLeverageRow);
return { k: 'leverage_levels', rows, malformed };
}
case 'position_response':
return decodePositionsObject(v, o);
case 'balance_response':
return decodeBalancesObject(v, o);
case 'twap_response':
return isObj(v) ? { k: 'twap', raw: v } : unknownFrame('TWAP response must be an object', o);
case 'stop_order_response':
return isObj(v) ? { k: 'stop_order', raw: v } : unknownFrame('Stop-order response must be an object', o);
}
return unknownFrame('Unsupported payload key', o, key);
}
export function decodeTradeValue(v: unknown): TradeFrame {
try {
return decodeValueUnsafe(v);
} catch (e) {
return unknownFrame('Frame decoder failed', v, e instanceof Error ? e.message : String(e));
}
}
function decodeValueUnsafe(v: unknown): TradeFrame {
if (!isObj(v)) return unknownFrame('Frame must be an object', v);
const o = v;
const markers = MARKER_KEYS.filter((key) => Object.hasOwn(o, key));
if (markers.length > 1) return unknownFrame('ambiguous', o, markers.join(','));
const payload = PAYLOAD_KEYS.find((key) => Object.hasOwn(o, key));
if (payload !== undefined) return decodeByPayloadKey(payload, o);
const type = strOrNull(o.type);
if (Object.hasOwn(o, 'error_code')) {
if (type === 'Err') return decodeErr(o, 'flat_Err', o);
if (type === 'error') return decodeErr(o, 'flat_error', o);
if (type === null) return decodeErr(o, 'flat_bare', o);
return unknownFrame('Error frame has an unsupported type', o, type);
}
if (Object.hasOwn(o, 'contents')) return decodeEnvelope(o);
if (type === 'auth') {
const result = strOrNull(o.result);
if (result === null) return unknownFrame('Auth response is missing result', o);
return { k: 'auth', ok: result === 'success', form: 'type_result', detail: result };
}
if (type === 'ping' || type === 'heartbeat') return { k: 'ping', pong: false, form: 'json' };
if (type === 'pong') return { k: 'ping', pong: true, form: 'json' };
return unknownFrame('Unsupported frame type', o, type);
}
const RAW_TEXT_LIMIT = 2000;
export function decodeTradeFrame(text: string): TradeFrame {
if (typeof text !== 'string') return unknownFrame('Frame input must be text', null, typeof text);
const t = text.trim();
if (t === 'ping') return { k: 'ping', pong: false, form: 'text' };
if (t === 'pong') return { k: 'ping', pong: true, form: 'text' };
let v: unknown;
try {
v = parseLossless(t);
} catch (e) {
return unknownFrame(
'Invalid JSON frame',
text.length > RAW_TEXT_LIMIT ? `${text.slice(0, RAW_TEXT_LIMIT)}…` : text,
e instanceof Error ? e.message : null,
);
}
return decodeTradeValue(v);
}