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