Skip to content

Commit 2a36c7f

Browse files
strategy: implement risk engine and portfolio construction (#8)
* strategy: implement risk engine with position, notional, and drawdown limits Adds RiskEngine class with synchronous pre-trade risk checks and five concrete limit types: MaxPositionLimit, MaxNotionalLimit, MaxOrderSizeLimit, MaxDrawdownLimit, MaxOpenOrdersLimit. The engine subscribes to PositionEvent on the event bus for internal state tracking. Includes 26 tests covering all limit types, limit management, drawdown tracking, and event handling. * strategy: implement portfolio constructor for target weight rebalancing Adds PortfolioConstructor that converts target portfolio weights into concrete OrderRequests. Supports long-only and long-short portfolios, computes position deltas, orders sells before buys for risk reduction, and optionally validates orders against the RiskEngine. Includes 16 tests covering target quantity computation, delta calculation, order generation, and full rebalance flow with risk engine integration.
1 parent 57b26c9 commit 2a36c7f

6 files changed

Lines changed: 1604 additions & 0 deletions

File tree

‎src/sysls/strategy/__init__.py‎

Whitespace-only changes.

‎src/sysls/strategy/portfolio.py‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Portfolio construction: target weights to order instructions.
2+
3+
Converts target portfolio weights into concrete OrderRequests by
4+
computing the difference between desired positions and current
5+
holdings. Supports both long-only and long-short portfolios, and
6+
optionally integrates with the RiskEngine for pre-trade validation.
7+
8+
Example usage::
9+
10+
constructor = PortfolioConstructor(risk_engine=engine)
11+
orders = constructor.compute_rebalance_orders(
12+
targets=[TargetWeight(instrument=nvda, weight=0.10)],
13+
current_positions={nvda: Decimal("0")},
14+
portfolio_value=Decimal("100000"),
15+
prices={nvda: Decimal("150")},
16+
)
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from decimal import ROUND_DOWN, Decimal
22+
from typing import TYPE_CHECKING
23+
24+
import structlog
25+
from pydantic import BaseModel
26+
27+
from sysls.core.types import Instrument, OrderType, Side, generate_order_id
28+
29+
if TYPE_CHECKING:
30+
from sysls.core.types import OrderRequest
31+
from sysls.strategy.risk import RiskEngine
32+
33+
34+
class TargetWeight(BaseModel, frozen=True):
35+
"""Target portfolio weight for an instrument.
36+
37+
Attributes:
38+
instrument: The target instrument.
39+
weight: Target weight as a fraction of portfolio (e.g. 0.10 = 10%).
40+
Positive = long, negative = short, 0 = flat.
41+
"""
42+
43+
instrument: Instrument
44+
weight: float
45+
46+
47+
class PortfolioConstructor:
48+
"""Converts target portfolio weights into order instructions.
49+
50+
Given target weights, current positions, and portfolio value,
51+
computes the trades needed to rebalance. Supports both long-only
52+
and long-short portfolios.
53+
54+
Args:
55+
risk_engine: Optional risk engine for pre-trade checks.
56+
"""
57+
58+
def __init__(self, risk_engine: RiskEngine | None = None) -> None:
59+
self._risk_engine = risk_engine
60+
self._logger = structlog.get_logger(__name__)
61+
62+
def compute_rebalance_orders(
63+
self,
64+
targets: list[TargetWeight],
65+
current_positions: dict[Instrument, Decimal],
66+
portfolio_value: Decimal,
67+
prices: dict[Instrument, Decimal],
68+
order_type: OrderType = OrderType.MARKET,
69+
) -> list[OrderRequest]:
70+
"""Compute orders needed to rebalance to target weights.
71+
72+
For each target:
73+
1. Compute target quantity = (weight * portfolio_value) / price
74+
2. Compute delta = target_quantity - current_position
75+
3. If delta != 0, create an OrderRequest
76+
77+
Orders that close existing positions are generated before
78+
orders that open new ones (sells before buys for risk reduction).
79+
80+
If a risk_engine is provided, each generated order is checked
81+
against risk limits. Orders that violate limits are excluded
82+
and a warning is logged.
83+
84+
Args:
85+
targets: List of target weights.
86+
current_positions: Current position quantities by instrument.
87+
portfolio_value: Total portfolio value in base currency.
88+
prices: Current prices for each instrument.
89+
order_type: Order type for generated orders (default: MARKET).
90+
91+
Returns:
92+
List of OrderRequests to execute the rebalance.
93+
Sells come before buys.
94+
"""
95+
target_quantities = self.compute_target_quantities(targets, portfolio_value, prices)
96+
deltas = self.compute_deltas(target_quantities, current_positions)
97+
orders = self.deltas_to_orders(deltas, prices, order_type)
98+
99+
if self._risk_engine is not None:
100+
checked_orders: list[OrderRequest] = []
101+
for order in orders:
102+
price = prices.get(order.instrument)
103+
violations = self._risk_engine.check_order(order, current_price=price)
104+
if violations:
105+
self._logger.warning(
106+
"order_excluded_by_risk",
107+
order_id=order.order_id,
108+
instrument=str(order.instrument),
109+
violations=[v.rule_name for v in violations],
110+
)
111+
else:
112+
checked_orders.append(order)
113+
return checked_orders
114+
115+
return orders
116+
117+
def compute_target_quantities(
118+
self,
119+
targets: list[TargetWeight],
120+
portfolio_value: Decimal,
121+
prices: dict[Instrument, Decimal],
122+
) -> dict[Instrument, Decimal]:
123+
"""Compute target quantities from weights without generating orders.
124+
125+
Useful for inspection/display before executing.
126+
127+
Args:
128+
targets: Target weights.
129+
portfolio_value: Total portfolio value.
130+
prices: Current instrument prices.
131+
132+
Returns:
133+
Mapping from instrument to target quantity.
134+
"""
135+
result: dict[Instrument, Decimal] = {}
136+
137+
for target in targets:
138+
price = prices.get(target.instrument)
139+
if price is None or price == Decimal("0"):
140+
self._logger.warning(
141+
"target_skipped_no_price",
142+
instrument=str(target.instrument),
143+
)
144+
continue
145+
146+
weight_decimal = Decimal(str(target.weight))
147+
notional = weight_decimal * portfolio_value
148+
quantity = (notional / price).quantize(Decimal("1"), rounding=ROUND_DOWN)
149+
result[target.instrument] = quantity
150+
151+
return result
152+
153+
@staticmethod
154+
def compute_deltas(
155+
target_quantities: dict[Instrument, Decimal],
156+
current_positions: dict[Instrument, Decimal],
157+
) -> dict[Instrument, Decimal]:
158+
"""Compute position deltas (target - current) per instrument.
159+
160+
Also includes instruments in current_positions but not in
161+
targets (delta = -current_position, i.e. close the position).
162+
163+
Args:
164+
target_quantities: Target quantities per instrument.
165+
current_positions: Current quantities per instrument.
166+
167+
Returns:
168+
Delta per instrument (positive = need to buy, negative = need to sell).
169+
"""
170+
deltas: dict[Instrument, Decimal] = {}
171+
172+
# Compute delta for all target instruments
173+
all_instruments = set(target_quantities.keys()) | set(current_positions.keys())
174+
for instrument in all_instruments:
175+
target = target_quantities.get(instrument, Decimal("0"))
176+
current = current_positions.get(instrument, Decimal("0"))
177+
delta = target - current
178+
if delta != Decimal("0"):
179+
deltas[instrument] = delta
180+
181+
return deltas
182+
183+
@staticmethod
184+
def deltas_to_orders(
185+
deltas: dict[Instrument, Decimal],
186+
prices: dict[Instrument, Decimal],
187+
order_type: OrderType = OrderType.MARKET,
188+
) -> list[OrderRequest]:
189+
"""Convert position deltas to OrderRequests.
190+
191+
Skips zero deltas. Sells are ordered before buys.
192+
193+
Args:
194+
deltas: Position delta per instrument.
195+
prices: Current prices (used for LIMIT orders).
196+
order_type: Type for generated orders.
197+
198+
Returns:
199+
List of OrderRequests (sells first, then buys).
200+
"""
201+
from sysls.core.types import OrderRequest
202+
203+
sells: list[OrderRequest] = []
204+
buys: list[OrderRequest] = []
205+
206+
for instrument, delta in deltas.items():
207+
if delta == Decimal("0"):
208+
continue
209+
210+
side = Side.BUY if delta > Decimal("0") else Side.SELL
211+
quantity = abs(delta)
212+
price = prices.get(instrument) if order_type == OrderType.LIMIT else None
213+
214+
order = OrderRequest(
215+
order_id=generate_order_id(),
216+
instrument=instrument,
217+
side=side,
218+
order_type=order_type,
219+
quantity=quantity,
220+
price=price,
221+
)
222+
223+
if side == Side.SELL:
224+
sells.append(order)
225+
else:
226+
buys.append(order)
227+
228+
return sells + buys

0 commit comments

Comments
 (0)