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 .chain(state.portfolio_state.by_symbol.keys().cloned())
93 .filter(|s| !s.trim().is_empty())
94 .collect();
95 asset_symbols.sort();
96 asset_symbols.dedup();
97 if asset_symbols.is_empty() {
98 asset_symbols.push(state.symbol.clone());
99 }
100 let assets = asset_symbols
101 .into_iter()
102 .map(|symbol| {
103 let pnl = state.portfolio_state.by_symbol.get(&symbol);
104 let inferred_qty = state
105 .balances
106 .get(&symbol)
107 .copied()
108 .or_else(|| {
109 let (base, _) = split_symbol_assets(&symbol);
110 if base.is_empty() {
111 None
112 } else {
113 state.balances.get(&base).copied()
114 }
115 })
116 .unwrap_or(0.0);
117 AssetEntry {
118 symbol: symbol.clone(),
119 is_futures: pnl
120 .map(|p| p.is_futures)
121 .unwrap_or_else(|| symbol.ends_with(" (FUT)")),
122 side: pnl.and_then(|p| p.side).map(|s| s.to_string()),
123 last_price: if symbol == state.symbol {
124 state.last_price()
125 } else {
126 None
127 },
128 position_qty: pnl.map(|p| p.position_qty).unwrap_or_else(|| {
129 if symbol == state.symbol {
130 state.position.qty
131 } else {
132 inferred_qty
133 }
134 }),
135 entry_price: pnl.and_then(|p| {
136 if p.entry_price > f64::EPSILON {
137 Some(p.entry_price)
138 } else {
139 None
140 }
141 }),
142 realized_pnl_usdt: pnl.map(|p| p.realized_pnl_usdt).unwrap_or_else(|| {
143 if symbol == state.symbol {
144 state.history_realized_pnl
145 } else {
146 0.0
147 }
148 }),
149 unrealized_pnl_usdt: pnl.map(|p| p.unrealized_pnl_usdt).unwrap_or_else(|| {
150 if symbol == state.symbol {
151 state.position.unrealized_pnl
152 } else {
153 0.0
154 }
155 }),
156 }
157 })
158 .collect();
159
160 Self {
161 portfolio: PortfolioSummary {
162 total_equity_usdt: state.current_equity_usdt,
163 total_realized_pnl_usdt: state.portfolio_state.total_realized_pnl_usdt,
164 total_unrealized_pnl_usdt: state.portfolio_state.total_unrealized_pnl_usdt,
165 ws_connected: state.ws_connected,
166 },
167 assets,
168 strategies: strategy_rows,
169 matrix: matrix_rows,
170 focus: FocusState {
171 symbol: Some(state.symbol.clone()),
172 strategy_id: Some(state.strategy_label.clone()),
173 },
174 }
175 }
176
177 pub fn strategy_lookup(&self) -> HashMap<String, StrategyEntry> {
178 self.strategies
179 .iter()
180 .cloned()
181 .map(|s| (s.strategy_id.clone(), s))
182 .collect()
183 }
184}
185
186fn split_symbol_assets(symbol: &str) -> (String, String) {
187 const QUOTE_SUFFIXES: [&str; 10] = [
188 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
189 ];
190 for q in QUOTE_SUFFIXES {
191 if let Some(base) = symbol.strip_suffix(q) {
192 if !base.is_empty() {
193 return (base.to_string(), q.to_string());
194 }
195 }
196 }
197 (String::new(), String::new())
198}