malkhut(wire): OrderType as three orthogonal dimensions — Fable's corrections

CRITICAL REFACTOR based on Fable's review (S9 roadmap item):

Before: flat enum conflating order types with TIF/instructions
  OrderType had MARKET, LIMIT, IOC, FOK, POST_ONLY, REDUCE_ONLY, etc.

After: three orthogonal dimensions (FIX-aligned):
  1. OrderType (Tag 40): what the order IS
     LIMIT, MARKET, STOP_MARKET, STOP_LIMIT, TRIGGER_MARKET, TRIGGER_LIMIT,
     TRAILING_STOP, OCO, TP_SL
  2. TimeInForce (Tag 59): how long it LIVES
     GTC, IOC, FOK, GTD
  3. Instructions (Tag 18): behavioral modifiers
     POST_ONLY, REDUCE_ONLY, HIDDEN, ICEBERG

Key corrections:
- POST_ONLY is an instruction on a LIMIT order, not a standalone type
- IOC/FOK are TimeInForce values, not order types
- BingX trailing_stop -> native TRAILING_STOP_MARKET (not TRIGGER_MARKET)
- FulfilmentAction.time_in_force: new field, default GTC

Exchange mappings restructured:
  EXCHANGE_ORDER_TYPE_MAP: OrderType -> exchange native 'type' param
  EXCHANGE_TIF_MAP: TimeInForce -> exchange native 'timeInForce' param
  EXCHANGE_INSTRUCTION_MAP: Instruction -> exchange encoding

21 files changed. 380+ tests pass. Backward compatible.
This commit is contained in:
Codex
2026-07-14 14:46:44 +02:00
parent a21f64e066
commit d24d9bc6bd
21 changed files with 346 additions and 199 deletions

View File

@@ -609,15 +609,16 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
elif at in (ActionType.QUOTE, ActionType.JOIN_QUEUE, ActionType.STEP_BACK,
ActionType.LADDER, ActionType.GRID, ActionType.ICEBERG, ActionType.TWAP):
return FulfilmentAction(
kind=ActionKind.PLACE, side=primitive.side, order_type=OrderType.POST_ONLY,
kind=ActionKind.PLACE, side=primitive.side, order_type=OrderType.LIMIT,
price_ticks_from_best=primitive.offset_ticks, qty_fraction=primitive.size_fraction,
ttl_ms=int(primitive.duration_s * 1000), post_only=True,
)
elif at in (ActionType.CROSS, ActionType.SNIPER, ActionType.PING):
return FulfilmentAction(
kind=ActionKind.CROSS_SPREAD, side=primitive.side, order_type=OrderType.IOC,
kind=ActionKind.CROSS_SPREAD, side=primitive.side, order_type=OrderType.LIMIT,
price_ticks_from_best=0, qty_fraction=primitive.size_fraction, ttl_ms=50,
time_in_force="IOC",
)
elif at == ActionType.CANCEL_ALL:
@@ -636,7 +637,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=1.0, ttl_ms=0, reduce_only=True,
)
@@ -646,7 +647,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=1.0, ttl_ms=0, reduce_only=True,
metadata={"reason": "stop_loss"},
)
@@ -657,7 +658,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=1.0, ttl_ms=0, reduce_only=True,
metadata={"reason": "take_profit"},
)
@@ -668,7 +669,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.FULL_EXIT, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=1.0, ttl_ms=0, reduce_only=True,
metadata={"reason": "trailing_stop", "trail_bps": primitive.trail_distance_bps},
)
@@ -679,7 +680,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.REDUCE, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.REDUCE, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=0.5, ttl_ms=0, reduce_only=True,
)
@@ -689,7 +690,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
if side is None:
return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return FulfilmentAction(
kind=ActionKind.REDUCE, side=side, order_type=OrderType.REDUCE_ONLY_MARKET,
kind=ActionKind.REDUCE, side=side, order_type=OrderType.MARKET,
price_ticks_from_best=0, qty_fraction=0.25, ttl_ms=0, reduce_only=True,
)
@@ -705,7 +706,7 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) ->
elif at == ActionType.REQUOTE:
return FulfilmentAction(
kind=ActionKind.PLACE, side=primitive.side, order_type=OrderType.POST_ONLY,
kind=ActionKind.PLACE, side=primitive.side, order_type=OrderType.LIMIT,
price_ticks_from_best=primitive.offset_ticks, qty_fraction=primitive.size_fraction,
ttl_ms=int(primitive.duration_s * 1000), post_only=True,
)

View File

