Skip to main content

sandbox_quant/
order_manager.rs

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    // Spot sell: close against existing long inventory.
253    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        // If fee is charged in base on sell, approximate its quote impact at fill price.
265        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, &quote_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    // Futures: realized_pnl is exchange-provided per fill.
310    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, &quote_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    /// Create a new order manager bound to a single symbol/market context.
387    ///
388    /// The instance keeps in-memory position, cached balances, and an embedded
389    /// `RiskModule` that enforces pre-trade checks and global rate budget.
390    ///
391    /// # Caution
392    /// This manager is stateful (`last_price`, balances, active orders). Reuse
393    /// the same instance for a symbol stream instead of recreating per tick.
394    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    /// Return current in-memory position snapshot.
474    ///
475    /// Values reflect fills processed by this process. They are not a full
476    /// exchange reconciliation snapshot.
477    pub fn position(&self) -> &Position {
478        &self.position
479    }
480
481    /// Return latest cached free balances.
482    ///
483    /// Cache is updated by `refresh_balances`. Missing assets should be treated
484    /// as zero balance.
485    pub fn balances(&self) -> &HashMap<String, f64> {
486        &self.balances
487    }
488
489    /// Update last price and recompute unrealized PnL.
490    ///
491    /// # Usage
492    /// Call on every market data tick before `submit_order`, so risk checks use
493    /// a valid `last_price`.
494    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    /// Return current global rate-budget snapshot from the risk module.
500    ///
501    /// Intended for UI display and observability.
502    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    /// Return whether a hypothetical fill would exceed symbol exposure limit.
638    ///
639    /// This is intended for validation and tests; it does not mutate state.
640    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    /// Fetch account balances from Binance and update internal state.
645    ///
646    /// Returns the map `asset -> free` for assets with non-zero total (spot) or
647    /// non-trivial wallet balance (futures).
648    ///
649    /// # Usage
650    /// Refresh before order submission cycles to reduce false "insufficient
651    /// balance" rejections from stale cache.
652    ///
653    /// # Caution
654    /// Network/API failures return `Err(_)` and leave previous cache untouched.
655    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    /// Fetch order history from exchange and format rows for UI display.
687    ///
688    /// This method combines order and trade endpoints, persists snapshots to
689    /// local sqlite, and emits a best-effort history view even if one endpoint
690    /// fails.
691    ///
692    /// # Caution
693    /// `trade_data_complete = false` means derived PnL may be partial.
694    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        // Include trades that did not match fetched order pages.
1066        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    /// Build an order intent, run risk checks, and submit to broker when approved.
1108    ///
1109    /// # Behavior
1110    /// - `Signal::Hold` returns `Ok(None)`.
1111    /// - For buy/sell signals, this method:
1112    ///   1. Builds `OrderIntent`.
1113    ///   2. Calls `RiskModule::evaluate_intent`.
1114    ///   3. Reserves one global rate token via `reserve_rate_budget`.
1115    ///   4. Submits market order to spot/futures broker endpoint.
1116    /// - Rejections are returned as `Ok(Some(OrderUpdate::Rejected { .. }))`
1117    ///   with structured `reason_code`.
1118    ///
1119    /// # Usage
1120    /// Recommended sequence:
1121    /// 1. `update_unrealized_pnl(last_price)`
1122    /// 2. `refresh_balances()` (periodic or before trading loop)
1123    /// 3. `submit_order(signal, source_tag)`
1124    ///
1125    /// # Caution
1126    /// - Spot sell requires base-asset balance (e.g. `ETH` for `ETHUSDT`).
1127    /// - If balances are stale, you may see "No position to sell" or
1128    ///   `"Insufficient <asset>"` even though exchange state changed recently.
1129    /// - This method returns transport/runtime errors as `Err(_)`; business
1130    ///   rejections are encoded in `OrderUpdate::Rejected`.
1131    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                // Refresh balances after fill
1264                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        // PendingSubmit -> Submitted
1406        let from = OrderStatus::PendingSubmit;
1407        let to = OrderStatus::Submitted;
1408        assert!(!from.is_terminal());
1409        assert!(!to.is_terminal());
1410
1411        // Submitted -> Filled
1412        let to = OrderStatus::Filled;
1413        assert!(to.is_terminal());
1414
1415        // Submitted -> Rejected
1416        let to = OrderStatus::Rejected;
1417        assert!(to.is_terminal());
1418
1419        // Submitted -> Cancelled
1420        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        // Buy 2.0 -> projected notional 200, but configured spot BTCUSDT limit is 150.
1526        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; // current notional 200 > limit 150
1541
1542        // Sell reduces exposure to 100; should be allowed.
1543        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}