Skip to content
markpaper

src/account/decoders.ts

v0.1.0 · 11.4 KB

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

const has = (value: Record<string, unknown>, key: string) => Object.hasOwn(value, key);
export type QfexEquitySource = 'derived' | 'endpoint' | 'both';
export interface QfexEquityJudgeOpts {
  source: QfexEquitySource;
  alertTolUsd: number;
  alertTolPct: number;
  bothTolUsd: number;
  bothTolPct: number;
}
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
function reqDec(o: Obj, key: string, where: string): Dec {
  const v = o[key];
  if (v === undefined) throw new Error(`${where}: missing ${key}`);
  if (typeof v !== 'string') throw new Error(`${where}: ${key} must be a decimal string; received ${typeof v}`);
  const d = parseDec(v);
  if (d === null) throw new Error(`${where}: invalid decimal in ${key}: ${v.slice(0, 40)}`);
  return d;
}
function softDec(o: Obj, key: string, where: string, notes: string[]): Dec | null {
  const v = o[key];
  if (v === undefined || v === null) return null;
  const d = typeof v === 'string' ? parseDec(v) : null;
  if (d === null)
    notes.push(`${where}: optional ${key} is invalid: ${typeof v === 'string' ? v.slice(0, 40) : typeof v}`);
  return d;
}
export interface QfexBalance {
  available: Dec;
  orderMargin: Dec;
  positionMargin: Dec;
  deposit: Dec | null;
  realised: Dec | null;
  unrealised: Dec | null;
  netFunding: Dec | null;
  builderRewards: Dec | null;
}
function decodeBalance(b: unknown, notes: string[]): QfexBalance {
  const where = 'qfex /user/positions balance';
  if (!isObj(b)) throw new Error(`${where}: expected an object`);
  return {
    available: reqDec(b, 'available_balance', where),
    orderMargin: reqDec(b, 'order_margin', where),
    positionMargin: reqDec(b, 'position_margin', where),
    deposit: softDec(b, 'deposit', where, notes),
    realised: softDec(b, 'realised_pnl', where, notes),
    unrealised: softDec(b, 'unrealised_pnl', where, notes),
    netFunding: softDec(b, 'net_funding', where, notes),
    builderRewards: softDec(b, 'builder_rewards', where, notes),
  };
}
export interface QfexDerivedEquity {
  derived: Dec;
  components: Dec | null;
  ambiguous: string | null;
}
export function deriveQfexEquity(b: QfexBalance): QfexDerivedEquity {
  const margin = decAdd(b.orderMargin, b.positionMargin);
  const derived = decAdd(b.available, margin);
  const parts = [b.deposit, b.realised, b.unrealised, b.netFunding];
  const components = parts.every((p) => p !== null) ? (parts as Dec[]).reduce((s, p) => decAdd(s, p)) : null;
  let ambiguous: string | null = null;
  if (decSign(b.orderMargin) < 0 || decSign(b.positionMargin) < 0) {
    ambiguous = `Negative margin: order ${decToString(b.orderMargin)}, position ${decToString(b.positionMargin)}`;
  } else if (decSign(b.available) === 0 && decSign(margin) > 0) {
    ambiguous = `Available balance is zero while reserved margin is ${decToString(margin)}; derived equity is ambiguous`;
  } else if (decSign(derived) < 0) {
    ambiguous = `Negative derived equity: ${decToString(derived)}`;
  }
  return { derived, components, ambiguous };
}
export interface QfexEquityVerdict {
  accountValue: number;
  equityFresh: boolean;
  source: 'derived' | 'endpoint' | 'min' | 'none';
  why: string | null;
  alert: string | null;
  derived: number | null;
  endpoint: number | null;
  components: number | null;
}
function divergence(
  a: Dec,
  b: Dec,
): {
  usd: number;
  pct: number;
} {
  const usd = decToNumber(decAbs(decSub(a, b)));
  const base = Math.max(Math.abs(decToNumber(a)), Math.abs(decToNumber(b)));
  return { usd, pct: base > 0 ? (usd / base) * 100 : usd > 0 ? Infinity : 0 };
}
const within = (
  d: {
    usd: number;
    pct: number;
  },
  tolUsd: number,
  tolPct: number,
): boolean => d.usd <= tolUsd || d.pct <= tolPct;
const fmtUsd = (x: number): string => `$${x.toFixed(2)}`;
export function judgeQfexEquity(
  d: QfexDerivedEquity | null,
  endpoint: Dec | null,
  opts: QfexEquityJudgeOpts,
): QfexEquityVerdict {
  const o = opts;
  for (const key of ['alertTolUsd', 'alertTolPct', 'bothTolUsd', 'bothTolPct'] as const)
    if (!Number.isFinite(o?.[key]) || o[key] < 0) throw new TypeError('Required equity policy: ' + key);
  const finite = (value: Dec | null): number | null => {
    if (value === null) return null;
    const result = decToNumber(value);
    return Number.isFinite(result) ? result : null;
  };
  const derivedN = finite(d?.derived ?? null);
  const endpointN = finite(endpoint);
  const componentsN = finite(d?.components ?? null);
  const base = { derived: derivedN, endpoint: endpointN, components: componentsN };
  const minOf = (): number => {
    const xs = [derivedN, endpointN, componentsN].filter((x): x is number => x !== null && Number.isFinite(x));
    return xs.length ? Math.max(0, Math.min(...xs)) : 0;
  };
  const distrust = (why: string, alert: string | null = null): QfexEquityVerdict => {
    const any = derivedN !== null || endpointN !== null || componentsN !== null;
    return { accountValue: minOf(), equityFresh: false, source: any ? 'min' : 'none', why, alert, ...base };
  };
  if ((d && derivedN === null) || (endpoint && endpointN === null) || (d?.components && componentsN === null))
    return distrust('Equity cannot be represented as a finite JavaScript number');
  const gap = d && endpoint ? divergence(d.derived, endpoint) : null;
  const gapText =
    gap && derivedN !== null && endpointN !== null
      ? `Derived equity ${fmtUsd(derivedN)}, endpoint equity ${fmtUsd(endpointN)} (difference ${fmtUsd(gap.usd)}, ${gap.pct.toFixed(2)}%)`
      : null;
  const alertIfWide = (): string | null =>
    gap && !within(gap, o.alertTolUsd, o.alertTolPct) ? `Equity sources diverge: ${gapText}` : null;
  if (o.source === 'endpoint') {
    if (endpoint === null || endpointN === null) return distrust('Endpoint equity is missing');
    if (decSign(endpoint) < 0) return distrust(`Negative endpoint equity: ${fmtUsd(endpointN)}`);
    return { accountValue: endpointN, equityFresh: true, source: 'endpoint', why: null, alert: alertIfWide(), ...base };
  }
  if (d === null || derivedN === null) return distrust('Derived equity is unavailable');
  if (d.ambiguous) return distrust(d.ambiguous, alertIfWide());
  if (o.source === 'both') {
    if (endpoint === null || gap === null) return distrust('Both equity sources are required');
    if (!within(gap, o.bothTolUsd, o.bothTolPct))
      return distrust(
        `Equity disagreement: ${gapText}; tolerances ${o.bothTolUsd} USD or ${o.bothTolPct}%`,
        `Equity sources diverge: ${gapText}`,
      );
    return { accountValue: derivedN, equityFresh: true, source: 'derived', why: null, alert: null, ...base };
  }
  return { accountValue: derivedN, equityFresh: true, source: 'derived', why: null, alert: alertIfWide(), ...base };
}
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
export type QfexEquityDecode =
  | {
      found: true;
      accountId: string;
      equity: Dec;
      isMaster: boolean;
      visibleAccounts: number;
    }
  | {
      found: false;
      visibleAccounts: number;
    };
