nanobook-risk 0.5.0

Pre-trade risk checks for nanobook orders and target-weight rebalances
Documentation
//! Pre-trade risk engine for nanobook.
//!
//! Validates orders against configurable risk limits before execution.
//! Uses generic broker types so it can be called from Python or any broker adapter.

pub mod checks;
pub mod config;
pub mod error;
pub mod report;

pub use config::RiskConfig;
pub use error::RiskError;
pub use report::{RiskCheck, RiskReport, RiskStatus};

use nanobook::Symbol;
use nanobook_broker::{Account, BrokerSide};

/// Pre-trade risk engine.
#[derive(Debug, Clone)]
pub struct RiskEngine {
    config: RiskConfig,
}

impl RiskEngine {
    /// Create a new risk engine with the given config.
    ///
    /// Validation is performed once at construction — every
    /// subsequent `check_order` / `check_batch` call can treat the
    /// config as well-formed.
    ///
    /// # Errors
    ///
    /// Returns [`RiskError::InvalidConfig`] if any field fails
    /// [`RiskConfig::validate`] (NaN, out-of-range, or otherwise
    /// nonsensical values). Callers with a statically-valid config
    /// can use `.expect("config")`; callers that load configs from
    /// files or untrusted input should propagate the error to the
    /// user.
    pub fn new(config: RiskConfig) -> Result<Self, RiskError> {
        config.validate().map_err(RiskError::InvalidConfig)?;
        Ok(Self { config })
    }

    /// Access the current config.
    pub fn config(&self) -> &RiskConfig {
        &self.config
    }

    /// Check a single order against risk limits.
    ///
    /// A lightweight check for one order — validates position concentration
    /// and order size.
    pub fn check_order(
        &self,
        symbol: &Symbol,
        side: BrokerSide,
        quantity: u64,
        price_cents: i64,
        account: &Account,
        current_positions: &[(Symbol, i64)],
    ) -> RiskReport {
        let equity = account.equity_cents;
        let notional = quantity as i64 * price_cents;

        let mut checks = Vec::new();

        let max_order = self.config.max_order_value_cents;
        let order_status = if max_order > 0 && notional > max_order {
            RiskStatus::Fail
        } else {
            RiskStatus::Pass
        };
        checks.push(RiskCheck {
            name: "Max order value",
            status: order_status,
            detail: format!(
                "${:.0} {} ${:.0} max_order_value_cents",
                notional as f64 / 100.0,
                if order_status == RiskStatus::Pass {
                    "<="
                } else {
                    ">"
                },
                max_order as f64 / 100.0,
            ),
        });

        // Position concentration after this order
        let current_qty = current_positions
            .iter()
            .find(|(s, _)| s == symbol)
            .map(|(_, q)| *q)
            .unwrap_or(0);

        let delta = match side {
            BrokerSide::Buy => quantity as i64,
            BrokerSide::Sell => -(quantity as i64),
        };
        let post_qty = current_qty + delta;
        let post_value = post_qty.abs() * price_cents;
        let post_pct = if equity > 0 {
            post_value as f64 / equity as f64
        } else {
            0.0
        };

        let pos_status = if post_pct > self.config.max_position_pct {
            RiskStatus::Fail
        } else {
            RiskStatus::Pass
        };
        checks.push(RiskCheck {
            name: "Max position",
            status: pos_status,
            detail: format!(
                "{:.1}% ({}) {} {:.1}% limit",
                post_pct * 100.0,
                symbol.as_str(),
                if pos_status == RiskStatus::Pass {
                    "<="
                } else {
                    ">"
                },
                self.config.max_position_pct * 100.0,
            ),
        });

        // Order size check
        let max_cents = (self.config.max_trade_usd * 100.0) as i64;
        let order_size_status = if notional > max_cents {
            RiskStatus::Warn
        } else {
            RiskStatus::Pass
        };
        checks.push(RiskCheck {
            name: "Order size",
            status: order_size_status,
            detail: format!(
                "${:.2} {} ${:.2} max",
                notional as f64 / 100.0,
                if order_size_status == RiskStatus::Pass {
                    "<="
                } else {
                    ">"
                },
                self.config.max_trade_usd,
            ),
        });

        // Short check
        if side == BrokerSide::Sell && post_qty < 0 && !self.config.allow_short {
            checks.push(RiskCheck {
                name: "Short selling",
                status: RiskStatus::Fail,
                detail: "short selling not allowed".into(),
            });
        }

        RiskReport { checks }
    }

    /// Check a batch of orders (e.g., a full rebalance).
    ///
    /// Validates all risk limits including leverage, short exposure, and
    /// aggregate position limits.
    pub fn check_batch(
        &self,
        orders: &[(Symbol, BrokerSide, u64, i64)], // (symbol, side, qty, price_cents)
        account: &Account,
        current_positions: &[(Symbol, i64)], // (symbol, current_qty)
        target_weights: &[(Symbol, f64)],
    ) -> RiskReport {
        checks::check_batch(
            &self.config,
            orders,
            account,
            current_positions,
            target_weights,
        )
    }
}