diff --git a/src/ml4t/live/safety.py b/src/ml4t/live/safety.py index 17bfad7..e9116cc 100644 --- a/src/ml4t/live/safety.py +++ b/src/ml4t/live/safety.py @@ -24,6 +24,7 @@ from enum import StrEnum from pathlib import Path from typing import Any +from uuid import uuid4 from ml4t.backtest.types import Order, OrderSide, OrderStatus, OrderType, Position from ml4t.specs import ExecutionCapability @@ -1130,7 +1131,7 @@ async def _reduce_position_unlocked( side=request.side, quantity=request.quantity, order_type=OrderType.MARKET, - order_id=f"SHADOW-REDUCE-{int(time.time() * 1_000_000)}", + order_id=self._new_shadow_order_id("SHADOW-REDUCE"), status=OrderStatus.FILLED, filled_quantity=request.quantity, filled_price=price, @@ -1440,7 +1441,7 @@ async def _submit_order_unlocked( order_type=order_type, limit_price=limit_price, stop_price=stop_price, - order_id=f"SHADOW-{int(time.time() * 1000)}", + order_id=self._new_shadow_order_id("SHADOW"), status=OrderStatus.FILLED, filled_quantity=quantity, filled_price=price, @@ -1510,6 +1511,10 @@ async def _submit_order_unlocked( return order + def _new_shadow_order_id(self, prefix: str) -> str: + """Keep shadow identities distinct across clock ticks and process restarts.""" + return f"{prefix}-{uuid4().hex}" + def _commit_accepted_order(self, request: CanonicalOrderRequest) -> None: """Commit counters and histories only after an order is accepted.""" now = time.time() diff --git a/tests/contracts/test_causal_strategy_parity.py b/tests/contracts/test_causal_strategy_parity.py index f1941a8..1d718a9 100644 --- a/tests/contracts/test_causal_strategy_parity.py +++ b/tests/contracts/test_causal_strategy_parity.py @@ -420,6 +420,37 @@ async def test_scheduled_target_matches_when_observable_state_is_equivalent(tmp_ assert live.strategy_trace.observations == backtest.strategy_trace.observations +@pytest.mark.asyncio +async def test_shadow_order_identities_do_not_collide_within_ticks_or_after_restart( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr("ml4t.live.safety.time.time", lambda: 1_700_000_000.0) + orders = [] + + class SubmitStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + orders.append(broker.submit_order("SPY", 1)) + orders.append(broker.submit_order("SPY", 1)) + orders.append(broker.close_position("SPY")) + + for _ in range(2): + safe = SafeBroker( + DeterministicLiveBroker(), + LiveRiskConfig( + execution_mode="shadow", + dedup_window_seconds=0, + state_file=str(tmp_path / "state.json"), + ), + ) + engine = LiveEngine(SubmitStrategy(), safe, DeterministicLiveFeed(market_events()[:1])) + await engine.connect() + await engine.run() + assert safe.positions == {} + + assert len(orders) == 6 + assert len({order.order_id for order in orders}) == 6 + + @pytest.mark.asyncio async def test_late_same_session_target_is_rejected_without_submission_in_both_engines( tmp_path, diff --git a/tests/unit/test_provider_contract.py b/tests/unit/test_provider_contract.py index cf9868e..4676aec 100644 --- a/tests/unit/test_provider_contract.py +++ b/tests/unit/test_provider_contract.py @@ -226,7 +226,17 @@ def test_tampered_reported_contract_fails_closed( assert classification.reason == "reported contract does not match its evidence commit" -def test_ib_tif_change_invalidates_v012_evidence() -> None: +@pytest.mark.parametrize( + ("provider", "changed_input"), + [ + ("ib", "src/ml4t/live/brokers/ib.py"), + ("ib", "src/ml4t/live/safety.py"), + ("alpaca", "src/ml4t/live/safety.py"), + ], +) +def test_changed_broker_inputs_require_fresh_provider_evidence( + provider: str, changed_input: str +) -> None: repository = Path(__file__).resolve().parents[2] tag = subprocess.run( ["git", "cat-file", "-e", "v0.1.2^{commit}"], @@ -236,7 +246,7 @@ def test_ib_tif_change_invalidates_v012_evidence() -> None: ) if tag.returncode != 0: pytest.skip("v0.1.2 history is required") - classification = _classification(repository, "ib", "v0.1.2", "HEAD") + classification = _classification(repository, provider, "v0.1.2", "HEAD") assert classification.reusable is False - assert "src/ml4t/live/brokers/ib.py" in classification.changed_inputs + assert changed_input in classification.changed_inputs diff --git a/tests/unit/test_safe_broker.py b/tests/unit/test_safe_broker.py index 8ee3ed0..f65466a 100644 --- a/tests/unit/test_safe_broker.py +++ b/tests/unit/test_safe_broker.py @@ -786,6 +786,29 @@ def test_prune_history_removes_old_recent_orders(mock_broker, config): # === Submit Order Integration Tests === +async def test_shadow_order_ids_stay_unique_when_the_clock_does_not_advance( + mock_broker, config, monkeypatch +): + """Millisecond shadow ids collide when two orders are accepted in the same tick.""" + config.shadow_mode = True + config.execution_mode = "shadow" + config.dedup_window_seconds = 0 + safe = SafeBroker(mock_broker, config) + monkeypatch.setattr("ml4t.live.safety.time.time", lambda: 1_700_000_000.0) + safe._record_market_data( + datetime.now(UTC), + {"AAPL": {"close": 150.0}, "MSFT": {"close": 300.0}}, + {}, + ) + + first = await safe.submit_order_async("AAPL", 1, OrderSide.BUY) + second = await safe.submit_order_async("MSFT", 1, OrderSide.BUY) + + assert first.order_id != second.order_id + assert first.order_id.startswith("SHADOW-") + assert second.order_id.startswith("SHADOW-") + + async def test_submit_order_shadow_mode(mock_broker, config): """Test submit_order_async in shadow mode.""" config.shadow_mode = True