export function decodeQfexEquity(payload: unknown, accountId: string): QfexEquityDecode {
  if (!UUID_RE.test(accountId)) throw new Error('Requested account must be a UUID');
  const where = 'qfex /user/subaccounts/equity';
  if (!isObj(payload) || !has(payload, 'accounts')) throw new Error(`${where}: missing accounts field`);
  const list = payload.accounts;
  if (list === null) return { found: false, visibleAccounts: 0 };
  if (!Array.isArray(list)) throw new Error(`${where}: accounts must be an array or null`);
  const want = accountId.toLowerCase();
  const seen = new Set<string>();
  let hit: {
    accountId: string;
    equity: Dec;
    isMaster: boolean;
  } | null = null;
  list.forEach((x, i) => {
    const w = `${where} accounts[${i}]`;
    if (!isObj(x)) throw new Error(`${w}: expected an object`);
    const id = x.account_id;
    if (typeof id !== 'string' || !UUID_RE.test(id)) throw new Error(`${w}: account_id must be a UUID`);
    const equity = reqDec(x, 'equity', w);
    if (typeof x.is_master !== 'boolean') throw new Error(`${w}: is_master must be boolean`);
    const k = id.toLowerCase();
    if (seen.has(k)) throw new Error(`${where}: duplicate account_id ${id}`);
    seen.add(k);
    if (k === want) hit = { accountId: id, equity, isMaster: x.is_master };
  });
  const found = hit as {
    accountId: string;
    equity: Dec;
    isMaster: boolean;
  } | null;
  if (found === null) return { found: false, visibleAccounts: list.length };
  return { found: true, ...found, visibleAccounts: list.length };
}
export interface QfexOrdersPage {
  orders: QfexOrderEvent[];
  twaps: unknown[];
  stopOrders: unknown[];
  malformed: string[];
}
export interface QfexOrderPagesInput {
  pages: readonly QfexOrdersPage[];
  limit: number;
  interrupted?: boolean;
  epochChanged?: boolean;
}
export type QfexOrderPagesJudge =
  | {
      ok: false;
      why: string;
    }
  | {
      ok: true;
      complete: boolean;
      why: string | null;
      multiPage: boolean;
      orders: QfexOrderEvent[];
      twaps: number;
      stopOrders: number;
      duplicates: number;
    };
