feat: Introduce P16 Order Router to `breeze_ccxt.py` for entry validation and cancel/replace order modification, accompanied by new tests and documentation.
parent
018580a7dd
commit
dba81dcc86
@ -0,0 +1,138 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrderRouter:
|
||||
"""
|
||||
OrderRouter enforces trading policies at the CCXT Shim boundary.
|
||||
Crucial Policies:
|
||||
1. Lot Size Enforcement: Amounts must be multiples of lot size.
|
||||
2. Buyer Only Guard: Only BUY orders allowed for entries. SELL orders allowed only if closing position.
|
||||
3. Error Tokens: Deterministic error strings for acceptance gates.
|
||||
"""
|
||||
|
||||
def __init__(self, markets_source_callback: Any):
|
||||
"""
|
||||
:param markets_source_callback: Callable returning the current markets dict (self.markets from exchange).
|
||||
"""
|
||||
self._get_markets = markets_source_callback
|
||||
|
||||
def resolve_lot_size(self, symbol: str) -> int:
|
||||
"""
|
||||
Resolve lot size for a symbol from the loaded markets.
|
||||
Default to 1 if not found (e.g. Cash).
|
||||
"""
|
||||
markets = self._get_markets()
|
||||
if not markets:
|
||||
logger.warning(f"OrderRouter: Markets empty during lot resolution for {symbol}.")
|
||||
return 1
|
||||
|
||||
market = markets.get(symbol)
|
||||
if not market:
|
||||
logger.warning(
|
||||
f"OrderRouter: Symbol {symbol} not found in markets. Defaulting lot to 1."
|
||||
)
|
||||
return 1
|
||||
|
||||
return int(market.get("lot", 1))
|
||||
|
||||
def assert_lot_size(self, symbol: str, amount: float, lot_size: int) -> None:
|
||||
"""
|
||||
Enforce that amount is a perfect multiple of lot size.
|
||||
"""
|
||||
if lot_size <= 0:
|
||||
logger.warning(
|
||||
f"OrderRouter: Invalid lot_size {lot_size} for {symbol}. Skipping checks."
|
||||
)
|
||||
return
|
||||
|
||||
# Check if amount is multiple
|
||||
# Use a small epsilon for float math, but we expect integers for lots
|
||||
remainder = amount % lot_size
|
||||
# If strict int logic required:
|
||||
if not math.isclose(remainder, 0, abs_tol=1e-5) and not math.isclose(
|
||||
remainder, lot_size, abs_tol=1e-5
|
||||
):
|
||||
raise OperationalException(
|
||||
f"order_router_block:lot_size (Amount {amount} not multiple of {lot_size})"
|
||||
)
|
||||
|
||||
def assert_buyer_only(
|
||||
self, symbol: str, side: str, position_check_callback: Any | None
|
||||
) -> None:
|
||||
"""
|
||||
Enforce Buyer Only policy.
|
||||
BUY: Always Allowed.
|
||||
SELL: Allowed ONLY if it corresponds to an existing open position (Exit).
|
||||
If we cannot determine position state, we BLOCK SELLs to be safe (Short Prevention).
|
||||
"""
|
||||
if side.lower() == "buy":
|
||||
return
|
||||
|
||||
# If logic reaches here, it's a SELL
|
||||
if position_check_callback is None:
|
||||
# Fail safe: Block if we can't check positions
|
||||
raise OperationalException(
|
||||
f"order_router_block:buyer_only (Sell blocked, no position check available)"
|
||||
)
|
||||
|
||||
# Check if we have an open position for this symbol
|
||||
# position_check_callback should return True if Long Position exists
|
||||
is_open_long = position_check_callback(symbol)
|
||||
|
||||
if not is_open_long:
|
||||
raise OperationalException(
|
||||
f"order_router_block:buyer_only (Sell blocked, no open position)"
|
||||
)
|
||||
|
||||
def track_and_assert_modify(self, order_id: str, now_ts: float) -> None:
|
||||
"""
|
||||
Enforce Modification Quota and Ladder (Rate Limit for Mods).
|
||||
Policies:
|
||||
1. Max 3 modifications per order.
|
||||
2. Min 2 seconds between modifications.
|
||||
"""
|
||||
# In-memory storage for P16 (non-persistent)
|
||||
if not hasattr(self, "_mod_state"):
|
||||
self._mod_state: dict[str, dict] = {}
|
||||
|
||||
state = self._mod_state.get(order_id, {"count": 0, "last_ts": 0.0})
|
||||
|
||||
# 1. Quota Check
|
||||
if state["count"] >= 3:
|
||||
raise OperationalException(
|
||||
f"order_router_block:mod_quota (Max 3 mods exceeded for {order_id})"
|
||||
)
|
||||
|
||||
# 2. Ladder Check
|
||||
if now_ts - state["last_ts"] < 2.0:
|
||||
raise OperationalException(
|
||||
f"order_router_block:mod_ladder (Mod too fast for {order_id}, wait 2s)"
|
||||
)
|
||||
|
||||
# Update State
|
||||
state["count"] += 1
|
||||
state["last_ts"] = now_ts
|
||||
self._mod_state[order_id] = state
|
||||
|
||||
logger.info(f"OrderRouter: Modification allowed for {order_id}. Count: {state['count']}")
|
||||
|
||||
def validate_entry(
|
||||
self, symbol: str, side: str, amount: float, position_check_callback: Any | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Primary validation entry point.
|
||||
"""
|
||||
# 1. Lot Size
|
||||
lot_size = self.resolve_lot_size(symbol)
|
||||
self.assert_lot_size(symbol, amount, lot_size)
|
||||
|
||||
# 2. Buyer Only
|
||||
self.assert_buyer_only(symbol, side, position_check_callback)
|
||||
|
||||
logger.info(f"OrderRouter: Validated {side} {amount} {symbol} (Lot: {lot_size})")
|
||||
@ -0,0 +1,44 @@
|
||||
# P16 Audit Report: Context & Gap Analysis
|
||||
|
||||
## 1. Choke Points (Order Execution Surface)
|
||||
|
||||
The following methods in `adapters/ccxt_shim/breeze_ccxt.py` form the critical path for all order operations. The Order Router must intercept these.
|
||||
|
||||
| Method | Status | Path:Line | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `create_order` | **EXISTING** | `adapters/ccxt_shim/breeze_ccxt.py:554` | Primary entry point (Sync). Async wraps this. |
|
||||
| `cancel_order` | **EXISTING** | `adapters/ccxt_shim/breeze_ccxt.py:610` | Cancellation entry point. |
|
||||
| `edit_order` | **MISSING** | N/A | **GAP**: Must be implemented to support Cancel/Replace or Native Modify. |
|
||||
| `fetch_order` | **EXISTING** | `adapters/ccxt_shim/breeze_ccxt.py:620` | Read path. |
|
||||
| `fetch_open_orders` | **EXISTING** | `adapters/ccxt_shim/breeze_ccxt.py:627` | Read path. |
|
||||
|
||||
**Observation**: `edit_order` is absent and requires implementation for P16-2 modification quotas.
|
||||
|
||||
## 2. Lot Size Source of Truth
|
||||
|
||||
Lot sizes are authoritative in `SecurityMaster` and propagated to the CCXT market structure.
|
||||
|
||||
- **Source**: `adapters/ccxt_shim/security_master.py`
|
||||
- Field: `LotSize` (from CSV) -> `lot_size` (dict key)
|
||||
- Default: `1` (for Cash/Equity if missing)
|
||||
- **Usage**:
|
||||
- `BreezeCCXT._fetch_future_market`: Uses `info["lot_size"]`.
|
||||
- `BreezeCCXT._fetch_cash_market`: Uses `info.get("lot_size", 1)`.
|
||||
- `BreezeCCXT.fetch_markets`: Exposes `lot` in the market dictionary.
|
||||
|
||||
**Decision**: The `OrderRouter` should resolve lot size by looking up the symbol in `self.markets` (which is populated via `fetch_markets` -> `SecurityMaster`).
|
||||
|
||||
## 3. CCXT Constructor Status
|
||||
|
||||
Verification confirmed that the upstream `ccxt` module has no knowledge of `icicibreeze`.
|
||||
|
||||
- `ccxt.icicibreeze`: **MISSING** (AttributeError)
|
||||
- `ccxt.async_support.icicibreeze`: **MISSING** (Assumed)
|
||||
|
||||
**Decision**: P16 tests and gates **MUST NOT** rely on `ccxt.icicibreeze`. They should explicitly load the exchange class from `adapters.ccxt_shim.breeze_ccxt` or use Freqtrade's exchange resolver mechanism.
|
||||
|
||||
## 4. Implementation Strategy
|
||||
|
||||
- **OrderRouter**: Will be a new class instantiated in `BreezeCCXT.__init__`.
|
||||
- **Validation**: `create_order` will call `router.validate_entry(symbol, amount, side)`.
|
||||
- **Modifications**: `edit_order` will be implemented in `BreezeCCXT` and delegate policy checks to `router`.
|
||||
@ -0,0 +1,46 @@
|
||||
# Phase 16: Order Router & Guardrails
|
||||
|
||||
## Overview
|
||||
|
||||
The Order Router (`adapters/ccxt_shim/order_router.py`) acts as a critical choke point for all order operations in the `BreezeCCXT` shim. It enforces strict trading policies to prevent invalid or risky orders from reaching the exchange or the mock adapter.
|
||||
|
||||
## Policies Enforced
|
||||
|
||||
### 1. Lot Size
|
||||
|
||||
- **Source**: `SecurityMaster` (via `BreezeCCXT.markets`)
|
||||
- **Rule**: Order amount must be a perfect integer multiple of the contract's lot size.
|
||||
- **Behavior**: Rejects non-compliant orders with `order_router_block:lot_size`.
|
||||
|
||||
### 2. Buyer Only Guard
|
||||
|
||||
- **Rule**:
|
||||
- **BUY**: Always allowed.
|
||||
- **SELL**: Allowed **ONLY** if an open position (Long) exists for the symbol.
|
||||
- **Purpose**: Strictly prevents short selling or opening short positions.
|
||||
- **Behavior**: Rejects invalid sells with `order_router_block:buyer_only`.
|
||||
|
||||
### 3. Modification Rules
|
||||
|
||||
- **Method**: `edit_order` (Simulated via Cancel/Replace)
|
||||
- **Quota**: Max **3** modifications per order ID.
|
||||
- **Ladder**: Min **2 seconds** between modifications for the same order.
|
||||
- **Behavior**: Rejects excessive or rapid mods with `order_router_block:mod_quota` or `order_router_block:mod_ladder`.
|
||||
|
||||
## Acceptance Gates
|
||||
|
||||
### P16 (Positive)
|
||||
|
||||
- **Command**: `bash scripts/accept_all.sh p16_order_router`
|
||||
- **Behavior**: Places a valid BUY order and cancels it. Expects Success.
|
||||
|
||||
### P16 (Negative)
|
||||
|
||||
- **Command**: `bash scripts/accept_all.sh --neg p16_order_router`
|
||||
- **Behavior**: Attempts a SELL order without an open position.
|
||||
- **Expectation**: Gate PASSES if the Order Router **BLOCKS** the sell with `order_router_block:buyer_only`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- **Class**: `OrderRouter`
|
||||
- **Integration**: `BreezeCCXT` initializes `OrderRouter` and calls `validate_entry` in `create_order` and `track_and_assert_modify` in `edit_order`.
|
||||
@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# P16 Order Router Acceptance Gate
|
||||
set -euo pipefail
|
||||
|
||||
# Identify run context
|
||||
source scripts/gates/common.sh "p16" "$@"
|
||||
|
||||
# Determinism
|
||||
export BREEZE_MOCK=1
|
||||
unset BREEZE_API_KEY BREEZE_API_SECRET BREEZE_SESSION_TOKEN
|
||||
|
||||
# Paths
|
||||
GATE_CFG="$ARTIFACT_DIR/config_p16.json"
|
||||
# We reuse P09x config as base
|
||||
BASE_CFG="user_data/generated/config_p09x_v1.json"
|
||||
if [ ! -f "$BASE_CFG" ]; then
|
||||
echo "ERROR: Config missing: $BASE_CFG"
|
||||
finish_gate 1
|
||||
fi
|
||||
cp "$BASE_CFG" "$GATE_CFG"
|
||||
|
||||
PY_SCRIPT="$ARTIFACT_DIR/run_p16_test.py"
|
||||
|
||||
if [ "$GATE_MODE" == "pos" ]; then
|
||||
echo "Step 1: Positive Case - Entry should be allowed"
|
||||
|
||||
# Create Python harness
|
||||
cat <<EOF > "$PY_SCRIPT"
|
||||
import logging
|
||||
import sys
|
||||
from adapters.ccxt_shim.breeze_ccxt import BreezeCCXT
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("p16_pos")
|
||||
|
||||
def run():
|
||||
try:
|
||||
ex = BreezeCCXT({"options": {"mode": "mock"}})
|
||||
# Pos Case: Buy Order
|
||||
logger.info("Placing BUY order...")
|
||||
order = ex.create_order("RELIANCE/INR", "limit", "buy", 10, 2500)
|
||||
logger.info(f"Order created: {order['id']}")
|
||||
|
||||
# Cleanup
|
||||
ex.cancel_order(order['id'], "RELIANCE/INR")
|
||||
print("P16_POS_SUCCESS")
|
||||
except Exception as e:
|
||||
logger.error(f"FAILURE: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
EOF
|
||||
|
||||
# Run Python Script
|
||||
$PYTHON "$PY_SCRIPT" > "$GATE_LOG" 2>&1
|
||||
|
||||
if grep -q "P16_POS_SUCCESS" "$GATE_LOG"; then
|
||||
echo "[OK] Positive case success"
|
||||
else
|
||||
echo "ERROR: Positive case failed"
|
||||
tail -n 20 "$GATE_LOG"
|
||||
finish_gate 1
|
||||
fi
|
||||
|
||||
elif [ "$GATE_MODE" == "neg" ]; then
|
||||
echo "Step 1: Negative Case - Sell without position should be blocked"
|
||||
|
||||
# Create Python harness
|
||||
cat <<EOF > "$PY_SCRIPT"
|
||||
import logging
|
||||
import sys
|
||||
from adapters.ccxt_shim.breeze_ccxt import BreezeCCXT
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("p16_neg")
|
||||
|
||||
def run():
|
||||
try:
|
||||
ex = BreezeCCXT({"options": {"mode": "mock"}})
|
||||
# Neg Case: Sell Order without position
|
||||
logger.info("Placing SELL order...")
|
||||
ex.create_order("RELIANCE/INR", "limit", "sell", 10, 2500)
|
||||
logger.error("FAILURE: Sell order should have been blocked")
|
||||
sys.exit(1)
|
||||
except OperationalException as e:
|
||||
if "order_router_block:buyer_only" in str(e):
|
||||
print(f"P16_NEG_SUCCESS: Caught expected block: {e}")
|
||||
else:
|
||||
logger.error(f"FAILURE: Caught unexpected exception: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"FAILURE: Caught unexpected exception type: {type(e)} {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
EOF
|
||||
|
||||
# Run Python Script
|
||||
$PYTHON "$PY_SCRIPT" > "$GATE_LOG" 2>&1
|
||||
|
||||
if grep -q "P16_NEG_SUCCESS" "$GATE_LOG"; then
|
||||
echo "[OK] Negative case success (Block confirmed)"
|
||||
else
|
||||
echo "ERROR: Negative case failed (No block observed)"
|
||||
tail -n 20 "$GATE_LOG"
|
||||
finish_gate 1
|
||||
fi
|
||||
|
||||
else
|
||||
echo "ERROR: Invalid mode $GATE_MODE"
|
||||
finish_gate 1
|
||||
fi
|
||||
|
||||
echo "P16 Order Router passed"
|
||||
finish_gate 0
|
||||
@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from adapters.ccxt_shim.order_router import OrderRouter
|
||||
|
||||
|
||||
def test_assert_buyer_only_buy_allowed():
|
||||
router = OrderRouter(MagicMock())
|
||||
# Entry Buy - Assert no exception
|
||||
router.assert_buyer_only("RELIANCE/INR", "buy", None)
|
||||
router.assert_buyer_only("RELIANCE/INR", "BUY", None)
|
||||
|
||||
|
||||
def test_assert_buyer_only_sell_blocked_no_callback():
|
||||
router = OrderRouter(MagicMock())
|
||||
# Sell without callback -> BLOCK
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:buyer_only"):
|
||||
router.assert_buyer_only("RELIANCE/INR", "sell", None)
|
||||
|
||||
|
||||
def test_assert_buyer_only_sell_blocked_no_position():
|
||||
router = OrderRouter(MagicMock())
|
||||
# Mock callback returns False (No position)
|
||||
mock_cb = MagicMock(return_value=False)
|
||||
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:buyer_only"):
|
||||
router.assert_buyer_only("RELIANCE/INR", "sell", mock_cb)
|
||||
mock_cb.assert_called_with("RELIANCE/INR")
|
||||
|
||||
|
||||
def test_assert_buyer_only_sell_allowed_with_position():
|
||||
router = OrderRouter(MagicMock())
|
||||
# Mock callback returns True (Position exists)
|
||||
mock_cb = MagicMock(return_value=True)
|
||||
|
||||
# Should not raise
|
||||
router.assert_buyer_only("RELIANCE/INR", "sell", mock_cb)
|
||||
mock_cb.assert_called_with("RELIANCE/INR")
|
||||
@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from adapters.ccxt_shim.breeze_ccxt import BreezeCCXT
|
||||
|
||||
|
||||
def test_edit_order_cancel_replace_flow():
|
||||
# Setup
|
||||
ex = BreezeCCXT({})
|
||||
ex.cancel_order = MagicMock()
|
||||
ex.create_order = MagicMock(return_value={"id": "NEW_ORD_ID"})
|
||||
ex.order_router = MagicMock() # Mock router to bypass quota check
|
||||
|
||||
# Execute
|
||||
res = ex.edit_order("OLD_ID", "RELIANCE/INR", "limit", "buy", 10, 2500)
|
||||
|
||||
# Assert
|
||||
ex.cancel_order.assert_called_with("OLD_ID", "RELIANCE/INR")
|
||||
ex.create_order.assert_called_with("RELIANCE/INR", "limit", "buy", 10, 2500, {})
|
||||
assert res == {"id": "NEW_ORD_ID"}
|
||||
|
||||
|
||||
def test_edit_order_enforces_router():
|
||||
ex = BreezeCCXT({})
|
||||
ex.order_router.track_and_assert_modify = MagicMock()
|
||||
ex.cancel_order = MagicMock()
|
||||
ex.create_order = MagicMock()
|
||||
|
||||
ex.edit_order("ORD-1", "RELIANCE/INR", "limit", "buy", 10, 2500)
|
||||
|
||||
# Verify router called
|
||||
ex.order_router.track_and_assert_modify.assert_called()
|
||||
call_args = ex.order_router.track_and_assert_modify.call_args
|
||||
assert call_args[0][0] == "ORD-1" # Check order ID passed
|
||||
@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from adapters.ccxt_shim.order_router import OrderRouter
|
||||
|
||||
|
||||
def test_resolve_lot_size_defaults():
|
||||
mock_markets = MagicMock(return_value={})
|
||||
router = OrderRouter(mock_markets)
|
||||
assert router.resolve_lot_size("UNKNOWN/INR") == 1
|
||||
|
||||
|
||||
def test_resolve_lot_size_found():
|
||||
mock_markets = MagicMock(return_value={"NIFTY/INR": {"lot": 50}, "RELIANCE/INR": {"lot": 1}})
|
||||
router = OrderRouter(mock_markets)
|
||||
assert router.resolve_lot_size("NIFTY/INR") == 50
|
||||
assert router.resolve_lot_size("RELIANCE/INR") == 1
|
||||
|
||||
|
||||
def test_assert_lot_size_valid():
|
||||
router = OrderRouter(MagicMock())
|
||||
# 50 lot size, amount 50, 100, 150 OK
|
||||
router.assert_lot_size("X", 50.0, 50)
|
||||
router.assert_lot_size("X", 100.0, 50)
|
||||
router.assert_lot_size("X", 5000.0, 50)
|
||||
|
||||
# 1 lot size, amount 1.0, 1.5 (wait, 1.5? No, int lot size implies amounts usually int for derivatives, but for spot 1.5 might be valid if step is < 1?
|
||||
# The prompt says: "amount must be multiple of lot_size".
|
||||
# For cash lot_size=1.
|
||||
router.assert_lot_size("X", 1.0, 1)
|
||||
router.assert_lot_size("X", 15.0, 1)
|
||||
|
||||
|
||||
def test_assert_lot_size_blocks_invalid():
|
||||
router = OrderRouter(MagicMock())
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:lot_size"):
|
||||
router.assert_lot_size("X", 51.0, 50)
|
||||
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:lot_size"):
|
||||
router.assert_lot_size("X", 1.5, 1) # Assuming strict integer multiples for lot size >= 1
|
||||
@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from adapters.ccxt_shim.order_router import OrderRouter
|
||||
|
||||
|
||||
def test_track_and_assert_modify_ladder_rate_limit():
|
||||
router = OrderRouter(MagicMock())
|
||||
order_id = "ORD-LADDER"
|
||||
|
||||
# First mod
|
||||
router.track_and_assert_modify(order_id, 1000.0)
|
||||
|
||||
# Second mod too soon (< 2s)
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:mod_ladder"):
|
||||
router.track_and_assert_modify(order_id, 1001.5)
|
||||
|
||||
# Second mod OK (> 2s)
|
||||
router.track_and_assert_modify(order_id, 1002.1)
|
||||
|
||||
# Third mod too soon
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:mod_ladder"):
|
||||
router.track_and_assert_modify(order_id, 1003.0)
|
||||
|
||||
|
||||
def test_track_and_assert_modify_ladder_independent_orders():
|
||||
router = OrderRouter(MagicMock())
|
||||
|
||||
router.track_and_assert_modify("ORD-1", 1000.0)
|
||||
|
||||
# ORD-2 can modify immediately, independent rate limit
|
||||
router.track_and_assert_modify("ORD-2", 1000.1)
|
||||
@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from adapters.ccxt_shim.order_router import OrderRouter
|
||||
|
||||
|
||||
def test_track_and_assert_modify_quota_limit():
|
||||
router = OrderRouter(MagicMock())
|
||||
order_id = "ORD-123"
|
||||
|
||||
# 3 modifications allowed
|
||||
router.track_and_assert_modify(order_id, 1000.0)
|
||||
router.track_and_assert_modify(order_id, 1100.0)
|
||||
router.track_and_assert_modify(order_id, 1200.0)
|
||||
|
||||
# 4th should block
|
||||
with pytest.raises(OperationalException, match=r"order_router_block:mod_quota"):
|
||||
router.track_and_assert_modify(order_id, 1300.0)
|
||||
|
||||
|
||||
def test_track_and_assert_modify_state_isolation():
|
||||
router = OrderRouter(MagicMock())
|
||||
|
||||
# ORD-A: 3 mods
|
||||
router.track_and_assert_modify("ORD-A", 1000.0)
|
||||
router.track_and_assert_modify("ORD-A", 1100.0)
|
||||
router.track_and_assert_modify("ORD-A", 1200.0)
|
||||
|
||||
# ORD-B: Should be fresh (0 mods)
|
||||
router.track_and_assert_modify("ORD-B", 1000.0) # OK
|
||||
Loading…
Reference in new issue