Skip to content
markpaper

src/frames/frames.ts

v0.1.0 · 28.5 KB

Download file
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);
}
All files