feat: Introduce P16 Order Router to `breeze_ccxt.py` for entry validation and cancel/replace order modification, accompanied by new tests and documentation.

pull/12760/head
vijay sharma 3 weeks ago
parent 018580a7dd
commit dba81dcc86

@ -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:

@ -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`.

@ -31,6 +31,7 @@ ALL_GATES=(
"p13_ops_security_and_deployment"
"p14_market_hours"
"p15_risk_guardrails"
"p16_order_router"
)
# Parse flags

@ -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…
Cancel
Save