From dba81dcc8620271ba3dc977905c5ea95ec97ecb6 Mon Sep 17 00:00:00 2001 From: vijay sharma Date: Tue, 27 Jan 2026 13:49:43 +0100 Subject: [PATCH] feat: Introduce P16 Order Router to `breeze_ccxt.py` for entry validation and cancel/replace order modification, accompanied by new tests and documentation. --- adapters/ccxt_shim/breeze_ccxt.py | 54 +++++++++ adapters/ccxt_shim/order_router.py | 138 ++++++++++++++++++++++ docs/P16_AUDIT_REPORT.md | 44 +++++++ docs/PHASE_P16.md | 46 ++++++++ scripts/accept_all.sh | 1 + scripts/gates/p16_order_router.sh | 118 ++++++++++++++++++ tests/test_order_router_buyer_only.py | 38 ++++++ tests/test_order_router_cancel_replace.py | 33 ++++++ tests/test_order_router_lot_size.py | 40 +++++++ tests/test_order_router_modify_ladder.py | 32 +++++ tests/test_order_router_modify_quota.py | 30 +++++ 11 files changed, 574 insertions(+) create mode 100644 adapters/ccxt_shim/order_router.py create mode 100644 docs/P16_AUDIT_REPORT.md create mode 100644 docs/PHASE_P16.md create mode 100644 scripts/gates/p16_order_router.sh create mode 100644 tests/test_order_router_buyer_only.py create mode 100644 tests/test_order_router_cancel_replace.py create mode 100644 tests/test_order_router_lot_size.py create mode 100644 tests/test_order_router_modify_ladder.py create mode 100644 tests/test_order_router_modify_quota.py diff --git a/adapters/ccxt_shim/breeze_ccxt.py b/adapters/ccxt_shim/breeze_ccxt.py index 27ca7924d..4e5965175 100644 --- a/adapters/ccxt_shim/breeze_ccxt.py +++ b/adapters/ccxt_shim/breeze_ccxt.py @@ -28,6 +28,7 @@ from adapters.ccxt_shim.security_master import ( ) from adapters.ccxt_shim.market_hours import MarketHoursGuard from adapters.ccxt_shim.risk_guard import RiskGuard +from adapters.ccxt_shim.order_router import OrderRouter from freqtrade.exceptions import OperationalException @@ -97,6 +98,7 @@ class BreezeCCXT(ccxt.Exchange): self.rate_limiter = InternalRateLimiter(rpm=rl_config) self._security_master_cache: dict[str, Any] | None = None self.market_hours = MarketHoursGuard() + self.order_router = OrderRouter(lambda: self.markets) # Mock Order Storage self._mock_orders: dict[str, dict] = {} @@ -577,6 +579,23 @@ class BreezeCCXT(ccxt.Exchange): self.risk_guard.record_trade_attempt(symbol, side) + # P16 Order Router Check + def position_check(sym: str) -> bool: + try: + positions = self.fetch_positions([sym]) + # Check for any open position (long or short) with non-zero contracts + for p in positions: + if p["symbol"] == sym and p["contracts"] > 0: + return True + return False + except Exception: + logger.warning( + f"OrderRouter: fetch_positions failed during buyer_only check for {sym}" + ) + return False + + self.order_router.validate_entry(symbol, side, amount, position_check) + if self._is_mock_mode(): import hashlib # nosec import random # nosec @@ -617,6 +636,41 @@ class BreezeCCXT(ccxt.Exchange): raise OperationalException(f"Mock order {order_id} not found.") raise OperationalException("cancel_order not supported in real mode yet.") + return self.fetch_order(order_id, symbol) + + def edit_order( + self, + id: str, + symbol: str, + type: str, + side: str, + amount: float | None = None, + price: float | None = None, + params: dict | None = None, + ): + """ + P16: Edit Order Implementation. + Since native modify is not fully supported/trusted yet, we use Cancel/Replace. + Enforces OrderRouter modification policies. + """ + if params is None: + params = {} + + logger.info(f"BreezeCCXT.edit_order called for {id} {symbol}") + + # 1. Enforce Router Checks (Quota & Ladder) + self.order_router.track_and_assert_modify(id, time.time()) + + # 2. Execute Cancel/Replace + # Note: In a real implementation with native modify, we would call it here. + # Fallback to Cancel + Create + logger.info(f"edit_order: Cancelling {id} to replace...") + self.cancel_order(id, symbol) + + # Wait a bit? Or assume atomic enough? + # Create new order behaves as a new entry/exit. + return self.create_order(symbol, type, side, amount, price, params) + def fetch_order(self, order_id, symbol=None, params: dict | None = None): if self._is_mock_mode(): if order_id in self._mock_orders: diff --git a/adapters/ccxt_shim/order_router.py b/adapters/ccxt_shim/order_router.py new file mode 100644 index 000000000..3e97adb33 --- /dev/null +++ b/adapters/ccxt_shim/order_router.py @@ -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})") diff --git a/docs/P16_AUDIT_REPORT.md b/docs/P16_AUDIT_REPORT.md new file mode 100644 index 000000000..4ff83c229 --- /dev/null +++ b/docs/P16_AUDIT_REPORT.md @@ -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`. diff --git a/docs/PHASE_P16.md b/docs/PHASE_P16.md new file mode 100644 index 000000000..325825a76 --- /dev/null +++ b/docs/PHASE_P16.md @@ -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`. diff --git a/scripts/accept_all.sh b/scripts/accept_all.sh index 1bc7f89b5..4e199f3ea 100755 --- a/scripts/accept_all.sh +++ b/scripts/accept_all.sh @@ -31,6 +31,7 @@ ALL_GATES=( "p13_ops_security_and_deployment" "p14_market_hours" "p15_risk_guardrails" + "p16_order_router" ) # Parse flags diff --git a/scripts/gates/p16_order_router.sh b/scripts/gates/p16_order_router.sh new file mode 100644 index 000000000..771926ecb --- /dev/null +++ b/scripts/gates/p16_order_router.sh @@ -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 < "$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 < "$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 diff --git a/tests/test_order_router_buyer_only.py b/tests/test_order_router_buyer_only.py new file mode 100644 index 000000000..1cf55a11b --- /dev/null +++ b/tests/test_order_router_buyer_only.py @@ -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") diff --git a/tests/test_order_router_cancel_replace.py b/tests/test_order_router_cancel_replace.py new file mode 100644 index 000000000..9d2eeea34 --- /dev/null +++ b/tests/test_order_router_cancel_replace.py @@ -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 diff --git a/tests/test_order_router_lot_size.py b/tests/test_order_router_lot_size.py new file mode 100644 index 000000000..cfe114441 --- /dev/null +++ b/tests/test_order_router_lot_size.py @@ -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 diff --git a/tests/test_order_router_modify_ladder.py b/tests/test_order_router_modify_ladder.py new file mode 100644 index 000000000..6c7deb9f4 --- /dev/null +++ b/tests/test_order_router_modify_ladder.py @@ -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) diff --git a/tests/test_order_router_modify_quota.py b/tests/test_order_router_modify_quota.py new file mode 100644 index 000000000..37847d798 --- /dev/null +++ b/tests/test_order_router_modify_quota.py @@ -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