1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Instant;
4
5use anyhow::Result;
6use chrono::TimeZone;
7
8use crate::binance::rest::BinanceRestClient;
9use crate::binance::types::{BinanceMyTrade, BinanceOrderResponse};
10use crate::config::RiskConfig;
11use crate::model::order::{Fill, Order, OrderSide, OrderStatus, OrderType};
12use crate::model::position::Position;
13use crate::model::signal::Signal;
14use crate::order_store;
15use crate::risk_module::{
16 ApiEndpointGroup, EndpointRateLimits, OrderIntent, RateBudgetSnapshot, RejectionReasonCode,
17 RiskModule,
18};
19
20pub use crate::risk_module::MarketKind;
21
22#[derive(Debug, Clone)]
23pub enum OrderUpdate {
24 Submitted {
25 intent_id: String,
26 client_order_id: String,
27 server_order_id: u64,
28 },
29 Filled {
30 intent_id: String,
31 client_order_id: String,
32 side: OrderSide,
33 fills: Vec<Fill>,
34 avg_price: f64,
35 },
36 Rejected {
37 intent_id: String,
38 client_order_id: String,
39 reason_code: String,
40 reason: String,
41 },
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct OrderHistoryStats {
46 pub trade_count: u32,
47 pub win_count: u32,
48 pub lose_count: u32,
49 pub realized_pnl: f64,
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct OrderHistorySnapshot {
54 pub rows: Vec<String>,
55 pub stats: OrderHistoryStats,
56 pub strategy_stats: HashMap<String, OrderHistoryStats>,
57 pub fills: Vec<OrderHistoryFill>,
58 pub open_qty: f64,
59 pub open_entry_price: f64,
60 pub estimated_total_pnl_usdt: Option<f64>,
61 pub trade_data_complete: bool,
62 pub fetched_at_ms: u64,
63 pub fetch_latency_ms: u64,
64 pub latest_event_ms: Option<u64>,
65}
66
67#[derive(Debug, Clone)]
68pub struct OrderHistoryFill {
69 pub timestamp_ms: u64,
70 pub side: OrderSide,
71 pub price: f64,
72}
73
74pub struct OrderManager {
75 rest_client: Arc<BinanceRestClient>,
76 active_orders: HashMap<String, Order>,
77 position: Position,
78 symbol: String,
79 market: MarketKind,
80 order_amount_usdt: f64,
81 balances: HashMap<String, f64>,
82 last_price: f64,
83 risk_module: RiskModule,
84 default_strategy_cooldown_ms: u64,
85 default_strategy_max_active_orders: u32,
86 strategy_limits_by_tag: HashMap<String, StrategyExecutionLimit>,
87 last_strategy_submit_ms: HashMap<String, u64>,
88 default_symbol_max_exposure_usdt: f64,
89 symbol_exposure_limit_by_key: HashMap<String, f64>,
90}
91
92#[derive(Debug, Clone, Copy)]
93struct StrategyExecutionLimit {
94 cooldown_ms: u64,
95 max_active_orders: u32,
96}
97
98fn normalize_market_label(market: MarketKind) -> &'static str {
99 match market {
100 MarketKind::Spot => "spot",
101 MarketKind::Futures => "futures",
102 }
103}
104
105fn symbol_limit_key(symbol: &str, market: MarketKind) -> String {
106 format!(
107 "{}:{}",
108 symbol.trim().to_ascii_uppercase(),
109 normalize_market_label(market)
110 )
111}
112
113fn storage_symbol(symbol: &str, market: MarketKind) -> String {
114 match market {
115 MarketKind::Spot => symbol.to_string(),
116 MarketKind::Futures => format!("{}#FUT", symbol),
117 }
118}
119
120fn display_qty_for_history(status: &str, orig_qty: f64, executed_qty: f64) -> f64 {
121 match status {
122 "FILLED" | "PARTIALLY_FILLED" => executed_qty,
123 _ => orig_qty,
124 }
125}
126
127fn format_history_time(timestamp_ms: u64) -> String {
128 chrono::Utc
129 .timestamp_millis_opt(timestamp_ms as i64)
130 .single()
131 .map(|dt| {
132 dt.with_timezone(&chrono::Local)
133 .format("%H:%M:%S")
134 .to_string()
135 })
136 .unwrap_or_else(|| "--:--:--".to_string())
137}
138
139fn format_order_history_row(
140 timestamp_ms: u64,
141 status: &str,
142 side: &str,
143 qty: f64,
144 avg_price: f64,
145 client_order_id: &str,
146) -> String {
147 format!(
148 "{} {:<10} {:<4} {:.5} @ {:.2} {}",
149 format_history_time(timestamp_ms),
150 status,
151 side,
152 qty,
153 avg_price,
154 client_order_id
155 )
156}
157
158fn source_label_from_client_order_id(client_order_id: &str) -> String {
159 if client_order_id.contains("-mnl-") {
160 "MANUAL".to_string()
161 } else if client_order_id.contains("-cfg-") {
162 "MA(Config)".to_string()
163 } else if client_order_id.contains("-fst-") {
164 "MA(Fast 5/20)".to_string()
165 } else if client_order_id.contains("-slw-") {
166 "MA(Slow 20/60)".to_string()
167 } else if let Some(source_tag) = parse_source_tag_from_client_order_id(client_order_id) {
168 source_tag.to_ascii_lowercase()
169 } else {
170 "UNKNOWN".to_string()
171 }
172}
173
174fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
175 let body = client_order_id.strip_prefix("sq-")?;
176 let (source_tag, _) = body.split_once('-')?;
177 if source_tag.is_empty() {
178 None
179 } else {
180 Some(source_tag)
181 }
182}
183
184fn format_trade_history_row(t: &BinanceMyTrade, source: &str) -> String {
185 let side = if t.is_buyer { "BUY" } else { "SELL" };
186 format_order_history_row(
187 t.time,
188 "FILLED",
189 side,
190 t.qty,
191 t.price,
192 &format!("order#{}#T{} [{}]", t.order_id, t.id, source),
193 )
194}
195
196fn split_symbol_assets(symbol: &str) -> (String, String) {
197 const QUOTE_SUFFIXES: [&str; 10] = [
198 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
199 ];
200 for q in QUOTE_SUFFIXES {
201 if let Some(base) = symbol.strip_suffix(q) {
202 if !base.is_empty() {
203 return (base.to_string(), q.to_string());
204 }
205 }
206 }
207 (symbol.to_string(), String::new())
208}
209
210#[derive(Clone, Copy, Default)]
211struct LongPos {
212 qty: f64,
213 cost_quote: f64,
214}
215
216fn apply_spot_trade_with_fee(
217 pos: &mut LongPos,
218 stats: &mut OrderHistoryStats,
219 t: &BinanceMyTrade,
220 base_asset: &str,
221 quote_asset: &str,
222) {
223 let qty = t.qty.max(0.0);
224 if qty <= f64::EPSILON {
225 return;
226 }
227 let fee_asset = t.commission_asset.as_str();
228 let fee_is_base = !base_asset.is_empty() && fee_asset.eq_ignore_ascii_case(base_asset);
229 let fee_is_quote = !quote_asset.is_empty() && fee_asset.eq_ignore_ascii_case(quote_asset);
230
231 if t.is_buyer {
232 let net_qty = (qty
233 - if fee_is_base {
234 t.commission.max(0.0)
235 } else {
236 0.0
237 })
238 .max(0.0);
239 if net_qty <= f64::EPSILON {
240 return;
241 }
242 let fee_quote = if fee_is_quote {
243 t.commission.max(0.0)
244 } else {
245 0.0
246 };
247 pos.qty += net_qty;
248 pos.cost_quote += qty * t.price + fee_quote;
249 return;
250 }
251
252 if pos.qty <= f64::EPSILON {
254 return;
255 }
256 let close_qty = qty.min(pos.qty);
257 if close_qty <= f64::EPSILON {
258 return;
259 }
260 let avg_cost = pos.cost_quote / pos.qty.max(f64::EPSILON);
261 let fee_quote_total = if fee_is_quote {
262 t.commission.max(0.0)
263 } else if fee_is_base {
264 t.commission.max(0.0) * t.price
266 } else {
267 0.0
268 };
269 let fee_quote = fee_quote_total * (close_qty / qty.max(f64::EPSILON));
270 let pnl_delta = (close_qty * t.price - fee_quote) - (avg_cost * close_qty);
271 if pnl_delta > 0.0 {
272 stats.win_count += 1;
273 stats.trade_count += 1;
274 } else if pnl_delta < 0.0 {
275 stats.lose_count += 1;
276 stats.trade_count += 1;
277 }
278 stats.realized_pnl += pnl_delta;
279
280 pos.qty -= close_qty;
281 pos.cost_quote -= avg_cost * close_qty;
282 if pos.qty <= f64::EPSILON {
283 pos.qty = 0.0;
284 pos.cost_quote = 0.0;
285 }
286}
287
288fn compute_trade_state(
289 mut trades: Vec<BinanceMyTrade>,
290 symbol: &str,
291) -> (OrderHistoryStats, LongPos) {
292 trades.sort_by_key(|t| (t.time, t.id));
293 let (base_asset, quote_asset) = split_symbol_assets(symbol);
294 let mut pos = LongPos::default();
295 let mut stats = OrderHistoryStats::default();
296 for t in trades {
297 apply_spot_trade_with_fee(&mut pos, &mut stats, &t, &base_asset, "e_asset);
298 }
299 (stats, pos)
300}
301
302fn compute_trade_stats_by_source(
303 mut trades: Vec<BinanceMyTrade>,
304 order_source_by_id: &HashMap<u64, String>,
305 symbol: &str,
306) -> HashMap<String, OrderHistoryStats> {
307 trades.sort_by_key(|t| (t.time, t.id));
308
309 if symbol.ends_with("#FUT") {
311 let mut stats_by_source: HashMap<String, OrderHistoryStats> = HashMap::new();
312 for t in trades {
313 let source = order_source_by_id
314 .get(&t.order_id)
315 .cloned()
316 .unwrap_or_else(|| "UNKNOWN".to_string());
317 let stats = stats_by_source.entry(source).or_default();
318 if t.realized_pnl > 0.0 {
319 stats.win_count += 1;
320 stats.trade_count += 1;
321 } else if t.realized_pnl < 0.0 {
322 stats.lose_count += 1;
323 stats.trade_count += 1;
324 }
325 stats.realized_pnl += t.realized_pnl;
326 }
327 return stats_by_source;
328 }
329
330 let (base_asset, quote_asset) = split_symbol_assets(symbol);
331 let mut pos_by_source: HashMap<String, LongPos> = HashMap::new();
332 let mut stats_by_source: HashMap<String, OrderHistoryStats> = HashMap::new();
333
334 for t in trades {
335 let source = order_source_by_id
336 .get(&t.order_id)
337 .cloned()
338 .unwrap_or_else(|| "UNKNOWN".to_string());
339 let pos = pos_by_source.entry(source.clone()).or_default();
340 let stats = stats_by_source.entry(source).or_default();
341 apply_spot_trade_with_fee(pos, stats, &t, &base_asset, "e_asset);
342 }
343
344 stats_by_source
345}
346
347fn to_persistable_stats_map(
348 strategy_stats: &HashMap<String, OrderHistoryStats>,
349) -> HashMap<String, order_store::StrategyScopedStats> {
350 strategy_stats
351 .iter()
352 .map(|(k, v)| {
353 (
354 k.clone(),
355 order_store::StrategyScopedStats {
356 trade_count: v.trade_count,
357 win_count: v.win_count,
358 lose_count: v.lose_count,
359 realized_pnl: v.realized_pnl,
360 },
361 )
362 })
363 .collect()
364}
365
366fn from_persisted_stats_map(
367 persisted: HashMap<String, order_store::StrategyScopedStats>,
368) -> HashMap<String, OrderHistoryStats> {
369 persisted
370 .into_iter()
371 .map(|(k, v)| {
372 (
373 k,
374 OrderHistoryStats {
375 trade_count: v.trade_count,
376 win_count: v.win_count,
377 lose_count: v.lose_count,
378 realized_pnl: v.realized_pnl,
379 },
380 )
381 })
382 .collect()
383}
384
385impl OrderManager {
386 pub fn new(
395 rest_client: Arc<BinanceRestClient>,
396 symbol: &str,
397 market: MarketKind,
398 order_amount_usdt: f64,
399 risk_config: &RiskConfig,
400 ) -> Self {
401 let mut strategy_limits_by_tag = HashMap::new();
402 let mut symbol_exposure_limit_by_key = HashMap::new();
403 let default_strategy_cooldown_ms = risk_config.default_strategy_cooldown_ms;
404 let default_strategy_max_active_orders =
405 risk_config.default_strategy_max_active_orders.max(1);
406 let default_symbol_max_exposure_usdt =
407 risk_config.default_symbol_max_exposure_usdt.max(0.0);
408 for profile in &risk_config.strategy_limits {
409 let source_tag = profile.source_tag.trim().to_ascii_lowercase();
410 if source_tag.is_empty() {
411 continue;
412 }
413 strategy_limits_by_tag.insert(
414 source_tag,
415 StrategyExecutionLimit {
416 cooldown_ms: profile.cooldown_ms.unwrap_or(default_strategy_cooldown_ms),
417 max_active_orders: profile
418 .max_active_orders
419 .unwrap_or(default_strategy_max_active_orders)
420 .max(1),
421 },
422 );
423 }
424 for limit in &risk_config.symbol_exposure_limits {
425 let symbol = limit.symbol.trim().to_ascii_uppercase();
426 if symbol.is_empty() {
427 continue;
428 }
429 let market = match limit
430 .market
431 .as_deref()
432 .unwrap_or("spot")
433 .trim()
434 .to_ascii_lowercase()
435 .as_str()
436 {
437 "spot" => MarketKind::Spot,
438 "futures" | "future" | "fut" => MarketKind::Futures,
439 _ => continue,
440 };
441 symbol_exposure_limit_by_key.insert(
442 symbol_limit_key(&symbol, market),
443 limit.max_exposure_usdt.max(0.0),
444 );
445 }
446 Self {
447 rest_client: rest_client.clone(),
448 active_orders: HashMap::new(),
449 position: Position::new(symbol.to_string()),
450 symbol: symbol.to_string(),
451 market,
452 order_amount_usdt,
453 balances: HashMap::new(),
454 last_price: 0.0,
455 risk_module: RiskModule::new(
456 rest_client.clone(),
457 risk_config.global_rate_limit_per_minute,
458 EndpointRateLimits {
459 orders_per_minute: risk_config.endpoint_rate_limits.orders_per_minute,
460 account_per_minute: risk_config.endpoint_rate_limits.account_per_minute,
461 market_data_per_minute: risk_config.endpoint_rate_limits.market_data_per_minute,
462 },
463 ),
464 default_strategy_cooldown_ms,
465 default_strategy_max_active_orders,
466 strategy_limits_by_tag,
467 last_strategy_submit_ms: HashMap::new(),
468 default_symbol_max_exposure_usdt,
469 symbol_exposure_limit_by_key,
470 }
471 }
472
473 pub fn position(&self) -> &Position {
478 &self.position
479 }
480
481 pub fn balances(&self) -> &HashMap<String, f64> {
486 &self.balances
487 }
488
489 pub fn update_unrealized_pnl(&mut self, current_price: f64) {
495 self.last_price = current_price;
496 self.position.update_unrealized_pnl(current_price);
497 }
498
499 pub fn rate_budget_snapshot(&self) -> RateBudgetSnapshot {
503 self.risk_module.rate_budget_snapshot()
504 }
505
506 pub fn orders_rate_budget_snapshot(&self) -> RateBudgetSnapshot {
507 self.risk_module
508 .endpoint_budget_snapshot(ApiEndpointGroup::Orders)
509 }
510
511 pub fn account_rate_budget_snapshot(&self) -> RateBudgetSnapshot {
512 self.risk_module
513 .endpoint_budget_snapshot(ApiEndpointGroup::Account)
514 }
515
516 pub fn market_data_rate_budget_snapshot(&self) -> RateBudgetSnapshot {
517 self.risk_module
518 .endpoint_budget_snapshot(ApiEndpointGroup::MarketData)
519 }
520
521 fn strategy_limits_for(&self, source_tag: &str) -> StrategyExecutionLimit {
522 self.strategy_limits_by_tag
523 .get(source_tag)
524 .copied()
525 .unwrap_or(StrategyExecutionLimit {
526 cooldown_ms: self.default_strategy_cooldown_ms,
527 max_active_orders: self.default_strategy_max_active_orders,
528 })
529 }
530
531 fn active_order_count_for_source(&self, source_tag: &str) -> u32 {
532 let prefix = format!("sq-{}-", source_tag);
533 self.active_orders
534 .values()
535 .filter(|o| !o.status.is_terminal() && o.client_order_id.starts_with(&prefix))
536 .count() as u32
537 }
538
539 fn evaluate_strategy_limits(
540 &self,
541 source_tag: &str,
542 created_at_ms: u64,
543 ) -> Option<(String, String)> {
544 let limits = self.strategy_limits_for(source_tag);
545 let active_count = self.active_order_count_for_source(source_tag);
546 if active_count >= limits.max_active_orders {
547 return Some((
548 RejectionReasonCode::RiskStrategyMaxActiveOrdersExceeded
549 .as_str()
550 .to_string(),
551 format!(
552 "Strategy '{}' active order limit exceeded (active {}, limit {})",
553 source_tag, active_count, limits.max_active_orders
554 ),
555 ));
556 }
557
558 if limits.cooldown_ms > 0 {
559 if let Some(last_submit_ms) = self.last_strategy_submit_ms.get(source_tag) {
560 let elapsed = created_at_ms.saturating_sub(*last_submit_ms);
561 if elapsed < limits.cooldown_ms {
562 let remaining = limits.cooldown_ms - elapsed;
563 return Some((
564 RejectionReasonCode::RiskStrategyCooldownActive
565 .as_str()
566 .to_string(),
567 format!(
568 "Strategy '{}' cooldown active ({}ms remaining)",
569 source_tag, remaining
570 ),
571 ));
572 }
573 }
574 }
575
576 None
577 }
578
579 fn mark_strategy_submit(&mut self, source_tag: &str, created_at_ms: u64) {
580 self.last_strategy_submit_ms
581 .insert(source_tag.to_string(), created_at_ms);
582 }
583
584 fn max_symbol_exposure_usdt(&self) -> f64 {
585 self.symbol_exposure_limit_by_key
586 .get(&symbol_limit_key(&self.symbol, self.market))
587 .copied()
588 .unwrap_or(self.default_symbol_max_exposure_usdt)
589 }
590
591 fn projected_notional_after_fill(&self, side: OrderSide, qty: f64) -> (f64, f64) {
592 let price = self.last_price.max(0.0);
593 if price <= f64::EPSILON {
594 return (0.0, 0.0);
595 }
596 let current_qty_signed = match self.position.side {
597 Some(OrderSide::Buy) => self.position.qty,
598 Some(OrderSide::Sell) => -self.position.qty,
599 None => 0.0,
600 };
601 let delta = match side {
602 OrderSide::Buy => qty,
603 OrderSide::Sell => -qty,
604 };
605 let projected_qty_signed = current_qty_signed + delta;
606 (
607 current_qty_signed.abs() * price,
608 projected_qty_signed.abs() * price,
609 )
610 }
611
612 fn evaluate_symbol_exposure_limit(
613 &self,
614 side: OrderSide,
615 qty: f64,
616 ) -> Option<(String, String)> {
617 let max_exposure = self.max_symbol_exposure_usdt();
618 if max_exposure <= f64::EPSILON {
619 return None;
620 }
621 let (current_notional, projected_notional) = self.projected_notional_after_fill(side, qty);
622 if projected_notional > max_exposure && projected_notional > current_notional + f64::EPSILON
623 {
624 return Some((
625 RejectionReasonCode::RiskSymbolExposureLimitExceeded
626 .as_str()
627 .to_string(),
628 format!(
629 "Symbol exposure limit exceeded for {} ({:?}): projected {:.2} USDT > limit {:.2} USDT",
630 self.symbol, self.market, projected_notional, max_exposure
631 ),
632 ));
633 }
634 None
635 }
636
637 pub fn would_exceed_symbol_exposure_limit(&self, side: OrderSide, qty: f64) -> bool {
641 self.evaluate_symbol_exposure_limit(side, qty).is_some()
642 }
643
644 pub async fn refresh_balances(&mut self) -> Result<HashMap<String, f64>> {
656 if !self
657 .risk_module
658 .reserve_endpoint_budget(ApiEndpointGroup::Account)
659 {
660 return Err(anyhow::anyhow!(
661 "Account endpoint budget exceeded; try again after reset"
662 ));
663 }
664 if self.market == MarketKind::Futures {
665 let account = self.rest_client.get_futures_account().await?;
666 self.balances.clear();
667 for a in &account.assets {
668 if a.wallet_balance.abs() > f64::EPSILON {
669 self.balances.insert(a.asset.clone(), a.available_balance);
670 }
671 }
672 return Ok(self.balances.clone());
673 }
674 let account = self.rest_client.get_account().await?;
675 self.balances.clear();
676 for b in &account.balances {
677 let total = b.free + b.locked;
678 if total > 0.0 {
679 self.balances.insert(b.asset.clone(), b.free);
680 }
681 }
682 tracing::info!(balances = ?self.balances, "Balances refreshed");
683 Ok(self.balances.clone())
684 }
685
686 pub async fn refresh_order_history(&mut self, limit: usize) -> Result<OrderHistorySnapshot> {
695 if !self
696 .risk_module
697 .reserve_endpoint_budget(ApiEndpointGroup::Orders)
698 {
699 return Err(anyhow::anyhow!(
700 "Orders endpoint budget exceeded; try again after reset"
701 ));
702 }
703 if self.market == MarketKind::Futures {
704 let fetch_started = Instant::now();
705 let fetched_at_ms = chrono::Utc::now().timestamp_millis() as u64;
706 let orders_result = self
707 .rest_client
708 .get_futures_all_orders(&self.symbol, limit)
709 .await;
710 let trades_result = self
711 .rest_client
712 .get_futures_my_trades_history(&self.symbol, limit.max(1))
713 .await;
714 let fetch_latency_ms = fetch_started.elapsed().as_millis() as u64;
715
716 if orders_result.is_err() && trades_result.is_err() {
717 let oe = orders_result.err().unwrap();
718 let te = trades_result.err().unwrap();
719 return Err(anyhow::anyhow!(
720 "futures order history fetch failed: allOrders={} | userTrades={}",
721 oe,
722 te
723 ));
724 }
725
726 let mut orders = orders_result.unwrap_or_default();
727 let trades = trades_result.unwrap_or_default();
728 orders.sort_by_key(|o| o.update_time.max(o.time));
729
730 let storage_key = storage_symbol(&self.symbol, self.market);
731 if let Err(e) = order_store::persist_order_snapshot(&storage_key, &orders, &trades) {
732 tracing::warn!(error = %e, "Failed to persist futures order snapshot to sqlite");
733 }
734
735 let mut order_source_by_id = HashMap::new();
736 for o in &orders {
737 order_source_by_id.insert(
738 o.order_id,
739 source_label_from_client_order_id(&o.client_order_id),
740 );
741 }
742 let mut trades_for_stats = trades.clone();
743 match order_store::load_persisted_trades(&storage_key) {
744 Ok(saved) if !saved.is_empty() => {
745 trades_for_stats = saved.iter().map(|r| r.trade.clone()).collect();
746 for row in saved {
747 order_source_by_id.entry(row.trade.order_id).or_insert(row.source);
748 }
749 }
750 Ok(_) => {}
751 Err(e) => {
752 tracing::warn!(
753 error = %e,
754 "Failed to load persisted futures trades; using API trades"
755 );
756 }
757 }
758
759 let mut history = Vec::new();
760 let mut fills = Vec::new();
761 for t in &trades {
762 let side = if t.is_buyer { "BUY" } else { "SELL" };
763 let source = order_source_by_id
764 .get(&t.order_id)
765 .cloned()
766 .unwrap_or_else(|| "UNKNOWN".to_string());
767 fills.push(OrderHistoryFill {
768 timestamp_ms: t.time,
769 side: if t.is_buyer {
770 OrderSide::Buy
771 } else {
772 OrderSide::Sell
773 },
774 price: t.price,
775 });
776 history.push(format_order_history_row(
777 t.time,
778 "FILLED",
779 side,
780 t.qty,
781 t.price,
782 &format!("order#{}#T{} [{}]", t.order_id, t.id, source),
783 ));
784 }
785 for o in &orders {
786 if o.executed_qty <= 0.0 {
787 history.push(format_order_history_row(
788 o.update_time.max(o.time),
789 &o.status,
790 &o.side,
791 display_qty_for_history(&o.status, o.orig_qty, o.executed_qty),
792 if o.executed_qty > 0.0 {
793 o.cummulative_quote_qty / o.executed_qty
794 } else {
795 o.price
796 },
797 &o.client_order_id,
798 ));
799 }
800 }
801
802 let mut stats = OrderHistoryStats::default();
803 for t in &trades {
804 if t.realized_pnl > 0.0 {
805 stats.win_count += 1;
806 stats.trade_count += 1;
807 } else if t.realized_pnl < 0.0 {
808 stats.lose_count += 1;
809 stats.trade_count += 1;
810 }
811 stats.realized_pnl += t.realized_pnl;
812 }
813 let estimated_total_pnl_usdt = Some(stats.realized_pnl);
814 let latest_order_event = orders.iter().map(|o| o.update_time.max(o.time)).max();
815 let latest_trade_event = trades.iter().map(|t| t.time).max();
816 let mut strategy_stats =
817 compute_trade_stats_by_source(trades_for_stats, &order_source_by_id, &storage_key);
818 let persisted_stats = to_persistable_stats_map(&strategy_stats);
819 if let Err(e) = order_store::persist_strategy_symbol_stats(&storage_key, &persisted_stats)
820 {
821 tracing::warn!(error = %e, "Failed to persist strategy stats (futures)");
822 }
823 if strategy_stats.is_empty() {
824 match order_store::load_strategy_symbol_stats(&storage_key) {
825 Ok(persisted) => {
826 strategy_stats = from_persisted_stats_map(persisted);
827 }
828 Err(e) => {
829 tracing::warn!(
830 error = %e,
831 "Failed to load persisted strategy stats (futures)"
832 );
833 }
834 }
835 }
836 return Ok(OrderHistorySnapshot {
837 rows: history,
838 stats,
839 strategy_stats,
840 fills,
841 open_qty: 0.0,
842 open_entry_price: 0.0,
843 estimated_total_pnl_usdt,
844 trade_data_complete: true,
845 fetched_at_ms,
846 fetch_latency_ms,
847 latest_event_ms: latest_order_event.max(latest_trade_event),
848 });
849 }
850
851 let fetch_started = Instant::now();
852 let fetched_at_ms = chrono::Utc::now().timestamp_millis() as u64;
853 let orders_result = self.rest_client.get_all_orders(&self.symbol, limit).await;
854 let storage_key = storage_symbol(&self.symbol, self.market);
855 let last_trade_id = order_store::load_last_trade_id(&storage_key).ok().flatten();
856 let persisted_trade_count = order_store::load_trade_count(&storage_key).unwrap_or(0);
857 let need_backfill = persisted_trade_count < limit;
858 let trades_result = match (need_backfill, last_trade_id) {
859 (true, _) => {
860 self.rest_client
861 .get_my_trades_history(&self.symbol, limit.max(1))
862 .await
863 }
864 (false, Some(last_id)) => {
865 self.rest_client
866 .get_my_trades_since(&self.symbol, last_id.saturating_add(1), 10)
867 .await
868 }
869 (false, None) => {
870 self.rest_client
871 .get_my_trades_history(&self.symbol, limit.max(1))
872 .await
873 }
874 };
875 let fetch_latency_ms = fetch_started.elapsed().as_millis() as u64;
876 let trade_data_complete = trades_result.is_ok();
877
878 if orders_result.is_err() && trades_result.is_err() {
879 let oe = orders_result.err().unwrap();
880 let te = trades_result.err().unwrap();
881 return Err(anyhow::anyhow!(
882 "order history fetch failed: allOrders={} | myTrades={}",
883 oe,
884 te
885 ));
886 }
887
888 let mut orders = match orders_result {
889 Ok(v) => v,
890 Err(e) => {
891 tracing::warn!(error = %e, "Failed to fetch allOrders; falling back to trade-only history");
892 Vec::new()
893 }
894 };
895 let recent_trades = match trades_result {
896 Ok(t) => t,
897 Err(e) => {
898 tracing::warn!(error = %e, "Failed to fetch myTrades; falling back to order-only history");
899 Vec::new()
900 }
901 };
902 let mut trades = recent_trades.clone();
903 orders.sort_by_key(|o| o.update_time.max(o.time));
904
905 if let Err(e) = order_store::persist_order_snapshot(&storage_key, &orders, &recent_trades) {
906 tracing::warn!(error = %e, "Failed to persist order snapshot to sqlite");
907 }
908 let mut persisted_source_by_order_id: HashMap<u64, String> = HashMap::new();
909 match order_store::load_persisted_trades(&storage_key) {
910 Ok(saved) => {
911 if !saved.is_empty() {
912 trades = saved.iter().map(|r| r.trade.clone()).collect();
913 for row in saved {
914 persisted_source_by_order_id
915 .entry(row.trade.order_id)
916 .or_insert(row.source);
917 }
918 }
919 }
920 Err(e) => {
921 tracing::warn!(error = %e, "Failed to load persisted trades; using recent API trades");
922 }
923 }
924
925 let (stats, open_pos) = compute_trade_state(trades.clone(), &self.symbol);
926 let estimated_total_pnl_usdt = if self.last_price > 0.0 {
927 Some(stats.realized_pnl + (open_pos.qty * self.last_price - open_pos.cost_quote))
928 } else {
929 Some(stats.realized_pnl)
930 };
931 let latest_order_event = orders.iter().map(|o| o.update_time.max(o.time)).max();
932 let latest_trade_event = trades.iter().map(|t| t.time).max();
933 let latest_event_ms = latest_order_event.max(latest_trade_event);
934
935 let mut trades_by_order_id: HashMap<u64, Vec<BinanceMyTrade>> = HashMap::new();
936 for trade in &trades {
937 trades_by_order_id
938 .entry(trade.order_id)
939 .or_default()
940 .push(trade.clone());
941 }
942 for bucket in trades_by_order_id.values_mut() {
943 bucket.sort_by_key(|t| t.time);
944 }
945
946 let mut order_source_by_id = HashMap::new();
947 for o in &orders {
948 order_source_by_id.insert(
949 o.order_id,
950 source_label_from_client_order_id(&o.client_order_id),
951 );
952 }
953 for (order_id, source) in persisted_source_by_order_id {
954 order_source_by_id.entry(order_id).or_insert(source);
955 }
956 let mut strategy_stats =
957 compute_trade_stats_by_source(trades.clone(), &order_source_by_id, &self.symbol);
958 let persisted_stats = to_persistable_stats_map(&strategy_stats);
959 if let Err(e) = order_store::persist_strategy_symbol_stats(&storage_key, &persisted_stats) {
960 tracing::warn!(error = %e, "Failed to persist strategy+symbol scoped stats");
961 }
962 if strategy_stats.is_empty() {
963 match order_store::load_strategy_symbol_stats(&storage_key) {
964 Ok(persisted) => {
965 strategy_stats = from_persisted_stats_map(persisted);
966 }
967 Err(e) => {
968 tracing::warn!(error = %e, "Failed to load persisted strategy+symbol stats");
969 }
970 }
971 }
972
973 let mut history = Vec::new();
974 let mut fills = Vec::new();
975 let mut used_trade_ids = std::collections::HashSet::new();
976
977 if orders.is_empty() && !trades.is_empty() {
978 let mut sorted = trades;
979 sorted.sort_by_key(|t| (t.time, t.id));
980 history.extend(sorted.iter().map(|t| {
981 fills.push(OrderHistoryFill {
982 timestamp_ms: t.time,
983 side: if t.is_buyer {
984 OrderSide::Buy
985 } else {
986 OrderSide::Sell
987 },
988 price: t.price,
989 });
990 format_trade_history_row(
991 t,
992 order_source_by_id
993 .get(&t.order_id)
994 .map(String::as_str)
995 .unwrap_or("UNKNOWN"),
996 )
997 }));
998 return Ok(OrderHistorySnapshot {
999 rows: history,
1000 stats,
1001 strategy_stats,
1002 fills,
1003 open_qty: open_pos.qty,
1004 open_entry_price: if open_pos.qty > f64::EPSILON {
1005 open_pos.cost_quote / open_pos.qty
1006 } else {
1007 0.0
1008 },
1009 estimated_total_pnl_usdt,
1010 trade_data_complete,
1011 fetched_at_ms,
1012 fetch_latency_ms,
1013 latest_event_ms,
1014 });
1015 }
1016
1017 for o in orders {
1018 if o.executed_qty > 0.0 {
1019 if let Some(order_trades) = trades_by_order_id.get(&o.order_id) {
1020 for t in order_trades {
1021 used_trade_ids.insert(t.id);
1022 let side = if t.is_buyer { "BUY" } else { "SELL" };
1023 fills.push(OrderHistoryFill {
1024 timestamp_ms: t.time,
1025 side: if t.is_buyer {
1026 OrderSide::Buy
1027 } else {
1028 OrderSide::Sell
1029 },
1030 price: t.price,
1031 });
1032 history.push(format_order_history_row(
1033 t.time,
1034 "FILLED",
1035 side,
1036 t.qty,
1037 t.price,
1038 &format!(
1039 "{}#T{} [{}]",
1040 o.client_order_id,
1041 t.id,
1042 source_label_from_client_order_id(&o.client_order_id)
1043 ),
1044 ));
1045 }
1046 continue;
1047 }
1048 }
1049
1050 let avg_price = if o.executed_qty > 0.0 {
1051 o.cummulative_quote_qty / o.executed_qty
1052 } else {
1053 o.price
1054 };
1055 history.push(format_order_history_row(
1056 o.update_time.max(o.time),
1057 &o.status,
1058 &o.side,
1059 display_qty_for_history(&o.status, o.orig_qty, o.executed_qty),
1060 avg_price,
1061 &o.client_order_id,
1062 ));
1063 }
1064
1065 for bucket in trades_by_order_id.values() {
1067 for t in bucket {
1068 if !used_trade_ids.contains(&t.id) {
1069 fills.push(OrderHistoryFill {
1070 timestamp_ms: t.time,
1071 side: if t.is_buyer {
1072 OrderSide::Buy
1073 } else {
1074 OrderSide::Sell
1075 },
1076 price: t.price,
1077 });
1078 history.push(format_trade_history_row(
1079 t,
1080 order_source_by_id
1081 .get(&t.order_id)
1082 .map(String::as_str)
1083 .unwrap_or("UNKNOWN"),
1084 ));
1085 }
1086 }
1087 }
1088 Ok(OrderHistorySnapshot {
1089 rows: history,
1090 stats,
1091 strategy_stats,
1092 fills,
1093 open_qty: open_pos.qty,
1094 open_entry_price: if open_pos.qty > f64::EPSILON {
1095 open_pos.cost_quote / open_pos.qty
1096 } else {
1097 0.0
1098 },
1099 estimated_total_pnl_usdt,
1100 trade_data_complete,
1101 fetched_at_ms,
1102 fetch_latency_ms,
1103 latest_event_ms,
1104 })
1105 }
1106
1107 pub async fn submit_order(
1132 &mut self,
1133 signal: Signal,
1134 source_tag: &str,
1135 ) -> Result<Option<OrderUpdate>> {
1136 let side = match &signal {
1137 Signal::Buy => OrderSide::Buy,
1138 Signal::Sell => OrderSide::Sell,
1139 Signal::Hold => return Ok(None),
1140 };
1141 let source_tag = source_tag.to_ascii_lowercase();
1142 let intent = OrderIntent {
1143 intent_id: format!("intent-{}", &uuid::Uuid::new_v4().to_string()[..8]),
1144 source_tag: source_tag.clone(),
1145 symbol: self.symbol.clone(),
1146 market: self.market,
1147 side,
1148 order_amount_usdt: self.order_amount_usdt,
1149 last_price: self.last_price,
1150 created_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1151 };
1152 if let Some((reason_code, reason)) =
1153 self.evaluate_strategy_limits(&intent.source_tag, intent.created_at_ms)
1154 {
1155 return Ok(Some(OrderUpdate::Rejected {
1156 intent_id: intent.intent_id.clone(),
1157 client_order_id: "n/a".to_string(),
1158 reason_code,
1159 reason,
1160 }));
1161 }
1162 let decision = self
1163 .risk_module
1164 .evaluate_intent(&intent, &self.balances)
1165 .await?;
1166 if !decision.approved {
1167 return Ok(Some(OrderUpdate::Rejected {
1168 intent_id: intent.intent_id.clone(),
1169 client_order_id: "n/a".to_string(),
1170 reason_code: decision
1171 .reason_code
1172 .unwrap_or_else(|| RejectionReasonCode::RiskUnknown.as_str().to_string()),
1173 reason: decision
1174 .reason
1175 .unwrap_or_else(|| "Rejected by RiskModule".to_string()),
1176 }));
1177 }
1178 if !self.risk_module.reserve_rate_budget() {
1179 return Ok(Some(OrderUpdate::Rejected {
1180 intent_id: intent.intent_id.clone(),
1181 client_order_id: "n/a".to_string(),
1182 reason_code: RejectionReasonCode::RateGlobalBudgetExceeded
1183 .as_str()
1184 .to_string(),
1185 reason: "Global rate budget exceeded; try again after reset".to_string(),
1186 }));
1187 }
1188 if !self
1189 .risk_module
1190 .reserve_endpoint_budget(ApiEndpointGroup::Orders)
1191 {
1192 return Ok(Some(OrderUpdate::Rejected {
1193 intent_id: intent.intent_id.clone(),
1194 client_order_id: "n/a".to_string(),
1195 reason_code: RejectionReasonCode::RateEndpointBudgetExceeded
1196 .as_str()
1197 .to_string(),
1198 reason: "Orders endpoint budget exceeded; try again after reset".to_string(),
1199 }));
1200 }
1201 let qty = decision.normalized_qty;
1202 if let Some((reason_code, reason)) = self.evaluate_symbol_exposure_limit(side, qty) {
1203 return Ok(Some(OrderUpdate::Rejected {
1204 intent_id: intent.intent_id.clone(),
1205 client_order_id: "n/a".to_string(),
1206 reason_code,
1207 reason,
1208 }));
1209 }
1210 self.mark_strategy_submit(&intent.source_tag, intent.created_at_ms);
1211
1212 let client_order_id = format!(
1213 "sq-{}-{}",
1214 intent.source_tag,
1215 &uuid::Uuid::new_v4().to_string()[..8]
1216 );
1217
1218 let order = Order {
1219 client_order_id: client_order_id.clone(),
1220 server_order_id: None,
1221 symbol: self.symbol.clone(),
1222 side,
1223 order_type: OrderType::Market,
1224 quantity: qty,
1225 price: None,
1226 status: OrderStatus::PendingSubmit,
1227 created_at: chrono::Utc::now(),
1228 updated_at: chrono::Utc::now(),
1229 fills: vec![],
1230 };
1231
1232 self.active_orders.insert(client_order_id.clone(), order);
1233
1234 tracing::info!(
1235 side = %side,
1236 qty,
1237 usdt_amount = intent.order_amount_usdt,
1238 price = intent.last_price,
1239 intent_id = %intent.intent_id,
1240 created_at_ms = intent.created_at_ms,
1241 "Submitting order"
1242 );
1243
1244 let submit_res = if self.market == MarketKind::Futures {
1245 self.rest_client
1246 .place_futures_market_order(&self.symbol, side, qty, &client_order_id)
1247 .await
1248 } else {
1249 self.rest_client
1250 .place_market_order(&self.symbol, side, qty, &client_order_id)
1251 .await
1252 };
1253
1254 match submit_res {
1255 Ok(response) => {
1256 let update = self.process_order_response(
1257 &intent.intent_id,
1258 &client_order_id,
1259 side,
1260 &response,
1261 );
1262
1263 if matches!(update, OrderUpdate::Filled { .. }) {
1265 if let Err(e) = self.refresh_balances().await {
1266 tracing::warn!(error = %e, "Failed to refresh balances after fill");
1267 }
1268 }
1269
1270 Ok(Some(update))
1271 }
1272 Err(e) => {
1273 tracing::error!(
1274 client_order_id,
1275 error = %e,
1276 "Order rejected"
1277 );
1278 if let Some(order) = self.active_orders.get_mut(&client_order_id) {
1279 order.status = OrderStatus::Rejected;
1280 order.updated_at = chrono::Utc::now();
1281 }
1282 Ok(Some(OrderUpdate::Rejected {
1283 intent_id: intent.intent_id.clone(),
1284 client_order_id,
1285 reason_code: RejectionReasonCode::BrokerSubmitFailed.as_str().to_string(),
1286 reason: e.to_string(),
1287 }))
1288 }
1289 }
1290 }
1291
1292 fn process_order_response(
1293 &mut self,
1294 intent_id: &str,
1295 client_order_id: &str,
1296 side: OrderSide,
1297 response: &BinanceOrderResponse,
1298 ) -> OrderUpdate {
1299 let fills: Vec<Fill> = response
1300 .fills
1301 .iter()
1302 .map(|f| Fill {
1303 price: f.price,
1304 qty: f.qty,
1305 commission: f.commission,
1306 commission_asset: f.commission_asset.clone(),
1307 })
1308 .collect();
1309
1310 let status = OrderStatus::from_binance_str(&response.status);
1311
1312 if let Some(order) = self.active_orders.get_mut(client_order_id) {
1313 order.server_order_id = Some(response.order_id);
1314 order.status = status;
1315 order.fills = fills.clone();
1316 order.updated_at = chrono::Utc::now();
1317 }
1318
1319 if status == OrderStatus::Filled || status == OrderStatus::PartiallyFilled {
1320 self.position.apply_fill(side, &fills);
1321
1322 let avg_price = if fills.is_empty() {
1323 0.0
1324 } else {
1325 let total_value: f64 = fills.iter().map(|f| f.price * f.qty).sum();
1326 let total_qty: f64 = fills.iter().map(|f| f.qty).sum();
1327 total_value / total_qty
1328 };
1329
1330 tracing::info!(
1331 client_order_id,
1332 order_id = response.order_id,
1333 side = %side,
1334 avg_price,
1335 filled_qty = response.executed_qty,
1336 "Order filled"
1337 );
1338
1339 OrderUpdate::Filled {
1340 intent_id: intent_id.to_string(),
1341 client_order_id: client_order_id.to_string(),
1342 side,
1343 fills,
1344 avg_price,
1345 }
1346 } else {
1347 OrderUpdate::Submitted {
1348 intent_id: intent_id.to_string(),
1349 client_order_id: client_order_id.to_string(),
1350 server_order_id: response.order_id,
1351 }
1352 }
1353 }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 use super::{
1359 compute_trade_stats_by_source, display_qty_for_history, split_symbol_assets, OrderManager,
1360 };
1361 use crate::binance::types::BinanceMyTrade;
1362 use crate::binance::rest::BinanceRestClient;
1363 use crate::config::{EndpointRateLimitConfig, RiskConfig, SymbolExposureLimitConfig};
1364 use crate::model::order::{Order, OrderSide, OrderStatus, OrderType};
1365 use std::sync::Arc;
1366
1367 fn build_test_order_manager() -> OrderManager {
1368 let rest = Arc::new(BinanceRestClient::new(
1369 "https://demo-api.binance.com",
1370 "https://demo-fapi.binance.com",
1371 "k",
1372 "s",
1373 "fk",
1374 "fs",
1375 5000,
1376 ));
1377 let risk = RiskConfig {
1378 global_rate_limit_per_minute: 600,
1379 default_strategy_cooldown_ms: 3_000,
1380 default_strategy_max_active_orders: 1,
1381 default_symbol_max_exposure_usdt: 200.0,
1382 strategy_limits: vec![],
1383 symbol_exposure_limits: vec![SymbolExposureLimitConfig {
1384 symbol: "BTCUSDT".to_string(),
1385 market: Some("spot".to_string()),
1386 max_exposure_usdt: 150.0,
1387 }],
1388 endpoint_rate_limits: EndpointRateLimitConfig {
1389 orders_per_minute: 240,
1390 account_per_minute: 180,
1391 market_data_per_minute: 360,
1392 },
1393 };
1394 OrderManager::new(
1395 rest,
1396 "BTCUSDT",
1397 crate::order_manager::MarketKind::Spot,
1398 10.0,
1399 &risk,
1400 )
1401 }
1402
1403 #[test]
1404 fn valid_state_transitions() {
1405 let from = OrderStatus::PendingSubmit;
1407 let to = OrderStatus::Submitted;
1408 assert!(!from.is_terminal());
1409 assert!(!to.is_terminal());
1410
1411 let to = OrderStatus::Filled;
1413 assert!(to.is_terminal());
1414
1415 let to = OrderStatus::Rejected;
1417 assert!(to.is_terminal());
1418
1419 let to = OrderStatus::Cancelled;
1421 assert!(to.is_terminal());
1422 }
1423
1424 #[test]
1425 fn from_binance_str_mapping() {
1426 assert_eq!(OrderStatus::from_binance_str("NEW"), OrderStatus::Submitted);
1427 assert_eq!(OrderStatus::from_binance_str("FILLED"), OrderStatus::Filled);
1428 assert_eq!(
1429 OrderStatus::from_binance_str("CANCELED"),
1430 OrderStatus::Cancelled
1431 );
1432 assert_eq!(
1433 OrderStatus::from_binance_str("REJECTED"),
1434 OrderStatus::Rejected
1435 );
1436 assert_eq!(
1437 OrderStatus::from_binance_str("EXPIRED"),
1438 OrderStatus::Expired
1439 );
1440 assert_eq!(
1441 OrderStatus::from_binance_str("PARTIALLY_FILLED"),
1442 OrderStatus::PartiallyFilled
1443 );
1444 }
1445
1446 #[test]
1447 fn order_history_uses_executed_qty_for_filled_states() {
1448 assert!((display_qty_for_history("FILLED", 1.0, 0.4) - 0.4).abs() < f64::EPSILON);
1449 assert!((display_qty_for_history("PARTIALLY_FILLED", 1.0, 0.4) - 0.4).abs() < f64::EPSILON);
1450 }
1451
1452 #[test]
1453 fn order_history_uses_orig_qty_for_non_filled_states() {
1454 assert!((display_qty_for_history("NEW", 1.0, 0.4) - 1.0).abs() < f64::EPSILON);
1455 assert!((display_qty_for_history("CANCELED", 1.0, 0.4) - 1.0).abs() < f64::EPSILON);
1456 assert!((display_qty_for_history("REJECTED", 1.0, 0.0) - 1.0).abs() < f64::EPSILON);
1457 }
1458
1459 #[test]
1460 fn split_symbol_assets_parses_known_quote_suffixes() {
1461 assert_eq!(
1462 split_symbol_assets("ETHUSDT"),
1463 ("ETH".to_string(), "USDT".to_string())
1464 );
1465 assert_eq!(
1466 split_symbol_assets("ETHBTC"),
1467 ("ETH".to_string(), "BTC".to_string())
1468 );
1469 }
1470
1471 #[test]
1472 fn split_symbol_assets_falls_back_when_quote_unknown() {
1473 assert_eq!(
1474 split_symbol_assets("FOOBAR"),
1475 ("FOOBAR".to_string(), String::new())
1476 );
1477 }
1478
1479 #[test]
1480 fn strategy_limit_rejects_when_active_orders_reach_limit() {
1481 let mut mgr = build_test_order_manager();
1482 let client_order_id = "sq-cfg-abcdef12".to_string();
1483 mgr.active_orders.insert(
1484 client_order_id.clone(),
1485 Order {
1486 client_order_id,
1487 server_order_id: None,
1488 symbol: "BTCUSDT".to_string(),
1489 side: OrderSide::Buy,
1490 order_type: OrderType::Market,
1491 quantity: 0.1,
1492 price: None,
1493 status: OrderStatus::Submitted,
1494 created_at: chrono::Utc::now(),
1495 updated_at: chrono::Utc::now(),
1496 fills: vec![],
1497 },
1498 );
1499
1500 let rejected = mgr
1501 .evaluate_strategy_limits("cfg", chrono::Utc::now().timestamp_millis() as u64)
1502 .expect("must be rejected");
1503 assert_eq!(
1504 rejected.0,
1505 "risk.strategy_max_active_orders_exceeded".to_string()
1506 );
1507 }
1508
1509 #[test]
1510 fn strategy_limit_rejects_during_cooldown_window() {
1511 let mut mgr = build_test_order_manager();
1512 let now = chrono::Utc::now().timestamp_millis() as u64;
1513 mgr.mark_strategy_submit("cfg", now);
1514
1515 let rejected = mgr
1516 .evaluate_strategy_limits("cfg", now + 500)
1517 .expect("must be rejected");
1518 assert_eq!(rejected.0, "risk.strategy_cooldown_active".to_string());
1519 }
1520
1521 #[test]
1522 fn symbol_exposure_limit_rejects_when_projected_notional_exceeds_limit() {
1523 let mut mgr = build_test_order_manager();
1524 mgr.last_price = 100.0;
1525 let rejected = mgr
1527 .evaluate_symbol_exposure_limit(OrderSide::Buy, 2.0)
1528 .expect("must be rejected");
1529 assert_eq!(
1530 rejected.0,
1531 "risk.symbol_exposure_limit_exceeded".to_string()
1532 );
1533 }
1534
1535 #[test]
1536 fn symbol_exposure_limit_allows_risk_reducing_order() {
1537 let mut mgr = build_test_order_manager();
1538 mgr.last_price = 100.0;
1539 mgr.position.side = Some(OrderSide::Buy);
1540 mgr.position.qty = 2.0; let rejected = mgr.evaluate_symbol_exposure_limit(OrderSide::Sell, 1.0);
1544 assert!(rejected.is_none());
1545 }
1546
1547 #[test]
1548 fn futures_trade_stats_by_source_use_realized_pnl() {
1549 let trades = vec![
1550 BinanceMyTrade {
1551 symbol: "XRPUSDT".to_string(),
1552 id: 1,
1553 order_id: 1001,
1554 price: 1.0,
1555 qty: 100.0,
1556 commission: 0.0,
1557 commission_asset: "USDT".to_string(),
1558 time: 1,
1559 is_buyer: false,
1560 is_maker: false,
1561 realized_pnl: 5.0,
1562 },
1563 BinanceMyTrade {
1564 symbol: "XRPUSDT".to_string(),
1565 id: 2,
1566 order_id: 1002,
1567 price: 1.0,
1568 qty: 100.0,
1569 commission: 0.0,
1570 commission_asset: "USDT".to_string(),
1571 time: 2,
1572 is_buyer: false,
1573 is_maker: false,
1574 realized_pnl: -2.5,
1575 },
1576 ];
1577 let mut source_by_order = std::collections::HashMap::new();
1578 source_by_order.insert(1001, "c20".to_string());
1579 source_by_order.insert(1002, "c20".to_string());
1580
1581 let stats = compute_trade_stats_by_source(trades, &source_by_order, "XRPUSDT#FUT");
1582 let c20 = stats.get("c20").expect("source tag must exist");
1583 assert_eq!(c20.trade_count, 2);
1584 assert_eq!(c20.win_count, 1);
1585 assert_eq!(c20.lose_count, 1);
1586 assert!((c20.realized_pnl - 2.5).abs() < f64::EPSILON);
1587 }
1588}