Skip to content
markpaper

src/orders/iocFill.ts

v0.2.1 · 4.9 KB

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