src/markets/status.ts
v0.2.0 · 4.5 KB
// `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;
}