src/orders/iocFill.ts
v0.2.1 · 4.9 KB
// IoC fill measured as the position delta (knowledge base: orders.md §7.3).
//
// `create_order` returns only a tx_hash: no status, no fill. The fill of an IoC is the change of the
// position around the write, polled for a few seconds (zk-rollup: between the tx_hash and the state
// update there is a lag; up to ~4 s was enough). The asymmetry is deliberate: a fill that could NOT be
// observed is reported REJECTED, never FILLED. An under-reported fill is recomputed from the next
// fresh read and cannot fire twice; an over-reported one corrupts position accounting.
import type { SignerErrorKind } from '../signer/errors.js';
import type { SignerWriteResult } from '../signer/types.js';
/** Default polls: 6 x 700 ms ~ 4.2 s, above the observed rollup lag (up to ~4 s). */
export const DEFAULT_IOC_POLLS = 6;
export const DEFAULT_IOC_POLL_INTERVAL_MS = 700;
export interface MeasureIocFillInput {
/** Signed position size of the coin; `null` = the read is not trustworthy. */
readPosition: () => Promise<number | null>;
/** The write itself (e.g. `signer.placeOrder({ ..., ioc: true })`). Called at most once. */
send: () => Promise<SignerWriteResult>;
/** Side of the IoC. */
isBuy: boolean;
/** Default {@link DEFAULT_IOC_POLLS}. */
polls?: number;
/** Default {@link DEFAULT_IOC_POLL_INTERVAL_MS}. */
intervalMs?: number;
/** Limit price used; reported back as `limitPrice` (the real average price is not in any response). */
limitPrice?: number;
/** Sleep, for tests. */
sleep?: (ms: number) => Promise<void>;
}
export type IocFillResult =
| {
status: 'FILLED';
/** `|after - before|` in base units. */
fillSize: number;
before: number;
after: number;
/** The limit price, not the executed average (unknown without trade history). */
limitPrice: number | undefined;
txHash: string | null;
/** Polls it took to see the move. */
polls: number;
}
| {
status: 'REJECTED';
reason: string;
/** True when the write's outcome is unknown (timeout): reconcile on the next tick, do not resend blindly. */
unknownOutcome: boolean;
kind: SignerErrorKind | undefined;
code: number | undefined;
before: number | null;
after: number | null;
txHash: string | null;
}
| {
/** Our own write window refused: nothing was sent; retry next tick. */
status: 'SKIPPED';
reason: string;
};
/**
* Sends an IoC and measures its fill as the position delta. See {@link IocFillResult} for the
* three outcomes; `send` is invoked only after the position BEFORE was read successfully.
*/
export async function measureIocFill(input: MeasureIocFillInput): Promise<IocFillResult> {
const polls = Math.max(1, Math.floor(input.polls ?? DEFAULT_IOC_POLLS));
const intervalMs = input.intervalMs ?? DEFAULT_IOC_POLL_INTERVAL_MS;
const sleep = input.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const before = await input.readPosition();
if (before === null) {
return rejected('position before the IoC could not be read - the fill would not be observable; not sent', {
before: null,
after: null,
});
}
const res = await input.send();
if (res.status === 'rateLimited') return { status: 'SKIPPED', reason: res.error };
if (res.status === 'unknown') {
return rejected(`unknown outcome: ${res.error} - reconcile against the position on the next tick`, {
unknownOutcome: true,
before,
after: null,
});
}
if (res.status === 'rejected') {
return rejected(res.error || 'rejected by the exchange', { kind: res.kind, code: res.code, before, after: null });
}
let after: number | null = null;
let used = 0;
for (let i = 0; i < polls; i++) {
await sleep(intervalMs);
used = i + 1;
after = await input.readPosition();
if (after !== null && after !== before) break;
}
if (after === null) {
return rejected('position after the IoC could not be read - the fill is not observable', {
before,
after: null,
txHash: res.txHash,
});
}
const moved = input.isBuy ? after - before : before - after;
if (moved <= 0) {
return rejected('position did not move - the IoC did not fill or is not sequenced yet', {
before,
after,
txHash: res.txHash,
});
}
return {
status: 'FILLED',
fillSize: Math.abs(moved),
before,
after,
limitPrice: input.limitPrice,
txHash: res.txHash,
polls: used,
};
}
function rejected(
reason: string,
extra: Partial<Omit<Extract<IocFillResult, { status: 'REJECTED' }>, 'status' | 'reason'>> & {
before: number | null;
after: number | null;
},
): IocFillResult {
return {
status: 'REJECTED',
reason,
unknownOutcome: extra.unknownOutcome ?? false,
kind: extra.kind,
code: extra.code,
before: extra.before,
after: extra.after,
txHash: extra.txHash ?? null,
};
}