Skip to content

Commit 28deb89

Browse files
committed
test: ExecutionEngine + PortfolioOrchestrator tests, RiskMonitor integration
Adds 22 tests covering: - Order FSM: valid/invalid transitions, terminal states, timestamps - Fill matching: partial/full fills, multiple fills, avg price - Position updates: buy adds, sell realizes PnL - ExecutionEngine: full order lifecycle, portfolio snapshot - PortfolioOrchestrator: signal→orders, rebalance, sell untargeted - Cash tracking, portfolio summary RiskMonitor integration: pre-trade checks before order submission. 1184 passed (all existing + 22 new)
1 parent b7064b4 commit 28deb89

3 files changed

Lines changed: 231 additions & 2 deletions

File tree

strategy/portfolio_orchestrator.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
from quant_platform.execution.engine import (
2828
ExecutionEngine,
2929
OrderSide,
30+
)
31+
from quant_platform.execution.models import (
32+
OrderStatus,
3033
OrderType,
3134
)
32-
from quant_platform.execution.models import OrderStatus
3335
from quant_platform.strategy.multi_strategy import (
3436
MultiStrategyManager,
3537
StrategyConfig,
@@ -53,11 +55,13 @@ def __init__(
5355
exec_engine: ExecutionEngine | None = None,
5456
lot_size: int = 100,
5557
t_plus: int = 1,
58+
risk_monitor=None,
5659
):
5760
self.multi_strategy = multi_strategy
5861
self.exec_engine = exec_engine or ExecutionEngine()
5962
self.lot_size = lot_size
60-
self.t_plus = t_plus # A-share T+1 settlement
63+
self.t_plus = t_plus
64+
self.risk_monitor = risk_monitor # A-share T+1 settlement
6165

6266
# Track positions per strategy (strategy_id -> {ticker: target_qty})
6367
self._targets: dict[str, dict[str, int]] = {}
@@ -181,6 +185,19 @@ def rebalance(self) -> list[dict]:
181185
quantity=self._round_lot(buy_qty),
182186
strategy=strategy_id,
183187
)
188+
# Pre-trade risk check
189+
if self.risk_monitor is not None:
190+
check = self.risk_monitor.check_pre_trade(
191+
ticker=ticker,
192+
quantity=order.quantity,
193+
price=price,
194+
side=OrderSide.BUY,
195+
)
196+
if not check.get('approved', True):
197+
logger.warning('Order blocked by risk: %s', check.get('reason', ''))
198+
order.notes = f'Risk blocked: {check.get("reason", "")}'
199+
self.exec_engine.reject_order(order)
200+
continue
184201
self.exec_engine.submit_order(order)
185202
self._cash_locked += order.quantity * price
186203

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Tests for ExecutionEngine and Order state machine."""
2+
3+
import pytest
4+
from datetime import datetime
5+
6+
from quant_platform.execution.engine import (
7+
ExecutionEngine,
8+
OrderSide,
9+
OrderStatus,
10+
validate_order_transition,
11+
transition_order,
12+
apply_fill,
13+
update_position,
14+
)
15+
from quant_platform.execution.models import Fill, Order, Position
16+
17+
18+
class TestOrderFSM:
19+
def test_valid_transition(self):
20+
order = Order(status=OrderStatus.PENDING)
21+
valid, reason = validate_order_transition(order, OrderStatus.SUBMITTED)
22+
assert valid
23+
assert reason == ""
24+
25+
def test_invalid_transition(self):
26+
order = Order(status=OrderStatus.FILLED)
27+
valid, reason = validate_order_transition(order, OrderStatus.SUBMITTED)
28+
assert not valid
29+
assert "Invalid transition" in reason
30+
31+
def test_transition_raises_on_invalid(self):
32+
order = Order(status=OrderStatus.CANCELLED)
33+
with pytest.raises(ValueError, match="Invalid transition"):
34+
transition_order(order, OrderStatus.SUBMITTED)
35+
36+
def test_transition_sets_submitted_at(self):
37+
order = Order(status=OrderStatus.PENDING)
38+
transition_order(order, OrderStatus.SUBMITTED)
39+
assert order.status == OrderStatus.SUBMITTED
40+
assert order.submitted_at is not None
41+
42+
def test_terminal_states(self):
43+
for terminal in [OrderStatus.FILLED, OrderStatus.CANCELLED, OrderStatus.REJECTED, OrderStatus.EXPIRED]:
44+
order = Order(status=terminal)
45+
valid, _ = validate_order_transition(order, OrderStatus.PENDING)
46+
assert not valid
47+
48+
def test_partial_to_filled(self):
49+
order = Order(status=OrderStatus.PARTIAL)
50+
transition_order(order, OrderStatus.FILLED)
51+
assert order.status == OrderStatus.FILLED
52+
assert order.filled_at is not None
53+
54+
55+
class TestApplyFill:
56+
def test_full_fill(self):
57+
order = Order(quantity=100, status=OrderStatus.SUBMITTED)
58+
fill = Fill(price=10.0, quantity=100)
59+
apply_fill(order, fill)
60+
assert order.status == OrderStatus.FILLED
61+
assert len(order.fills) == 1
62+
assert order.filled_quantity == 100
63+
64+
def test_partial_fill(self):
65+
order = Order(quantity=100, status=OrderStatus.SUBMITTED)
66+
fill = Fill(price=10.0, quantity=40)
67+
apply_fill(order, fill)
68+
assert order.status == OrderStatus.PARTIAL
69+
assert order.filled_quantity == 40
70+
assert order.remaining_quantity == 60
71+
72+
def test_multiple_fills(self):
73+
order = Order(quantity=100, status=OrderStatus.SUBMITTED)
74+
apply_fill(order, Fill(price=10.0, quantity=30))
75+
apply_fill(order, Fill(price=11.0, quantity=70))
76+
assert order.status == OrderStatus.FILLED
77+
assert order.filled_quantity == 100
78+
assert order.avg_fill_price == 10.7 # (30*10 + 70*11) / 100
79+
80+
81+
class TestUpdatePosition:
82+
def test_buy_updates_position(self):
83+
pos = Position(ticker="600519")
84+
order = Order(ticker="600519", side=OrderSide.BUY, quantity=100)
85+
fill = Fill(price=150.0, quantity=100)
86+
update_position(pos, order, fill)
87+
assert pos.quantity == 100
88+
assert pos.avg_cost == 150.0
89+
90+
def test_sell_realizes_pnl(self):
91+
pos = Position(ticker="600519", quantity=100, avg_cost=150.0)
92+
order = Order(ticker="600519", side=OrderSide.SELL, quantity=50)
93+
fill = Fill(price=170.0, quantity=50)
94+
update_position(pos, order, fill)
95+
assert pos.quantity == 50
96+
assert pos.realized_pnl == 1000.0 # (170-150)*50
97+
98+
99+
class TestExecutionEngine:
100+
def test_create_order(self):
101+
engine = ExecutionEngine()
102+
order = engine.create_order("600519", OrderSide.BUY, 100)
103+
assert order.ticker == "600519"
104+
assert order.quantity == 100
105+
assert order.status == OrderStatus.PENDING
106+
107+
def test_submit_order(self):
108+
engine = ExecutionEngine()
109+
order = engine.create_order("600519", OrderSide.BUY, 100)
110+
engine.submit_order(order)
111+
assert order.status == OrderStatus.SUBMITTED
112+
113+
def test_process_fill_updates_position(self):
114+
engine = ExecutionEngine()
115+
order = engine.create_order("600519", OrderSide.BUY, 100)
116+
engine.submit_order(order)
117+
engine.process_fill(order, price=150.0, quantity=100)
118+
assert order.status == OrderStatus.FILLED
119+
pos = engine.get_position("600519")
120+
assert pos is not None
121+
assert pos.quantity == 100
122+
123+
def test_cancel_order(self):
124+
engine = ExecutionEngine()
125+
order = engine.create_order("600519", OrderSide.BUY, 100)
126+
engine.submit_order(order)
127+
engine.cancel_order(order)
128+
assert order.status == OrderStatus.CANCELLED
129+
130+
def test_portfolio_snapshot(self):
131+
engine = ExecutionEngine()
132+
o1 = engine.create_order("600519", OrderSide.BUY, 100)
133+
engine.submit_order(o1)
134+
engine.process_fill(o1, price=150.0, quantity=100)
135+
136+
snapshot = engine.portfolio_snapshot({"600519": 155.0})
137+
assert snapshot["n_positions"] == 1
138+
assert snapshot["total_unrealized_pnl"] == 500.0 # (155-150)*100
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests for PortfolioOrchestrator."""
2+
3+
import pandas as pd
4+
import pytest
5+
6+
from quant_platform.execution.engine import ExecutionEngine, OrderSide
7+
from quant_platform.execution.models import OrderStatus
8+
from quant_platform.strategy.multi_strategy import MultiStrategyManager, StrategyConfig
9+
from quant_platform.strategy.portfolio_orchestrator import PortfolioOrchestrator
10+
11+
12+
@pytest.fixture
13+
def orch():
14+
ms = MultiStrategyManager(total_capital=1_000_000)
15+
sid = ms.add_strategy(StrategyConfig(
16+
name="test", allocation_pct=1.0, is_active=True,
17+
))
18+
engine = ExecutionEngine()
19+
return PortfolioOrchestrator(ms, exec_engine=engine), sid
20+
21+
22+
class TestPortfolioOrchestrator:
23+
def test_on_signal_creates_targets(self, orch):
24+
o, sid = orch
25+
signal = pd.Series({"A0001": 0.5, "A0002": 0.3, "A0003": 0.1})
26+
o._last_prices = {"A0001": 100.0, "A0002": 50.0, "A0003": 20.0}
27+
o.on_signal("2025-01-01", signal, strategy_id=sid)
28+
assert len(o._targets[sid]) > 0
29+
30+
def test_rebalance_creates_orders(self, orch):
31+
o, sid = orch
32+
signal = pd.Series({"A0001": 0.5, "A0002": 0.3})
33+
o._last_prices = {"A0001": 100.0, "A0002": 50.0}
34+
o.on_signal("2025-01-01", signal, strategy_id=sid)
35+
orders = o.rebalance()
36+
assert len(orders) > 0
37+
assert orders[0]["side"] == "buy"
38+
39+
def test_rebalance_sells_untargeted(self, orch):
40+
o, sid = orch
41+
# First establish a position
42+
signal = pd.Series({"A0001": 0.5})
43+
o._last_prices = {"A0001": 100.0}
44+
o.on_signal("2025-01-01", signal, strategy_id=sid)
45+
o.rebalance()
46+
o.process_fills({"A0001": 100.0})
47+
48+
# Now signal changes — exit A0001
49+
o._targets["test"] = {}
50+
orders = o.rebalance()
51+
sells = [o for o in orders if o["side"] == "sell"]
52+
assert len(sells) > 0
53+
assert sells[0]["ticker"] == "A0001"
54+
55+
def test_process_fills_updates_positions(self, orch):
56+
o, sid = orch
57+
signal = pd.Series({"A0001": 0.5})
58+
o._last_prices = {"A0001": 100.0}
59+
o.on_signal("2025-01-01", signal, strategy_id=sid)
60+
o.rebalance()
61+
o.process_fills({"A0001": 100.0})
62+
pos = o.exec_engine.get_position("A0001")
63+
assert pos is not None
64+
assert pos.quantity > 0
65+
66+
def test_cash_available(self, orch):
67+
o, _ = orch
68+
assert o.cash_available == 1_000_000.0
69+
70+
def test_portfolio_summary(self, orch):
71+
o, _ = orch
72+
summary = o.portfolio_summary()
73+
assert summary["n_positions"] == 0
74+
assert "cash_available" in summary

0 commit comments

Comments
 (0)