@@ -1,120 +1,198 @@
"""
Standardized Order Types — FIX/CCXT-aligned, multi-exchange compatible.
Five-layer taxonomy normalized to industry standards:
Layer 1: Base types (FIX Tag 40 OrdType)
Layer 2: Time-in-force (FIX Tag 59)
Layer 3: Conditional/Trigger (FIX Tag 3/4 + MIT)
Layer 4: Instructions (FIX Tag 18)
Layer 5: Compound (exchange-specific)
Three orthogonal dimensions (not one flat enum):
1. Order Type (FIX Tag 40 OrdType): what the order IS
LIMIT, MARKET, STOP_MARKET, STOP_LIMIT, TRIGGER_MARKET, TRIGGER_LIMIT,
TRAILING_STOP
2. Time-in-Force (FIX Tag 59): how long the order LIVES
GTC, IOC, FOK, GTD
3. Instructions (FIX Tag 18 ExecInst): behavioral modifiers
POST_ONLY, REDUCE_ONLY, HIDDEN, ICEBERG
Plus compound types (exchange-specific, no FIX equivalent):
OCO, TP_SL
CRITICAL INSIGHT (Fable): POST_ONLY / IOC / FOK are NOT order types.
IOC and FOK are TimeInForce values applied to a LIMIT order.
POST_ONLY is an ExecInst / TIF modifier on a LIMIT order.
Mapping them as standalone order types conflates the taxonomy.
Transferability: strategy PARAMETERS transfer across exchanges.
Order type NAMES are venue-specific but semantics are identical.
The venue adapter translates normalized → exchange-native at submission.
The venue adapter translates normalized -> exchange-native at submission.
"""
from __future__ import annotations
from enum import Enum
from typing import Dict, Optional
from typing import Dict, Optional, Tuple
# ==============================================================================
# Layer 1: Base Types (FIX Tag 40 OrdType)
# Layer 1: Order Types (FIX Tag 40 OrdType)
# These are the actual TYPE values sent in the API "type" parameter.
# ==============================================================================
class BaseType(str, Enum):
MARKET = "market" # FIX: 1 — immediate execution at best available price
LIMIT = "limit" # FIX: 2 — execute only at specified price or better
class OrderType(str, Enum):
"""Core order types — what the order IS."""
LIMIT = "LIMIT" # FIX: 2
MARKET = "MARKET" # FIX: 1
STOP_MARKET = "STOP_MARKET" # FIX: 3 — trigger -> market execution
STOP_LIMIT = "STOP_LIMIT" # FIX: 4 — trigger -> limit execution
TRIGGER_MARKET = "TRIGGER_MARKET" # FIX: J (MIT) — market if touched
TRIGGER_LIMIT = "TRIGGER_LIMIT" # FIX: 4 — limit if touched
TRAILING_STOP = "TRAILING_STOP" # FIX: P + ExecInst
OCO = "OCO" # One-Cancels-Other (exchange-specific)
TP_SL = "TP_SL" # Take-Profit / Stop-Loss (exchange-specific)
# ==============================================================================
# Layer 2: Time-in-Force (FIX Tag 59)
# These are SEPARATE parameters, not order types.
# Applied to LIMIT/MARKET: "How long should this order live?"
# ==============================================================================
class TimeInForce(str, Enum):
GTC = "gtc" # FIX: 1 — Good Till Cancel
IOC = "ioc" # FIX: 3 — Immediate or Cancel (partial fill OK)
FOK = "fok" # FIX: 4 — Fill or Kill (all-or-nothing)
GTD = "gtd" # FIX: 6 — Good Till Date
GTC = "GTC" # FIX: 1 — Good Till Cancel
IOC = "IOC" # FIX: 3 — Immediate or Cancel (partial fill OK)
FOK = "FOK" # FIX: 4 — Fill or Kill (all-or-nothing)
GTD = "GTD" # FIX: 6 — Good Till Date
# ==============================================================================
# Layer 3: Conditional/Trigger (FIX Tag 3/4 + MIT)
# ==============================================================================
class ConditionalType(str, Enum):
STOP_MARKET = "stop_market" # FIX: 3 — trigger → market
STOP_LIMIT = "stop_limit" # FIX: 4 — trigger → limit
TRIGGER_MARKET = "trigger_market" # FIX: J (MIT) — market if touched
TRIGGER_LIMIT = "trigger_limit" # FIX: 4 — limit if touched
TRAILING_STOP = "trailing_stop" # FIX: P + ExecInst — trailing stop
# ==============================================================================
# Layer 4: Instructions (FIX Tag 18 ExecInst)
# Layer 3: Instructions (FIX Tag 18 ExecInst)
# Behavioral modifiers — also SEPARATE from the order type.
# ==============================================================================
class OrderInstruction(str, Enum):
POST_ONLY = "post_only" # FIX: ExecInst — maker-only, reject if crossing
REDUCE_ONLY = "reduce_only" # FIX: ExecInst — only reduce existing position
HIDDEN = "hidden" # FIX: ExecInst — iceberg/hidden order
ICEBERG = "iceberg" # FIX: ExecInst — visible portion only
POST_ONLY = "POST_ONLY" # FIX: ExecInst — maker-only, reject if crossing
REDUCE_ONLY = "REDUCE_ONLY" # FIX: ExecInst — only reduce existing position
HIDDEN = "HIDDEN" # FIX: ExecInst — iceberg/hidden
ICEBERG = "ICEBERG" # FIX: ExecInst — visible portion only
# ==============================================================================
# Layer 5: Compound (exchange-specific, no FIX equivalent)
# Exchange-specific order type mapping
# Maps normalized OrderType enum value -> exchange-native "type" parameter string.
#
# NOTE: This maps ONLY the order type dimension. TimeInForce and Instructions
# are sent as separate API parameters (timeInForce=, postOnly=, reduceOnly=).
# ==============================================================================
class CompoundOrder(str, Enum):
OCO = "oco" # One-Cancels-Other (linked orders)
TP_SL = "tp_sl" # Take-Profit / Stop-Loss (attached to position)
# ==============================================================================
# Exchange-specific mapping tables
# ==============================================================================
EXCHANGE_ORDER_MAP: Dict[str, Dict[str, str]] = {
# Maps normalized OrderType.value -> exchange-native "type" parameter
EXCHANGE_ORDER_TYPE_MAP: Dict[str, Dict[str, str]] = {
"binance": {
"limit": "LIMIT", "market": "MARKET",
"stop_market": "STOP_MARKET", "stop_limit": "STOP_LOSS_LIMIT",
"trigger_market": "TAKE_PROFIT", "trigger_limit": "TAKE_PROFIT_LIMIT",
"trailing_stop": "TRAILING_STOP_MARKET",
"post_only": "LIMIT_MAKER", "ioc": "IOC", "fok": "FOK",
"reduce_only": "REDUCE_ONLY",
"LIMIT": "LIMIT", "MARKET": "MARKET",
"STOP_MARKET": "STOP_MARKET", "STOP_LIMIT": "STOP_LOSS_LIMIT",
"TRIGGER_MARKET": "TAKE_PROFIT", "TRIGGER_LIMIT": "TAKE_PROFIT_LIMIT",
"TRAILING_STOP": "TRAILING_STOP_MARKET",
},
"bingx": {
"limit": "LIMIT", "market": "MARKET",
"stop_market": "TRIGGER_MARKET", "stop_limit": "TRIGGER_LIMIT",
"trigger_market": "TRIGGER_MARKET", "trigger_limit": "TRIGGER_LIMIT",
"trailing_stop": "TRIGGER_MARKET",
"post_only": "POST_ONLY", "ioc": "IOC", "fok": "FOK",
"reduce_only": "REDUCE_ONLY",
"LIMIT": "LIMIT", "MARKET": "MARKET",
"STOP_MARKET": "TRIGGER_MARKET", "STOP_LIMIT": "TRIGGER_LIMIT",
"TRIGGER_MARKET": "TRIGGER_MARKET", "TRIGGER_LIMIT": "TRIGGER_LIMIT",
"TRAILING_STOP": "TRAILING_STOP_MARKET",
},
"bybit": {
"limit": "LIMIT", "market": "MARKET",
"stop_market": "STOP_MARKET", "stop_limit": "STOP_LIMIT",
"trigger_market": "TAKE_PROFIT_MARKET", "trigger_limit": "TAKE_PROFIT_LIMIT",
"trailing_stop": "TRAILING_STOP",
"post_only": "POST_ONLY", "ioc": "IOC", "fok": "FOK",
"reduce_only": "REDUCE_ONLY",
"LIMIT": "LIMIT", "MARKET": "MARKET",
"STOP_MARKET": "STOP_MARKET", "STOP_LIMIT": "STOP_LIMIT",
"TRIGGER_MARKET": "TAKE_PROFIT_MARKET", "TRIGGER_LIMIT": "TAKE_PROFIT_LIMIT",
"TRAILING_STOP": "TRAILING_STOP",
},
}
# Maps normalized TimeInForce.value -> exchange-native "timeInForce" parameter
EXCHANGE_TIF_MAP: Dict[str, Dict[str, str]] = {
"binance": {
"GTC": "GTC", "IOC": "IOC", "FOK": "FOK", "GTD": "GTD",
},
"bingx": {
"GTC": "GTC", "IOC": "IOC", "FOK": "FOK", "GTD": "GTD",
},
"bybit": {
"GTC": "GTC", "IOC": "IOC", "FOK": "FOK", "GTD": "GTD",
},
}
# Maps normalized OrderInstruction.value -> exchange parameter name + value
# POST_ONLY varies: Binance uses type=LIMIT_MAKER, BingX/Bybit use postOnly=true
# This maps the instruction to how it's sent on each exchange.
EXCHANGE_INSTRUCTION_MAP: Dict[str, Dict[str, str]] = {
"binance": {
"POST_ONLY": "LIMIT_MAKER", # Binance encodes post-only as a type
"REDUCE_ONLY": "REDUCE_ONLY",
"HIDDEN": "HIDDEN",
"ICEBERG": "ICEBERG",
},
"bingx": {
"POST_ONLY": "POST_ONLY", # BingX: postOnly=true param on LIMIT
"REDUCE_ONLY": "REDUCE_ONLY",
"HIDDEN": "HIDDEN",
"ICEBERG": "ICEBERG",
},
"bybit": {
"POST_ONLY": "GTX", # Bybit: timeInForce=GTX on Limit
"REDUCE_ONLY": "REDUCE_ONLY",
"HIDDEN": "HIDDEN",
"ICEBERG": "ICEBERG",
},
}
def normalize_to_exchange(normalized_type: str, exchange_id: str) -> Optional[str]:
"""Convert a normalized order type to exchange-native string."""
mapping = EXCHANGE_ORDER_MAP.get(exchange_id, {})
return mapping.get(normalized_type)
def normalize_type_to_exchange(order_type: OrderType, exchange_id: str) -> Optional[str]:
"""Convert a normalized OrderType to exchange-native 'type' parameter string."""
mapping = EXCHANGE_ORDER_TYPE_MAP.get(exchange_id, {})
return mapping.get(order_type.value)
def normalize_tif_to_exchange(tif: TimeInForce, exchange_id: str) -> Optional[str]:
"""Convert a normalized TimeInForce to exchange-native 'timeInForce' parameter."""
mapping = EXCHANGE_TIF_MAP.get(exchange_id, {})
return mapping.get(tif.value)
def is_type_available(order_type: OrderType, exchange_id: str) -> bool:
"""Check if a normalized order type is available on an exchange."""
mapping = EXCHANGE_ORDER_TYPE_MAP.get(exchange_id, {})
return order_type.value in mapping
def get_supported_types(exchange_id: str) -> list[str]:
"""Get all normalized order types supported by an exchange."""
mapping = EXCHANGE_ORDER_MAP.get(exchange_id, {})
mapping = EXCHANGE_ORDER_TYPE_MAP.get(exchange_id, {})
return list(mapping.keys())
def is_type_available(normalized_type: str, exchange_id: str) -> bool:
"""Check if a normalized order type is available on an exchange."""
mapping = EXCHANGE_ORDER_MAP.get(exchange_id, {})
return normalized_type in mapping
def decompose_order(order_type_value: str) -> Tuple[str, str, str]:
"""Decompose a flat order specification into (type, tif, instruction).
For backward compatibility with code that passes flat strings:
"IOC" -> ("LIMIT", "IOC", None) (IOC = LIMIT + timeInForce)
"FOK" -> ("LIMIT", "FOK", None) (FOK = LIMIT + timeInForce)
"POST_ONLY" -> ("LIMIT", None, "POST_ONLY") (POST_ONLY = LIMIT + instruction)
"REDUCE_ONLY" -> ("MARKET", None, "REDUCE_ONLY")
"MARKET" -> ("MARKET", "GTC", None)
"LIMIT" -> ("LIMIT", "GTC", None)
"""
ot = order_type_value.upper()
tif_map = {"IOC": "IOC", "FOK": "FOK", "GTC": "GTC", "GTD": "GTD"}
inst_map = {"POST_ONLY": "POST_ONLY", "REDUCE_ONLY": "REDUCE_ONLY",
"HIDDEN": "HIDDEN", "ICEBERG": "ICEBERG"}
base_map = {"MARKET": "MARKET", "LIMIT": "LIMIT",
"STOP_MARKET": "STOP_MARKET", "STOP_LIMIT": "STOP_LIMIT",
"TRIGGER_MARKET": "TRIGGER_MARKET", "TRIGGER_LIMIT": "TRIGGER_LIMIT",
"TRAILING_STOP": "TRAILING_STOP", "OCO": "OCO", "TP_SL": "TP_SL"}
if ot in base_map:
return (ot, "GTC", None)
if ot in tif_map:
return ("LIMIT", ot, None)
if ot in inst_map:
if ot == "REDUCE_ONLY":
return ("MARKET", "GTC", ot)
return ("LIMIT", "GTC", ot)
return (ot, "GTC", None)