1pub mod chart;
2pub mod dashboard;
3pub mod app_state_v2;
4
5use std::collections::HashMap;
6
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
11use ratatui::Frame;
12
13use crate::event::{AppEvent, WsConnectionStatus};
14use crate::model::candle::{Candle, CandleBuilder};
15use crate::model::order::{Fill, OrderSide};
16use crate::model::position::Position;
17use crate::model::signal::Signal;
18use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
19use crate::order_store;
20use crate::risk_module::RateBudgetSnapshot;
21
22use app_state_v2::AppStateV2;
23use chart::{FillMarker, PriceChart};
24use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
25
26const MAX_LOG_MESSAGES: usize = 200;
27const MAX_FILL_MARKERS: usize = 200;
28
29pub struct AppState {
30 pub symbol: String,
31 pub strategy_label: String,
32 pub candles: Vec<Candle>,
33 pub current_candle: Option<CandleBuilder>,
34 pub candle_interval_ms: u64,
35 pub timeframe: String,
36 pub price_history_len: usize,
37 pub position: Position,
38 pub last_signal: Option<Signal>,
39 pub last_order: Option<OrderUpdate>,
40 pub open_order_history: Vec<String>,
41 pub filled_order_history: Vec<String>,
42 pub fast_sma: Option<f64>,
43 pub slow_sma: Option<f64>,
44 pub ws_connected: bool,
45 pub paused: bool,
46 pub tick_count: u64,
47 pub log_messages: Vec<String>,
48 pub balances: HashMap<String, f64>,
49 pub initial_equity_usdt: Option<f64>,
50 pub current_equity_usdt: Option<f64>,
51 pub history_estimated_total_pnl_usdt: Option<f64>,
52 pub fill_markers: Vec<FillMarker>,
53 pub history_trade_count: u32,
54 pub history_win_count: u32,
55 pub history_lose_count: u32,
56 pub history_realized_pnl: f64,
57 pub strategy_stats: HashMap<String, OrderHistoryStats>,
58 pub history_fills: Vec<OrderHistoryFill>,
59 pub last_price_update_ms: Option<u64>,
60 pub last_price_event_ms: Option<u64>,
61 pub last_price_latency_ms: Option<u64>,
62 pub last_order_history_update_ms: Option<u64>,
63 pub last_order_history_event_ms: Option<u64>,
64 pub last_order_history_latency_ms: Option<u64>,
65 pub trade_stats_reset_warned: bool,
66 pub symbol_selector_open: bool,
67 pub symbol_selector_index: usize,
68 pub symbol_items: Vec<String>,
69 pub strategy_selector_open: bool,
70 pub strategy_selector_index: usize,
71 pub strategy_items: Vec<String>,
72 pub strategy_item_symbols: Vec<String>,
73 pub strategy_item_active: Vec<bool>,
74 pub strategy_item_created_at_ms: Vec<i64>,
75 pub strategy_item_total_running_ms: Vec<u64>,
76 pub account_popup_open: bool,
77 pub history_popup_open: bool,
78 pub focus_popup_open: bool,
79 pub strategy_editor_open: bool,
80 pub strategy_editor_index: usize,
81 pub strategy_editor_field: usize,
82 pub strategy_editor_symbol_index: usize,
83 pub strategy_editor_fast: usize,
84 pub strategy_editor_slow: usize,
85 pub strategy_editor_cooldown: u64,
86 pub v2_grid_symbol_index: usize,
87 pub v2_grid_strategy_index: usize,
88 pub v2_grid_select_on_panel: bool,
89 pub history_rows: Vec<String>,
90 pub history_bucket: order_store::HistoryBucket,
91 pub last_applied_fee: String,
92 pub v2_grid_open: bool,
93 pub v2_state: AppStateV2,
94 pub rate_budget_global: RateBudgetSnapshot,
95 pub rate_budget_orders: RateBudgetSnapshot,
96 pub rate_budget_account: RateBudgetSnapshot,
97 pub rate_budget_market_data: RateBudgetSnapshot,
98}
99
100impl AppState {
101 pub fn new(
102 symbol: &str,
103 strategy_label: &str,
104 price_history_len: usize,
105 candle_interval_ms: u64,
106 timeframe: &str,
107 ) -> Self {
108 Self {
109 symbol: symbol.to_string(),
110 strategy_label: strategy_label.to_string(),
111 candles: Vec::with_capacity(price_history_len),
112 current_candle: None,
113 candle_interval_ms,
114 timeframe: timeframe.to_string(),
115 price_history_len,
116 position: Position::new(symbol.to_string()),
117 last_signal: None,
118 last_order: None,
119 open_order_history: Vec::new(),
120 filled_order_history: Vec::new(),
121 fast_sma: None,
122 slow_sma: None,
123 ws_connected: false,
124 paused: false,
125 tick_count: 0,
126 log_messages: Vec::new(),
127 balances: HashMap::new(),
128 initial_equity_usdt: None,
129 current_equity_usdt: None,
130 history_estimated_total_pnl_usdt: None,
131 fill_markers: Vec::new(),
132 history_trade_count: 0,
133 history_win_count: 0,
134 history_lose_count: 0,
135 history_realized_pnl: 0.0,
136 strategy_stats: HashMap::new(),
137 history_fills: Vec::new(),
138 last_price_update_ms: None,
139 last_price_event_ms: None,
140 last_price_latency_ms: None,
141 last_order_history_update_ms: None,
142 last_order_history_event_ms: None,
143 last_order_history_latency_ms: None,
144 trade_stats_reset_warned: false,
145 symbol_selector_open: false,
146 symbol_selector_index: 0,
147 symbol_items: Vec::new(),
148 strategy_selector_open: false,
149 strategy_selector_index: 0,
150 strategy_items: vec![
151 "MA(Config)".to_string(),
152 "MA(Fast 5/20)".to_string(),
153 "MA(Slow 20/60)".to_string(),
154 ],
155 strategy_item_symbols: vec![
156 symbol.to_ascii_uppercase(),
157 symbol.to_ascii_uppercase(),
158 symbol.to_ascii_uppercase(),
159 ],
160 strategy_item_active: vec![false, false, false],
161 strategy_item_created_at_ms: vec![0, 0, 0],
162 strategy_item_total_running_ms: vec![0, 0, 0],
163 account_popup_open: false,
164 history_popup_open: false,
165 focus_popup_open: false,
166 strategy_editor_open: false,
167 strategy_editor_index: 0,
168 strategy_editor_field: 0,
169 strategy_editor_symbol_index: 0,
170 strategy_editor_fast: 5,
171 strategy_editor_slow: 20,
172 strategy_editor_cooldown: 1,
173 v2_grid_symbol_index: 0,
174 v2_grid_strategy_index: 0,
175 v2_grid_select_on_panel: true,
176 history_rows: Vec::new(),
177 history_bucket: order_store::HistoryBucket::Day,
178 last_applied_fee: "---".to_string(),
179 v2_grid_open: false,
180 v2_state: AppStateV2::new(),
181 rate_budget_global: RateBudgetSnapshot {
182 used: 0,
183 limit: 0,
184 reset_in_ms: 0,
185 },
186 rate_budget_orders: RateBudgetSnapshot {
187 used: 0,
188 limit: 0,
189 reset_in_ms: 0,
190 },
191 rate_budget_account: RateBudgetSnapshot {
192 used: 0,
193 limit: 0,
194 reset_in_ms: 0,
195 },
196 rate_budget_market_data: RateBudgetSnapshot {
197 used: 0,
198 limit: 0,
199 reset_in_ms: 0,
200 },
201 }
202 }
203
204 pub fn last_price(&self) -> Option<f64> {
206 self.current_candle
207 .as_ref()
208 .map(|cb| cb.close)
209 .or_else(|| self.candles.last().map(|c| c.close))
210 }
211
212 pub fn push_log(&mut self, msg: String) {
213 self.log_messages.push(msg);
214 if self.log_messages.len() > MAX_LOG_MESSAGES {
215 self.log_messages.remove(0);
216 }
217 }
218
219 pub fn refresh_history_rows(&mut self) {
220 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
221 Ok(rows) => {
222 use std::collections::{BTreeMap, BTreeSet};
223
224 let mut date_set: BTreeSet<String> = BTreeSet::new();
225 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
226 for row in rows {
227 date_set.insert(row.date.clone());
228 ticker_map
229 .entry(row.symbol.clone())
230 .or_default()
231 .insert(row.date, row.realized_return_pct);
232 }
233
234 let mut dates: Vec<String> = date_set.into_iter().collect();
236 dates.sort();
237 const MAX_DATE_COLS: usize = 6;
238 if dates.len() > MAX_DATE_COLS {
239 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
240 }
241
242 let mut lines = Vec::new();
243 if dates.is_empty() {
244 lines.push("Ticker (no daily realized roi data)".to_string());
245 self.history_rows = lines;
246 return;
247 }
248
249 let mut header = format!("{:<14}", "Ticker");
250 for d in &dates {
251 header.push_str(&format!(" {:>10}", d));
252 }
253 lines.push(header);
254
255 for (ticker, by_date) in ticker_map {
256 let mut line = format!("{:<14}", ticker);
257 for d in &dates {
258 let cell = by_date
259 .get(d)
260 .map(|v| format!("{:.2}%", v))
261 .unwrap_or_else(|| "-".to_string());
262 line.push_str(&format!(" {:>10}", cell));
263 }
264 lines.push(line);
265 }
266 self.history_rows = lines;
267 }
268 Err(e) => {
269 self.history_rows = vec![
270 "Ticker Date RealizedROI RealizedPnL".to_string(),
271 format!("(failed to load history: {})", e),
272 ];
273 }
274 }
275 }
276
277 fn refresh_equity_usdt(&mut self) {
278 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
279 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
280 let mark_price = self
281 .last_price()
282 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
283 if let Some(price) = mark_price {
284 let total = usdt + btc * price;
285 self.current_equity_usdt = Some(total);
286 self.recompute_initial_equity_from_history();
287 }
288 }
289
290 fn recompute_initial_equity_from_history(&mut self) {
291 if let Some(current) = self.current_equity_usdt {
292 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
293 self.initial_equity_usdt = Some(current - total_pnl);
294 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
295 self.initial_equity_usdt = Some(current);
296 }
297 }
298 }
299
300 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
301 if let Some((idx, _)) = self
302 .candles
303 .iter()
304 .enumerate()
305 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
306 {
307 return Some(idx);
308 }
309 if let Some(cb) = &self.current_candle {
310 if cb.contains(timestamp_ms) {
311 return Some(self.candles.len());
312 }
313 }
314 if let Some((idx, _)) = self
317 .candles
318 .iter()
319 .enumerate()
320 .rev()
321 .find(|(_, c)| c.open_time <= timestamp_ms)
322 {
323 return Some(idx);
324 }
325 None
326 }
327
328 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
329 self.fill_markers.clear();
330 for fill in fills {
331 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
332 self.fill_markers.push(FillMarker {
333 candle_index,
334 price: fill.price,
335 side: fill.side,
336 });
337 }
338 }
339 if self.fill_markers.len() > MAX_FILL_MARKERS {
340 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
341 self.fill_markers.drain(..excess);
342 }
343 }
344
345 pub fn apply(&mut self, event: AppEvent) {
346 let prev_focus = self.v2_state.focus.clone();
347 match event {
348 AppEvent::MarketTick(tick) => {
349 self.tick_count += 1;
350 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
351 self.last_price_update_ms = Some(now_ms);
352 self.last_price_event_ms = Some(tick.timestamp_ms);
353 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
354
355 let should_new = match &self.current_candle {
357 Some(cb) => !cb.contains(tick.timestamp_ms),
358 None => true,
359 };
360 if should_new {
361 if let Some(cb) = self.current_candle.take() {
362 self.candles.push(cb.finish());
363 if self.candles.len() > self.price_history_len {
364 self.candles.remove(0);
365 self.fill_markers.retain_mut(|m| {
367 if m.candle_index == 0 {
368 false
369 } else {
370 m.candle_index -= 1;
371 true
372 }
373 });
374 }
375 }
376 self.current_candle = Some(CandleBuilder::new(
377 tick.price,
378 tick.timestamp_ms,
379 self.candle_interval_ms,
380 ));
381 } else if let Some(cb) = self.current_candle.as_mut() {
382 cb.update(tick.price);
383 } else {
384 self.current_candle = Some(CandleBuilder::new(
386 tick.price,
387 tick.timestamp_ms,
388 self.candle_interval_ms,
389 ));
390 self.push_log("[WARN] Recovered missing current candle state".to_string());
391 }
392
393 self.position.update_unrealized_pnl(tick.price);
394 self.refresh_equity_usdt();
395 }
396 AppEvent::StrategySignal(ref signal) => {
397 self.last_signal = Some(signal.clone());
398 match signal {
399 Signal::Buy { .. } => {
400 self.push_log("Signal: BUY".to_string());
401 }
402 Signal::Sell { .. } => {
403 self.push_log("Signal: SELL".to_string());
404 }
405 Signal::Hold => {}
406 }
407 }
408 AppEvent::StrategyState { fast_sma, slow_sma } => {
409 self.fast_sma = fast_sma;
410 self.slow_sma = slow_sma;
411 }
412 AppEvent::OrderUpdate(ref update) => {
413 match update {
414 OrderUpdate::Filled {
415 intent_id,
416 client_order_id,
417 side,
418 fills,
419 avg_price,
420 } => {
421 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
422 self.last_applied_fee = summary;
423 }
424 self.position.apply_fill(*side, fills);
425 self.refresh_equity_usdt();
426 let candle_index = if self.current_candle.is_some() {
427 self.candles.len()
428 } else {
429 self.candles.len().saturating_sub(1)
430 };
431 self.fill_markers.push(FillMarker {
432 candle_index,
433 price: *avg_price,
434 side: *side,
435 });
436 if self.fill_markers.len() > MAX_FILL_MARKERS {
437 self.fill_markers.remove(0);
438 }
439 self.push_log(format!(
440 "FILLED {} {} ({}) @ {:.2}",
441 side, client_order_id, intent_id, avg_price
442 ));
443 }
444 OrderUpdate::Submitted {
445 intent_id,
446 client_order_id,
447 server_order_id,
448 } => {
449 self.refresh_equity_usdt();
450 self.push_log(format!(
451 "Submitted {} (id: {}, {})",
452 client_order_id, server_order_id, intent_id
453 ));
454 }
455 OrderUpdate::Rejected {
456 intent_id,
457 client_order_id,
458 reason_code,
459 reason,
460 } => {
461 self.push_log(format!(
462 "[ERR] Rejected {} ({}) [{}]: {}",
463 client_order_id, intent_id, reason_code, reason
464 ));
465 }
466 }
467 self.last_order = Some(update.clone());
468 }
469 AppEvent::WsStatus(ref status) => match status {
470 WsConnectionStatus::Connected => {
471 self.ws_connected = true;
472 self.push_log("WebSocket Connected".to_string());
473 }
474 WsConnectionStatus::Disconnected => {
475 self.ws_connected = false;
476 self.push_log("[WARN] WebSocket Disconnected".to_string());
477 }
478 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
479 self.ws_connected = false;
480 self.push_log(format!(
481 "[WARN] Reconnecting (attempt {}, wait {}ms)",
482 attempt, delay_ms
483 ));
484 }
485 },
486 AppEvent::HistoricalCandles {
487 candles,
488 interval_ms,
489 interval,
490 } => {
491 self.candles = candles;
492 if self.candles.len() > self.price_history_len {
493 let excess = self.candles.len() - self.price_history_len;
494 self.candles.drain(..excess);
495 }
496 self.candle_interval_ms = interval_ms;
497 self.timeframe = interval;
498 self.current_candle = None;
499 let fills = self.history_fills.clone();
500 self.rebuild_fill_markers_from_history(&fills);
501 self.push_log(format!(
502 "Switched to {} ({} candles)",
503 self.timeframe,
504 self.candles.len()
505 ));
506 }
507 AppEvent::BalanceUpdate(balances) => {
508 self.balances = balances;
509 self.refresh_equity_usdt();
510 }
511 AppEvent::OrderHistoryUpdate(snapshot) => {
512 let mut open = Vec::new();
513 let mut filled = Vec::new();
514
515 for row in snapshot.rows {
516 let status = row.split_whitespace().nth(1).unwrap_or_default();
517 if status == "FILLED" {
518 filled.push(row);
519 } else {
520 open.push(row);
521 }
522 }
523
524 if open.len() > MAX_LOG_MESSAGES {
525 let excess = open.len() - MAX_LOG_MESSAGES;
526 open.drain(..excess);
527 }
528 if filled.len() > MAX_LOG_MESSAGES {
529 let excess = filled.len() - MAX_LOG_MESSAGES;
530 filled.drain(..excess);
531 }
532
533 self.open_order_history = open;
534 self.filled_order_history = filled;
535 if snapshot.trade_data_complete {
536 let stats_looks_reset = snapshot.stats.trade_count == 0
537 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
538 if stats_looks_reset {
539 if !self.trade_stats_reset_warned {
540 self.push_log(
541 "[WARN] Ignored transient trade stats reset from order-history sync"
542 .to_string(),
543 );
544 self.trade_stats_reset_warned = true;
545 }
546 } else {
547 self.trade_stats_reset_warned = false;
548 self.history_trade_count = snapshot.stats.trade_count;
549 self.history_win_count = snapshot.stats.win_count;
550 self.history_lose_count = snapshot.stats.lose_count;
551 self.history_realized_pnl = snapshot.stats.realized_pnl;
552 self.strategy_stats = snapshot.strategy_stats;
553 if snapshot.open_qty > f64::EPSILON {
556 self.position.side = Some(OrderSide::Buy);
557 self.position.qty = snapshot.open_qty;
558 self.position.entry_price = snapshot.open_entry_price;
559 if let Some(px) = self.last_price() {
560 self.position.unrealized_pnl =
561 (px - snapshot.open_entry_price) * snapshot.open_qty;
562 }
563 } else {
564 self.position.side = None;
565 self.position.qty = 0.0;
566 self.position.entry_price = 0.0;
567 self.position.unrealized_pnl = 0.0;
568 }
569 }
570 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
571 self.history_fills = snapshot.fills.clone();
572 self.rebuild_fill_markers_from_history(&snapshot.fills);
573 }
574 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
575 self.recompute_initial_equity_from_history();
576 }
577 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
578 self.last_order_history_event_ms = snapshot.latest_event_ms;
579 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
580 self.refresh_history_rows();
581 }
582 AppEvent::RiskRateSnapshot {
583 global,
584 orders,
585 account,
586 market_data,
587 } => {
588 self.rate_budget_global = global;
589 self.rate_budget_orders = orders;
590 self.rate_budget_account = account;
591 self.rate_budget_market_data = market_data;
592 }
593 AppEvent::LogMessage(msg) => {
594 self.push_log(msg);
595 }
596 AppEvent::Error(msg) => {
597 self.push_log(format!("[ERR] {}", msg));
598 }
599 }
600 let mut next = AppStateV2::from_legacy(self);
601 if prev_focus.symbol.is_some() {
602 next.focus.symbol = prev_focus.symbol;
603 }
604 if prev_focus.strategy_id.is_some() {
605 next.focus.strategy_id = prev_focus.strategy_id;
606 }
607 self.v2_state = next;
608 }
609}
610
611pub fn render(frame: &mut Frame, state: &AppState) {
612 let outer = Layout::default()
613 .direction(Direction::Vertical)
614 .constraints([
615 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
622 .split(frame.area());
623
624 frame.render_widget(
626 StatusBar {
627 symbol: &state.symbol,
628 strategy_label: &state.strategy_label,
629 ws_connected: state.ws_connected,
630 paused: state.paused,
631 timeframe: &state.timeframe,
632 last_price_update_ms: state.last_price_update_ms,
633 last_price_latency_ms: state.last_price_latency_ms,
634 last_order_history_update_ms: state.last_order_history_update_ms,
635 last_order_history_latency_ms: state.last_order_history_latency_ms,
636 },
637 outer[0],
638 );
639
640 let main_area = Layout::default()
642 .direction(Direction::Horizontal)
643 .constraints([Constraint::Min(40), Constraint::Length(24)])
644 .split(outer[1]);
645
646 let current_price = state.last_price();
648 frame.render_widget(
649 PriceChart::new(&state.candles, &state.symbol)
650 .current_candle(state.current_candle.as_ref())
651 .fill_markers(&state.fill_markers)
652 .fast_sma(state.fast_sma)
653 .slow_sma(state.slow_sma),
654 main_area[0],
655 );
656
657 frame.render_widget(
659 PositionPanel::new(
660 &state.position,
661 current_price,
662 &state.balances,
663 state.initial_equity_usdt,
664 state.current_equity_usdt,
665 state.history_trade_count,
666 state.history_realized_pnl,
667 &state.last_applied_fee,
668 ),
669 main_area[1],
670 );
671
672 frame.render_widget(
674 OrderLogPanel::new(
675 &state.last_signal,
676 &state.last_order,
677 state.fast_sma,
678 state.slow_sma,
679 state.history_trade_count,
680 state.history_win_count,
681 state.history_lose_count,
682 state.history_realized_pnl,
683 ),
684 outer[2],
685 );
686
687 frame.render_widget(
689 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
690 outer[3],
691 );
692
693 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
695
696 frame.render_widget(KeybindBar, outer[5]);
698
699 if state.symbol_selector_open {
700 render_selector_popup(
701 frame,
702 " Select Symbol ",
703 &state.symbol_items,
704 state.symbol_selector_index,
705 None,
706 None,
707 None,
708 );
709 } else if state.strategy_selector_open {
710 let selected_strategy_symbol = state
711 .strategy_item_symbols
712 .get(state.strategy_selector_index)
713 .map(String::as_str)
714 .unwrap_or(state.symbol.as_str());
715 render_selector_popup(
716 frame,
717 " Select Strategy ",
718 &state.strategy_items,
719 state.strategy_selector_index,
720 Some(&state.strategy_stats),
721 Some(OrderHistoryStats {
722 trade_count: state.history_trade_count,
723 win_count: state.history_win_count,
724 lose_count: state.history_lose_count,
725 realized_pnl: state.history_realized_pnl,
726 }),
727 Some(selected_strategy_symbol),
728 );
729 } else if state.account_popup_open {
730 render_account_popup(frame, &state.balances);
731 } else if state.history_popup_open {
732 render_history_popup(frame, &state.history_rows, state.history_bucket);
733 } else if state.focus_popup_open {
734 render_focus_popup(frame, state);
735 } else if state.strategy_editor_open {
736 render_strategy_editor_popup(frame, state);
737 } else if state.v2_grid_open {
738 render_v2_grid_popup(frame, state);
739 }
740}
741
742fn render_focus_popup(frame: &mut Frame, state: &AppState) {
743 let area = frame.area();
744 let popup = Rect {
745 x: area.x + 1,
746 y: area.y + 1,
747 width: area.width.saturating_sub(2).max(70),
748 height: area.height.saturating_sub(2).max(22),
749 };
750 frame.render_widget(Clear, popup);
751 let block = Block::default()
752 .title(" Focus View (V2 Drill-down) ")
753 .borders(Borders::ALL)
754 .border_style(Style::default().fg(Color::Green));
755 let inner = block.inner(popup);
756 frame.render_widget(block, popup);
757
758 let rows = Layout::default()
759 .direction(Direction::Vertical)
760 .constraints([
761 Constraint::Length(2),
762 Constraint::Min(8),
763 Constraint::Length(7),
764 ])
765 .split(inner);
766
767 let focus_symbol = state
768 .v2_state
769 .focus
770 .symbol
771 .as_deref()
772 .unwrap_or(&state.symbol);
773 let focus_strategy = state
774 .v2_state
775 .focus
776 .strategy_id
777 .as_deref()
778 .unwrap_or(&state.strategy_label);
779 frame.render_widget(
780 Paragraph::new(vec![
781 Line::from(vec![
782 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
783 Span::styled(
784 focus_symbol,
785 Style::default()
786 .fg(Color::Cyan)
787 .add_modifier(Modifier::BOLD),
788 ),
789 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
790 Span::styled(
791 focus_strategy,
792 Style::default()
793 .fg(Color::Magenta)
794 .add_modifier(Modifier::BOLD),
795 ),
796 ]),
797 Line::from(Span::styled(
798 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
799 Style::default().fg(Color::DarkGray),
800 )),
801 ]),
802 rows[0],
803 );
804
805 let main_cols = Layout::default()
806 .direction(Direction::Horizontal)
807 .constraints([Constraint::Min(48), Constraint::Length(28)])
808 .split(rows[1]);
809
810 frame.render_widget(
811 PriceChart::new(&state.candles, focus_symbol)
812 .current_candle(state.current_candle.as_ref())
813 .fill_markers(&state.fill_markers)
814 .fast_sma(state.fast_sma)
815 .slow_sma(state.slow_sma),
816 main_cols[0],
817 );
818 frame.render_widget(
819 PositionPanel::new(
820 &state.position,
821 state.last_price(),
822 &state.balances,
823 state.initial_equity_usdt,
824 state.current_equity_usdt,
825 state.history_trade_count,
826 state.history_realized_pnl,
827 &state.last_applied_fee,
828 ),
829 main_cols[1],
830 );
831
832 frame.render_widget(
833 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
834 rows[2],
835 );
836}
837
838fn render_v2_grid_popup(frame: &mut Frame, state: &AppState) {
839 let area = frame.area();
840 let popup = Rect {
841 x: area.x + 1,
842 y: area.y + 1,
843 width: area.width.saturating_sub(2).max(60),
844 height: area.height.saturating_sub(2).max(20),
845 };
846 frame.render_widget(Clear, popup);
847 let block = Block::default()
848 .title(" Portfolio Grid (V2) ")
849 .borders(Borders::ALL)
850 .border_style(Style::default().fg(Color::Cyan));
851 let inner = block.inner(popup);
852 frame.render_widget(block, popup);
853
854 let heat_height: u16 = 2;
855 let rejection_min_height: u16 = 1;
856 let strategy_min_height: u16 = 16;
857 let asset_min_height: u16 = 3;
858 let desired_asset_height = (state.v2_state.assets.len() as u16).saturating_add(1);
859 let max_asset_height = inner
860 .height
861 .saturating_sub(strategy_min_height + heat_height + rejection_min_height);
862 let asset_height = desired_asset_height
863 .max(asset_min_height)
864 .min(max_asset_height.max(asset_min_height));
865
866 let chunks = Layout::default()
867 .direction(Direction::Vertical)
868 .constraints([
869 Constraint::Length(asset_height),
870 Constraint::Min(strategy_min_height),
871 Constraint::Length(heat_height),
872 Constraint::Min(rejection_min_height),
873 ])
874 .split(inner);
875
876 let asset_header = Row::new(vec![
877 Cell::from("Symbol"),
878 Cell::from("Qty"),
879 Cell::from("Price"),
880 Cell::from("RlzPnL"),
881 Cell::from("UnrPnL"),
882 ])
883 .style(Style::default().fg(Color::DarkGray));
884 let mut asset_rows: Vec<Row> = state
885 .v2_state
886 .assets
887 .iter()
888 .map(|a| {
889 let price = a
890 .last_price
891 .map(|v| format!("{:.2}", v))
892 .unwrap_or_else(|| "---".to_string());
893 let rlz = format!("{:+.4}", a.realized_pnl_usdt);
894 let unrlz = format!("{:+.4}", a.unrealized_pnl_usdt);
895 Row::new(vec![
896 Cell::from(a.symbol.clone()),
897 Cell::from(format!("{:.5}", a.position_qty)),
898 Cell::from(price),
899 Cell::from(rlz),
900 Cell::from(unrlz),
901 ])
902 })
903 .collect();
904 if asset_rows.is_empty() {
905 asset_rows.push(
906 Row::new(vec![
907 Cell::from("(no assets)"),
908 Cell::from("-"),
909 Cell::from("-"),
910 Cell::from("-"),
911 Cell::from("-"),
912 ])
913 .style(Style::default().fg(Color::DarkGray)),
914 );
915 }
916 let asset_table = Table::new(
917 asset_rows,
918 [
919 Constraint::Length(16),
920 Constraint::Length(12),
921 Constraint::Length(10),
922 Constraint::Length(10),
923 Constraint::Length(10),
924 ],
925 )
926 .header(asset_header)
927 .column_spacing(1)
928 .block(
929 Block::default()
930 .title(format!(" Asset Table | Total {} ", state.v2_state.assets.len()))
931 .borders(Borders::ALL)
932 .border_style(Style::default().fg(Color::DarkGray)),
933 );
934 frame.render_widget(asset_table, chunks[0]);
935
936 let selected_symbol = state
937 .symbol_items
938 .get(state.v2_grid_symbol_index)
939 .map(String::as_str)
940 .unwrap_or(state.symbol.as_str());
941 let strategy_chunks = Layout::default()
942 .direction(Direction::Vertical)
943 .constraints([Constraint::Length(3), Constraint::Min(10), Constraint::Length(1)])
944 .split(chunks[1]);
945
946 let mut on_indices: Vec<usize> = Vec::new();
947 let mut off_indices: Vec<usize> = Vec::new();
948 for idx in 0..state.strategy_items.len() {
949 if state.strategy_item_active.get(idx).copied().unwrap_or(false) {
950 on_indices.push(idx);
951 } else {
952 off_indices.push(idx);
953 }
954 }
955 let on_weight = on_indices.len().max(1) as u32;
956 let off_weight = off_indices.len().max(1) as u32;
957 let strategy_area = strategy_chunks[1];
958 let min_panel_height: u16 = 6;
959 let total_height = strategy_area.height;
960 let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
961 let total_weight = on_weight + off_weight;
962 let mut on_h =
963 ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
964 let max_on_h = total_height.saturating_sub(min_panel_height);
965 if on_h > max_on_h {
966 on_h = max_on_h;
967 }
968 let off_h = total_height.saturating_sub(on_h);
969 (on_h, off_h)
970 } else {
971 let on_h = (total_height / 2).max(1);
972 let off_h = total_height.saturating_sub(on_h).max(1);
973 (on_h, off_h)
974 };
975 let on_area = Rect {
976 x: strategy_area.x,
977 y: strategy_area.y,
978 width: strategy_area.width,
979 height: on_height,
980 };
981 let off_area = Rect {
982 x: strategy_area.x,
983 y: strategy_area.y.saturating_add(on_height),
984 width: strategy_area.width,
985 height: off_height,
986 };
987
988 let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
989 indices
990 .iter()
991 .map(|idx| {
992 state
993 .strategy_items
994 .get(*idx)
995 .and_then(|item| strategy_stats_for_item(&state.strategy_stats, item))
996 .map(|s| s.realized_pnl)
997 .unwrap_or(0.0)
998 })
999 .sum()
1000 };
1001 let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1002 let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1003 let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1004
1005 let total_row = Row::new(vec![
1006 Cell::from("ON Total"),
1007 Cell::from(on_indices.len().to_string()),
1008 Cell::from(format!("{:+.4}", on_pnl_sum)),
1009 Cell::from("OFF Total"),
1010 Cell::from(off_indices.len().to_string()),
1011 Cell::from(format!("{:+.4}", off_pnl_sum)),
1012 Cell::from("All Total"),
1013 Cell::from(format!("{:+.4}", total_pnl_sum)),
1014 ]);
1015 let total_table = Table::new(
1016 vec![total_row],
1017 [
1018 Constraint::Length(10),
1019 Constraint::Length(5),
1020 Constraint::Length(12),
1021 Constraint::Length(10),
1022 Constraint::Length(5),
1023 Constraint::Length(12),
1024 Constraint::Length(10),
1025 Constraint::Length(12),
1026 ],
1027 )
1028 .column_spacing(1)
1029 .block(
1030 Block::default()
1031 .title(" Total ")
1032 .borders(Borders::ALL)
1033 .border_style(Style::default().fg(Color::DarkGray)),
1034 );
1035 frame.render_widget(total_table, strategy_chunks[0]);
1036
1037 let render_strategy_window =
1038 |frame: &mut Frame,
1039 area: Rect,
1040 title: &str,
1041 indices: &[usize],
1042 state: &AppState,
1043 pnl_sum: f64,
1044 selected_panel: bool| {
1045 let inner_height = area.height.saturating_sub(2);
1046 let row_capacity = inner_height.saturating_sub(1) as usize;
1047 let selected_pos = indices
1048 .iter()
1049 .position(|idx| *idx == state.v2_grid_strategy_index);
1050 let window_start = if row_capacity == 0 {
1051 0
1052 } else if let Some(pos) = selected_pos {
1053 pos.saturating_sub(row_capacity.saturating_sub(1))
1054 } else {
1055 0
1056 };
1057 let window_end = if row_capacity == 0 {
1058 0
1059 } else {
1060 (window_start + row_capacity).min(indices.len())
1061 };
1062 let visible_indices = if indices.is_empty() || row_capacity == 0 {
1063 &indices[0..0]
1064 } else {
1065 &indices[window_start..window_end]
1066 };
1067 let header = Row::new(vec![
1068 Cell::from(" "),
1069 Cell::from("Symbol"),
1070 Cell::from("Strategy"),
1071 Cell::from("Run"),
1072 Cell::from("W"),
1073 Cell::from("L"),
1074 Cell::from("T"),
1075 Cell::from("PnL"),
1076 ])
1077 .style(Style::default().fg(Color::DarkGray));
1078 let mut rows: Vec<Row> = visible_indices
1079 .iter()
1080 .map(|idx| {
1081 let row_symbol = state
1082 .strategy_item_symbols
1083 .get(*idx)
1084 .map(String::as_str)
1085 .unwrap_or("-");
1086 let item = state
1087 .strategy_items
1088 .get(*idx)
1089 .cloned()
1090 .unwrap_or_else(|| "-".to_string());
1091 let running = state
1092 .strategy_item_total_running_ms
1093 .get(*idx)
1094 .copied()
1095 .map(format_running_time)
1096 .unwrap_or_else(|| "-".to_string());
1097 let stats = strategy_stats_for_item(&state.strategy_stats, &item);
1098 let (w, l, t, pnl) = if let Some(s) = stats {
1099 (
1100 s.win_count.to_string(),
1101 s.lose_count.to_string(),
1102 s.trade_count.to_string(),
1103 format!("{:+.4}", s.realized_pnl),
1104 )
1105 } else {
1106 ("0".to_string(), "0".to_string(), "0".to_string(), "+0.0000".to_string())
1107 };
1108 let marker = if *idx == state.v2_grid_strategy_index {
1109 "▶"
1110 } else {
1111 " "
1112 };
1113 let mut row = Row::new(vec![
1114 Cell::from(marker),
1115 Cell::from(row_symbol.to_string()),
1116 Cell::from(item),
1117 Cell::from(running),
1118 Cell::from(w),
1119 Cell::from(l),
1120 Cell::from(t),
1121 Cell::from(pnl),
1122 ]);
1123 if *idx == state.v2_grid_strategy_index {
1124 row = row.style(
1125 Style::default()
1126 .fg(Color::Yellow)
1127 .add_modifier(Modifier::BOLD),
1128 );
1129 }
1130 row
1131 })
1132 .collect();
1133
1134 if rows.is_empty() {
1135 rows.push(
1136 Row::new(vec![
1137 Cell::from(" "),
1138 Cell::from("-"),
1139 Cell::from("(empty)"),
1140 Cell::from("-"),
1141 Cell::from("-"),
1142 Cell::from("-"),
1143 Cell::from("-"),
1144 Cell::from("-"),
1145 ])
1146 .style(Style::default().fg(Color::DarkGray)),
1147 );
1148 }
1149
1150 let table = Table::new(
1151 rows,
1152 [
1153 Constraint::Length(2),
1154 Constraint::Length(12),
1155 Constraint::Min(16),
1156 Constraint::Length(9),
1157 Constraint::Length(3),
1158 Constraint::Length(3),
1159 Constraint::Length(4),
1160 Constraint::Length(11),
1161 ],
1162 )
1163 .header(header)
1164 .column_spacing(1)
1165 .block(
1166 Block::default()
1167 .title(format!(
1168 "{} | Total {:+.4} | {}/{}",
1169 title,
1170 pnl_sum,
1171 visible_indices.len(),
1172 indices.len()
1173 ))
1174 .borders(Borders::ALL)
1175 .border_style(if selected_panel {
1176 Style::default().fg(Color::Yellow)
1177 } else {
1178 Style::default().fg(Color::DarkGray)
1179 }),
1180 );
1181 frame.render_widget(table, area);
1182 };
1183
1184 render_strategy_window(
1185 frame,
1186 on_area,
1187 " ON Strategies ",
1188 &on_indices,
1189 state,
1190 on_pnl_sum,
1191 state.v2_grid_select_on_panel,
1192 );
1193 render_strategy_window(
1194 frame,
1195 off_area,
1196 " OFF Strategies ",
1197 &off_indices,
1198 state,
1199 off_pnl_sum,
1200 !state.v2_grid_select_on_panel,
1201 );
1202 frame.render_widget(
1203 Paragraph::new(Line::from(vec![
1204 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1205 Span::styled(
1206 selected_symbol,
1207 Style::default()
1208 .fg(Color::Green)
1209 .add_modifier(Modifier::BOLD),
1210 ),
1211 Span::styled(
1212 " [Tab]panel [N]new [C]cfg [O]on/off [X]del [J/K]strategy [H/L]symbol [Enter/F]run [G/Esc]close",
1213 Style::default().fg(Color::DarkGray),
1214 ),
1215 ])),
1216 strategy_chunks[2],
1217 );
1218
1219 let heat = format!(
1220 "Risk/Rate Heatmap global {}/{} | orders {}/{} | account {}/{} | mkt {}/{}",
1221 state.rate_budget_global.used,
1222 state.rate_budget_global.limit,
1223 state.rate_budget_orders.used,
1224 state.rate_budget_orders.limit,
1225 state.rate_budget_account.used,
1226 state.rate_budget_account.limit,
1227 state.rate_budget_market_data.used,
1228 state.rate_budget_market_data.limit
1229 );
1230 frame.render_widget(
1231 Paragraph::new(vec![
1232 Line::from(Span::styled(
1233 "Risk/Rate Heatmap",
1234 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1235 )),
1236 Line::from(heat),
1237 ]),
1238 chunks[2],
1239 );
1240
1241 let mut rejection_lines = vec![Line::from(Span::styled(
1242 "Rejection Stream",
1243 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1244 ))];
1245 let recent_rejections: Vec<&String> = state
1246 .log_messages
1247 .iter()
1248 .filter(|m| m.contains("[ERR] Rejected"))
1249 .rev()
1250 .take(20)
1251 .collect();
1252 for msg in recent_rejections.into_iter().rev() {
1253 rejection_lines.push(Line::from(Span::styled(
1254 msg.as_str(),
1255 Style::default().fg(Color::Red),
1256 )));
1257 }
1258 if rejection_lines.len() == 1 {
1259 rejection_lines.push(Line::from(Span::styled(
1260 "(no rejections yet)",
1261 Style::default().fg(Color::DarkGray),
1262 )));
1263 }
1264 frame.render_widget(Paragraph::new(rejection_lines), chunks[3]);
1265}
1266
1267fn format_running_time(total_running_ms: u64) -> String {
1268 let total_sec = total_running_ms / 1000;
1269 let days = total_sec / 86_400;
1270 let hours = (total_sec % 86_400) / 3_600;
1271 let minutes = (total_sec % 3_600) / 60;
1272 if days > 0 {
1273 format!("{}d {:02}h", days, hours)
1274 } else {
1275 format!("{:02}h {:02}m", hours, minutes)
1276 }
1277}
1278
1279fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
1280 let area = frame.area();
1281 let popup = Rect {
1282 x: area.x + 8,
1283 y: area.y + 4,
1284 width: area.width.saturating_sub(16).max(50),
1285 height: area.height.saturating_sub(8).max(12),
1286 };
1287 frame.render_widget(Clear, popup);
1288 let block = Block::default()
1289 .title(" Strategy Config ")
1290 .borders(Borders::ALL)
1291 .border_style(Style::default().fg(Color::Yellow));
1292 let inner = block.inner(popup);
1293 frame.render_widget(block, popup);
1294 let selected_name = state
1295 .strategy_items
1296 .get(state.strategy_editor_index)
1297 .map(String::as_str)
1298 .unwrap_or("Unknown");
1299 let rows = [
1300 (
1301 "Symbol",
1302 state
1303 .symbol_items
1304 .get(state.strategy_editor_symbol_index)
1305 .cloned()
1306 .unwrap_or_else(|| state.symbol.clone()),
1307 ),
1308 ("Fast Period", state.strategy_editor_fast.to_string()),
1309 ("Slow Period", state.strategy_editor_slow.to_string()),
1310 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
1311 ];
1312 let mut lines = vec![
1313 Line::from(vec![
1314 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
1315 Span::styled(
1316 selected_name,
1317 Style::default()
1318 .fg(Color::White)
1319 .add_modifier(Modifier::BOLD),
1320 ),
1321 ]),
1322 Line::from(Span::styled(
1323 "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
1324 Style::default().fg(Color::DarkGray),
1325 )),
1326 ];
1327 for (idx, (name, value)) in rows.iter().enumerate() {
1328 let marker = if idx == state.strategy_editor_field {
1329 "▶ "
1330 } else {
1331 " "
1332 };
1333 let style = if idx == state.strategy_editor_field {
1334 Style::default()
1335 .fg(Color::Yellow)
1336 .add_modifier(Modifier::BOLD)
1337 } else {
1338 Style::default().fg(Color::White)
1339 };
1340 lines.push(Line::from(vec![
1341 Span::styled(marker, Style::default().fg(Color::Yellow)),
1342 Span::styled(format!("{:<14}", name), style),
1343 Span::styled(value, style),
1344 ]));
1345 }
1346 frame.render_widget(Paragraph::new(lines), inner);
1347}
1348
1349fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
1350 let area = frame.area();
1351 let popup = Rect {
1352 x: area.x + 4,
1353 y: area.y + 2,
1354 width: area.width.saturating_sub(8).max(30),
1355 height: area.height.saturating_sub(4).max(10),
1356 };
1357 frame.render_widget(Clear, popup);
1358 let block = Block::default()
1359 .title(" Account Assets ")
1360 .borders(Borders::ALL)
1361 .border_style(Style::default().fg(Color::Cyan));
1362 let inner = block.inner(popup);
1363 frame.render_widget(block, popup);
1364
1365 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
1366 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
1367
1368 let mut lines = Vec::with_capacity(assets.len() + 2);
1369 lines.push(Line::from(vec![
1370 Span::styled(
1371 "Asset",
1372 Style::default()
1373 .fg(Color::Cyan)
1374 .add_modifier(Modifier::BOLD),
1375 ),
1376 Span::styled(
1377 " Free",
1378 Style::default()
1379 .fg(Color::Cyan)
1380 .add_modifier(Modifier::BOLD),
1381 ),
1382 ]));
1383 for (asset, qty) in assets {
1384 lines.push(Line::from(vec![
1385 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
1386 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
1387 ]));
1388 }
1389 if lines.len() == 1 {
1390 lines.push(Line::from(Span::styled(
1391 "No assets",
1392 Style::default().fg(Color::DarkGray),
1393 )));
1394 }
1395
1396 frame.render_widget(Paragraph::new(lines), inner);
1397}
1398
1399fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
1400 let area = frame.area();
1401 let popup = Rect {
1402 x: area.x + 2,
1403 y: area.y + 1,
1404 width: area.width.saturating_sub(4).max(40),
1405 height: area.height.saturating_sub(2).max(12),
1406 };
1407 frame.render_widget(Clear, popup);
1408 let block = Block::default()
1409 .title(match bucket {
1410 order_store::HistoryBucket::Day => " History (Day ROI) ",
1411 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
1412 order_store::HistoryBucket::Month => " History (Month ROI) ",
1413 })
1414 .borders(Borders::ALL)
1415 .border_style(Style::default().fg(Color::Cyan));
1416 let inner = block.inner(popup);
1417 frame.render_widget(block, popup);
1418
1419 let max_rows = inner.height.saturating_sub(1) as usize;
1420 let mut visible: Vec<Line> = Vec::new();
1421 for (idx, row) in rows.iter().take(max_rows).enumerate() {
1422 let color = if idx == 0 {
1423 Color::Cyan
1424 } else if row.contains('-') && row.contains('%') {
1425 Color::White
1426 } else {
1427 Color::DarkGray
1428 };
1429 visible.push(Line::from(Span::styled(
1430 row.clone(),
1431 Style::default().fg(color),
1432 )));
1433 }
1434 if visible.is_empty() {
1435 visible.push(Line::from(Span::styled(
1436 "No history rows",
1437 Style::default().fg(Color::DarkGray),
1438 )));
1439 }
1440 frame.render_widget(Paragraph::new(visible), inner);
1441}
1442
1443fn render_selector_popup(
1444 frame: &mut Frame,
1445 title: &str,
1446 items: &[String],
1447 selected: usize,
1448 stats: Option<&HashMap<String, OrderHistoryStats>>,
1449 total_stats: Option<OrderHistoryStats>,
1450 selected_symbol: Option<&str>,
1451) {
1452 let area = frame.area();
1453 let available_width = area.width.saturating_sub(2).max(1);
1454 let width = if stats.is_some() {
1455 let min_width = 44;
1456 let preferred = 84;
1457 preferred
1458 .min(available_width)
1459 .max(min_width.min(available_width))
1460 } else {
1461 let min_width = 24;
1462 let preferred = 48;
1463 preferred
1464 .min(available_width)
1465 .max(min_width.min(available_width))
1466 };
1467 let available_height = area.height.saturating_sub(2).max(1);
1468 let desired_height = if stats.is_some() {
1469 items.len() as u16 + 7
1470 } else {
1471 items.len() as u16 + 4
1472 };
1473 let height = desired_height
1474 .min(available_height)
1475 .max(6.min(available_height));
1476 let popup = Rect {
1477 x: area.x + (area.width.saturating_sub(width)) / 2,
1478 y: area.y + (area.height.saturating_sub(height)) / 2,
1479 width,
1480 height,
1481 };
1482
1483 frame.render_widget(Clear, popup);
1484 let block = Block::default()
1485 .title(title)
1486 .borders(Borders::ALL)
1487 .border_style(Style::default().fg(Color::Cyan));
1488 let inner = block.inner(popup);
1489 frame.render_widget(block, popup);
1490
1491 let mut lines: Vec<Line> = Vec::new();
1492 if stats.is_some() {
1493 if let Some(symbol) = selected_symbol {
1494 lines.push(Line::from(vec![
1495 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
1496 Span::styled(
1497 symbol,
1498 Style::default()
1499 .fg(Color::Green)
1500 .add_modifier(Modifier::BOLD),
1501 ),
1502 ]));
1503 }
1504 lines.push(Line::from(vec![Span::styled(
1505 " Strategy W L T PnL",
1506 Style::default()
1507 .fg(Color::Cyan)
1508 .add_modifier(Modifier::BOLD),
1509 )]));
1510 }
1511
1512 let mut item_lines: Vec<Line> = items
1513 .iter()
1514 .enumerate()
1515 .map(|(idx, item)| {
1516 let item_text = if let Some(stats_map) = stats {
1517 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1518 format!(
1519 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1520 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
1521 )
1522 } else {
1523 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
1524 }
1525 } else {
1526 item.clone()
1527 };
1528 if idx == selected {
1529 Line::from(vec![
1530 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
1531 Span::styled(
1532 item_text,
1533 Style::default()
1534 .fg(Color::White)
1535 .add_modifier(Modifier::BOLD),
1536 ),
1537 ])
1538 } else {
1539 Line::from(vec![
1540 Span::styled(" ", Style::default()),
1541 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
1542 ])
1543 }
1544 })
1545 .collect();
1546 lines.append(&mut item_lines);
1547 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
1548 let mut strategy_sum = OrderHistoryStats::default();
1549 for item in items {
1550 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1551 strategy_sum.trade_count += s.trade_count;
1552 strategy_sum.win_count += s.win_count;
1553 strategy_sum.lose_count += s.lose_count;
1554 strategy_sum.realized_pnl += s.realized_pnl;
1555 }
1556 }
1557 let manual = subtract_stats(t, &strategy_sum);
1558 lines.push(Line::from(vec![Span::styled(
1559 format!(
1560 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1561 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
1562 ),
1563 Style::default().fg(Color::LightBlue),
1564 )]));
1565 }
1566 if let Some(t) = total_stats {
1567 lines.push(Line::from(vec![Span::styled(
1568 format!(
1569 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1570 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
1571 ),
1572 Style::default()
1573 .fg(Color::Yellow)
1574 .add_modifier(Modifier::BOLD),
1575 )]));
1576 }
1577
1578 frame.render_widget(
1579 Paragraph::new(lines).style(Style::default().fg(Color::White)),
1580 inner,
1581 );
1582}
1583
1584fn strategy_stats_for_item<'a>(
1585 stats_map: &'a HashMap<String, OrderHistoryStats>,
1586 item: &str,
1587) -> Option<&'a OrderHistoryStats> {
1588 if let Some(s) = stats_map.get(item) {
1589 return Some(s);
1590 }
1591 let source_tag = source_tag_for_strategy_item(item);
1592 source_tag.and_then(|tag| {
1593 stats_map
1594 .get(&tag)
1595 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
1596 })
1597}
1598
1599fn source_tag_for_strategy_item(item: &str) -> Option<String> {
1600 match item {
1601 "MA(Config)" => return Some("cfg".to_string()),
1602 "MA(Fast 5/20)" => return Some("fst".to_string()),
1603 "MA(Slow 20/60)" => return Some("slw".to_string()),
1604 _ => {}
1605 }
1606 if let Some((_, tail)) = item.rsplit_once('[') {
1607 if let Some(tag) = tail.strip_suffix(']') {
1608 let tag = tag.trim();
1609 if !tag.is_empty() {
1610 return Some(tag.to_ascii_lowercase());
1611 }
1612 }
1613 }
1614 None
1615}
1616
1617fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
1618 OrderHistoryStats {
1619 trade_count: total.trade_count.saturating_sub(used.trade_count),
1620 win_count: total.win_count.saturating_sub(used.win_count),
1621 lose_count: total.lose_count.saturating_sub(used.lose_count),
1622 realized_pnl: total.realized_pnl - used.realized_pnl,
1623 }
1624}
1625
1626fn split_symbol_assets(symbol: &str) -> (String, String) {
1627 const QUOTE_SUFFIXES: [&str; 10] = [
1628 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
1629 ];
1630 for q in QUOTE_SUFFIXES {
1631 if let Some(base) = symbol.strip_suffix(q) {
1632 if !base.is_empty() {
1633 return (base.to_string(), q.to_string());
1634 }
1635 }
1636 }
1637 (symbol.to_string(), String::new())
1638}
1639
1640fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
1641 if fills.is_empty() {
1642 return None;
1643 }
1644 let (base_asset, quote_asset) = split_symbol_assets(symbol);
1645 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
1646 let mut notional_quote = 0.0;
1647 let mut fee_quote_equiv = 0.0;
1648 let mut quote_convertible = !quote_asset.is_empty();
1649
1650 for f in fills {
1651 if f.qty > 0.0 && f.price > 0.0 {
1652 notional_quote += f.qty * f.price;
1653 }
1654 if f.commission <= 0.0 {
1655 continue;
1656 }
1657 *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
1658 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
1659 fee_quote_equiv += f.commission;
1660 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
1661 fee_quote_equiv += f.commission * f.price.max(0.0);
1662 } else {
1663 quote_convertible = false;
1664 }
1665 }
1666
1667 if fee_by_asset.is_empty() {
1668 return Some("0".to_string());
1669 }
1670
1671 if quote_convertible && notional_quote > f64::EPSILON {
1672 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
1673 return Some(format!(
1674 "{:.3}% ({:.4} {})",
1675 fee_pct, fee_quote_equiv, quote_asset
1676 ));
1677 }
1678
1679 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
1680 items.sort_by(|a, b| a.0.cmp(&b.0));
1681 if items.len() == 1 {
1682 let (asset, amount) = &items[0];
1683 Some(format!("{:.6} {}", amount, asset))
1684 } else {
1685 Some(format!("mixed fees ({})", items.len()))
1686 }
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use super::format_last_applied_fee;
1692 use crate::model::order::Fill;
1693
1694 #[test]
1695 fn fee_summary_from_quote_asset_commission() {
1696 let fills = vec![Fill {
1697 price: 2000.0,
1698 qty: 0.5,
1699 commission: 1.0,
1700 commission_asset: "USDT".to_string(),
1701 }];
1702 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1703 assert_eq!(summary, "0.100% (1.0000 USDT)");
1704 }
1705
1706 #[test]
1707 fn fee_summary_from_base_asset_commission() {
1708 let fills = vec![Fill {
1709 price: 2000.0,
1710 qty: 0.5,
1711 commission: 0.0005,
1712 commission_asset: "ETH".to_string(),
1713 }];
1714 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1715 assert_eq!(summary, "0.100% (1.0000 USDT)");
1716 }
1717}