Skip to content

Commit 636a2fb

Browse files
committed
release: v0.9.2 — risk hard caps, broker abstraction, execution guardrails
nanobook-risk 0.4.0: - max_order_value_cents / max_batch_value_cents checks (single + batch) - Config validation for new fields - Python bindings for new params nanobook-rebalancer 0.4.0: - Centralize risk: delegate to nanobook-risk instead of hand-rolled checks - BrokerGateway trait decouples execution from IbkrClient - enforce_max_orders_per_run() guardrail before order submission - as_connection_error() helper replaces repeated .map_err chains
1 parent 2157248 commit 636a2fb

File tree

20 files changed

+593
-305
lines changed

20 files changed

+593
-305
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Cargo.lock
1212
__pycache__/
1313
*.pyc
1414
*.so
15+
uv.lock
1516

1617
# OS
1718
.DS_Store

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.9.2] - 2026-02-12
11+
12+
### Added
13+
14+
- **Risk engine hard caps** (`nanobook-risk` 0.4.0):
15+
- `max_order_value_cents` — per-order notional limit (single-order and batch checks)
16+
- `max_batch_value_cents` — aggregate batch notional limit
17+
- Config validation for both fields
18+
- Python bindings: `RiskEngine(max_order_value_cents=..., max_batch_value_cents=...)`
19+
- **Rebalancer execution guardrail** (`nanobook-rebalancer` 0.4.0):
20+
- `enforce_max_orders_per_run()` — aborts rebalance when generated orders exceed `max_orders_per_run` config
21+
- Config validation: `max_orders_per_run` must be > 0
22+
23+
### Changed
24+
25+
- **Rebalancer risk centralization** (`nanobook-rebalancer` 0.4.0):
26+
- Replaced ~140 lines of hand-rolled risk checks with delegation to `nanobook-risk` crate
27+
- Re-exports `RiskReport`/`RiskCheck`/`RiskStatus` from shared risk crate
28+
- **Broker abstraction** (`nanobook-rebalancer` 0.4.0):
29+
- New `BrokerGateway` trait decouples execution from IBKR internals
30+
- `connect_ibkr()` returns `Box<dyn BrokerGateway>` instead of concrete `IbkrClient`
31+
- `as_connection_error()` helper replaces repeated `.map_err(...)` chains
32+
33+
### Fixed
34+
35+
- **README**: documented that `max_drawdown_pct` is validated at construction but not yet enforced at execution time
36+
1037
## [0.9.1] - 2026-02-11
1138

1239
### Fixed
@@ -317,7 +344,8 @@ Initial release of nanobook - a deterministic limit order book and matching engi
317344
- Fixed-point price representation (avoids floating-point errors)
318345
- Deterministic via monotonic timestamps (not system clock)
319346

320-
[Unreleased]: https://github.com/ricardofrantz/nanobook/compare/v0.9.1...HEAD
347+
[Unreleased]: https://github.com/ricardofrantz/nanobook/compare/v0.9.2...HEAD
348+
[0.9.2]: https://github.com/ricardofrantz/nanobook/compare/v0.9.1...v0.9.2
321349
[0.9.1]: https://github.com/ricardofrantz/nanobook/compare/v0.9.0...v0.9.1
322350
[0.9.0]: https://github.com/ricardofrantz/nanobook/compare/v0.8.0...v0.9.0
323351
[0.8.0]: https://github.com/ricardofrantz/nanobook/compare/v0.7.0...v0.8.0

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ resolver = "2"
44

55
[package]
66
name = "nanobook"
7-
version = "0.9.1"
7+
version = "0.9.2"
88
edition = "2024"
99
rust-version = "1.85"
1010
description = "Production-grade Rust execution infrastructure for automated trading: LOB engine, portfolio simulator, broker abstraction, risk engine"

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,11 @@ pub struct RiskConfig {
812812
}
813813
```
814814

815+
Notes:
816+
817+
- `max_drawdown_pct` is validated at engine construction and preserved in config,
818+
but not yet used in execution-time checks.
819+
815820
### Single Order Check
816821

817822
```rust

python/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nanobook-python"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
edition = "2024"
55
publish = false
66

python/nanobook.pyi

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,17 @@ class BinanceBroker:
2525
def quote(self, symbol: str) -> Dict[str, Any]: ...
2626

