Skip to content
markpaper

src/orders/classifiers.ts

v0.1.0 · 6.6 KB

Download file
import { type QfexFill, type QfexOrderEvent, statusClass } from '../frames/index.js';
import {
  type Dec,
  decAbs,
  decAdd,
  decCmp,
  decMul,
  decSign,
  decSub,
  decToString,
  parseDec,
  snapToStep,
} from '../numbers/index.js';

type RejectClass =
  | 'margin'
  | 'band'
  | 'too_many'
  | 'market_closed'
  | 'min'
  | 'max_qty'
  | 'liquidation'
  | 'permission'
  | 'rate'
  | 'kyc'
  | 'adapter'
  | 'other';
const BAND_STATUSES: ReadonlySet<string> = new Set([
  'PRICE_LESS_THAN_MIN_PRICE',
  'PRICE_GREATER_THAN_MAX_PRICE',
  'REJECTED_LESS_THAN_MIN_PRICE_BAND',
  'REJECTED_GREATER_THAN_MAX_PRICE_BAND',
]);
const MIN_STATUSES: ReadonlySet<string> = new Set([
  'QUANTITY_LESS_THAN_MIN_QUANTITY',
  'REJECTED_LESS_THAN_MIN_NOTIONAL',
]);
const ADAPTER_STATUSES: ReadonlySet<string> = new Set([
  'INVALID_PRICE',
  'INVALID_QUANTITY',
  'INVALID_ORDER_TYPE',
  'INVALID_TIME_IN_FORCE',
  'BAD_SYMBOL',
  'INVALID_TICK_SIZE_PRECISION_PRICE',
  'INVALID_TICK_SIZE_PRECISION_QUANTITY',
  'INVALID_TAKE_PROFIT_PRICE',
  'INVALID_STOP_LOSS_PRICE',
  'INVALID_CLIENT_ORDER_ID',
  'InvalidJSONFormat',
  'InvalidParameter',
  'InvalidOrder',
  'InvalidOrderId',
  'Unauthenticated',
]);
export function classifyReject(st: string): RejectClass {
  if (rejectScope(st) !== null) return 'margin';
  if (BAND_STATUSES.has(st)) return 'band';
  if (st === 'REJECTED_TOO_MANY_OPEN_ORDERS') return 'too_many';
  if (st === 'REJECTED_MARKET_CLOSED') return 'market_closed';
  if (MIN_STATUSES.has(st)) return 'min';
  if (st === 'QUANTITY_GREATER_THAN_MAX_QUANTITY') return 'max_qty';
  if (st === 'USER_IN_LIQUIDATION') return 'liquidation';
  if (st === 'PERMISSION_DENIED' || st === 'PermissionDenied' || st === 'USER_NOT_FOUND') return 'permission';
  if (st === 'RATE_LIMITED' || st === 'RateLimited') return 'rate';
  if (st === 'KycRequired' || st === 'TncRequired') return 'kyc';
  if (ADAPTER_STATUSES.has(st)) return 'adapter';
  return 'other';
}
const ZERO: Dec = { mant: 0n, exp: 0 };
const decMin = (a: Dec, b: Dec): Dec => (decCmp(a, b) <= 0 ? a : b);
const decMax = (a: Dec, b: Dec): Dec => (decCmp(a, b) >= 0 ? a : b);
export function reducible(pos: Dec, isBuy: boolean): Dec {
  const s = decSign(pos);
  if (isBuy) return s < 0 ? decAbs(pos) : ZERO;
  return s > 0 ? pos : ZERO;
}
function fillsWitness(fills: readonly QfexFill[]): {
  qty: Dec;
  notional: Dec;
  n: number;
} {
  const seen = new Set<string>();
  let qty = ZERO;
  let notional = ZERO;
  let n = 0;
  for (const f of fills) {
    if (seen.has(f.tradeId)) continue;
    seen.add(f.tradeId);
    const q = parseDec(f.qty);
    const p = parseDec(f.price);
    if (!q || !p || decSign(q) <= 0) continue;
    qty = decAdd(qty, q);
    notional = decAdd(notional, decMul(q, p));
    n++;
  }
  return { qty, notional, n };
}
function impliedWitness(events: readonly QfexOrderEvent[], sentSz: string): Dec | null {
  const sent = parseDec(sentSz);
  if (!sent) return null;
  let best: Dec | null = null;
  for (const e of events) {
    if (statusClass(e.status) !== 'fill') continue;
    const r = parseDec(e.remaining);
    if (!r) continue;
    const f = decSub(sent, r);
    if (best === null || decCmp(f, best) > 0) best = f;
  }
  return best;
}
export function rejectScope(status: string): 'account' | 'symbol' | null {
  if (status === 'FAILED_MARGIN_CHECK') return 'account';
  return [
    'REJECTED_WOULD_BREACH_MAX_POSITION',
    'REJECTED_WOULD_BREACH_MAX_NOTIONAL',
    'REJECTED_OPEN_INTEREST_LIMIT',
    'REJECTED_TOTAL_OPEN_INTEREST_LIMIT',
  ].includes(status)
    ? 'symbol'
    : null;
}
export function clampIocPrice(
  price: Dec,
  side: 'BUY' | 'SELL',
  tick: Dec,
  band: { min: string; max: string; fresh: boolean } | null,
): Dec {
  let value = price;
  if (band?.fresh) {
    const edge = parseDec(side === 'BUY' ? band.max : band.min);
    if (!edge) throw new TypeError('Invalid live band');
    value = side === 'BUY' ? decMin(value, edge) : decMax(value, edge);
  }
  return snapToStep(value, tick, side === 'BUY' ? 'floor' : 'ceil');
}
export function fullCloseSize(position: Dec, lot: Dec, maxQuantity?: Dec | null): Dec {
  const quantity = snapToStep(decAbs(position), lot, 'ceil');
  return maxQuantity ? decMin(quantity, snapToStep(maxQuantity, lot, 'floor')) : quantity;
}
/** Position-delta evidence requires caller-proven freshness and no intervening execution. */
export function iocFill(options: {
  symbol: string;
  cloid: string;
  orderId: string | null;
  sent: string;
  side: 'BUY' | 'SELL';
  events: readonly QfexOrderEvent[];
  fills: readonly QfexFill[];
  positionBefore?: Dec | null;
  positionAfter?: Dec | null;
}): { quantity: string; vwap: string | null; witnesses: string[]; confirmed: boolean } {
  const sent = parseDec(options.sent);
  if (!sent || decSign(sent) <= 0 || !options.symbol || !options.cloid)
    throw new TypeError('Required sent quantity and IOC identity');
  const rows = [...options.events, ...options.fills];
  let orderId = options.orderId?.toLowerCase() ?? null;
  for (const row of rows) {
    const id = row.orderId.toLowerCase();
    if (
      row.symbol !== options.symbol ||
      row.side !== options.side ||
      (row.cloid !== null && row.cloid !== options.cloid) ||
      (orderId !== null && id !== orderId) ||
      (row.cloid === null && orderId === null)
    )
      return { quantity: '0', vwap: null, witnesses: ['contradictory_identity'], confirmed: false };
    orderId = id;
  }
  const witnesses: Dec[] = [];
  const evidence: string[] = [];
  const filled = fillsWitness(options.fills);
  if (decCmp(filled.qty, sent) > 0)
    return { quantity: '0', vwap: null, witnesses: ['contradictory_quantity'], confirmed: false };
  if (filled.n) {
    witnesses.push(filled.qty);
    evidence.push('fills');
  }
  const implied = impliedWitness(options.events, options.sent);
  if (implied && decSign(implied) >= 0) {
    witnesses.push(implied);
    evidence.push('order_remainder');
  }
  if (options.positionBefore && options.positionAfter) {
    const delta =
      options.side === 'BUY'
        ? decSub(options.positionAfter, options.positionBefore)
        : decSub(options.positionBefore, options.positionAfter);
    if (decSign(delta) >= 0) {
      witnesses.push(delta);
      evidence.push('position_delta');
    }
  }
  const quantity = witnesses.length ? witnesses.reduce(decMin, sent) : ZERO;
  const safe = decMax(ZERO, decMin(sent, quantity));
  const average =
    filled.n && decSign(filled.qty) > 0 ? Number(decToString(filled.notional)) / Number(decToString(filled.qty)) : NaN;
  return {
    quantity: decToString(safe),
    vwap: Number.isFinite(average) ? String(average) : null,
    witnesses: evidence,
    confirmed: witnesses.length > 0,
  };
}
All files