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 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 pub intent_id: String,
87 pub source_tag: String,
89 pub symbol: String,
91 pub market: MarketKind,
93 pub side: OrderSide,
95 pub order_amount_usdt: f64,
97 pub last_price: f64,
99 pub created_at_ms: u64,
104}
105
106#[derive(Debug, Clone)]
107pub struct RiskDecision {
108 pub approved: bool,
113 pub normalized_qty: f64,
118 pub reason_code: Option<String>,
120 pub reason: Option<String>,
122}
123
124#[derive(Debug, Clone, Copy)]
125pub struct RateBudgetSnapshot {
126 pub used: u32,
128 pub limit: u32,
130 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 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 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 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 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}