Skip to content
markpaper

src/markets/status.ts

v0.2.0 · 4.5 KB

Download file
// `trading_status` -> what the venue accepts.
//
// Statuses observed 2026-09-10/11: `not_tradable` refuses every order with code 2069, `post_only`
// refuses everything except POST_ONLY with code 2117. Parsing `trading_status` is not enough: an order
// path that does not consume it keeps sending orders the venue refuses. Stock perps (equities, ETFs)
// also switch to `post_only` on weekends.

import type { OrderType, TradingStatus } from './types.js';

const ALL_TYPES: readonly OrderType[] = ['default', 'ioc', 'fok', 'post_only'];
const TAKER_TYPES: readonly OrderType[] = ['ioc', 'fok'];
const NONE: readonly OrderType[] = [];

/** What a market in a given status accepts. */
export interface MarketMode {
  readonly status: TradingStatus | (string & {});
  /** Order types the venue accepts in this status (empty = nothing). */
  readonly accepts: readonly OrderType[];
  /** True when only position-reducing orders may be sent. */
  readonly reduceOnlyOnly: boolean;
  /** True when a resting order must be POST_ONLY (DEFAULT is refused with 2117 at any price). */
  readonly postOnly: boolean;
  /** Error code the venue answers to an order of a refused type, when known. */
  readonly refusalCode: 2117 | 2069 | undefined;
  /** False for statuses whose semantics were never observed live (`reduce_only`, unknown values). */
  readonly verified: boolean;
}

/**
 * Accepted order types by `trading_status`:
 *
 * - `live`: everything;
 * - `post_only`: ONLY `post_only` (DEFAULT / IOC / FOK -> 2117 at any price; observed 2026-09-11);
 * - `not_tradable`: nothing (2069; observed 2026-09-10);
 * - `reduce_only` / `soft_reduce_only`: by name, taker reduce-only orders only - **not observed live**;
 * - anything else: nothing (fail-closed).
 *
 * @experimental for `reduce_only` / `soft_reduce_only` and unknown statuses.
 */
export function marketMode(status: string): MarketMode {
  switch (status) {
    case 'live':
      return {
        status,
        accepts: ALL_TYPES,
        reduceOnlyOnly: false,
        postOnly: false,
        refusalCode: undefined,
        verified: true,
      };
    case 'post_only':
      return {
        status,
        accepts: ['post_only'],
        reduceOnlyOnly: false,
        postOnly: true,
        refusalCode: 2117,
        verified: true,
      };
    case 'not_tradable':
      return { status, accepts: NONE, reduceOnlyOnly: false, postOnly: false, refusalCode: 2069, verified: true };
    case 'reduce_only':
    case 'soft_reduce_only':
      return {
        status,
        accepts: TAKER_TYPES,
        reduceOnlyOnly: true,
        postOnly: false,
        refusalCode: undefined,
        verified: false,
      };
    default:
      return { status, accepts: NONE, reduceOnlyOnly: false, postOnly: false, refusalCode: undefined, verified: false };
  }
}

/** Order types accepted in `status` (see {@link marketMode}). */
export function acceptedOrderTypes(status: string): readonly OrderType[] {
  return marketMode(status).accepts;
}

/**
 * Market mode as a FACT for decisions: the status comes from a cache (TTL minutes), so it is published
 * only when the cache is fresh; a stale or frozen cache yields `undefined`, and callers fall back to
 * DEFAULT + re-send as POST_ONLY on 2117, IOC not gated.
 *
 * A frozen cache that still said `post_only` would keep holding back position reductions (reduce-only
 * exists only on taker orders, and a post-only market refuses takers) on a market that has long been live; a frozen `live` would only cost one refused DEFAULT per resting order, which the 2117
 * fallback repairs.
 */
export function resolveMarketMode(status: string | undefined, fresh: boolean): MarketMode | undefined {
  if (!fresh || status === undefined) return undefined;
  return marketMode(status);
}

/**
 * `true` / `false` when the fresh status says so, `undefined` when the status is unknown or stale.
 * Pass it to `orderTypeForMarket` to choose DEFAULT vs POST_ONLY for a resting order.
 */
export function isPostOnlyMarket(status: string | undefined, fresh: boolean): boolean | undefined {
  const mode = resolveMarketMode(status, fresh);
  return mode === undefined ? undefined : mode.postOnly;
}

/**
 * Whether trading may be newly ENABLED on this market. `not_tradable` markets have meta but accept
 * nothing: enabling one only produces 2069 refusals. Markets already being traded keep their meta
 * (hiding it would look like a delisting that did not happen).
 */
export function canActivateMarket(status: string | undefined): boolean {
  if (status === undefined) return false;
  return marketMode(status).accepts.length > 0;
}
All files