2727
class RiskEngine:
28-
def __init__(self, max_position_pct: float = 0.25, max_leverage: float = 1.5, max_drawdown_pct: float = 0.20, allow_short: bool = True, max_short_pct: float = 0.30, max_trade_usd: float = 100_000.0) -> None: ...
28+
def __init__(
29+
self,
30+
max_position_pct: float = 0.25,
31+
max_leverage: float = 1.5,
32+
max_drawdown_pct: float = 0.20,
33+
allow_short: bool = True,
34+
max_short_pct: float = 0.30,
35+
max_trade_usd: float = 100_000.0,
36+
max_order_value_cents: int = 10_000_000,
37+
max_batch_value_cents: int = 100_000_000,
38+
) -> None: ...
2939
def check_order(self, symbol: str, side: str, quantity: int, price_cents: int, equity_cents: int, positions: List[Tuple[str, int]]) -> List[Dict[str, Any]]: ...
3040
def check_batch(self, orders: List[Tuple[str, str, int, int]], equity_cents: int, positions: List[Tuple[str, int]], target_weights: List[Tuple[str, float]]) -> List[Dict[str, Any]]: ...
3141

python/src/risk.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use crate::types::parse_symbol;
1818
/// allow_short: Whether short selling is allowed
1919
/// max_short_pct: Max short exposure as fraction of equity
2020
/// max_trade_usd: Max single trade size in USD
21+
/// max_order_value_cents: Max single-order value in cents
22+
/// max_batch_value_cents: Max rebalance batch value in cents
2123
///
2224
/// Example::
2325
///
@@ -38,6 +40,8 @@ impl PyRiskEngine {
3840
allow_short=true,
3941
max_short_pct=0.30,
4042
max_trade_usd=100_000.0,
43+
max_order_value_cents=10_000_000,
44+
max_batch_value_cents=100_000_000,
4145
))]
4246
fn new(
4347
max_position_pct: f64,
@@ -46,6 +50,8 @@ impl PyRiskEngine {
4650
allow_short: bool,
4751
max_short_pct: f64,
4852
max_trade_usd: f64,
53+
max_order_value_cents: i64,
54+
max_batch_value_cents: i64,
4955
) -> Self {
5056
let config = RiskConfig {
5157
max_position_pct,
@@ -54,6 +60,8 @@ impl PyRiskEngine {
5460
allow_short,
5561
max_short_pct,
5662
max_trade_usd,
63+
max_order_value_cents,
64+
max_batch_value_cents,
5765
..RiskConfig::default()
5866
};
5967
Self {

python/tests/test_risk.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import nanobook
2+
3+
4+
def _checks_by_name(report):
5+
return {check["name"]: check for check in report}
6+
7+
8+
def test_risk_engine_accepts_new_cap_defaults():
9+
risk = nanobook.RiskEngine(
10+
max_order_value_cents=10_000,
11+
max_batch_value_cents=10_000,
12+
)
13+
report = risk.check_order("AAPL", "buy", 50, 200, 100_000_000, [])
14+
assert isinstance(report, list)
15+
assert len(report) > 0
16+
17+
18+
def test_risk_order_value_boundary():
19+
risk = nanobook.RiskEngine(
20+
max_order_value_cents=10_000,
21+
max_position_pct=1.0,
22+
max_batch_value_cents=100_000_000,
23+
)
24+
report = risk.check_order("AAPL", "buy", 50, 200, 100_000_000, [])
25+
checks = _checks_by_name(report)
26+
assert checks["Max order value"]["status"] == "PASS"
27+
28+
fail_report = risk.check_order("AAPL", "buy", 51, 200, 100_000_000, [])
29+
fail_checks = _checks_by_name(fail_report)
30+
assert fail_checks["Max order value"]["status"] == "FAIL"
31+
32+
33+
def test_risk_batch_report_includes_cap_checks():
34+
risk = nanobook.RiskEngine(
35+
max_batch_value_cents=10_000,
36+
max_order_value_cents=10_000,
37+
max_position_pct=1.0,
38+
)
39+
report = risk.check_batch(
40+
orders=[("AAPL", "buy", 100, 100), ("MSFT", "buy", 0, 0)],
41+
equity_cents=100_000_000,
42+
positions=[],
43+
target_weights=[("AAPL", 0.5), ("MSFT", 0.5)],
44+
)
45+
checks = _checks_by_name(report)
46+
assert "Max batch value" not in checks
47+
assert "Max order value" not in checks
48+
49+
fail_report = risk.check_batch(
50+
orders=[("AAPL", "buy", 30, 400), ("MSFT", "buy", 30, 400)],
51+
equity_cents=100_000_000,
52+
positions=[],
53+
target_weights=[("AAPL", 0.5), ("MSFT", 0.5)],
54+
)
55+
fail_checks = _checks_by_name(fail_report)
56+
assert fail_checks["Max batch value"]["status"] == "FAIL"

rebalancer/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nanobook-rebalancer"
3-
version = "0.3.1"
3+
version = "0.4.0"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Portfolio rebalancer bridging nanobook to Interactive Brokers"
@@ -20,6 +20,7 @@ path = "src/lib.rs"
2020
[dependencies]
2121
nanobook = { version = "0.9.1", path = "..", features = ["portfolio", "serde"] }
2222
nanobook-broker = { version = "0.3.1", path = "../broker", features = ["ibkr"] }
23+
nanobook-risk = { version = "0.4.0", path = "../risk" }
2324
thiserror = "2.0"
2425
serde = { version = "1", features = ["derive"] }
2526
serde_json = "1"

rebalancer/src/broker.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! Broker abstraction used by rebalancer execution.
2+
3+
use std::time::Duration;
4+
5+
use nanobook::Symbol;
6+
use nanobook_broker::ibkr::client::IbkrClient;
7+
use nanobook_broker::ibkr::orders;
8+
use nanobook_broker::{error::BrokerError, types::{Account, Position}, BrokerSide};
9+
10+
use crate::config::Config;
11+
use crate::error::{Error, Result};
12+
13+
pub type BrokerResult<T> = std::result::Result<T, BrokerError>;
14+
15+
pub fn as_connection_error<T>(result: BrokerResult<T>) -> Result<T> {
16+
result.map_err(|e| Error::Connection(e.to_string()))
17+
}
18+
19+
/// Minimal broker API needed by the rebalancer runtime.
20+
pub trait BrokerGateway {
21+
fn account_summary(&self) -> BrokerResult<Account>;
22+
fn positions(&self) -> BrokerResult<Vec<Position>>;
23+
fn prices(&self, symbols: &[Symbol]) -> BrokerResult<Vec<(Symbol, i64)>>;
24+
fn execute_limit_order(
25+
&self,
26+
symbol: Symbol,
27+
side: BrokerSide,
28+
shares: u64,
29+
limit_price_cents: i64,
30+
timeout: Duration,
31+
) -> BrokerResult<orders::OrderResult>;
32+
}
33+
34+
impl BrokerGateway for IbkrClient {
35+
fn account_summary(&self) -> BrokerResult<Account> {
36+
self.account_summary()
37+
}
38+
39+
fn positions(&self) -> BrokerResult<Vec<Position>> {
40+
self.positions()
41+
}
42+
43+
fn prices(&self, symbols: &[Symbol]) -> BrokerResult<Vec<(Symbol, i64)>> {
44+
self.prices(symbols)
45+
}
46+
47+
fn execute_limit_order(
48+
&self,
49+
symbol: Symbol,
50+
side: BrokerSide,
51+
shares: u64,
52+
limit_price_cents: i64,
53+
timeout: Duration,
54+
) -> BrokerResult<orders::OrderResult> {
55+
let shares = i64::try_from(shares)
56+
.map_err(|_| BrokerError::Order("share quantity exceeds i64::MAX".into()))?;
57+
58+
orders::execute_limit_order(
59+
self.inner(),
60+
symbol,
61+
side,
62+
shares,
63+
limit_price_cents,
64+
timeout,
65+
)
66+
}
67+
}
68+
69+
pub fn connect_ibkr(config: &Config) -> Result<Box<dyn BrokerGateway>> {
70+
IbkrClient::connect(
71+
&config.connection.host,
72+
config.connection.port,
73+
config.connection.client_id,
74+
)
75+
.map(|client| Box::new(client) as Box<dyn BrokerGateway>)
76+
.map_err(|e| Error::Connection(e.to_string()))
77+
}

0 commit comments

Comments
 (0)