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#[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 pub intent_id: String,
61 pub source_tag: String,
63 pub symbol: String,
65 pub market: MarketKind,
67 pub side: OrderSide,
69 pub order_amount_usdt: f64,
71 pub last_price: f64,
73 pub created_at_ms: u64,
78}
79
80#[derive(Debug, Clone)]
81pub struct RiskDecision {
82 pub approved: bool,
87 pub normalized_qty: f64,
92 pub reason_code: Option<String>,
94 pub reason: Option<String>,
96}
97
98#[derive(Debug, Clone, Copy)]
99pub struct RateBudgetSnapshot {
100 pub used: u32,
102 pub limit: u32,
104 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 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 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 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 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}