Skip to main content

sandbox_quant/
risk_module.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use anyhow::Result;
6
7use crate::binance::rest::BinanceRestClient;
8use crate::model::order::OrderSide;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum MarketKind {
12    Spot,
13    Futures,
14}
15
16/// Stable taxonomy for order rejection reasons emitted by the risk path.
17///
18/// These codes are intended for machine consumption (UI badges, metrics tags,
19/// alert routing). Keep values stable once released.
20#[derive(Debug, Clone, Copy)]
21pub enum RejectionReasonCode {
22    RiskNoPriceData,
23    RiskNoSpotBaseBalance,
24    RiskQtyTooSmall,
25    RiskQtyBelowMin,
26    RiskQtyAboveMax,
27    RiskInsufficientQuoteBalance,
28    RiskInsufficientBaseBalance,
29    RiskStrategyCooldownActive,
30    RiskStrategyMaxActiveOrdersExceeded,
31    RiskSymbolExposureLimitExceeded,
32    RateGlobalBudgetExceeded,
33    RateEndpointBudgetExceeded,
34    BrokerSubmitFailed,
35    RiskUnknown,
36}
37
38impl RejectionReasonCode {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::RiskNoPriceData => "risk.no_price_data",
42            Self::RiskNoSpotBaseBalance => "risk.no_spot_base_balance",
43            Self::RiskQtyTooSmall => "risk.qty_too_small",
44            Self::RiskQtyBelowMin => "risk.qty_below_min",
45            Self::RiskQtyAboveMax => "risk.qty_above_max",
46            Self::RiskInsufficientQuoteBalance => "risk.insufficient_quote_balance",
47            Self::RiskInsufficientBaseBalance => "risk.insufficient_base_balance",
48            Self::RiskStrategyCooldownActive => "risk.strategy_cooldown_active",
49            Self::RiskStrategyMaxActiveOrdersExceeded => {
50                "risk.strategy_max_active_orders_exceeded"
51            }
52            Self::RiskSymbolExposureLimitExceeded => "risk.symbol_exposure_limit_exceeded",
53            Self::RateGlobalBudgetExceeded => "rate.global_budget_exceeded",
54            Self::RateEndpointBudgetExceeded => "rate.endpoint_budget_exceeded",
55            Self::BrokerSubmitFailed => "broker.submit_failed",
56            Self::RiskUnknown => "risk.unknown",
57        }
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ApiEndpointGroup {
63    Orders,
64    Account,
65    MarketData,
66}
67
68impl ApiEndpointGroup {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Orders => "orders",
72            Self::Account => "account",
73            Self::MarketData => "market_data",
74        }
75    }
76}
77
78#[derive(Debug, Clone, Copy)]
79pub struct EndpointRateLimits {
80    pub orders_per_minute: u32,
81    pub account_per_minute: u32,
82    pub market_data_per_minute: u32,
83}
84
85#[derive(Debug, Clone)]
86pub struct OrderIntent {
87    /// Globally unique ID for this intent.
88    pub intent_id: String,
89    /// Source strategy tag (e.g. `cfg`, `fst`, `mnl`).
90    pub source_tag: String,
91    /// Trading symbol (e.g. `BTCUSDT`).
92    pub symbol: String,
93    /// Spot/Futures market kind.
94    pub market: MarketKind,
95    /// Intended order side.
96    pub side: OrderSide,
97    /// Notional size basis in USDT.
98    pub order_amount_usdt: f64,
99    /// Last known mark/last trade price.
100    pub last_price: f64,
101    /// Millisecond timestamp when intent was created.
102    ///
103    /// This is informational and can be used for trace correlation and latency
104    /// analysis in logs.
105    pub created_at_ms: u64,
106}
107
108#[derive(Debug, Clone)]
109pub struct RiskDecision {
110    /// `true` if intent passed checks and can be submitted.
111    ///
112    /// When `false`, caller should surface `reason_code` and `reason` to users
113    /// and skip broker submission.
114    pub approved: bool,
115    /// Quantity after exchange/risk normalization.
116    ///
117    /// For spot: rounded down to step size.
118    /// For futures: rounded up to satisfy minimum tradable size/notional.
119    pub normalized_qty: f64,
120    /// Machine-readable reason code when rejected.
121    pub reason_code: Option<String>,
122    /// Human-readable rejection reason.
123    pub reason: Option<String>,
124}
125
126#[derive(Debug, Clone, Copy)]
127pub struct RateBudgetSnapshot {
128    /// Consumed request budget in current minute window.
129    pub used: u32,
130    /// Total budget limit in current minute window.
131    pub limit: u32,
132    /// Milliseconds until budget window reset.
133    pub reset_in_ms: u64,
134}
135
136pub struct RiskModule {
137    rest_client: Arc<BinanceRestClient>,
138    rate_budget_window_started_at: Instant,
139    rate_budget_used: u32,
140    rate_budget_limit_per_minute: u32,
141    endpoint_budget_used_orders: u32,
142    endpoint_budget_used_account: u32,
143    endpoint_budget_used_market_data: u32,
144    endpoint_budget_limit_orders_per_minute: u32,
145    endpoint_budget_limit_account_per_minute: u32,
146    endpoint_budget_limit_market_data_per_minute: u32,
147}
148
149impl RiskModule {
150    /// Build a risk module with a per-minute global rate budget.
151    ///
152    /// `global_rate_limit_per_minute` is clamped to at least `1` to prevent an
153    /// always-rejecting configuration.
154    pub fn new(
155        rest_client: Arc<BinanceRestClient>,
156        global_rate_limit_per_minute: u32,
157        endpoint_limits: EndpointRateLimits,
158    ) -> Self {
159        Self {
160            rest_client,
161            rate_budget_window_started_at: Instant::now(),
162            rate_budget_used: 0,
163            rate_budget_limit_per_minute: global_rate_limit_per_minute.max(1),
164            endpoint_budget_used_orders: 0,
165            endpoint_budget_used_account: 0,
166            endpoint_budget_used_market_data: 0,
167            endpoint_budget_limit_orders_per_minute: endpoint_limits.orders_per_minute.max(1),
168            endpoint_budget_limit_account_per_minute: endpoint_limits.account_per_minute.max(1),
169            endpoint_budget_limit_market_data_per_minute: endpoint_limits
170                .market_data_per_minute
171                .max(1),
172        }
173    }
174
175    /// Return current global rate-budget usage.
176    ///
177    /// Use this for UI/telemetry only. It does not reserve capacity.
178    pub fn rate_budget_snapshot(&self) -> RateBudgetSnapshot {
179        let elapsed = self.rate_budget_window_started_at.elapsed();
180        let reset = Duration::from_secs(60).saturating_sub(elapsed);
181        RateBudgetSnapshot {
182            used: self.rate_budget_used,
183            limit: self.rate_budget_limit_per_minute,
184            reset_in_ms: reset.as_millis() as u64,
185        }
186    }
187
188    /// Reserve one unit from the global rate budget.
189    ///
190    /// This method resets the rolling minute window when needed and then
191    /// consumes exactly one request token.
192    ///
193    /// Returns `false` when the current minute budget is exhausted.
194    ///
195    /// # Usage
196    /// Call this once per outbound broker request, after risk approval and
197    /// immediately before submission.
198    ///
199    /// # Caution
200    /// Do not call this speculatively and then skip submission; doing so
201    /// reduces usable throughput and can cause unnecessary rejections.
202    pub fn reserve_rate_budget(&mut self) -> bool {
203        self.roll_budget_window_if_needed();
204        if self.rate_budget_used >= self.rate_budget_limit_per_minute {
205            return false;
206        }
207        self.rate_budget_used += 1;
208        true
209    }
210
211    fn roll_budget_window_if_needed(&mut self) {
212        if self.rate_budget_window_started_at.elapsed() < Duration::from_secs(60) {
213            return;
214        }
215        self.rate_budget_window_started_at = Instant::now();
216        self.rate_budget_used = 0;
217        self.endpoint_budget_used_orders = 0;
218        self.endpoint_budget_used_account = 0;
219        self.endpoint_budget_used_market_data = 0;
220    }
221
222    pub fn reserve_endpoint_budget(&mut self, group: ApiEndpointGroup) -> bool {
223        self.roll_budget_window_if_needed();
224        match group {
225            ApiEndpointGroup::Orders => {
226                if self.endpoint_budget_used_orders >= self.endpoint_budget_limit_orders_per_minute
227                {
228                    return false;
229                }
230                self.endpoint_budget_used_orders += 1;
231            }
232            ApiEndpointGroup::Account => {
233                if self.endpoint_budget_used_account
234                    >= self.endpoint_budget_limit_account_per_minute
235                {
236                    return false;
237                }
238                self.endpoint_budget_used_account += 1;
239            }
240            ApiEndpointGroup::MarketData => {
241                if self.endpoint_budget_used_market_data
242                    >= self.endpoint_budget_limit_market_data_per_minute
243                {
244                    return false;
245                }
246                self.endpoint_budget_used_market_data += 1;
247            }
248        }
249        true
250    }
251
252    pub fn endpoint_budget_snapshot(&self, group: ApiEndpointGroup) -> RateBudgetSnapshot {
253        let elapsed = self.rate_budget_window_started_at.elapsed();
254        let reset = Duration::from_secs(60).saturating_sub(elapsed);
255        let (used, limit) = match group {
256            ApiEndpointGroup::Orders => (
257                self.endpoint_budget_used_orders,
258                self.endpoint_budget_limit_orders_per_minute,
259            ),
260            ApiEndpointGroup::Account => (
261                self.endpoint_budget_used_account,
262                self.endpoint_budget_limit_account_per_minute,
263            ),
264            ApiEndpointGroup::MarketData => (
265                self.endpoint_budget_used_market_data,
266                self.endpoint_budget_limit_market_data_per_minute,
267            ),
268        };
269        RateBudgetSnapshot {
270            used,
271            limit,
272            reset_in_ms: reset.as_millis() as u64,
273        }
274    }
275
276    /// Evaluate an order intent against risk rules and exchange filters.
277    ///
278    /// This performs:
279    /// - price availability validation,
280    /// - quantity derivation and normalization by market rules,
281    /// - min/max quantity validation,
282    /// - spot balance sufficiency checks.
283    ///
284    /// # Returns
285    /// - `Ok(RiskDecision { approved: true, .. })` when submission is allowed.
286    /// - `Ok(RiskDecision { approved: false, .. })` for expected business-rule
287    ///   rejection (insufficient balance, too-small qty, etc).
288    /// - `Err(_)` when exchange metadata fetch fails or other runtime errors occur.
289    ///
290    /// # Usage
291    /// Use as the first gate in an order pipeline:
292    /// 1. Build `OrderIntent`.
293    /// 2. Call `evaluate_intent`.
294    /// 3. If approved, call `reserve_rate_budget`.
295    /// 4. Submit order to broker.
296    ///
297    /// # Caution
298    /// - `balances` should be recently refreshed. Stale balances can produce
299    ///   false approvals/rejections.
300    /// - For spot sell, requested size is currently driven by available base
301    ///   balance and then normalized, not by `order_amount_usdt`.
302    /// - This function does not place orders or mutate state.
303    pub async fn evaluate_intent(
304        &self,
305        intent: &OrderIntent,
306        balances: &HashMap<String, f64>,
307    ) -> Result<RiskDecision> {
308        if intent.last_price <= 0.0 {
309            return Ok(RiskDecision {
310                approved: false,
311                normalized_qty: 0.0,
312                reason_code: Some(RejectionReasonCode::RiskNoPriceData.as_str().to_string()),
313                reason: Some("No price data yet".to_string()),
314            });
315        }
316
317        let raw_qty = match intent.side {
318            OrderSide::Buy => intent.order_amount_usdt / intent.last_price,
319            OrderSide::Sell => {
320                if intent.market == MarketKind::Spot {
321                    let (base_asset, _) = split_symbol_assets(&intent.symbol);
322                    let base_free = balances.get(base_asset.as_str()).copied().unwrap_or(0.0);
323                    if base_free <= f64::EPSILON {
324                        return Ok(RiskDecision {
325                            approved: false,
326                            normalized_qty: 0.0,
327                            reason_code: Some(
328                                RejectionReasonCode::RiskNoSpotBaseBalance
329                                    .as_str()
330                                    .to_string(),
331                            ),
332                            reason: Some(format!("No {} balance to sell", base_asset)),
333                        });
334                    }
335                    base_free
336                } else {
337                    intent.order_amount_usdt / intent.last_price
338                }
339            }
340        };
341
342        let rules = if intent.market == MarketKind::Futures {
343            self.rest_client
344                .get_futures_symbol_order_rules(&intent.symbol)
345                .await?
346        } else {
347            self.rest_client
348                .get_spot_symbol_order_rules(&intent.symbol)
349                .await?
350        };
351
352        let qty = if intent.market == MarketKind::Futures {
353            let mut required = rules.min_qty.max(raw_qty);
354            if let Some(min_notional) = rules.min_notional {
355                if min_notional > 0.0 && intent.last_price > 0.0 {
356                    required = required.max(min_notional / intent.last_price);
357                }
358            }
359            ceil_to_step(required, rules.step_size)
360        } else {
361            floor_to_step(raw_qty, rules.step_size)
362        };
363
364        if qty <= 0.0 {
365            return Ok(RiskDecision {
366                approved: false,
367                normalized_qty: 0.0,
368                reason_code: Some(RejectionReasonCode::RiskQtyTooSmall.as_str().to_string()),
369                reason: Some(format!(
370                    "Calculated qty too small after normalization (raw {:.8}, step {:.8}, minQty {:.8})",
371                    raw_qty, rules.step_size, rules.min_qty
372                )),
373            });
374        }
375        if qty < rules.min_qty {
376            return Ok(RiskDecision {
377                approved: false,
378                normalized_qty: 0.0,
379                reason_code: Some(RejectionReasonCode::RiskQtyBelowMin.as_str().to_string()),
380                reason: Some(format!(
381                    "Qty below minQty (qty {:.8} < min {:.8}, step {:.8})",
382                    qty, rules.min_qty, rules.step_size
383                )),
384            });
385        }
386        if rules.max_qty > 0.0 && qty > rules.max_qty {
387            return Ok(RiskDecision {
388                approved: false,
389                normalized_qty: 0.0,
390                reason_code: Some(RejectionReasonCode::RiskQtyAboveMax.as_str().to_string()),
391                reason: Some(format!(
392                    "Qty above maxQty (qty {:.8} > max {:.8})",
393                    qty, rules.max_qty
394                )),
395            });
396        }
397
398        if intent.market == MarketKind::Spot {
399            let (base_asset, quote_asset) = split_symbol_assets(&intent.symbol);
400            match intent.side {
401                OrderSide::Buy => {
402                    let quote_asset_name = if quote_asset.is_empty() {
403                        "USDT"
404                    } else {
405                        quote_asset.as_str()
406                    };
407                    let quote_free = balances.get(quote_asset_name).copied().unwrap_or(0.0);
408                    let order_value = qty * intent.last_price;
409                    if quote_free < order_value {
410                        return Ok(RiskDecision {
411                            approved: false,
412                            normalized_qty: 0.0,
413                            reason_code: Some(
414                                RejectionReasonCode::RiskInsufficientQuoteBalance
415                                    .as_str()
416                                    .to_string(),
417                            ),
418                            reason: Some(format!(
419                                "Insufficient {}: need {:.2}, have {:.2}",
420                                quote_asset_name, order_value, quote_free
421                            )),
422                        });
423                    }
424                }
425                OrderSide::Sell => {
426                    let base_free = balances.get(base_asset.as_str()).copied().unwrap_or(0.0);
427                    if base_free < qty {
428                        return Ok(RiskDecision {
429                            approved: false,
430                            normalized_qty: 0.0,
431                            reason_code: Some(
432                                RejectionReasonCode::RiskInsufficientBaseBalance
433                                    .as_str()
434                                    .to_string(),
435                            ),
436                            reason: Some(format!(
437                                "Insufficient {}: need {:.5}, have {:.5}",
438                                base_asset, qty, base_free
439                            )),
440                        });
441                    }
442                }
443            }
444        }
445
446        Ok(RiskDecision {
447            approved: true,
448            normalized_qty: qty,
449            reason_code: None,
450            reason: None,
451        })
452    }
453}
454
455fn floor_to_step(value: f64, step: f64) -> f64 {
456    if !value.is_finite() || !step.is_finite() || step <= 0.0 {
457        return 0.0;
458    }
459    let units = (value / step).floor();
460    let floored = units * step;
461    if floored < 0.0 { 0.0 } else { floored }
462}
463
464fn ceil_to_step(value: f64, step: f64) -> f64 {
465    if !value.is_finite() || !step.is_finite() || step <= 0.0 {
466        return 0.0;
467    }
468    let units = (value / step).ceil();
469    let ceiled = units * step;
470    if ceiled < 0.0 { 0.0 } else { ceiled }
471}
472
473fn split_symbol_assets(symbol: &str) -> (String, String) {
474    const QUOTE_SUFFIXES: [&str; 10] = [
475        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
476    ];
477    for q in QUOTE_SUFFIXES {
478        if let Some(base) = symbol.strip_suffix(q) {
479            if !base.is_empty() {
480                return (base.to_string(), q.to_string());
481            }
482        }
483    }
484    (symbol.to_string(), String::new())
485}