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