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