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