Skip to content
9 changes: 7 additions & 2 deletions src/ml4t/live/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions tests/contracts/test_causal_strategy_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions tests/unit/test_provider_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"],
Expand All @@ -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
23 changes: 23 additions & 0 deletions tests/unit/test_safe_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading