Skip to main content

sandbox_quant/ui/
ui_projection.rs

1use std::collections::HashMap;
2
3use super::AppState;
4
5#[derive(Debug, Clone, Default)]
6pub struct PortfolioSummary {
7    pub total_equity_usdt: Option<f64>,
8    pub total_realized_pnl_usdt: f64,
9    pub total_unrealized_pnl_usdt: f64,
10    pub ws_connected: bool,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct AssetEntry {
15    pub symbol: String,
16    pub is_futures: bool,
17    pub side: Option<String>,
18    pub last_price: Option<f64>,
19    pub position_qty: f64,
20    pub entry_price: Option<f64>,
21    pub realized_pnl_usdt: f64,
22    pub unrealized_pnl_usdt: f64,
23}
24
25#[derive(Debug, Clone, Default)]
26pub struct StrategyEntry {
27    pub strategy_id: String,
28    pub trade_count: u32,
29    pub win_count: u32,
30    pub lose_count: u32,
31    pub realized_pnl_usdt: f64,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct MatrixCell {
36    pub symbol: String,
37    pub strategy_id: String,
38    pub trade_count: u32,
39    pub realized_pnl_usdt: f64,
40}
41
42#[derive(Debug, Clone, Default)]
43pub struct FocusState {
44    pub symbol: Option<String>,
45    pub strategy_id: Option<String>,
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct UiProjection {
50    pub portfolio: PortfolioSummary,
51    pub assets: Vec<AssetEntry>,
52    pub strategies: Vec<StrategyEntry>,
53    pub matrix: Vec<MatrixCell>,
54    pub focus: FocusState,
55}
56
57impl UiProjection {
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    pub fn from_legacy(state: &AppState) -> Self {
63        let mut strategy_rows = Vec::new();
64        let mut matrix_rows = Vec::new();
65        for (strategy_id, stats) in &state.strategy_stats {
66            strategy_rows.push(StrategyEntry {
67                strategy_id: strategy_id.clone(),
68                trade_count: stats.trade_count,
69                win_count: stats.win_count,
70                lose_count: stats.lose_count,
71                realized_pnl_usdt: stats.realized_pnl,
72            });
73            matrix_rows.push(MatrixCell {
74                symbol: state.symbol.clone(),
75                strategy_id: strategy_id.clone(),
76                trade_count: stats.trade_count,
77                realized_pnl_usdt: stats.realized_pnl,
78            });
79        }
80        strategy_rows.sort_by(|a, b| a.strategy_id.cmp(&b.strategy_id));
81        matrix_rows.sort_by(|a, b| {
82            a.symbol
83                .cmp(&b.symbol)
84                .then_with(|| a.strategy_id.cmp(&b.strategy_id))
85        });
86        let mut asset_symbols: Vec<String> = state
87            .symbol_items
88            .iter()
89            .cloned()
90            .chain(state.strategy_item_symbols.iter().cloned())
91            .chain(state.balances.keys().cloned())
92            .filter(|s| !s.trim().is_empty())
93            .collect();
94        asset_symbols.sort();
95        asset_symbols.dedup();
96        if asset_symbols.is_empty() {
97            asset_symbols.push(state.symbol.clone());
98        }
99        let assets = asset_symbols
100            .into_iter()
101            .map(|symbol| {
102                let pnl = state.asset_pnl_by_symbol.get(&symbol);
103                let inferred_qty = state
104                    .balances
105                    .get(&symbol)
106                    .copied()
107                    .or_else(|| {
108                        let (base, _) = split_symbol_assets(&symbol);
109                        if base.is_empty() {
110                            None
111                        } else {
112                            state.balances.get(&base).copied()
113                        }
114                    })
115                    .unwrap_or(0.0);
116                AssetEntry {
117                    symbol: symbol.clone(),
118                    is_futures: pnl
119                        .map(|p| p.is_futures)
120                        .unwrap_or_else(|| symbol.ends_with(" (FUT)")),
121                    side: pnl.and_then(|p| p.side).map(|s| s.to_string()),
122                    last_price: if symbol == state.symbol {
123                        state.last_price()
124                    } else {
125                        None
126                    },
127                    position_qty: pnl.map(|p| p.position_qty).unwrap_or_else(|| {
128                        if symbol == state.symbol {
129                            state.position.qty
130                        } else {
131                            inferred_qty
132                        }
133                    }),
134                    entry_price: pnl.and_then(|p| {
135                        if p.entry_price > f64::EPSILON {
136                            Some(p.entry_price)
137                        } else {
138                            None
139                        }
140                    }),
141                    realized_pnl_usdt: pnl.map(|p| p.realized_pnl_usdt).unwrap_or_else(|| {
142                        if symbol == state.symbol {
143                            state.history_realized_pnl
144                        } else {
145                            0.0
146                        }
147                    }),
148                    unrealized_pnl_usdt: pnl.map(|p| p.unrealized_pnl_usdt).unwrap_or_else(|| {
149                        if symbol == state.symbol {
150                            state.position.unrealized_pnl
151                        } else {
152                            0.0
153                        }
154                    }),
155                }
156            })
157            .collect();
158
159        Self {
160            portfolio: PortfolioSummary {
161                total_equity_usdt: state.current_equity_usdt,
162                total_realized_pnl_usdt: state.history_realized_pnl,
163                total_unrealized_pnl_usdt: state.position.unrealized_pnl,
164                ws_connected: state.ws_connected,
165            },
166            assets,
167            strategies: strategy_rows,
168            matrix: matrix_rows,
169            focus: FocusState {
170                symbol: Some(state.symbol.clone()),
171                strategy_id: Some(state.strategy_label.clone()),
172            },
173        }
174    }
175
176    pub fn strategy_lookup(&self) -> HashMap<String, StrategyEntry> {
177        self.strategies
178            .iter()
179            .cloned()
180            .map(|s| (s.strategy_id.clone(), s))
181            .collect()
182    }
183}
184
185fn split_symbol_assets(symbol: &str) -> (String, String) {
186    const QUOTE_SUFFIXES: [&str; 10] = [
187        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
188    ];
189    for q in QUOTE_SUFFIXES {
190        if let Some(base) = symbol.strip_suffix(q) {
191            if !base.is_empty() {
192                return (base.to_string(), q.to_string());
193            }
194        }
195    }
196    (String::new(), String::new())
197}