src/orders/classifiers.ts
v0.1.0 · 6.6 KB
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,
};
}