export function judgeQfexOrderPages(
  r: QfexOrderPagesInput,
  o: {
    allowMultiPage?: boolean;
  } = {},
): QfexOrderPagesJudge {
  if (!Number.isSafeInteger(r.limit) || r.limit < 1)
    return { ok: false, why: `Invalid page limit: ${String(r.limit)}` };
  if (r.pages.length === 0) return { ok: false, why: 'No order pages were received' };
  for (let i = 0; i < r.pages.length; i++) {
    const p = r.pages[i]!;
    if (p.malformed.length > 0)
      return { ok: false, why: `Malformed order page ${i}: ${p.malformed.slice(0, 3).join('; ')}` };
  }
  const reasons: string[] = [];
  const last = r.pages.length - 1;
  for (let i = 0; i < last; i++) {
    if (r.pages[i]!.orders.length !== r.limit)
      reasons.push(`Intermediate page ${i} has ${r.pages[i]!.orders.length} orders instead of limit ${r.limit}`);
  }
  if (r.pages[last]!.orders.length >= r.limit)
    reasons.push(
      `Last page has ${r.pages[last]!.orders.length} orders, reaching limit ${r.limit}; more pages may exist`,
    );
  if (r.interrupted) reasons.push('Order pagination was interrupted');
  if (r.epochChanged) reasons.push('Connection epoch changed during pagination');
  const multiPage = r.pages.length > 1;
  if (multiPage && !o.allowMultiPage)
    reasons.push(`Multi-page read (${r.pages.length} pages) has no atomic snapshot guarantee`);
  const orders: QfexOrderEvent[] = [];
  const ids = new Set<string>();
  let duplicates = 0;
  let twaps = 0;
  let stopOrders = 0;
  for (const p of r.pages) {
    twaps += p.twaps.length;
    stopOrders += p.stopOrders.length;
    for (const ev of p.orders) {
      const k = ev.orderId.toLowerCase();
      if (ids.has(k)) {
        duplicates++;
        continue;
      }
      ids.add(k);
      orders.push(ev);
    }
  }
  if (duplicates > 0) reasons.push(`${duplicates} duplicate order IDs across pages`);
  return {
    ok: true,
    complete: reasons.length === 0,
    why: reasons.length ? reasons.join('; ') : null,
    multiPage,
    orders,
    twaps,
    stopOrders,
    duplicates,
  };
}
export {
  decodeQfexEquity as decodeSubaccountEquity,
  deriveQfexEquity as deriveEquity,
  judgeQfexEquity as judgeEquity,
  judgeQfexOrderPages as judgeOrderPages,
};
All files