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