src/account/decoders.ts
v0.1.0 · 11.4 KB
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,
};