Skip to content
markpaper

src/orders/ioc.ts

v0.2.0 · 4.4 KB

Download file
// 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,
  };
}
All files