diff --git a/MALKHUT/malkhut/cwm/core.py b/MALKHUT/malkhut/cwm/core.py index 4ef0663..fb525ab 100644 --- a/MALKHUT/malkhut/cwm/core.py +++ b/MALKHUT/malkhut/cwm/core.py @@ -333,6 +333,7 @@ class MinimalCryptoLOBCWM: last_update_ts_ns=ts, reduce_only=action.reduce_only, post_only=action.post_only, + ttl_ms=action.ttl_ms if action.ttl_ms > 0 else 0, ) def transition( @@ -356,6 +357,17 @@ class MinimalCryptoLOBCWM: if our_action.kind.value == "CANCEL_REPLACE" and our_action.cancel_order_id: open_orders = [o for o in open_orders if o.client_order_id != our_action.cancel_order_id] + # 1b. TTL enforcement — auto-cancel expired open orders (CHASE mechanic) + if open_orders: + alive = [] + for oo in open_orders: + age_ms = (now_ts - oo.created_ts_ns) / 1_000_000 + ttl = getattr(oo, 'ttl_ms', 0) + if ttl > 0 and age_ms >= ttl: + continue # expired + alive.append(oo) + open_orders = alive + # 2. Process counterparty cancels for cp in counterparty_actions: if isinstance(cp, CounterpartyAction) and cp.kind.value == "CANCEL": diff --git a/MALKHUT/malkhut/cwm/hft_cwm.py b/MALKHUT/malkhut/cwm/hft_cwm.py index 6234647..cea7ab8 100644 --- a/MALKHUT/malkhut/cwm/hft_cwm.py +++ b/MALKHUT/malkhut/cwm/hft_cwm.py @@ -202,6 +202,7 @@ class HftBacktestCWM: last_update_ts_ns=ts, reduce_only=action.reduce_only, post_only=action.post_only, + ttl_ms=action.ttl_ms if action.ttl_ms > 0 else 0, ) def transition( @@ -225,7 +226,22 @@ class HftBacktestCWM: if our_action.kind.value == "CANCEL_REPLACE" and our_action.cancel_order_id: open_orders = [o for o in open_orders if o.client_order_id != our_action.cancel_order_id] - # 2. Process counterparty cancels + # 2. TTL enforcement — auto-cancel expired open orders (CHASE mechanic) + # Orders with ttl_ms > 0 expire after ttl_ms. This is how CHASE works: + # place → wait → auto-cancel → next step re-places at new offset. + if open_orders: + alive = [] + for oo in open_orders: + age_ms = (now_ts - oo.created_ts_ns) / 1_000_000 + if oo.last_update_ts_ns > oo.created_ts_ns: + age_ms = (now_ts - oo.last_update_ts_ns) / 1_000_000 + ttl = oo.ttl_ms + if ttl > 0 and age_ms >= ttl: + continue # expired — auto-cancel + alive.append(oo) + open_orders = alive + + # 3. Process counterparty cancels for cp in counterparty_actions: if isinstance(cp, CounterpartyAction) and cp.kind.value == "CANCEL": open_orders = [o for o in open_orders if o.symbol != state.venue.symbol] diff --git a/MALKHUT/malkhut/state.py b/MALKHUT/malkhut/state.py index 30a82eb..9bccbc9 100644 --- a/MALKHUT/malkhut/state.py +++ b/MALKHUT/malkhut/state.py @@ -210,6 +210,7 @@ class OpenOrderState: last_update_ts_ns: int reduce_only: bool = False post_only: bool = False + ttl_ms: int = 0 # 0 = no expiry; >0 = auto-cancel after ttl_ms (CHASE) @dataclass(frozen=True, slots=True) diff --git a/MALKHUT/malkhut/tests/test_hft_cwm.py b/MALKHUT/malkhut/tests/test_hft_cwm.py index 2fde3af..fb11e09 100644 --- a/MALKHUT/malkhut/tests/test_hft_cwm.py +++ b/MALKHUT/malkhut/tests/test_hft_cwm.py @@ -716,3 +716,85 @@ class TestHftAvailability: assert cwm._use_queue_model is False assert cwm._fill_probability_at_level(0) == 1.0 assert cwm._fill_probability_at_level(50) == 1.0 + + +# ============================================================================== +# PART 16: CHASE Mechanics +# ============================================================================== + +class TestChaseMechanics: + def test_ttl_enforcement_cancels_expired_order(self): + cwm = HftBacktestCWM(use_queue_model=False) + s = _state() + # Place order with TTL=1ms + a = _place(Side.BUY, price_ticks=5, qty=0.10) + a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 5, 0.10, 1, post_only=True) # ttl_ms=1 + s2 = cwm.transition(s, (a,)) + assert len(s2.open_orders) == 1 + + # Next step: order should be expired (1ms < 1ms tick) + noop = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) + s3 = cwm.transition(s2, (noop,)) + assert len(s3.open_orders) == 0 + + def test_ttl_zero_means_no_expiry(self): + cwm = HftBacktestCWM(use_queue_model=False) + s = _state() + a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 5, 0.10, 0, post_only=True) # ttl_ms=0 + s2 = cwm.transition(s, (a,)) + assert len(s2.open_orders) == 1 + + noop = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) + s3 = cwm.transition(s2, (noop,)) + assert len(s3.open_orders) == 1 # not expired + + def test_chase_action_has_ttl(self): + a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 5, 0.10, 100, post_only=True, metadata={"chase": True}) + assert a.ttl_ms == 100 + + def test_chase_cancel_retry_cycle(self): + cwm = HftBacktestCWM(use_queue_model=False) + s = _state() + noop = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) + + # Step 1: Place chase order with short TTL + a1 = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 5, 0.10, 1, post_only=True, metadata={"chase": True}) + s1 = cwm.transition(s, (a1,)) + assert len(s1.open_orders) == 1 + + # Step 2: Order expired (TTL=1ms) + s2 = cwm.transition(s1, (noop,)) + assert len(s2.open_orders) == 0 + + # Step 3: Re-place at offset 3 (simulating cancel-retry) + a3 = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 3, 0.10, 1, post_only=True, metadata={"chase": True}) + s3 = cwm.transition(s2, (a3,)) + assert len(s3.open_orders) == 1 + assert s3.open_orders[0].price != s1.open_orders[0].price # different offset + + def test_chase_max_retries_in_metadata(self): + a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, + 2, 0.10, 100, post_only=True, + metadata={"chase": True, "chase_max_retries": 3}) + assert a.metadata["chase_max_retries"] == 3 + + def test_wait_to_retry_in_params(self): + p = _params() + assert p.wait_to_retry_ms == 0 + assert p.chase_enabled is False + assert p.chase_offset_ticks == 1 + assert p.chase_max_retries == 0 + + def test_cma_codec_includes_chase_params(self): + from malkhut.training.cma_trainer import CMAParameterCodec + codec = CMAParameterCodec() + param_names = [s.name for s in codec.SPECS] + assert "wait_to_retry_ms" in param_names + assert "chase_offset_ticks" in param_names + assert "chase_max_retries" in param_names + assert "urgency_taker_threshold" in param_names diff --git a/MALKHUT/malkhut/training/dsl.py b/MALKHUT/malkhut/training/dsl.py index 4d96607..f68aeeb 100644 --- a/MALKHUT/malkhut/training/dsl.py +++ b/MALKHUT/malkhut/training/dsl.py @@ -712,6 +712,19 @@ def _primitive_to_action(primitive: ActionPrimitive, state: MarketWorldState) -> ttl_ms=int(primitive.duration_s * 1000), post_only=True, ) + elif at == ActionType.CHASE: + # Chase: place at target with short TTL. CWM auto-cancels when TTL expires. + # Next step the planner re-places at new offset (cancel-retry across CWM steps). + # Key: uses primitive.duration_s as the wait_to_retry_ms (how long before cancel). + ttl = int(primitive.duration_s * 1000) if primitive.duration_s > 0 else 100 + return FulfilmentAction( + kind=ActionKind.PLACE, side=primitive.side, order_type=OrderType.LIMIT, + price_ticks_from_best=primitive.offset_ticks, qty_fraction=primitive.size_fraction, + ttl_ms=ttl, post_only=True, + metadata={"chase": True, "chase_offset": primitive.offset_ticks, + "chase_ttl_ms": ttl, "chase_target": "follow_price"}, + ) + return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)