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, Clear, Paragraph};
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 account_popup_open: bool,
73 pub history_popup_open: bool,
74 pub focus_popup_open: bool,
75 pub history_rows: Vec<String>,
76 pub history_bucket: order_store::HistoryBucket,
77 pub last_applied_fee: String,
78 pub v2_grid_open: bool,
79 pub v2_state: AppStateV2,
80 pub rate_budget_global: RateBudgetSnapshot,
81 pub rate_budget_orders: RateBudgetSnapshot,
82 pub rate_budget_account: RateBudgetSnapshot,
83 pub rate_budget_market_data: RateBudgetSnapshot,
84}
85
86impl AppState {
87 pub fn new(
88 symbol: &str,
89 strategy_label: &str,
90 price_history_len: usize,
91 candle_interval_ms: u64,
92 timeframe: &str,
93 ) -> Self {
94 Self {
95 symbol: symbol.to_string(),
96 strategy_label: strategy_label.to_string(),
97 candles: Vec::with_capacity(price_history_len),
98 current_candle: None,
99 candle_interval_ms,
100 timeframe: timeframe.to_string(),
101 price_history_len,
102 position: Position::new(symbol.to_string()),
103 last_signal: None,
104 last_order: None,
105 open_order_history: Vec::new(),
106 filled_order_history: Vec::new(),
107 fast_sma: None,
108 slow_sma: None,
109 ws_connected: false,
110 paused: false,
111 tick_count: 0,
112 log_messages: Vec::new(),
113 balances: HashMap::new(),
114 initial_equity_usdt: None,
115 current_equity_usdt: None,
116 history_estimated_total_pnl_usdt: None,
117 fill_markers: Vec::new(),
118 history_trade_count: 0,
119 history_win_count: 0,
120 history_lose_count: 0,
121 history_realized_pnl: 0.0,
122 strategy_stats: HashMap::new(),
123 history_fills: Vec::new(),
124 last_price_update_ms: None,
125 last_price_event_ms: None,
126 last_price_latency_ms: None,
127 last_order_history_update_ms: None,
128 last_order_history_event_ms: None,
129 last_order_history_latency_ms: None,
130 trade_stats_reset_warned: false,
131 symbol_selector_open: false,
132 symbol_selector_index: 0,
133 symbol_items: Vec::new(),
134 strategy_selector_open: false,
135 strategy_selector_index: 0,
136 strategy_items: vec![
137 "MA(Config)".to_string(),
138 "MA(Fast 5/20)".to_string(),
139 "MA(Slow 20/60)".to_string(),
140 ],
141 account_popup_open: false,
142 history_popup_open: false,
143 focus_popup_open: false,
144 history_rows: Vec::new(),
145 history_bucket: order_store::HistoryBucket::Day,
146 last_applied_fee: "---".to_string(),
147 v2_grid_open: false,
148 v2_state: AppStateV2::new(),
149 rate_budget_global: RateBudgetSnapshot {
150 used: 0,
151 limit: 0,
152 reset_in_ms: 0,
153 },
154 rate_budget_orders: RateBudgetSnapshot {
155 used: 0,
156 limit: 0,
157 reset_in_ms: 0,
158 },
159 rate_budget_account: RateBudgetSnapshot {
160 used: 0,
161 limit: 0,
162 reset_in_ms: 0,
163 },
164 rate_budget_market_data: RateBudgetSnapshot {
165 used: 0,
166 limit: 0,
167 reset_in_ms: 0,
168 },
169 }
170 }
171
172 pub fn last_price(&self) -> Option<f64> {
174 self.current_candle
175 .as_ref()
176 .map(|cb| cb.close)
177 .or_else(|| self.candles.last().map(|c| c.close))
178 }
179
180 pub fn push_log(&mut self, msg: String) {
181 self.log_messages.push(msg);
182 if self.log_messages.len() > MAX_LOG_MESSAGES {
183 self.log_messages.remove(0);
184 }
185 }
186
187 pub fn refresh_history_rows(&mut self) {
188 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
189 Ok(rows) => {
190 use std::collections::{BTreeMap, BTreeSet};
191
192 let mut date_set: BTreeSet<String> = BTreeSet::new();
193 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
194 for row in rows {
195 date_set.insert(row.date.clone());
196 ticker_map
197 .entry(row.symbol.clone())
198 .or_default()
199 .insert(row.date, row.realized_return_pct);
200 }
201
202 let mut dates: Vec<String> = date_set.into_iter().collect();
204 dates.sort();
205 const MAX_DATE_COLS: usize = 6;
206 if dates.len() > MAX_DATE_COLS {
207 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
208 }
209
210 let mut lines = Vec::new();
211 if dates.is_empty() {
212 lines.push("Ticker (no daily realized roi data)".to_string());
213 self.history_rows = lines;
214 return;
215 }
216
217 let mut header = format!("{:<14}", "Ticker");
218 for d in &dates {
219 header.push_str(&format!(" {:>10}", d));
220 }
221 lines.push(header);
222
223 for (ticker, by_date) in ticker_map {
224 let mut line = format!("{:<14}", ticker);
225 for d in &dates {
226 let cell = by_date
227 .get(d)
228 .map(|v| format!("{:.2}%", v))
229 .unwrap_or_else(|| "-".to_string());
230 line.push_str(&format!(" {:>10}", cell));
231 }
232 lines.push(line);
233 }
234 self.history_rows = lines;
235 }
236 Err(e) => {
237 self.history_rows = vec![
238 "Ticker Date RealizedROI RealizedPnL".to_string(),
239 format!("(failed to load history: {})", e),
240 ];
241 }
242 }
243 }
244
245 fn refresh_equity_usdt(&mut self) {
246 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
247 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
248 let mark_price = self
249 .last_price()
250 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
251 if let Some(price) = mark_price {
252 let total = usdt + btc * price;
253 self.current_equity_usdt = Some(total);
254 self.recompute_initial_equity_from_history();
255 }
256 }
257
258 fn recompute_initial_equity_from_history(&mut self) {
259 if let Some(current) = self.current_equity_usdt {
260 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
261 self.initial_equity_usdt = Some(current - total_pnl);
262 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
263 self.initial_equity_usdt = Some(current);
264 }
265 }
266 }
267
268 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
269 if let Some((idx, _)) = self
270 .candles
271 .iter()
272 .enumerate()
273 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
274 {
275 return Some(idx);
276 }
277 if let Some(cb) = &self.current_candle {
278 if cb.contains(timestamp_ms) {
279 return Some(self.candles.len());
280 }
281 }
282 if let Some((idx, _)) = self
285 .candles
286 .iter()
287 .enumerate()
288 .rev()
289 .find(|(_, c)| c.open_time <= timestamp_ms)
290 {
291 return Some(idx);
292 }
293 None
294 }
295
296 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
297 self.fill_markers.clear();
298 for fill in fills {
299 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
300 self.fill_markers.push(FillMarker {
301 candle_index,
302 price: fill.price,
303 side: fill.side,
304 });
305 }
306 }
307 if self.fill_markers.len() > MAX_FILL_MARKERS {
308 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
309 self.fill_markers.drain(..excess);
310 }
311 }
312
313 pub fn apply(&mut self, event: AppEvent) {
314 let prev_focus = self.v2_state.focus.clone();
315 match event {
316 AppEvent::MarketTick(tick) => {
317 self.tick_count += 1;
318 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
319 self.last_price_update_ms = Some(now_ms);
320 self.last_price_event_ms = Some(tick.timestamp_ms);
321 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
322
323 let should_new = match &self.current_candle {
325 Some(cb) => !cb.contains(tick.timestamp_ms),
326 None => true,
327 };
328 if should_new {
329 if let Some(cb) = self.current_candle.take() {
330 self.candles.push(cb.finish());
331 if self.candles.len() > self.price_history_len {
332 self.candles.remove(0);
333 self.fill_markers.retain_mut(|m| {
335 if m.candle_index == 0 {
336 false
337 } else {
338 m.candle_index -= 1;
339 true
340 }
341 });
342 }
343 }
344 self.current_candle = Some(CandleBuilder::new(
345 tick.price,
346 tick.timestamp_ms,
347 self.candle_interval_ms,
348 ));
349 } else if let Some(cb) = self.current_candle.as_mut() {
350 cb.update(tick.price);
351 } else {
352 self.current_candle = Some(CandleBuilder::new(
354 tick.price,
355 tick.timestamp_ms,
356 self.candle_interval_ms,
357 ));
358 self.push_log("[WARN] Recovered missing current candle state".to_string());
359 }
360
361 self.position.update_unrealized_pnl(tick.price);
362 self.refresh_equity_usdt();
363 }
364 AppEvent::StrategySignal(ref signal) => {
365 self.last_signal = Some(signal.clone());
366 match signal {
367 Signal::Buy { .. } => {
368 self.push_log("Signal: BUY".to_string());
369 }
370 Signal::Sell { .. } => {
371 self.push_log("Signal: SELL".to_string());
372 }
373 Signal::Hold => {}
374 }
375 }
376 AppEvent::StrategyState { fast_sma, slow_sma } => {
377 self.fast_sma = fast_sma;
378 self.slow_sma = slow_sma;
379 }
380 AppEvent::OrderUpdate(ref update) => {
381 match update {
382 OrderUpdate::Filled {
383 intent_id,
384 client_order_id,
385 side,
386 fills,
387 avg_price,
388 } => {
389 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
390 self.last_applied_fee = summary;
391 }
392 self.position.apply_fill(*side, fills);
393 self.refresh_equity_usdt();
394 let candle_index = if self.current_candle.is_some() {
395 self.candles.len()
396 } else {
397 self.candles.len().saturating_sub(1)
398 };
399 self.fill_markers.push(FillMarker {
400 candle_index,
401 price: *avg_price,
402 side: *side,
403 });
404 if self.fill_markers.len() > MAX_FILL_MARKERS {
405 self.fill_markers.remove(0);
406 }
407 self.push_log(format!(
408 "FILLED {} {} ({}) @ {:.2}",
409 side, client_order_id, intent_id, avg_price
410 ));
411 }
412 OrderUpdate::Submitted {
413 intent_id,
414 client_order_id,
415 server_order_id,
416 } => {
417 self.refresh_equity_usdt();
418 self.push_log(format!(
419 "Submitted {} (id: {}, {})",
420 client_order_id, server_order_id, intent_id
421 ));
422 }
423 OrderUpdate::Rejected {
424 intent_id,
425 client_order_id,
426 reason_code,
427 reason,
428 } => {
429 self.push_log(format!(
430 "[ERR] Rejected {} ({}) [{}]: {}",
431 client_order_id, intent_id, reason_code, reason
432 ));
433 }
434 }
435 self.last_order = Some(update.clone());
436 }
437 AppEvent::WsStatus(ref status) => match status {
438 WsConnectionStatus::Connected => {
439 self.ws_connected = true;
440 self.push_log("WebSocket Connected".to_string());
441 }
442 WsConnectionStatus::Disconnected => {
443 self.ws_connected = false;
444 self.push_log("[WARN] WebSocket Disconnected".to_string());
445 }
446 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
447 self.ws_connected = false;
448 self.push_log(format!(
449 "[WARN] Reconnecting (attempt {}, wait {}ms)",
450 attempt, delay_ms
451 ));
452 }
453 },
454 AppEvent::HistoricalCandles {
455 candles,
456 interval_ms,
457 interval,
458 } => {
459 self.candles = candles;
460 if self.candles.len() > self.price_history_len {
461 let excess = self.candles.len() - self.price_history_len;
462 self.candles.drain(..excess);
463 }
464 self.candle_interval_ms = interval_ms;
465 self.timeframe = interval;
466 self.current_candle = None;
467 let fills = self.history_fills.clone();
468 self.rebuild_fill_markers_from_history(&fills);
469 self.push_log(format!(
470 "Switched to {} ({} candles)",
471 self.timeframe,
472 self.candles.len()
473 ));
474 }
475 AppEvent::BalanceUpdate(balances) => {
476 self.balances = balances;
477 self.refresh_equity_usdt();
478 }
479 AppEvent::OrderHistoryUpdate(snapshot) => {
480 let mut open = Vec::new();
481 let mut filled = Vec::new();
482
483 for row in snapshot.rows {
484 let status = row.split_whitespace().nth(1).unwrap_or_default();
485 if status == "FILLED" {
486 filled.push(row);
487 } else {
488 open.push(row);
489 }
490 }
491
492 if open.len() > MAX_LOG_MESSAGES {
493 let excess = open.len() - MAX_LOG_MESSAGES;
494 open.drain(..excess);
495 }
496 if filled.len() > MAX_LOG_MESSAGES {
497 let excess = filled.len() - MAX_LOG_MESSAGES;
498 filled.drain(..excess);
499 }
500
501 self.open_order_history = open;
502 self.filled_order_history = filled;
503 if snapshot.trade_data_complete {
504 let stats_looks_reset = snapshot.stats.trade_count == 0
505 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
506 if stats_looks_reset {
507 if !self.trade_stats_reset_warned {
508 self.push_log(
509 "[WARN] Ignored transient trade stats reset from order-history sync"
510 .to_string(),
511 );
512 self.trade_stats_reset_warned = true;
513 }
514 } else {
515 self.trade_stats_reset_warned = false;
516 self.history_trade_count = snapshot.stats.trade_count;
517 self.history_win_count = snapshot.stats.win_count;
518 self.history_lose_count = snapshot.stats.lose_count;
519 self.history_realized_pnl = snapshot.stats.realized_pnl;
520 self.strategy_stats = snapshot.strategy_stats;
521 if snapshot.open_qty > f64::EPSILON {
524 self.position.side = Some(OrderSide::Buy);
525 self.position.qty = snapshot.open_qty;
526 self.position.entry_price = snapshot.open_entry_price;
527 if let Some(px) = self.last_price() {
528 self.position.unrealized_pnl =
529 (px - snapshot.open_entry_price) * snapshot.open_qty;
530 }
531 } else {
532 self.position.side = None;
533 self.position.qty = 0.0;
534 self.position.entry_price = 0.0;
535 self.position.unrealized_pnl = 0.0;
536 }
537 }
538 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
539 self.history_fills = snapshot.fills.clone();
540 self.rebuild_fill_markers_from_history(&snapshot.fills);
541 }
542 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
543 self.recompute_initial_equity_from_history();
544 }
545 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
546 self.last_order_history_event_ms = snapshot.latest_event_ms;
547 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
548 self.refresh_history_rows();
549 }
550 AppEvent::RiskRateSnapshot {
551 global,
552 orders,
553 account,
554 market_data,
555 } => {
556 self.rate_budget_global = global;
557 self.rate_budget_orders = orders;
558 self.rate_budget_account = account;
559 self.rate_budget_market_data = market_data;
560 }
561 AppEvent::LogMessage(msg) => {
562 self.push_log(msg);
563 }
564 AppEvent::Error(msg) => {
565 self.push_log(format!("[ERR] {}", msg));
566 }
567 }
568 let mut next = AppStateV2::from_legacy(self);
569 if prev_focus.symbol.is_some() {
570 next.focus.symbol = prev_focus.symbol;
571 }
572 if prev_focus.strategy_id.is_some() {
573 next.focus.strategy_id = prev_focus.strategy_id;
574 }
575 self.v2_state = next;
576 }
577}
578
579pub fn render(frame: &mut Frame, state: &AppState) {
580 let outer = Layout::default()
581 .direction(Direction::Vertical)
582 .constraints([
583 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
590 .split(frame.area());
591
592 frame.render_widget(
594 StatusBar {
595 symbol: &state.symbol,
596 strategy_label: &state.strategy_label,
597 ws_connected: state.ws_connected,
598 paused: state.paused,
599 timeframe: &state.timeframe,
600 last_price_update_ms: state.last_price_update_ms,
601 last_price_latency_ms: state.last_price_latency_ms,
602 last_order_history_update_ms: state.last_order_history_update_ms,
603 last_order_history_latency_ms: state.last_order_history_latency_ms,
604 },
605 outer[0],
606 );
607
608 let main_area = Layout::default()
610 .direction(Direction::Horizontal)
611 .constraints([Constraint::Min(40), Constraint::Length(24)])
612 .split(outer[1]);
613
614 let current_price = state.last_price();
616 frame.render_widget(
617 PriceChart::new(&state.candles, &state.symbol)
618 .current_candle(state.current_candle.as_ref())
619 .fill_markers(&state.fill_markers)
620 .fast_sma(state.fast_sma)
621 .slow_sma(state.slow_sma),
622 main_area[0],
623 );
624
625 frame.render_widget(
627 PositionPanel::new(
628 &state.position,
629 current_price,
630 &state.balances,
631 state.initial_equity_usdt,
632 state.current_equity_usdt,
633 state.history_trade_count,
634 state.history_realized_pnl,
635 &state.last_applied_fee,
636 ),
637 main_area[1],
638 );
639
640 frame.render_widget(
642 OrderLogPanel::new(
643 &state.last_signal,
644 &state.last_order,
645 state.fast_sma,
646 state.slow_sma,
647 state.history_trade_count,
648 state.history_win_count,
649 state.history_lose_count,
650 state.history_realized_pnl,
651 ),
652 outer[2],
653 );
654
655 frame.render_widget(
657 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
658 outer[3],
659 );
660
661 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
663
664 frame.render_widget(KeybindBar, outer[5]);
666
667 if state.symbol_selector_open {
668 render_selector_popup(
669 frame,
670 " Select Symbol ",
671 &state.symbol_items,
672 state.symbol_selector_index,
673 None,
674 None,
675 );
676 } else if state.strategy_selector_open {
677 render_selector_popup(
678 frame,
679 " Select Strategy ",
680 &state.strategy_items,
681 state.strategy_selector_index,
682 Some(&state.strategy_stats),
683 Some(OrderHistoryStats {
684 trade_count: state.history_trade_count,
685 win_count: state.history_win_count,
686 lose_count: state.history_lose_count,
687 realized_pnl: state.history_realized_pnl,
688 }),
689 );
690 } else if state.account_popup_open {
691 render_account_popup(frame, &state.balances);
692 } else if state.history_popup_open {
693 render_history_popup(frame, &state.history_rows, state.history_bucket);
694 } else if state.focus_popup_open {
695 render_focus_popup(frame, state);
696 } else if state.v2_grid_open {
697 render_v2_grid_popup(frame, state);
698 }
699}
700
701fn render_focus_popup(frame: &mut Frame, state: &AppState) {
702 let area = frame.area();
703 let popup = Rect {
704 x: area.x + 1,
705 y: area.y + 1,
706 width: area.width.saturating_sub(2).max(70),
707 height: area.height.saturating_sub(2).max(22),
708 };
709 frame.render_widget(Clear, popup);
710 let block = Block::default()
711 .title(" Focus View (V2 Drill-down) ")
712 .borders(Borders::ALL)
713 .border_style(Style::default().fg(Color::Green));
714 let inner = block.inner(popup);
715 frame.render_widget(block, popup);
716
717 let rows = Layout::default()
718 .direction(Direction::Vertical)
719 .constraints([
720 Constraint::Length(2),
721 Constraint::Min(8),
722 Constraint::Length(7),
723 ])
724 .split(inner);
725
726 let focus_symbol = state
727 .v2_state
728 .focus
729 .symbol
730 .as_deref()
731 .unwrap_or(&state.symbol);
732 let focus_strategy = state
733 .v2_state
734 .focus
735 .strategy_id
736 .as_deref()
737 .unwrap_or(&state.strategy_label);
738 frame.render_widget(
739 Paragraph::new(vec![
740 Line::from(vec![
741 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
742 Span::styled(
743 focus_symbol,
744 Style::default()
745 .fg(Color::Cyan)
746 .add_modifier(Modifier::BOLD),
747 ),
748 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
749 Span::styled(
750 focus_strategy,
751 Style::default()
752 .fg(Color::Magenta)
753 .add_modifier(Modifier::BOLD),
754 ),
755 ]),
756 Line::from(Span::styled(
757 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
758 Style::default().fg(Color::DarkGray),
759 )),
760 ]),
761 rows[0],
762 );
763
764 let main_cols = Layout::default()
765 .direction(Direction::Horizontal)
766 .constraints([Constraint::Min(48), Constraint::Length(28)])
767 .split(rows[1]);
768
769 frame.render_widget(
770 PriceChart::new(&state.candles, focus_symbol)
771 .current_candle(state.current_candle.as_ref())
772 .fill_markers(&state.fill_markers)
773 .fast_sma(state.fast_sma)
774 .slow_sma(state.slow_sma),
775 main_cols[0],
776 );
777 frame.render_widget(
778 PositionPanel::new(
779 &state.position,
780 state.last_price(),
781 &state.balances,
782 state.initial_equity_usdt,
783 state.current_equity_usdt,
784 state.history_trade_count,
785 state.history_realized_pnl,
786 &state.last_applied_fee,
787 ),
788 main_cols[1],
789 );
790
791 frame.render_widget(
792 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
793 rows[2],
794 );
795}
796
797fn render_v2_grid_popup(frame: &mut Frame, state: &AppState) {
798 let area = frame.area();
799 let popup = Rect {
800 x: area.x + 1,
801 y: area.y + 1,
802 width: area.width.saturating_sub(2).max(60),
803 height: area.height.saturating_sub(2).max(20),
804 };
805 frame.render_widget(Clear, popup);
806 let block = Block::default()
807 .title(" Portfolio Grid (V2) ")
808 .borders(Borders::ALL)
809 .border_style(Style::default().fg(Color::Cyan));
810 let inner = block.inner(popup);
811 frame.render_widget(block, popup);
812
813 let chunks = Layout::default()
814 .direction(Direction::Vertical)
815 .constraints([
816 Constraint::Length(5),
817 Constraint::Length(6),
818 Constraint::Length(5),
819 Constraint::Min(4),
820 ])
821 .split(inner);
822
823 let mut asset_lines = vec![Line::from(Span::styled(
824 "Asset Table",
825 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
826 ))];
827 for a in &state.v2_state.assets {
828 asset_lines.push(Line::from(format!(
829 "{} px={} qty={:.5} rlz={:+.4} unrlz={:+.4}",
830 a.symbol,
831 a.last_price
832 .map(|v| format!("{:.2}", v))
833 .unwrap_or_else(|| "---".to_string()),
834 a.position_qty,
835 a.realized_pnl_usdt,
836 a.unrealized_pnl_usdt
837 )));
838 }
839 frame.render_widget(Paragraph::new(asset_lines), chunks[0]);
840
841 let mut strategy_lines = vec![Line::from(Span::styled(
842 "Strategy Table",
843 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
844 ))];
845 for s in &state.v2_state.strategies {
846 strategy_lines.push(Line::from(format!(
847 "{} W:{} L:{} T:{} PnL:{:+.4}",
848 s.strategy_id, s.win_count, s.lose_count, s.trade_count, s.realized_pnl_usdt
849 )));
850 }
851 frame.render_widget(Paragraph::new(strategy_lines), chunks[1]);
852
853 let heat = format!(
854 "Risk/Rate Heatmap global {}/{} | orders {}/{} | account {}/{} | mkt {}/{}",
855 state.rate_budget_global.used,
856 state.rate_budget_global.limit,
857 state.rate_budget_orders.used,
858 state.rate_budget_orders.limit,
859 state.rate_budget_account.used,
860 state.rate_budget_account.limit,
861 state.rate_budget_market_data.used,
862 state.rate_budget_market_data.limit
863 );
864 frame.render_widget(
865 Paragraph::new(vec![
866 Line::from(Span::styled(
867 "Risk/Rate Heatmap",
868 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
869 )),
870 Line::from(heat),
871 ]),
872 chunks[2],
873 );
874
875 let mut rejection_lines = vec![Line::from(Span::styled(
876 "Rejection Stream",
877 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
878 ))];
879 let recent_rejections: Vec<&String> = state
880 .log_messages
881 .iter()
882 .filter(|m| m.contains("[ERR] Rejected"))
883 .rev()
884 .take(20)
885 .collect();
886 for msg in recent_rejections.into_iter().rev() {
887 rejection_lines.push(Line::from(Span::styled(
888 msg.as_str(),
889 Style::default().fg(Color::Red),
890 )));
891 }
892 if rejection_lines.len() == 1 {
893 rejection_lines.push(Line::from(Span::styled(
894 "(no rejections yet)",
895 Style::default().fg(Color::DarkGray),
896 )));
897 }
898 frame.render_widget(Paragraph::new(rejection_lines), chunks[3]);
899}
900
901fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
902 let area = frame.area();
903 let popup = Rect {
904 x: area.x + 4,
905 y: area.y + 2,
906 width: area.width.saturating_sub(8).max(30),
907 height: area.height.saturating_sub(4).max(10),
908 };
909 frame.render_widget(Clear, popup);
910 let block = Block::default()
911 .title(" Account Assets ")
912 .borders(Borders::ALL)
913 .border_style(Style::default().fg(Color::Cyan));
914 let inner = block.inner(popup);
915 frame.render_widget(block, popup);
916
917 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
918 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
919
920 let mut lines = Vec::with_capacity(assets.len() + 2);
921 lines.push(Line::from(vec![
922 Span::styled(
923 "Asset",
924 Style::default()
925 .fg(Color::Cyan)
926 .add_modifier(Modifier::BOLD),
927 ),
928 Span::styled(
929 " Free",
930 Style::default()
931 .fg(Color::Cyan)
932 .add_modifier(Modifier::BOLD),
933 ),
934 ]));
935 for (asset, qty) in assets {
936 lines.push(Line::from(vec![
937 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
938 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
939 ]));
940 }
941 if lines.len() == 1 {
942 lines.push(Line::from(Span::styled(
943 "No assets",
944 Style::default().fg(Color::DarkGray),
945 )));
946 }
947
948 frame.render_widget(Paragraph::new(lines), inner);
949}
950
951fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
952 let area = frame.area();
953 let popup = Rect {
954 x: area.x + 2,
955 y: area.y + 1,
956 width: area.width.saturating_sub(4).max(40),
957 height: area.height.saturating_sub(2).max(12),
958 };
959 frame.render_widget(Clear, popup);
960 let block = Block::default()
961 .title(match bucket {
962 order_store::HistoryBucket::Day => " History (Day ROI) ",
963 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
964 order_store::HistoryBucket::Month => " History (Month ROI) ",
965 })
966 .borders(Borders::ALL)
967 .border_style(Style::default().fg(Color::Cyan));
968 let inner = block.inner(popup);
969 frame.render_widget(block, popup);
970
971 let max_rows = inner.height.saturating_sub(1) as usize;
972 let mut visible: Vec<Line> = Vec::new();
973 for (idx, row) in rows.iter().take(max_rows).enumerate() {
974 let color = if idx == 0 {
975 Color::Cyan
976 } else if row.contains('-') && row.contains('%') {
977 Color::White
978 } else {
979 Color::DarkGray
980 };
981 visible.push(Line::from(Span::styled(
982 row.clone(),
983 Style::default().fg(color),
984 )));
985 }
986 if visible.is_empty() {
987 visible.push(Line::from(Span::styled(
988 "No history rows",
989 Style::default().fg(Color::DarkGray),
990 )));
991 }
992 frame.render_widget(Paragraph::new(visible), inner);
993}
994
995fn render_selector_popup(
996 frame: &mut Frame,
997 title: &str,
998 items: &[String],
999 selected: usize,
1000 stats: Option<&HashMap<String, OrderHistoryStats>>,
1001 total_stats: Option<OrderHistoryStats>,
1002) {
1003 let area = frame.area();
1004 let available_width = area.width.saturating_sub(2).max(1);
1005 let width = if stats.is_some() {
1006 let min_width = 44;
1007 let preferred = 84;
1008 preferred
1009 .min(available_width)
1010 .max(min_width.min(available_width))
1011 } else {
1012 let min_width = 24;
1013 let preferred = 48;
1014 preferred
1015 .min(available_width)
1016 .max(min_width.min(available_width))
1017 };
1018 let available_height = area.height.saturating_sub(2).max(1);
1019 let desired_height = if stats.is_some() {
1020 items.len() as u16 + 7
1021 } else {
1022 items.len() as u16 + 4
1023 };
1024 let height = desired_height
1025 .min(available_height)
1026 .max(6.min(available_height));
1027 let popup = Rect {
1028 x: area.x + (area.width.saturating_sub(width)) / 2,
1029 y: area.y + (area.height.saturating_sub(height)) / 2,
1030 width,
1031 height,
1032 };
1033
1034 frame.render_widget(Clear, popup);
1035 let block = Block::default()
1036 .title(title)
1037 .borders(Borders::ALL)
1038 .border_style(Style::default().fg(Color::Cyan));
1039 let inner = block.inner(popup);
1040 frame.render_widget(block, popup);
1041
1042 let mut lines: Vec<Line> = Vec::new();
1043 if stats.is_some() {
1044 lines.push(Line::from(vec![Span::styled(
1045 " Strategy W L T PnL",
1046 Style::default()
1047 .fg(Color::Cyan)
1048 .add_modifier(Modifier::BOLD),
1049 )]));
1050 }
1051
1052 let mut item_lines: Vec<Line> = items
1053 .iter()
1054 .enumerate()
1055 .map(|(idx, item)| {
1056 let item_text = if let Some(stats_map) = stats {
1057 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1058 format!(
1059 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1060 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
1061 )
1062 } else {
1063 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
1064 }
1065 } else {
1066 item.clone()
1067 };
1068 if idx == selected {
1069 Line::from(vec![
1070 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
1071 Span::styled(
1072 item_text,
1073 Style::default()
1074 .fg(Color::White)
1075 .add_modifier(Modifier::BOLD),
1076 ),
1077 ])
1078 } else {
1079 Line::from(vec![
1080 Span::styled(" ", Style::default()),
1081 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
1082 ])
1083 }
1084 })
1085 .collect();
1086 lines.append(&mut item_lines);
1087 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
1088 let mut strategy_sum = OrderHistoryStats::default();
1089 for item in items {
1090 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1091 strategy_sum.trade_count += s.trade_count;
1092 strategy_sum.win_count += s.win_count;
1093 strategy_sum.lose_count += s.lose_count;
1094 strategy_sum.realized_pnl += s.realized_pnl;
1095 }
1096 }
1097 let manual = subtract_stats(t, &strategy_sum);
1098 lines.push(Line::from(vec![Span::styled(
1099 format!(
1100 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1101 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
1102 ),
1103 Style::default().fg(Color::LightBlue),
1104 )]));
1105 }
1106 if let Some(t) = total_stats {
1107 lines.push(Line::from(vec![Span::styled(
1108 format!(
1109 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1110 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
1111 ),
1112 Style::default()
1113 .fg(Color::Yellow)
1114 .add_modifier(Modifier::BOLD),
1115 )]));
1116 }
1117
1118 frame.render_widget(
1119 Paragraph::new(lines).style(Style::default().fg(Color::White)),
1120 inner,
1121 );
1122}
1123
1124fn strategy_stats_for_item<'a>(
1125 stats_map: &'a HashMap<String, OrderHistoryStats>,
1126 item: &str,
1127) -> Option<&'a OrderHistoryStats> {
1128 if let Some(s) = stats_map.get(item) {
1129 return Some(s);
1130 }
1131 let source_tag = match item {
1132 "MA(Config)" => Some("cfg"),
1133 "MA(Fast 5/20)" => Some("fst"),
1134 "MA(Slow 20/60)" => Some("slw"),
1135 _ => None,
1136 };
1137 source_tag.and_then(|tag| {
1138 stats_map
1139 .get(tag)
1140 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
1141 })
1142}
1143
1144fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
1145 OrderHistoryStats {
1146 trade_count: total.trade_count.saturating_sub(used.trade_count),
1147 win_count: total.win_count.saturating_sub(used.win_count),
1148 lose_count: total.lose_count.saturating_sub(used.lose_count),
1149 realized_pnl: total.realized_pnl - used.realized_pnl,
1150 }
1151}
1152
1153fn split_symbol_assets(symbol: &str) -> (String, String) {
1154 const QUOTE_SUFFIXES: [&str; 10] = [
1155 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
1156 ];
1157 for q in QUOTE_SUFFIXES {
1158 if let Some(base) = symbol.strip_suffix(q) {
1159 if !base.is_empty() {
1160 return (base.to_string(), q.to_string());
1161 }
1162 }
1163 }
1164 (symbol.to_string(), String::new())
1165}
1166
1167fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
1168 if fills.is_empty() {
1169 return None;
1170 }
1171 let (base_asset, quote_asset) = split_symbol_assets(symbol);
1172 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
1173 let mut notional_quote = 0.0;
1174 let mut fee_quote_equiv = 0.0;
1175 let mut quote_convertible = !quote_asset.is_empty();
1176
1177 for f in fills {
1178 if f.qty > 0.0 && f.price > 0.0 {
1179 notional_quote += f.qty * f.price;
1180 }
1181 if f.commission <= 0.0 {
1182 continue;
1183 }
1184 *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
1185 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
1186 fee_quote_equiv += f.commission;
1187 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
1188 fee_quote_equiv += f.commission * f.price.max(0.0);
1189 } else {
1190 quote_convertible = false;
1191 }
1192 }
1193
1194 if fee_by_asset.is_empty() {
1195 return Some("0".to_string());
1196 }
1197
1198 if quote_convertible && notional_quote > f64::EPSILON {
1199 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
1200 return Some(format!(
1201 "{:.3}% ({:.4} {})",
1202 fee_pct, fee_quote_equiv, quote_asset
1203 ));
1204 }
1205
1206 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
1207 items.sort_by(|a, b| a.0.cmp(&b.0));
1208 if items.len() == 1 {
1209 let (asset, amount) = &items[0];
1210 Some(format!("{:.6} {}", amount, asset))
1211 } else {
1212 Some(format!("mixed fees ({})", items.len()))
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::format_last_applied_fee;
1219 use crate::model::order::Fill;
1220
1221 #[test]
1222 fn fee_summary_from_quote_asset_commission() {
1223 let fills = vec![Fill {
1224 price: 2000.0,
1225 qty: 0.5,
1226 commission: 1.0,
1227 commission_asset: "USDT".to_string(),
1228 }];
1229 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1230 assert_eq!(summary, "0.100% (1.0000 USDT)");
1231 }
1232
1233 #[test]
1234 fn fee_summary_from_base_asset_commission() {
1235 let fills = vec![Fill {
1236 price: 2000.0,
1237 qty: 0.5,
1238 commission: 0.0005,
1239 commission_asset: "ETH".to_string(),
1240 }];
1241 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1242 assert_eq!(summary, "0.100% (1.0000 USDT)");
1243 }
1244}