src/markets/meta.ts
v0.1.0 · 10.7 KB
import {
type Dec,
decCmp,
decDecimals,
decEq,
decFromNumber,
decSign,
decToNumber,
decToString,
isOnStep,
parseDec,
snapToStep,
} from '../numbers/index.js';
import { QFEX_QUOTE } from './symbols.js';
export interface QfexQuant {
readonly qfex: true;
readonly tick: Dec;
readonly lotDec: Dec;
readonly minQtyDec: Dec;
readonly maxQtyDec: Dec | null;
readonly minSz: number;
readonly lot: number;
readonly decimals: number;
pxToStr(px: number): string;
floorSz(sz: number): number;
ceilSz(sz: number): number;
pxToStrDir(px: number, dir: 'down' | 'up'): string;
szToStr(sz: number): string;
pxStrOnTick(pxStr: string): boolean;
exceedsMaxQty(sz: number): boolean;
}
export function isQfexQuant(q: QfexQuant | undefined): q is QfexQuant {
return !!q && (q as Partial<QfexQuant>).qfex === true;
}
function positiveDec(s: string, what: string): Dec {
const d = parseDec(s);
if (!d || decSign(d) <= 0 || !Number.isFinite(decToNumber(d)) || decToNumber(d) <= 0)
throw new Error(`qfexQuant: ${what} must be a positive decimal: ${String(s).slice(0, 40)}`);
return d;
}
function posDecOf(x: number): Dec | null {
const d = decFromNumber(x);
return d && decSign(d) > 0 ? d : null;
}
export function qfexQuant(m: Pick<QfexMarket, 'tick' | 'lot' | 'minQty' | 'maxQty'>): QfexQuant {
const tick = positiveDec(m.tick, 'tick_size');
const lotDec = positiveDec(m.lot, 'lot_size');
const minQtyDec = positiveDec(m.minQty, 'min_quantity');
const maxQtyDec = m.maxQty === '' ? null : positiveDec(m.maxQty, 'max_quantity');
const minOnGrid = snapToStep(minQtyDec, lotDec, 'ceil');
if (!Number.isFinite(decToNumber(minOnGrid)))
throw new RangeError('Quantized minimum quantity overflows a JavaScript number');
const onTick = (px: number, mode: 'nearest' | 'floor' | 'ceil'): string => {
const d = posDecOf(px);
if (!d) return '0';
const s = snapToStep(d, tick, mode, mode === 'nearest' ? 'exact' : 'float');
return decSign(s) > 0 ? decToString(s) : '0';
};
const onLot = (sz: number, mode: 'floor' | 'ceil'): Dec | null => {
const d = posDecOf(sz);
return d ? snapToStep(d, lotDec, mode, 'float') : null;
};
return {
qfex: true,
tick,
lotDec,
minQtyDec,
maxQtyDec,
lot: decToNumber(lotDec),
decimals: decDecimals(lotDec),
minSz: decToNumber(minOnGrid),
pxToStr: (px: number) => onTick(px, 'nearest'),
pxToStrDir: (px: number, dir: 'down' | 'up') => onTick(px, dir === 'down' ? 'floor' : 'ceil'),
floorSz(sz: number): number {
const d = onLot(sz, 'floor');
return d ? decToNumber(d) : 0;
},
ceilSz(sz: number): number {
const d = onLot(sz, 'ceil');
return d ? decToNumber(d) : 0;
},
szToStr(sz: number): string {
const d = onLot(sz, 'floor');
return d && decSign(d) > 0 ? decToString(d) : '0';
},
pxStrOnTick(pxStr: string): boolean {
const d = parseDec(pxStr);
return !!d && decSign(d) > 0 && isOnStep(d, tick);
},
exceedsMaxQty(sz: number): boolean {
if (!maxQtyDec) return false;
const d = decFromNumber(sz);
return !d || decCmp(d, maxQtyDec) > 0;
},
};
}
export const QFEX_STATUS_ACTIVE = 'ACTIVE';
export const QFEX_STATUSES_OFF: readonly string[] = ['INACTIVE', 'DELISTED'];
export const QFEX_MARGIN_ASSET = 'USDC';
export interface QfexMarket {
symbol: string;
base: string;
quote: string;
status: string;
tick: string;
lot: string;
minQty: string;
maxQty: string;
refBand: {
min: string;
max: string;
} | null;
maxLeverage: number;
marginAsset: string;
marketHours: unknown;
category: string | null;
}
export interface QfexRefdataDecoded {
markets: Map<string, QfexMarket>;
bad: Map<string, string>;
unnamed: number;
}
const SYMBOL_RE = /^[A-Za-z0-9._]{1,32}-[A-Za-z0-9]{2,8}$/;
const QUOTE_RE = /^[A-Za-z0-9]{2,8}$/;
const BASE_ANY_RE = /^[A-Za-z0-9._]{1,32}$/;
const MAX_LEVERAGE = 1000;
const MAX_ROWS = 20000;
function intLike(v: unknown): number | null {
if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
if (typeof v === 'string' && /^\d{1,6}$/.test(v)) return Number(v);
return null;
}
function decStr(v: unknown): string | null {
const d = typeof v === 'string' ? parseDec(v) : typeof v === 'number' ? decFromNumber(v) : null;
return d ? decToString(d) : null;
}
function bandOf(
minRaw: unknown,
maxRaw: unknown,
): {
min: string;
max: string;
} | null {
const min = decStr(minRaw);
const max = decStr(maxRaw);
if (min === null || max === null) return null;
const a = parseDec(min)!;
const b = parseDec(max)!;
return decSign(a) >= 0 && decSign(b) > 0 && decCmp(a, b) < 0 ? { min, max } : null;
}
function decodeRow(m: Record<string, unknown>, symbol: string): QfexMarket | string {
const base = m.base_asset;
const quote = m.quote_asset;
if (typeof base !== 'string' || !BASE_ANY_RE.test(base))
return `Invalid base_asset: ${JSON.stringify(base)?.slice(0, 40)}`;
if (typeof quote !== 'string' || !QUOTE_RE.test(quote))
return `Invalid quote_asset: ${JSON.stringify(quote)?.slice(0, 40)}`;
if (symbol !== `${base}-${quote}`) return `Symbol does not match base and quote: expected ${base}-${quote}`;
const status = typeof m.status === 'string' ? m.status.trim() : '';
if (!status) return 'Missing market status';
const tick = decStr(m.tick_size);
const lot = decStr(m.lot_size);
const minQty = decStr(m.min_quantity);
const maxQty = decStr(m.max_quantity);
if (tick === null || lot === null || minQty === null || maxQty === null)
return 'Invalid tick, lot, minimum or maximum quantity';
for (const [name, s] of [
['tick_size', tick],
['lot_size', lot],
['min_quantity', minQty],
['max_quantity', maxQty],
] as const) {
if (decSign(parseDec(s)!) <= 0) return `${name} ${s} ≤ 0`;
}
if (decCmp(parseDec(maxQty)!, parseDec(minQty)!) < 0) return `max_quantity ${maxQty} < min_quantity ${minQty}`;
const lev = intLike(m.default_max_leverage);
if (lev === null || lev < 1 || lev > MAX_LEVERAGE)
return `default_max_leverage ${String(m.default_max_leverage).slice(0, 20)} must be an integer from 1 to ${MAX_LEVERAGE}`;
const marginAsset = typeof m.margin_asset === 'string' ? m.margin_asset : '';
if (!marginAsset) return 'Missing margin_asset';
const mh = m.market_hours;
return {
symbol,
base,
quote,
status,
tick,
lot,
minQty,
maxQty,
refBand: bandOf(m.min_price, m.max_price),
maxLeverage: lev,
marginAsset,
marketHours: mh && typeof mh === 'object' && !Array.isArray(mh) ? mh : null,
category: typeof m.product_category === 'string' ? m.product_category : null,
};
}
export function decodeQfexRefdataRows(payload: unknown): QfexRefdataDecoded {
const arr = Array.isArray(payload)
? payload
: (
payload as {
data?: unknown;
} | null
)?.data;
if (!Array.isArray(arr)) throw new Error('Refdata must contain an array of markets');
if (arr.length > MAX_ROWS) throw new Error(`Refdata has ${arr.length} rows, exceeding the decoder safety limit`);
const markets = new Map<string, QfexMarket>();
const bad = new Map<string, string>();
let unnamed = 0;
for (const raw of arr as unknown[]) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
unnamed++;
continue;
}
const m = raw as Record<string, unknown>;
const symbol = typeof m.symbol === 'string' ? m.symbol : '';
if (!SYMBOL_RE.test(symbol)) {
unnamed++;
continue;
}
if (markets.has(symbol) || bad.has(symbol)) {
markets.delete(symbol);
bad.set(symbol, 'Duplicate symbol in refdata');
continue;
}
const r = decodeRow(m, symbol);
if (typeof r === 'string') bad.set(symbol, r);
else markets.set(symbol, r);
}
const total = arr.length;
if (markets.size === 0) throw new Error(`Refdata contains no valid markets among ${total} rows`);
if (markets.size * 2 <= total) {
const sample = [...bad]
.slice(0, 3)
.map(([s, why]) => `${s}: ${why}`)
.join('; ');
throw new Error(`Refdata is mostly invalid: ${markets.size} valid of ${total}; ${sample}`);
}
return { markets, bad, unnamed };
}
export function decodeQfexRefdata(payload: unknown): Map<string, QfexMarket> {
return decodeQfexRefdataRows(payload).markets;
}
export type QfexFreezeReason = 'lost' | 'bad_record' | 'grid_changed';
export interface QfexFrozenSymbol {
symbol: string;
reason: QfexFreezeReason;
detail: string;
since: number;
candidate: QfexMarket | null;
}
function gridDiff(before: QfexMarket, after: QfexMarket): string | null {
const parts: string[] = [];
const eq = (a: string, b: string): boolean => {
const x = parseDec(a);
const y = parseDec(b);
return !!x && !!y && decEq(x, y);
};
if (!eq(before.tick, after.tick)) parts.push(`tick ${before.tick} → ${after.tick}`);
if (!eq(before.lot, after.lot)) parts.push(`lot ${before.lot} → ${after.lot}`);
if (!eq(before.minQty, after.minQty)) parts.push(`min_quantity ${before.minQty} → ${after.minQty}`);
return parts.length ? parts.join(', ') : null;
}
export function mergeQfexMarkets(
prev: Map<string, QfexMarket> | null,
next: QfexRefdataDecoded,
interest: Iterable<string>,
frozenBefore: ReadonlyMap<string, QfexFrozenSymbol> = new Map(),
now: number = Date.now(),
): {
markets: Map<string, QfexMarket>;
frozen: Map<string, QfexFrozenSymbol>;
} {
const markets = new Map(next.markets);
const frozen = new Map<string, QfexFrozenSymbol>();
if (!prev) return { markets, frozen };
for (const sym of new Set(interest)) {
const before = prev.get(sym);
if (!before) continue;
const after = next.markets.get(sym);
let reason: QfexFreezeReason | null = null;
let detail = '';
if (!after) {
const why = next.bad.get(sym);
reason = why ? 'bad_record' : 'lost';
detail = why ? `New refdata record is invalid: ${why}` : 'Symbol is absent from the new refdata';
} else {
const diff = gridDiff(before, after);
if (diff) {
reason = 'grid_changed';
detail = diff;
}
}
if (!reason) continue;
markets.set(sym, before);
const was = frozenBefore.get(sym);
frozen.set(sym, {
symbol: sym,
reason,
detail,
since: was && was.reason === reason && was.detail === detail ? was.since : now,
candidate: after ?? null,
});
}
return { markets, frozen };
}
export function assertQfexMarketsSane(
prev: Map<string, QfexMarket> | null,
next: Map<string, QfexMarket>,
interest: Iterable<string>,
): void {
const { frozen } = mergeQfexMarkets(prev, { markets: next, bad: new Map(), unnamed: 0 }, interest);
if (frozen.size) {
throw new Error(
`Market metadata requires explicit review: ${[...frozen.values()].map((f) => `${f.symbol} (${f.detail})`).join('; ')}`,
);
}
}