src/orders/ioc.ts
v0.2.0 · 4.4 KB
// IOC fill measured as the position delta around the write.
//
// `place_order` returns only a digest: no status, no fill size. The sequencer is strongly consistent for
// its own state, so `subaccount_info` right after the execute already reflects the fill. Rules
// (`orders.md` §7):
// - read the signed amount BEFORE sending; if that read fails, do not send (nothing to compare with);
// - send; a rejection is REJECTED with its code;
// - read up to 3 times, 150 ms apart; delta in the order's direction = fill, capped by the sent size
// (someone else's simultaneous trade must not be credited to our order);
// - no delta after the attempts -> REJECTED although a digest came back. Under-reporting a fill is
// safe (the next decision starts from a fresh position read); over-reporting is not.
import type { Hex } from '../signing/types.js';
import type { PlaceOutcome, PlaceRejection } from './build.js';
/** Default number of post-write reads. */
export const DEFAULT_IOC_READ_ATTEMPTS = 3;
/** Default pause between post-write reads, ms. */
export const DEFAULT_IOC_READ_DELAY_MS = 150;
/** Inputs of {@link measureIocFill}. */
export interface MeasureIocFillInput {
/** Reads the signed position amount (x18) of the product; may throw. */
readPositionX18: () => Promise<bigint>;
/** Sends the IOC; resolves with a digest or a rejection (see `sendPlaceOrder`), throws on transport failure. */
send: () => Promise<PlaceOutcome>;
isBuy: boolean;
/** Absolute size sent, x18 (the cap of the reported fill). */
sentX18: bigint;
attempts?: number;
delayMs?: number;
/** Sleep function (injected in tests). */
sleep?: (ms: number) => Promise<void>;
}
/** Result of {@link measureIocFill}. */
export type IocFillResult =
| {
readonly status: 'FILLED';
readonly digest: Hex;
/** Observed fill, x18, absolute, capped by `sentX18`. */
readonly fillX18: bigint;
/** Position before and after (signed, x18). */
readonly beforeX18: bigint;
readonly afterX18: bigint;
/** True when the fill was smaller than the sent size (partial fill or a competing trade). */
readonly partial: boolean;
}
| {
readonly status: 'REJECTED';
readonly reason: 'pre-read-failed' | 'rejected' | 'no-delta';
readonly error: string;
/** Present when the venue refused the order. */
readonly rejection?: PlaceRejection;
/** Present when a digest came back but no delta was observed. */
readonly digest?: Hex;
readonly fillX18: 0n;
};
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** Measures an IOC fill as the position delta (see the module header). */
export async function measureIocFill(input: MeasureIocFillInput): Promise<IocFillResult> {
const attempts = input.attempts ?? DEFAULT_IOC_READ_ATTEMPTS;
const delayMs = input.delayMs ?? DEFAULT_IOC_READ_DELAY_MS;
const sleep = input.sleep ?? defaultSleep;
if (!(input.sentX18 > 0n)) throw new RangeError('sentX18 must be positive');
if (!Number.isInteger(attempts) || attempts < 1) throw new RangeError('attempts must be a positive integer');
let before: bigint;
try {
before = await input.readPositionX18();
} catch (e) {
return {
status: 'REJECTED',
reason: 'pre-read-failed',
error: `pre-write position read failed: ${(e as Error).message}`,
fillX18: 0n,
};
}
const sent = await input.send();
if (sent.rejection) {
return {
status: 'REJECTED',
reason: 'rejected',
error: `${sent.rejection.code}: ${sent.rejection.message}`,
rejection: sent.rejection,
fillX18: 0n,
};
}
for (let attempt = 0; attempt < attempts; attempt++) {
if (attempt > 0) await sleep(delayMs);
let after: bigint;
try {
after = await input.readPositionX18();
} catch {
continue;
}
const delta = after - before;
const directional = input.isBuy ? delta : -delta;
if (directional > 0n) {
const fillX18 = directional > input.sentX18 ? input.sentX18 : directional;
return {
status: 'FILLED',
digest: sent.digest,
fillX18,
beforeX18: before,
afterX18: after,
partial: fillX18 < input.sentX18,
};
}
}
return {
status: 'REJECTED',
reason: 'no-delta',
error: 'ioc accepted (digest returned) but no position delta observed - treated as not applied',
digest: sent.digest,
fillX18: 0n,
};
}