1pub mod ui_projection;
2pub mod chart;
3pub mod dashboard;
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 ui_projection::UiProjection;
23use ui_projection::AssetEntry;
24use chart::{FillMarker, PriceChart};
25use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
26
27const MAX_LOG_MESSAGES: usize = 200;
28const MAX_FILL_MARKERS: usize = 200;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum GridTab {
32 Assets,
33 Strategies,
34 Risk,
35 Network,
36 SystemLog,
37}
38
39#[derive(Debug, Clone)]
40pub struct StrategyLastEvent {
41 pub side: OrderSide,
42 pub price: Option<f64>,
43 pub timestamp_ms: u64,
44 pub is_filled: bool,
45}
46
47#[derive(Debug, Clone)]
48pub struct ViewState {
49 pub is_grid_open: bool,
50 pub selected_grid_tab: GridTab,
51 pub selected_symbol_index: usize,
52 pub selected_strategy_index: usize,
53 pub is_on_panel_selected: bool,
54 pub is_symbol_selector_open: bool,
55 pub selected_symbol_selector_index: usize,
56 pub is_strategy_selector_open: bool,
57 pub selected_strategy_selector_index: usize,
58 pub is_account_popup_open: bool,
59 pub is_history_popup_open: bool,
60 pub is_focus_popup_open: bool,
61 pub is_strategy_editor_open: bool,
62}
63
64pub struct AppState {
65 pub symbol: String,
66 pub strategy_label: String,
67 pub candles: Vec<Candle>,
68 pub current_candle: Option<CandleBuilder>,
69 pub candle_interval_ms: u64,
70 pub timeframe: String,
71 pub price_history_len: usize,
72 pub position: Position,
73 pub last_signal: Option<Signal>,
74 pub last_order: Option<OrderUpdate>,
75 pub open_order_history: Vec<String>,
76 pub filled_order_history: Vec<String>,
77 pub fast_sma: Option<f64>,
78 pub slow_sma: Option<f64>,
79 pub ws_connected: bool,
80 pub paused: bool,
81 pub tick_count: u64,
82 pub log_messages: Vec<String>,
83 pub balances: HashMap<String, f64>,
84 pub initial_equity_usdt: Option<f64>,
85 pub current_equity_usdt: Option<f64>,
86 pub history_estimated_total_pnl_usdt: Option<f64>,
87 pub fill_markers: Vec<FillMarker>,
88 pub history_trade_count: u32,
89 pub history_win_count: u32,
90 pub history_lose_count: u32,
91 pub history_realized_pnl: f64,
92 pub strategy_stats: HashMap<String, OrderHistoryStats>,
93 pub history_fills: Vec<OrderHistoryFill>,
94 pub last_price_update_ms: Option<u64>,
95 pub last_price_event_ms: Option<u64>,
96 pub last_price_latency_ms: Option<u64>,
97 pub last_order_history_update_ms: Option<u64>,
98 pub last_order_history_event_ms: Option<u64>,
99 pub last_order_history_latency_ms: Option<u64>,
100 pub trade_stats_reset_warned: bool,
101 pub symbol_selector_open: bool,
102 pub symbol_selector_index: usize,
103 pub symbol_items: Vec<String>,
104 pub strategy_selector_open: bool,
105 pub strategy_selector_index: usize,
106 pub strategy_items: Vec<String>,
107 pub strategy_item_symbols: Vec<String>,
108 pub strategy_item_active: Vec<bool>,
109 pub strategy_item_created_at_ms: Vec<i64>,
110 pub strategy_item_total_running_ms: Vec<u64>,
111 pub account_popup_open: bool,
112 pub history_popup_open: bool,
113 pub focus_popup_open: bool,
114 pub strategy_editor_open: bool,
115 pub strategy_editor_index: usize,
116 pub strategy_editor_field: usize,
117 pub strategy_editor_symbol_index: usize,
118 pub strategy_editor_fast: usize,
119 pub strategy_editor_slow: usize,
120 pub strategy_editor_cooldown: u64,
121 pub grid_symbol_index: usize,
122 pub grid_strategy_index: usize,
123 pub grid_select_on_panel: bool,
124 pub grid_tab: GridTab,
125 pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
126 pub network_tick_drop_count: u64,
127 pub network_reconnect_count: u64,
128 pub network_tick_latencies_ms: Vec<u64>,
129 pub network_fill_latencies_ms: Vec<u64>,
130 pub network_order_sync_latencies_ms: Vec<u64>,
131 pub network_last_fill_ms: Option<u64>,
132 pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
133 pub history_rows: Vec<String>,
134 pub history_bucket: order_store::HistoryBucket,
135 pub last_applied_fee: String,
136 pub grid_open: bool,
137 pub ui_projection: UiProjection,
138 pub rate_budget_global: RateBudgetSnapshot,
139 pub rate_budget_orders: RateBudgetSnapshot,
140 pub rate_budget_account: RateBudgetSnapshot,
141 pub rate_budget_market_data: RateBudgetSnapshot,
142}
143
144impl AppState {
145 pub fn new(
146 symbol: &str,
147 strategy_label: &str,
148 price_history_len: usize,
149 candle_interval_ms: u64,
150 timeframe: &str,
151 ) -> Self {
152 Self {
153 symbol: symbol.to_string(),
154 strategy_label: strategy_label.to_string(),
155 candles: Vec::with_capacity(price_history_len),
156 current_candle: None,
157 candle_interval_ms,
158 timeframe: timeframe.to_string(),
159 price_history_len,
160 position: Position::new(symbol.to_string()),
161 last_signal: None,
162 last_order: None,
163 open_order_history: Vec::new(),
164 filled_order_history: Vec::new(),
165 fast_sma: None,
166 slow_sma: None,
167 ws_connected: false,
168 paused: false,
169 tick_count: 0,
170 log_messages: Vec::new(),
171 balances: HashMap::new(),
172 initial_equity_usdt: None,
173 current_equity_usdt: None,
174 history_estimated_total_pnl_usdt: None,
175 fill_markers: Vec::new(),
176 history_trade_count: 0,
177 history_win_count: 0,
178 history_lose_count: 0,
179 history_realized_pnl: 0.0,
180 strategy_stats: HashMap::new(),
181 history_fills: Vec::new(),
182 last_price_update_ms: None,
183 last_price_event_ms: None,
184 last_price_latency_ms: None,
185 last_order_history_update_ms: None,
186 last_order_history_event_ms: None,
187 last_order_history_latency_ms: None,
188 trade_stats_reset_warned: false,
189 symbol_selector_open: false,
190 symbol_selector_index: 0,
191 symbol_items: Vec::new(),
192 strategy_selector_open: false,
193 strategy_selector_index: 0,
194 strategy_items: vec![
195 "MA(Config)".to_string(),
196 "MA(Fast 5/20)".to_string(),
197 "MA(Slow 20/60)".to_string(),
198 ],
199 strategy_item_symbols: vec![
200 symbol.to_ascii_uppercase(),
201 symbol.to_ascii_uppercase(),
202 symbol.to_ascii_uppercase(),
203 ],
204 strategy_item_active: vec![false, false, false],
205 strategy_item_created_at_ms: vec![0, 0, 0],
206 strategy_item_total_running_ms: vec![0, 0, 0],
207 account_popup_open: false,
208 history_popup_open: false,
209 focus_popup_open: false,
210 strategy_editor_open: false,
211 strategy_editor_index: 0,
212 strategy_editor_field: 0,
213 strategy_editor_symbol_index: 0,
214 strategy_editor_fast: 5,
215 strategy_editor_slow: 20,
216 strategy_editor_cooldown: 1,
217 grid_symbol_index: 0,
218 grid_strategy_index: 0,
219 grid_select_on_panel: true,
220 grid_tab: GridTab::Strategies,
221 strategy_last_event_by_tag: HashMap::new(),
222 network_tick_drop_count: 0,
223 network_reconnect_count: 0,
224 network_tick_latencies_ms: Vec::new(),
225 network_fill_latencies_ms: Vec::new(),
226 network_order_sync_latencies_ms: Vec::new(),
227 network_last_fill_ms: None,
228 network_pending_submit_ms_by_intent: HashMap::new(),
229 history_rows: Vec::new(),
230 history_bucket: order_store::HistoryBucket::Day,
231 last_applied_fee: "---".to_string(),
232 grid_open: false,
233 ui_projection: UiProjection::new(),
234 rate_budget_global: RateBudgetSnapshot {
235 used: 0,
236 limit: 0,
237 reset_in_ms: 0,
238 },
239 rate_budget_orders: RateBudgetSnapshot {
240 used: 0,
241 limit: 0,
242 reset_in_ms: 0,
243 },
244 rate_budget_account: RateBudgetSnapshot {
245 used: 0,
246 limit: 0,
247 reset_in_ms: 0,
248 },
249 rate_budget_market_data: RateBudgetSnapshot {
250 used: 0,
251 limit: 0,
252 reset_in_ms: 0,
253 },
254 }
255 }
256
257 pub fn last_price(&self) -> Option<f64> {
259 self.current_candle
260 .as_ref()
261 .map(|cb| cb.close)
262 .or_else(|| self.candles.last().map(|c| c.close))
263 }
264
265 pub fn push_log(&mut self, msg: String) {
266 self.log_messages.push(msg);
267 if self.log_messages.len() > MAX_LOG_MESSAGES {
268 self.log_messages.remove(0);
269 }
270 }
271
272 fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
273 const MAX_SAMPLES: usize = 200;
274 samples.push(value);
275 if samples.len() > MAX_SAMPLES {
276 let drop_n = samples.len() - MAX_SAMPLES;
277 samples.drain(..drop_n);
278 }
279 }
280
281 pub fn view_state(&self) -> ViewState {
284 ViewState {
285 is_grid_open: self.grid_open,
286 selected_grid_tab: self.grid_tab,
287 selected_symbol_index: self.grid_symbol_index,
288 selected_strategy_index: self.grid_strategy_index,
289 is_on_panel_selected: self.grid_select_on_panel,
290 is_symbol_selector_open: self.symbol_selector_open,
291 selected_symbol_selector_index: self.symbol_selector_index,
292 is_strategy_selector_open: self.strategy_selector_open,
293 selected_strategy_selector_index: self.strategy_selector_index,
294 is_account_popup_open: self.account_popup_open,
295 is_history_popup_open: self.history_popup_open,
296 is_focus_popup_open: self.focus_popup_open,
297 is_strategy_editor_open: self.strategy_editor_open,
298 }
299 }
300
301 pub fn is_grid_open(&self) -> bool {
302 self.grid_open
303 }
304 pub fn set_grid_open(&mut self, open: bool) {
305 self.grid_open = open;
306 }
307 pub fn grid_tab(&self) -> GridTab {
308 self.grid_tab
309 }
310 pub fn set_grid_tab(&mut self, tab: GridTab) {
311 self.grid_tab = tab;
312 }
313 pub fn selected_grid_symbol_index(&self) -> usize {
314 self.grid_symbol_index
315 }
316 pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
317 self.grid_symbol_index = idx;
318 }
319 pub fn selected_grid_strategy_index(&self) -> usize {
320 self.grid_strategy_index
321 }
322 pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
323 self.grid_strategy_index = idx;
324 }
325 pub fn is_on_panel_selected(&self) -> bool {
326 self.grid_select_on_panel
327 }
328 pub fn set_on_panel_selected(&mut self, selected: bool) {
329 self.grid_select_on_panel = selected;
330 }
331 pub fn is_symbol_selector_open(&self) -> bool {
332 self.symbol_selector_open
333 }
334 pub fn set_symbol_selector_open(&mut self, open: bool) {
335 self.symbol_selector_open = open;
336 }
337 pub fn symbol_selector_index(&self) -> usize {
338 self.symbol_selector_index
339 }
340 pub fn set_symbol_selector_index(&mut self, idx: usize) {
341 self.symbol_selector_index = idx;
342 }
343 pub fn is_strategy_selector_open(&self) -> bool {
344 self.strategy_selector_open
345 }
346 pub fn set_strategy_selector_open(&mut self, open: bool) {
347 self.strategy_selector_open = open;
348 }
349 pub fn strategy_selector_index(&self) -> usize {
350 self.strategy_selector_index
351 }
352 pub fn set_strategy_selector_index(&mut self, idx: usize) {
353 self.strategy_selector_index = idx;
354 }
355 pub fn is_account_popup_open(&self) -> bool {
356 self.account_popup_open
357 }
358 pub fn set_account_popup_open(&mut self, open: bool) {
359 self.account_popup_open = open;
360 }
361 pub fn is_history_popup_open(&self) -> bool {
362 self.history_popup_open
363 }
364 pub fn set_history_popup_open(&mut self, open: bool) {
365 self.history_popup_open = open;
366 }
367 pub fn is_focus_popup_open(&self) -> bool {
368 self.focus_popup_open
369 }
370 pub fn set_focus_popup_open(&mut self, open: bool) {
371 self.focus_popup_open = open;
372 }
373 pub fn is_strategy_editor_open(&self) -> bool {
374 self.strategy_editor_open
375 }
376 pub fn set_strategy_editor_open(&mut self, open: bool) {
377 self.strategy_editor_open = open;
378 }
379 pub fn focus_symbol(&self) -> Option<&str> {
380 self.ui_projection.focus.symbol.as_deref()
381 }
382 pub fn focus_strategy_id(&self) -> Option<&str> {
383 self.ui_projection.focus.strategy_id.as_deref()
384 }
385 pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
386 self.ui_projection.focus.symbol = symbol;
387 }
388 pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
389 self.ui_projection.focus.strategy_id = strategy_id;
390 }
391 pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
392 (
393 self.ui_projection.focus.symbol.clone(),
394 self.ui_projection.focus.strategy_id.clone(),
395 )
396 }
397 pub fn assets_view(&self) -> &[AssetEntry] {
398 &self.ui_projection.assets
399 }
400
401 pub fn refresh_history_rows(&mut self) {
402 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
403 Ok(rows) => {
404 use std::collections::{BTreeMap, BTreeSet};
405
406 let mut date_set: BTreeSet<String> = BTreeSet::new();
407 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
408 for row in rows {
409 date_set.insert(row.date.clone());
410 ticker_map
411 .entry(row.symbol.clone())
412 .or_default()
413 .insert(row.date, row.realized_return_pct);
414 }
415
416 let mut dates: Vec<String> = date_set.into_iter().collect();
418 dates.sort();
419 const MAX_DATE_COLS: usize = 6;
420 if dates.len() > MAX_DATE_COLS {
421 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
422 }
423
424 let mut lines = Vec::new();
425 if dates.is_empty() {
426 lines.push("Ticker (no daily realized roi data)".to_string());
427 self.history_rows = lines;
428 return;
429 }
430
431 let mut header = format!("{:<14}", "Ticker");
432 for d in &dates {
433 header.push_str(&format!(" {:>10}", d));
434 }
435 lines.push(header);
436
437 for (ticker, by_date) in ticker_map {
438 let mut line = format!("{:<14}", ticker);
439 for d in &dates {
440 let cell = by_date
441 .get(d)
442 .map(|v| format!("{:.2}%", v))
443 .unwrap_or_else(|| "-".to_string());
444 line.push_str(&format!(" {:>10}", cell));
445 }
446 lines.push(line);
447 }
448 self.history_rows = lines;
449 }
450 Err(e) => {
451 self.history_rows = vec![
452 "Ticker Date RealizedROI RealizedPnL".to_string(),
453 format!("(failed to load history: {})", e),
454 ];
455 }
456 }
457 }
458
459 fn refresh_equity_usdt(&mut self) {
460 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
461 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
462 let mark_price = self
463 .last_price()
464 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
465 if let Some(price) = mark_price {
466 let total = usdt + btc * price;
467 self.current_equity_usdt = Some(total);
468 self.recompute_initial_equity_from_history();
469 }
470 }
471
472 fn recompute_initial_equity_from_history(&mut self) {
473 if let Some(current) = self.current_equity_usdt {
474 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
475 self.initial_equity_usdt = Some(current - total_pnl);
476 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
477 self.initial_equity_usdt = Some(current);
478 }
479 }
480 }
481
482 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
483 if let Some((idx, _)) = self
484 .candles
485 .iter()
486 .enumerate()
487 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
488 {
489 return Some(idx);
490 }
491 if let Some(cb) = &self.current_candle {
492 if cb.contains(timestamp_ms) {
493 return Some(self.candles.len());
494 }
495 }
496 if let Some((idx, _)) = self
499 .candles
500 .iter()
501 .enumerate()
502 .rev()
503 .find(|(_, c)| c.open_time <= timestamp_ms)
504 {
505 return Some(idx);
506 }
507 None
508 }
509
510 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
511 self.fill_markers.clear();
512 for fill in fills {
513 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
514 self.fill_markers.push(FillMarker {
515 candle_index,
516 price: fill.price,
517 side: fill.side,
518 });
519 }
520 }
521 if self.fill_markers.len() > MAX_FILL_MARKERS {
522 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
523 self.fill_markers.drain(..excess);
524 }
525 }
526
527 fn sync_projection_portfolio_summary(&mut self) {
528 self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
529 self.ui_projection.portfolio.total_realized_pnl_usdt = self.history_realized_pnl;
530 self.ui_projection.portfolio.total_unrealized_pnl_usdt = self.position.unrealized_pnl;
531 self.ui_projection.portfolio.ws_connected = self.ws_connected;
532 }
533
534 fn ensure_projection_focus_defaults(&mut self) {
535 if self.ui_projection.focus.symbol.is_none() {
536 self.ui_projection.focus.symbol = Some(self.symbol.clone());
537 }
538 if self.ui_projection.focus.strategy_id.is_none() {
539 self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
540 }
541 }
542
543 fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
544 let mut next = UiProjection::from_legacy(self);
545 if prev_focus.0.is_some() {
546 next.focus.symbol = prev_focus.0;
547 }
548 if prev_focus.1.is_some() {
549 next.focus.strategy_id = prev_focus.1;
550 }
551 self.ui_projection = next;
552 self.ensure_projection_focus_defaults();
553 }
554
555 pub fn apply(&mut self, event: AppEvent) {
556 let prev_focus = self.focus_pair();
557 let mut rebuild_projection = false;
558 match event {
559 AppEvent::MarketTick(tick) => {
560 rebuild_projection = true;
561 self.tick_count += 1;
562 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
563 self.last_price_update_ms = Some(now_ms);
564 self.last_price_event_ms = Some(tick.timestamp_ms);
565 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
566 if let Some(lat) = self.last_price_latency_ms {
567 Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
568 }
569
570 let should_new = match &self.current_candle {
572 Some(cb) => !cb.contains(tick.timestamp_ms),
573 None => true,
574 };
575 if should_new {
576 if let Some(cb) = self.current_candle.take() {
577 self.candles.push(cb.finish());
578 if self.candles.len() > self.price_history_len {
579 self.candles.remove(0);
580 self.fill_markers.retain_mut(|m| {
582 if m.candle_index == 0 {
583 false
584 } else {
585 m.candle_index -= 1;
586 true
587 }
588 });
589 }
590 }
591 self.current_candle = Some(CandleBuilder::new(
592 tick.price,
593 tick.timestamp_ms,
594 self.candle_interval_ms,
595 ));
596 } else if let Some(cb) = self.current_candle.as_mut() {
597 cb.update(tick.price);
598 } else {
599 self.current_candle = Some(CandleBuilder::new(
601 tick.price,
602 tick.timestamp_ms,
603 self.candle_interval_ms,
604 ));
605 self.push_log("[WARN] Recovered missing current candle state".to_string());
606 }
607
608 self.position.update_unrealized_pnl(tick.price);
609 self.refresh_equity_usdt();
610 }
611 AppEvent::StrategySignal {
612 ref signal,
613 source_tag,
614 price,
615 timestamp_ms,
616 } => {
617 self.last_signal = Some(signal.clone());
618 match signal {
619 Signal::Buy { .. } => {
620 self.push_log("Signal: BUY".to_string());
621 self.strategy_last_event_by_tag.insert(
622 source_tag.to_ascii_lowercase(),
623 StrategyLastEvent {
624 side: OrderSide::Buy,
625 price,
626 timestamp_ms,
627 is_filled: false,
628 },
629 );
630 }
631 Signal::Sell { .. } => {
632 self.push_log("Signal: SELL".to_string());
633 self.strategy_last_event_by_tag.insert(
634 source_tag.to_ascii_lowercase(),
635 StrategyLastEvent {
636 side: OrderSide::Sell,
637 price,
638 timestamp_ms,
639 is_filled: false,
640 },
641 );
642 }
643 Signal::Hold => {}
644 }
645 }
646 AppEvent::StrategyState { fast_sma, slow_sma } => {
647 self.fast_sma = fast_sma;
648 self.slow_sma = slow_sma;
649 }
650 AppEvent::OrderUpdate(ref update) => {
651 rebuild_projection = true;
652 match update {
653 OrderUpdate::Filled {
654 intent_id,
655 client_order_id,
656 side,
657 fills,
658 avg_price,
659 } => {
660 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
661 let source_tag = parse_source_tag_from_client_order_id(client_order_id)
662 .map(|s| s.to_ascii_lowercase());
663 if let Some(submit_ms) = self.network_pending_submit_ms_by_intent.remove(intent_id)
664 {
665 Self::push_latency_sample(
666 &mut self.network_fill_latencies_ms,
667 now_ms.saturating_sub(submit_ms),
668 );
669 } else if let Some(signal_ms) = source_tag
670 .as_deref()
671 .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
672 .map(|e| e.timestamp_ms)
673 {
674 Self::push_latency_sample(
676 &mut self.network_fill_latencies_ms,
677 now_ms.saturating_sub(signal_ms),
678 );
679 }
680 self.network_last_fill_ms = Some(now_ms);
681 if let Some(source_tag) = source_tag {
682 self.strategy_last_event_by_tag.insert(
683 source_tag,
684 StrategyLastEvent {
685 side: *side,
686 price: Some(*avg_price),
687 timestamp_ms: now_ms,
688 is_filled: true,
689 },
690 );
691 }
692 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
693 self.last_applied_fee = summary;
694 }
695 self.position.apply_fill(*side, fills);
696 self.refresh_equity_usdt();
697 let candle_index = if self.current_candle.is_some() {
698 self.candles.len()
699 } else {
700 self.candles.len().saturating_sub(1)
701 };
702 self.fill_markers.push(FillMarker {
703 candle_index,
704 price: *avg_price,
705 side: *side,
706 });
707 if self.fill_markers.len() > MAX_FILL_MARKERS {
708 self.fill_markers.remove(0);
709 }
710 self.push_log(format!(
711 "FILLED {} {} ({}) @ {:.2}",
712 side, client_order_id, intent_id, avg_price
713 ));
714 }
715 OrderUpdate::Submitted {
716 intent_id,
717 client_order_id,
718 server_order_id,
719 } => {
720 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
721 self.network_pending_submit_ms_by_intent
722 .insert(intent_id.clone(), now_ms);
723 self.refresh_equity_usdt();
724 self.push_log(format!(
725 "Submitted {} (id: {}, {})",
726 client_order_id, server_order_id, intent_id
727 ));
728 }
729 OrderUpdate::Rejected {
730 intent_id,
731 client_order_id,
732 reason_code,
733 reason,
734 } => {
735 self.push_log(format!(
736 "[ERR] Rejected {} ({}) [{}]: {}",
737 client_order_id, intent_id, reason_code, reason
738 ));
739 }
740 }
741 self.last_order = Some(update.clone());
742 }
743 AppEvent::WsStatus(ref status) => match status {
744 WsConnectionStatus::Connected => {
745 self.ws_connected = true;
746 self.push_log("WebSocket Connected".to_string());
747 }
748 WsConnectionStatus::Disconnected => {
749 self.ws_connected = false;
750 self.push_log("[WARN] WebSocket Disconnected".to_string());
751 }
752 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
753 self.ws_connected = false;
754 self.network_reconnect_count += 1;
755 self.push_log(format!(
756 "[WARN] Reconnecting (attempt {}, wait {}ms)",
757 attempt, delay_ms
758 ));
759 }
760 },
761 AppEvent::HistoricalCandles {
762 candles,
763 interval_ms,
764 interval,
765 } => {
766 rebuild_projection = true;
767 self.candles = candles;
768 if self.candles.len() > self.price_history_len {
769 let excess = self.candles.len() - self.price_history_len;
770 self.candles.drain(..excess);
771 }
772 self.candle_interval_ms = interval_ms;
773 self.timeframe = interval;
774 self.current_candle = None;
775 let fills = self.history_fills.clone();
776 self.rebuild_fill_markers_from_history(&fills);
777 self.push_log(format!(
778 "Switched to {} ({} candles)",
779 self.timeframe,
780 self.candles.len()
781 ));
782 }
783 AppEvent::BalanceUpdate(balances) => {
784 rebuild_projection = true;
785 self.balances = balances;
786 self.refresh_equity_usdt();
787 }
788 AppEvent::OrderHistoryUpdate(snapshot) => {
789 rebuild_projection = true;
790 let mut open = Vec::new();
791 let mut filled = Vec::new();
792
793 for row in snapshot.rows {
794 let status = row.split_whitespace().nth(1).unwrap_or_default();
795 if status == "FILLED" {
796 filled.push(row);
797 } else {
798 open.push(row);
799 }
800 }
801
802 if open.len() > MAX_LOG_MESSAGES {
803 let excess = open.len() - MAX_LOG_MESSAGES;
804 open.drain(..excess);
805 }
806 if filled.len() > MAX_LOG_MESSAGES {
807 let excess = filled.len() - MAX_LOG_MESSAGES;
808 filled.drain(..excess);
809 }
810
811 self.open_order_history = open;
812 self.filled_order_history = filled;
813 if snapshot.trade_data_complete {
814 let stats_looks_reset = snapshot.stats.trade_count == 0
815 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
816 if stats_looks_reset {
817 if !self.trade_stats_reset_warned {
818 self.push_log(
819 "[WARN] Ignored transient trade stats reset from order-history sync"
820 .to_string(),
821 );
822 self.trade_stats_reset_warned = true;
823 }
824 } else {
825 self.trade_stats_reset_warned = false;
826 self.history_trade_count = snapshot.stats.trade_count;
827 self.history_win_count = snapshot.stats.win_count;
828 self.history_lose_count = snapshot.stats.lose_count;
829 self.history_realized_pnl = snapshot.stats.realized_pnl;
830 if snapshot.open_qty > f64::EPSILON {
833 self.position.side = Some(OrderSide::Buy);
834 self.position.qty = snapshot.open_qty;
835 self.position.entry_price = snapshot.open_entry_price;
836 if let Some(px) = self.last_price() {
837 self.position.unrealized_pnl =
838 (px - snapshot.open_entry_price) * snapshot.open_qty;
839 }
840 } else {
841 self.position.side = None;
842 self.position.qty = 0.0;
843 self.position.entry_price = 0.0;
844 self.position.unrealized_pnl = 0.0;
845 }
846 }
847 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
848 self.history_fills = snapshot.fills.clone();
849 self.rebuild_fill_markers_from_history(&snapshot.fills);
850 }
851 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
852 self.recompute_initial_equity_from_history();
853 }
854 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
855 self.last_order_history_event_ms = snapshot.latest_event_ms;
856 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
857 Self::push_latency_sample(
858 &mut self.network_order_sync_latencies_ms,
859 snapshot.fetch_latency_ms,
860 );
861 self.refresh_history_rows();
862 }
863 AppEvent::StrategyStatsUpdate { strategy_stats } => {
864 rebuild_projection = true;
865 self.strategy_stats = strategy_stats;
866 }
867 AppEvent::RiskRateSnapshot {
868 global,
869 orders,
870 account,
871 market_data,
872 } => {
873 self.rate_budget_global = global;
874 self.rate_budget_orders = orders;
875 self.rate_budget_account = account;
876 self.rate_budget_market_data = market_data;
877 }
878 AppEvent::TickDropped => {
879 self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
880 }
881 AppEvent::LogMessage(msg) => {
882 self.push_log(msg);
883 }
884 AppEvent::Error(msg) => {
885 self.push_log(format!("[ERR] {}", msg));
886 }
887 }
888 self.sync_projection_portfolio_summary();
889 if rebuild_projection {
890 self.rebuild_projection_preserve_focus(prev_focus);
891 } else {
892 self.ensure_projection_focus_defaults();
893 }
894 }
895}
896
897pub fn render(frame: &mut Frame, state: &AppState) {
898 let view = state.view_state();
899 if view.is_grid_open {
900 render_grid_popup(frame, state);
901 if view.is_strategy_editor_open {
902 render_strategy_editor_popup(frame, state);
903 }
904 return;
905 }
906
907 let outer = Layout::default()
908 .direction(Direction::Vertical)
909 .constraints([
910 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
917 .split(frame.area());
918
919 frame.render_widget(
921 StatusBar {
922 symbol: &state.symbol,
923 strategy_label: &state.strategy_label,
924 ws_connected: state.ws_connected,
925 paused: state.paused,
926 timeframe: &state.timeframe,
927 last_price_update_ms: state.last_price_update_ms,
928 last_price_latency_ms: state.last_price_latency_ms,
929 last_order_history_update_ms: state.last_order_history_update_ms,
930 last_order_history_latency_ms: state.last_order_history_latency_ms,
931 },
932 outer[0],
933 );
934
935 let main_area = Layout::default()
937 .direction(Direction::Horizontal)
938 .constraints([Constraint::Min(40), Constraint::Length(24)])
939 .split(outer[1]);
940
941 let current_price = state.last_price();
943 frame.render_widget(
944 PriceChart::new(&state.candles, &state.symbol)
945 .current_candle(state.current_candle.as_ref())
946 .fill_markers(&state.fill_markers)
947 .fast_sma(state.fast_sma)
948 .slow_sma(state.slow_sma),
949 main_area[0],
950 );
951
952 frame.render_widget(
954 PositionPanel::new(
955 &state.position,
956 current_price,
957 &state.balances,
958 state.initial_equity_usdt,
959 state.current_equity_usdt,
960 state.history_trade_count,
961 state.history_realized_pnl,
962 &state.last_applied_fee,
963 ),
964 main_area[1],
965 );
966
967 frame.render_widget(
969 OrderLogPanel::new(
970 &state.last_signal,
971 &state.last_order,
972 state.fast_sma,
973 state.slow_sma,
974 state.history_trade_count,
975 state.history_win_count,
976 state.history_lose_count,
977 state.history_realized_pnl,
978 ),
979 outer[2],
980 );
981
982 frame.render_widget(
984 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
985 outer[3],
986 );
987
988 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
990
991 frame.render_widget(KeybindBar, outer[5]);
993
994 if view.is_symbol_selector_open {
995 render_selector_popup(
996 frame,
997 " Select Symbol ",
998 &state.symbol_items,
999 view.selected_symbol_selector_index,
1000 None,
1001 None,
1002 None,
1003 );
1004 } else if view.is_strategy_selector_open {
1005 let selected_strategy_symbol = state
1006 .strategy_item_symbols
1007 .get(view.selected_strategy_selector_index)
1008 .map(String::as_str)
1009 .unwrap_or(state.symbol.as_str());
1010 render_selector_popup(
1011 frame,
1012 " Select Strategy ",
1013 &state.strategy_items,
1014 view.selected_strategy_selector_index,
1015 Some(&state.strategy_stats),
1016 Some(OrderHistoryStats {
1017 trade_count: state.history_trade_count,
1018 win_count: state.history_win_count,
1019 lose_count: state.history_lose_count,
1020 realized_pnl: state.history_realized_pnl,
1021 }),
1022 Some(selected_strategy_symbol),
1023 );
1024 } else if view.is_account_popup_open {
1025 render_account_popup(frame, &state.balances);
1026 } else if view.is_history_popup_open {
1027 render_history_popup(frame, &state.history_rows, state.history_bucket);
1028 } else if view.is_focus_popup_open {
1029 render_focus_popup(frame, state);
1030 } else if view.is_strategy_editor_open {
1031 render_strategy_editor_popup(frame, state);
1032 }
1033}
1034
1035fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1036 let area = frame.area();
1037 let popup = Rect {
1038 x: area.x + 1,
1039 y: area.y + 1,
1040 width: area.width.saturating_sub(2).max(70),
1041 height: area.height.saturating_sub(2).max(22),
1042 };
1043 frame.render_widget(Clear, popup);
1044 let block = Block::default()
1045 .title(" Focus View (Drill-down) ")
1046 .borders(Borders::ALL)
1047 .border_style(Style::default().fg(Color::Green));
1048 let inner = block.inner(popup);
1049 frame.render_widget(block, popup);
1050
1051 let rows = Layout::default()
1052 .direction(Direction::Vertical)
1053 .constraints([
1054 Constraint::Length(2),
1055 Constraint::Min(8),
1056 Constraint::Length(7),
1057 ])
1058 .split(inner);
1059
1060 let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1061 let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1062 frame.render_widget(
1063 Paragraph::new(vec![
1064 Line::from(vec![
1065 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1066 Span::styled(
1067 focus_symbol,
1068 Style::default()
1069 .fg(Color::Cyan)
1070 .add_modifier(Modifier::BOLD),
1071 ),
1072 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
1073 Span::styled(
1074 focus_strategy,
1075 Style::default()
1076 .fg(Color::Magenta)
1077 .add_modifier(Modifier::BOLD),
1078 ),
1079 ]),
1080 Line::from(Span::styled(
1081 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1082 Style::default().fg(Color::DarkGray),
1083 )),
1084 ]),
1085 rows[0],
1086 );
1087
1088 let main_cols = Layout::default()
1089 .direction(Direction::Horizontal)
1090 .constraints([Constraint::Min(48), Constraint::Length(28)])
1091 .split(rows[1]);
1092
1093 frame.render_widget(
1094 PriceChart::new(&state.candles, focus_symbol)
1095 .current_candle(state.current_candle.as_ref())
1096 .fill_markers(&state.fill_markers)
1097 .fast_sma(state.fast_sma)
1098 .slow_sma(state.slow_sma),
1099 main_cols[0],
1100 );
1101 frame.render_widget(
1102 PositionPanel::new(
1103 &state.position,
1104 state.last_price(),
1105 &state.balances,
1106 state.initial_equity_usdt,
1107 state.current_equity_usdt,
1108 state.history_trade_count,
1109 state.history_realized_pnl,
1110 &state.last_applied_fee,
1111 ),
1112 main_cols[1],
1113 );
1114
1115 frame.render_widget(
1116 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1117 rows[2],
1118 );
1119}
1120
1121fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1122 let view = state.view_state();
1123 let area = frame.area();
1124 let popup = area;
1125 frame.render_widget(Clear, popup);
1126 let block = Block::default()
1127 .title(" Portfolio Grid ")
1128 .borders(Borders::ALL)
1129 .border_style(Style::default().fg(Color::Cyan));
1130 let inner = block.inner(popup);
1131 frame.render_widget(block, popup);
1132
1133 let root = Layout::default()
1134 .direction(Direction::Vertical)
1135 .constraints([Constraint::Length(2), Constraint::Min(1)])
1136 .split(inner);
1137 let tab_area = root[0];
1138 let body_area = root[1];
1139
1140 let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1141 let selected = view.selected_grid_tab == tab;
1142 Span::styled(
1143 format!("[{} {}]", key, label),
1144 if selected {
1145 Style::default()
1146 .fg(Color::Yellow)
1147 .add_modifier(Modifier::BOLD)
1148 } else {
1149 Style::default().fg(Color::DarkGray)
1150 },
1151 )
1152 };
1153 frame.render_widget(
1154 Paragraph::new(Line::from(vec![
1155 tab_span(GridTab::Assets, "1", "Assets"),
1156 Span::raw(" "),
1157 tab_span(GridTab::Strategies, "2", "Strategies"),
1158 Span::raw(" "),
1159 tab_span(GridTab::Risk, "3", "Risk"),
1160 Span::raw(" "),
1161 tab_span(GridTab::Network, "4", "Network"),
1162 Span::raw(" "),
1163 tab_span(GridTab::SystemLog, "5", "SystemLog"),
1164 ])),
1165 tab_area,
1166 );
1167
1168 let global_pressure =
1169 state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1170 let orders_pressure =
1171 state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1172 let account_pressure =
1173 state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1174 let market_pressure = state.rate_budget_market_data.used as f64
1175 / (state.rate_budget_market_data.limit.max(1) as f64);
1176 let max_pressure = global_pressure
1177 .max(orders_pressure)
1178 .max(account_pressure)
1179 .max(market_pressure);
1180 let (risk_label, risk_color) = if max_pressure >= 0.90 {
1181 ("CRIT", Color::Red)
1182 } else if max_pressure >= 0.70 {
1183 ("WARN", Color::Yellow)
1184 } else {
1185 ("OK", Color::Green)
1186 };
1187
1188 if view.selected_grid_tab == GridTab::Assets {
1189 let chunks = Layout::default()
1190 .direction(Direction::Vertical)
1191 .constraints([Constraint::Min(3), Constraint::Length(1)])
1192 .split(body_area);
1193 let asset_header = Row::new(vec![
1194 Cell::from("Symbol"),
1195 Cell::from("Qty"),
1196 Cell::from("Price"),
1197 Cell::from("RlzPnL"),
1198 Cell::from("UnrPnL"),
1199 ])
1200 .style(Style::default().fg(Color::DarkGray));
1201 let mut asset_rows: Vec<Row> = state
1202 .assets_view()
1203 .iter()
1204 .map(|a| {
1205 let price = a
1206 .last_price
1207 .map(|v| format!("{:.2}", v))
1208 .unwrap_or_else(|| "---".to_string());
1209 Row::new(vec![
1210 Cell::from(a.symbol.clone()),
1211 Cell::from(format!("{:.5}", a.position_qty)),
1212 Cell::from(price),
1213 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1214 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1215 ])
1216 })
1217 .collect();
1218 if asset_rows.is_empty() {
1219 asset_rows.push(
1220 Row::new(vec![
1221 Cell::from("(no assets)"),
1222 Cell::from("-"),
1223 Cell::from("-"),
1224 Cell::from("-"),
1225 Cell::from("-"),
1226 ])
1227 .style(Style::default().fg(Color::DarkGray)),
1228 );
1229 }
1230 frame.render_widget(
1231 Table::new(
1232 asset_rows,
1233 [
1234 Constraint::Length(16),
1235 Constraint::Length(12),
1236 Constraint::Length(10),
1237 Constraint::Length(10),
1238 Constraint::Length(10),
1239 ],
1240 )
1241 .header(asset_header)
1242 .column_spacing(1)
1243 .block(
1244 Block::default()
1245 .title(format!(" Assets | Total {} ", state.assets_view().len()))
1246 .borders(Borders::ALL)
1247 .border_style(Style::default().fg(Color::DarkGray)),
1248 ),
1249 chunks[0],
1250 );
1251 frame.render_widget(Paragraph::new("[1/2/3] tab [G/Esc] close"), chunks[1]);
1252 return;
1253 }
1254
1255 if view.selected_grid_tab == GridTab::Risk {
1256 let chunks = Layout::default()
1257 .direction(Direction::Vertical)
1258 .constraints([
1259 Constraint::Length(2),
1260 Constraint::Length(4),
1261 Constraint::Min(3),
1262 Constraint::Length(1),
1263 ])
1264 .split(body_area);
1265 frame.render_widget(
1266 Paragraph::new(Line::from(vec![
1267 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1268 Span::styled(
1269 risk_label,
1270 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1271 ),
1272 Span::styled(
1273 " (70%=WARN, 90%=CRIT)",
1274 Style::default().fg(Color::DarkGray),
1275 ),
1276 ])),
1277 chunks[0],
1278 );
1279 let risk_rows = vec![
1280 Row::new(vec![
1281 Cell::from("GLOBAL"),
1282 Cell::from(format!(
1283 "{}/{}",
1284 state.rate_budget_global.used, state.rate_budget_global.limit
1285 )),
1286 Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1287 ]),
1288 Row::new(vec![
1289 Cell::from("ORDERS"),
1290 Cell::from(format!(
1291 "{}/{}",
1292 state.rate_budget_orders.used, state.rate_budget_orders.limit
1293 )),
1294 Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1295 ]),
1296 Row::new(vec![
1297 Cell::from("ACCOUNT"),
1298 Cell::from(format!(
1299 "{}/{}",
1300 state.rate_budget_account.used, state.rate_budget_account.limit
1301 )),
1302 Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1303 ]),
1304 Row::new(vec![
1305 Cell::from("MARKET"),
1306 Cell::from(format!(
1307 "{}/{}",
1308 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1309 )),
1310 Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1311 ]),
1312 ];
1313 frame.render_widget(
1314 Table::new(
1315 risk_rows,
1316 [
1317 Constraint::Length(10),
1318 Constraint::Length(16),
1319 Constraint::Length(12),
1320 ],
1321 )
1322 .header(Row::new(vec![
1323 Cell::from("Group"),
1324 Cell::from("Used/Limit"),
1325 Cell::from("Reset In"),
1326 ]))
1327 .column_spacing(1)
1328 .block(
1329 Block::default()
1330 .title(" Risk Budgets ")
1331 .borders(Borders::ALL)
1332 .border_style(Style::default().fg(Color::DarkGray)),
1333 ),
1334 chunks[1],
1335 );
1336 let recent_rejections: Vec<&String> = state
1337 .log_messages
1338 .iter()
1339 .filter(|m| m.contains("[ERR] Rejected"))
1340 .rev()
1341 .take(20)
1342 .collect();
1343 let mut lines = vec![Line::from(Span::styled(
1344 "Recent Rejections",
1345 Style::default()
1346 .fg(Color::Cyan)
1347 .add_modifier(Modifier::BOLD),
1348 ))];
1349 for msg in recent_rejections.into_iter().rev() {
1350 lines.push(Line::from(Span::styled(
1351 msg.as_str(),
1352 Style::default().fg(Color::Red),
1353 )));
1354 }
1355 if lines.len() == 1 {
1356 lines.push(Line::from(Span::styled(
1357 "(no rejections yet)",
1358 Style::default().fg(Color::DarkGray),
1359 )));
1360 }
1361 frame.render_widget(
1362 Paragraph::new(lines).block(
1363 Block::default()
1364 .borders(Borders::ALL)
1365 .border_style(Style::default().fg(Color::DarkGray)),
1366 ),
1367 chunks[2],
1368 );
1369 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[3]);
1370 return;
1371 }
1372
1373 if view.selected_grid_tab == GridTab::Network {
1374 let chunks = Layout::default()
1375 .direction(Direction::Vertical)
1376 .constraints([
1377 Constraint::Length(2),
1378 Constraint::Min(6),
1379 Constraint::Length(1),
1380 ])
1381 .split(body_area);
1382 let network_state = if !state.ws_connected {
1383 ("CRIT", Color::Red)
1384 } else if state.network_tick_drop_count > 0 || state.network_reconnect_count > 0 {
1385 ("WARN", Color::Yellow)
1386 } else {
1387 ("OK", Color::Green)
1388 };
1389 frame.render_widget(
1390 Paragraph::new(Line::from(vec![
1391 Span::styled("Network: ", Style::default().fg(Color::DarkGray)),
1392 Span::styled(
1393 network_state.0,
1394 Style::default()
1395 .fg(network_state.1)
1396 .add_modifier(Modifier::BOLD),
1397 ),
1398 Span::styled(" WS: ", Style::default().fg(Color::DarkGray)),
1399 Span::styled(
1400 if state.ws_connected {
1401 "CONNECTED"
1402 } else {
1403 "DISCONNECTED"
1404 },
1405 Style::default().fg(if state.ws_connected {
1406 Color::Green
1407 } else {
1408 Color::Red
1409 }),
1410 ),
1411 Span::styled(
1412 format!(
1413 " reconnect={} tick_drop={}",
1414 state.network_reconnect_count, state.network_tick_drop_count
1415 ),
1416 Style::default().fg(Color::DarkGray),
1417 ),
1418 ])),
1419 chunks[0],
1420 );
1421
1422 let tick_stats = latency_stats(&state.network_tick_latencies_ms);
1423 let fill_stats = latency_stats(&state.network_fill_latencies_ms);
1424 let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
1425 let last_fill_age = state
1426 .network_last_fill_ms
1427 .map(|ts| {
1428 format_age_ms((chrono::Utc::now().timestamp_millis() as u64).saturating_sub(ts))
1429 })
1430 .unwrap_or_else(|| "-".to_string());
1431 let rows = vec![
1432 Row::new(vec![
1433 Cell::from("Tick Latency"),
1434 Cell::from(tick_stats.0),
1435 Cell::from(tick_stats.1),
1436 Cell::from(tick_stats.2),
1437 Cell::from(
1438 state
1439 .last_price_latency_ms
1440 .map(|v| format!("{}ms", v))
1441 .unwrap_or_else(|| "-".to_string()),
1442 ),
1443 ]),
1444 Row::new(vec![
1445 Cell::from("Fill Latency"),
1446 Cell::from(fill_stats.0),
1447 Cell::from(fill_stats.1),
1448 Cell::from(fill_stats.2),
1449 Cell::from(last_fill_age),
1450 ]),
1451 Row::new(vec![
1452 Cell::from("Order Sync"),
1453 Cell::from(sync_stats.0),
1454 Cell::from(sync_stats.1),
1455 Cell::from(sync_stats.2),
1456 Cell::from(
1457 state
1458 .last_order_history_latency_ms
1459 .map(|v| format!("{}ms", v))
1460 .unwrap_or_else(|| "-".to_string()),
1461 ),
1462 ]),
1463 ];
1464 frame.render_widget(
1465 Table::new(
1466 rows,
1467 [
1468 Constraint::Length(14),
1469 Constraint::Length(12),
1470 Constraint::Length(12),
1471 Constraint::Length(12),
1472 Constraint::Length(14),
1473 ],
1474 )
1475 .header(Row::new(vec![
1476 Cell::from("Metric"),
1477 Cell::from("p50"),
1478 Cell::from("p95"),
1479 Cell::from("max"),
1480 Cell::from("last/age"),
1481 ]))
1482 .column_spacing(1)
1483 .block(
1484 Block::default()
1485 .title(" Network Metrics ")
1486 .borders(Borders::ALL)
1487 .border_style(Style::default().fg(Color::DarkGray)),
1488 ),
1489 chunks[1],
1490 );
1491 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[2]);
1492 return;
1493 }
1494
1495 if view.selected_grid_tab == GridTab::SystemLog {
1496 let chunks = Layout::default()
1497 .direction(Direction::Vertical)
1498 .constraints([Constraint::Min(6), Constraint::Length(1)])
1499 .split(body_area);
1500 let max_rows = chunks[0].height.saturating_sub(2) as usize;
1501 let mut log_rows: Vec<Row> = state
1502 .log_messages
1503 .iter()
1504 .rev()
1505 .take(max_rows.max(1))
1506 .rev()
1507 .map(|line| Row::new(vec![Cell::from(line.clone())]))
1508 .collect();
1509 if log_rows.is_empty() {
1510 log_rows.push(
1511 Row::new(vec![Cell::from("(no system logs yet)")])
1512 .style(Style::default().fg(Color::DarkGray)),
1513 );
1514 }
1515 frame.render_widget(
1516 Table::new(log_rows, [Constraint::Min(1)])
1517 .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
1518 .column_spacing(1)
1519 .block(
1520 Block::default()
1521 .title(" System Log ")
1522 .borders(Borders::ALL)
1523 .border_style(Style::default().fg(Color::DarkGray)),
1524 ),
1525 chunks[0],
1526 );
1527 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[1]);
1528 return;
1529 }
1530
1531 let selected_symbol = state
1532 .symbol_items
1533 .get(view.selected_symbol_index)
1534 .map(String::as_str)
1535 .unwrap_or(state.symbol.as_str());
1536 let strategy_chunks = Layout::default()
1537 .direction(Direction::Vertical)
1538 .constraints([
1539 Constraint::Length(2),
1540 Constraint::Length(3),
1541 Constraint::Min(12),
1542 Constraint::Length(1),
1543 ])
1544 .split(body_area);
1545
1546 let mut on_indices: Vec<usize> = Vec::new();
1547 let mut off_indices: Vec<usize> = Vec::new();
1548 for idx in 0..state.strategy_items.len() {
1549 if state
1550 .strategy_item_active
1551 .get(idx)
1552 .copied()
1553 .unwrap_or(false)
1554 {
1555 on_indices.push(idx);
1556 } else {
1557 off_indices.push(idx);
1558 }
1559 }
1560 let on_weight = on_indices.len().max(1) as u32;
1561 let off_weight = off_indices.len().max(1) as u32;
1562
1563 frame.render_widget(
1564 Paragraph::new(Line::from(vec![
1565 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1566 Span::styled(
1567 risk_label,
1568 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1569 ),
1570 Span::styled(" GLOBAL ", Style::default().fg(Color::DarkGray)),
1571 Span::styled(
1572 format!(
1573 "{}/{}",
1574 state.rate_budget_global.used, state.rate_budget_global.limit
1575 ),
1576 Style::default().fg(if global_pressure >= 0.9 {
1577 Color::Red
1578 } else if global_pressure >= 0.7 {
1579 Color::Yellow
1580 } else {
1581 Color::Cyan
1582 }),
1583 ),
1584 Span::styled(" ORD ", Style::default().fg(Color::DarkGray)),
1585 Span::styled(
1586 format!(
1587 "{}/{}",
1588 state.rate_budget_orders.used, state.rate_budget_orders.limit
1589 ),
1590 Style::default().fg(if orders_pressure >= 0.9 {
1591 Color::Red
1592 } else if orders_pressure >= 0.7 {
1593 Color::Yellow
1594 } else {
1595 Color::Cyan
1596 }),
1597 ),
1598 Span::styled(" ACC ", Style::default().fg(Color::DarkGray)),
1599 Span::styled(
1600 format!(
1601 "{}/{}",
1602 state.rate_budget_account.used, state.rate_budget_account.limit
1603 ),
1604 Style::default().fg(if account_pressure >= 0.9 {
1605 Color::Red
1606 } else if account_pressure >= 0.7 {
1607 Color::Yellow
1608 } else {
1609 Color::Cyan
1610 }),
1611 ),
1612 Span::styled(" MKT ", Style::default().fg(Color::DarkGray)),
1613 Span::styled(
1614 format!(
1615 "{}/{}",
1616 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1617 ),
1618 Style::default().fg(if market_pressure >= 0.9 {
1619 Color::Red
1620 } else if market_pressure >= 0.7 {
1621 Color::Yellow
1622 } else {
1623 Color::Cyan
1624 }),
1625 ),
1626 ])),
1627 strategy_chunks[0],
1628 );
1629
1630 let strategy_area = strategy_chunks[2];
1631 let min_panel_height: u16 = 6;
1632 let total_height = strategy_area.height;
1633 let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
1634 let total_weight = on_weight + off_weight;
1635 let mut on_h =
1636 ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
1637 let max_on_h = total_height.saturating_sub(min_panel_height);
1638 if on_h > max_on_h {
1639 on_h = max_on_h;
1640 }
1641 let off_h = total_height.saturating_sub(on_h);
1642 (on_h, off_h)
1643 } else {
1644 let on_h = (total_height / 2).max(1);
1645 let off_h = total_height.saturating_sub(on_h).max(1);
1646 (on_h, off_h)
1647 };
1648 let on_area = Rect {
1649 x: strategy_area.x,
1650 y: strategy_area.y,
1651 width: strategy_area.width,
1652 height: on_height,
1653 };
1654 let off_area = Rect {
1655 x: strategy_area.x,
1656 y: strategy_area.y.saturating_add(on_height),
1657 width: strategy_area.width,
1658 height: off_height,
1659 };
1660
1661 let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
1662 indices
1663 .iter()
1664 .map(|idx| {
1665 state
1666 .strategy_items
1667 .get(*idx)
1668 .and_then(|item| strategy_stats_for_item(&state.strategy_stats, item))
1669 .map(|s| s.realized_pnl)
1670 .unwrap_or(0.0)
1671 })
1672 .sum()
1673 };
1674 let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1675 let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1676 let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1677
1678 let total_row = Row::new(vec![
1679 Cell::from("ON Total"),
1680 Cell::from(on_indices.len().to_string()),
1681 Cell::from(format!("{:+.4}", on_pnl_sum)),
1682 Cell::from("OFF Total"),
1683 Cell::from(off_indices.len().to_string()),
1684 Cell::from(format!("{:+.4}", off_pnl_sum)),
1685 Cell::from("All Total"),
1686 Cell::from(format!("{:+.4}", total_pnl_sum)),
1687 ]);
1688 let total_table = Table::new(
1689 vec![total_row],
1690 [
1691 Constraint::Length(10),
1692 Constraint::Length(5),
1693 Constraint::Length(12),
1694 Constraint::Length(10),
1695 Constraint::Length(5),
1696 Constraint::Length(12),
1697 Constraint::Length(10),
1698 Constraint::Length(12),
1699 ],
1700 )
1701 .column_spacing(1)
1702 .block(
1703 Block::default()
1704 .title(" Total ")
1705 .borders(Borders::ALL)
1706 .border_style(Style::default().fg(Color::DarkGray)),
1707 );
1708 frame.render_widget(total_table, strategy_chunks[1]);
1709
1710 let render_strategy_window = |frame: &mut Frame,
1711 area: Rect,
1712 title: &str,
1713 indices: &[usize],
1714 state: &AppState,
1715 pnl_sum: f64,
1716 selected_panel: bool| {
1717 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1718 let inner_height = area.height.saturating_sub(2);
1719 let row_capacity = inner_height.saturating_sub(1) as usize;
1720 let selected_pos = indices
1721 .iter()
1722 .position(|idx| *idx == view.selected_strategy_index);
1723 let window_start = if row_capacity == 0 {
1724 0
1725 } else if let Some(pos) = selected_pos {
1726 pos.saturating_sub(row_capacity.saturating_sub(1))
1727 } else {
1728 0
1729 };
1730 let window_end = if row_capacity == 0 {
1731 0
1732 } else {
1733 (window_start + row_capacity).min(indices.len())
1734 };
1735 let visible_indices = if indices.is_empty() || row_capacity == 0 {
1736 &indices[0..0]
1737 } else {
1738 &indices[window_start..window_end]
1739 };
1740 let header = Row::new(vec![
1741 Cell::from(" "),
1742 Cell::from("Symbol"),
1743 Cell::from("Strategy"),
1744 Cell::from("Run"),
1745 Cell::from("Last"),
1746 Cell::from("Px"),
1747 Cell::from("Age"),
1748 Cell::from("W"),
1749 Cell::from("L"),
1750 Cell::from("T"),
1751 Cell::from("PnL"),
1752 ])
1753 .style(Style::default().fg(Color::DarkGray));
1754 let mut rows: Vec<Row> = visible_indices
1755 .iter()
1756 .map(|idx| {
1757 let row_symbol = state
1758 .strategy_item_symbols
1759 .get(*idx)
1760 .map(String::as_str)
1761 .unwrap_or("-");
1762 let item = state
1763 .strategy_items
1764 .get(*idx)
1765 .cloned()
1766 .unwrap_or_else(|| "-".to_string());
1767 let running = state
1768 .strategy_item_total_running_ms
1769 .get(*idx)
1770 .copied()
1771 .map(format_running_time)
1772 .unwrap_or_else(|| "-".to_string());
1773 let stats = strategy_stats_for_item(&state.strategy_stats, &item);
1774 let source_tag = source_tag_for_strategy_item(&item);
1775 let last_evt = source_tag
1776 .as_ref()
1777 .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
1778 let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
1779 let age = now_ms.saturating_sub(evt.timestamp_ms);
1780 let age_txt = if age < 1_000 {
1781 format!("{}ms", age)
1782 } else if age < 60_000 {
1783 format!("{}s", age / 1_000)
1784 } else {
1785 format!("{}m", age / 60_000)
1786 };
1787 let side_txt = match evt.side {
1788 OrderSide::Buy => "BUY",
1789 OrderSide::Sell => "SELL",
1790 };
1791 let px_txt = evt
1792 .price
1793 .map(|v| format!("{:.2}", v))
1794 .unwrap_or_else(|| "-".to_string());
1795 let style = match evt.side {
1796 OrderSide::Buy => Style::default()
1797 .fg(Color::Green)
1798 .add_modifier(Modifier::BOLD),
1799 OrderSide::Sell => {
1800 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
1801 }
1802 };
1803 (side_txt.to_string(), px_txt, age_txt, style)
1804 } else {
1805 (
1806 "-".to_string(),
1807 "-".to_string(),
1808 "-".to_string(),
1809 Style::default().fg(Color::DarkGray),
1810 )
1811 };
1812 let (w, l, t, pnl) = if let Some(s) = stats {
1813 (
1814 s.win_count.to_string(),
1815 s.lose_count.to_string(),
1816 s.trade_count.to_string(),
1817 format!("{:+.4}", s.realized_pnl),
1818 )
1819 } else {
1820 (
1821 "0".to_string(),
1822 "0".to_string(),
1823 "0".to_string(),
1824 "+0.0000".to_string(),
1825 )
1826 };
1827 let marker = if *idx == view.selected_strategy_index {
1828 "▶"
1829 } else {
1830 " "
1831 };
1832 let mut row = Row::new(vec![
1833 Cell::from(marker),
1834 Cell::from(row_symbol.to_string()),
1835 Cell::from(item),
1836 Cell::from(running),
1837 Cell::from(last_label).style(last_style),
1838 Cell::from(last_px),
1839 Cell::from(last_age),
1840 Cell::from(w),
1841 Cell::from(l),
1842 Cell::from(t),
1843 Cell::from(pnl),
1844 ]);
1845 if *idx == view.selected_strategy_index {
1846 row = row.style(
1847 Style::default()
1848 .fg(Color::Yellow)
1849 .add_modifier(Modifier::BOLD),
1850 );
1851 }
1852 row
1853 })
1854 .collect();
1855
1856 if rows.is_empty() {
1857 rows.push(
1858 Row::new(vec![
1859 Cell::from(" "),
1860 Cell::from("-"),
1861 Cell::from("(empty)"),
1862 Cell::from("-"),
1863 Cell::from("-"),
1864 Cell::from("-"),
1865 Cell::from("-"),
1866 Cell::from("-"),
1867 Cell::from("-"),
1868 Cell::from("-"),
1869 Cell::from("-"),
1870 ])
1871 .style(Style::default().fg(Color::DarkGray)),
1872 );
1873 }
1874
1875 let table = Table::new(
1876 rows,
1877 [
1878 Constraint::Length(2),
1879 Constraint::Length(12),
1880 Constraint::Min(14),
1881 Constraint::Length(9),
1882 Constraint::Length(5),
1883 Constraint::Length(9),
1884 Constraint::Length(6),
1885 Constraint::Length(3),
1886 Constraint::Length(3),
1887 Constraint::Length(4),
1888 Constraint::Length(11),
1889 ],
1890 )
1891 .header(header)
1892 .column_spacing(1)
1893 .block(
1894 Block::default()
1895 .title(format!(
1896 "{} | Total {:+.4} | {}/{}",
1897 title,
1898 pnl_sum,
1899 visible_indices.len(),
1900 indices.len()
1901 ))
1902 .borders(Borders::ALL)
1903 .border_style(if selected_panel {
1904 Style::default().fg(Color::Yellow)
1905 } else if risk_label == "CRIT" {
1906 Style::default().fg(Color::Red)
1907 } else if risk_label == "WARN" {
1908 Style::default().fg(Color::Yellow)
1909 } else {
1910 Style::default().fg(Color::DarkGray)
1911 }),
1912 );
1913 frame.render_widget(table, area);
1914 };
1915
1916 render_strategy_window(
1917 frame,
1918 on_area,
1919 " ON Strategies ",
1920 &on_indices,
1921 state,
1922 on_pnl_sum,
1923 view.is_on_panel_selected,
1924 );
1925 render_strategy_window(
1926 frame,
1927 off_area,
1928 " OFF Strategies ",
1929 &off_indices,
1930 state,
1931 off_pnl_sum,
1932 !view.is_on_panel_selected,
1933 );
1934 frame.render_widget(
1935 Paragraph::new(Line::from(vec![
1936 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1937 Span::styled(
1938 selected_symbol,
1939 Style::default()
1940 .fg(Color::Green)
1941 .add_modifier(Modifier::BOLD),
1942 ),
1943 Span::styled(
1944 " [1/2/3/4]tab [Tab]panel [N]new [C]cfg [O]on/off [X]del [J/K]strategy [H/L]symbol [Enter/F]run [G/Esc]close",
1945 Style::default().fg(Color::DarkGray),
1946 ),
1947 ])),
1948 strategy_chunks[3],
1949 );
1950}
1951
1952fn format_running_time(total_running_ms: u64) -> String {
1953 let total_sec = total_running_ms / 1000;
1954 let days = total_sec / 86_400;
1955 let hours = (total_sec % 86_400) / 3_600;
1956 let minutes = (total_sec % 3_600) / 60;
1957 if days > 0 {
1958 format!("{}d {:02}h", days, hours)
1959 } else {
1960 format!("{:02}h {:02}m", hours, minutes)
1961 }
1962}
1963
1964fn format_age_ms(age_ms: u64) -> String {
1965 if age_ms < 1_000 {
1966 format!("{}ms", age_ms)
1967 } else if age_ms < 60_000 {
1968 format!("{}s", age_ms / 1_000)
1969 } else {
1970 format!("{}m", age_ms / 60_000)
1971 }
1972}
1973
1974fn latency_stats(samples: &[u64]) -> (String, String, String) {
1975 if samples.is_empty() {
1976 return ("-".to_string(), "-".to_string(), "-".to_string());
1977 }
1978 let mut sorted = samples.to_vec();
1979 sorted.sort_unstable();
1980 let len = sorted.len();
1981 let p50 = sorted[(len * 50 / 100).min(len - 1)];
1982 let p95 = sorted[(len * 95 / 100).min(len - 1)];
1983 let max = *sorted.last().unwrap_or(&0);
1984 (
1985 format!("{}ms", p50),
1986 format!("{}ms", p95),
1987 format!("{}ms", max),
1988 )
1989}
1990
1991fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
1992 let area = frame.area();
1993 let popup = Rect {
1994 x: area.x + 8,
1995 y: area.y + 4,
1996 width: area.width.saturating_sub(16).max(50),
1997 height: area.height.saturating_sub(8).max(12),
1998 };
1999 frame.render_widget(Clear, popup);
2000 let block = Block::default()
2001 .title(" Strategy Config ")
2002 .borders(Borders::ALL)
2003 .border_style(Style::default().fg(Color::Yellow));
2004 let inner = block.inner(popup);
2005 frame.render_widget(block, popup);
2006 let selected_name = state
2007 .strategy_items
2008 .get(state.strategy_editor_index)
2009 .map(String::as_str)
2010 .unwrap_or("Unknown");
2011 let rows = [
2012 (
2013 "Symbol",
2014 state
2015 .symbol_items
2016 .get(state.strategy_editor_symbol_index)
2017 .cloned()
2018 .unwrap_or_else(|| state.symbol.clone()),
2019 ),
2020 ("Fast Period", state.strategy_editor_fast.to_string()),
2021 ("Slow Period", state.strategy_editor_slow.to_string()),
2022 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
2023 ];
2024 let mut lines = vec![
2025 Line::from(vec![
2026 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
2027 Span::styled(
2028 selected_name,
2029 Style::default()
2030 .fg(Color::White)
2031 .add_modifier(Modifier::BOLD),
2032 ),
2033 ]),
2034 Line::from(Span::styled(
2035 "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
2036 Style::default().fg(Color::DarkGray),
2037 )),
2038 ];
2039 for (idx, (name, value)) in rows.iter().enumerate() {
2040 let marker = if idx == state.strategy_editor_field {
2041 "▶ "
2042 } else {
2043 " "
2044 };
2045 let style = if idx == state.strategy_editor_field {
2046 Style::default()
2047 .fg(Color::Yellow)
2048 .add_modifier(Modifier::BOLD)
2049 } else {
2050 Style::default().fg(Color::White)
2051 };
2052 lines.push(Line::from(vec![
2053 Span::styled(marker, Style::default().fg(Color::Yellow)),
2054 Span::styled(format!("{:<14}", name), style),
2055 Span::styled(value, style),
2056 ]));
2057 }
2058 frame.render_widget(Paragraph::new(lines), inner);
2059}
2060
2061fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
2062 let area = frame.area();
2063 let popup = Rect {
2064 x: area.x + 4,
2065 y: area.y + 2,
2066 width: area.width.saturating_sub(8).max(30),
2067 height: area.height.saturating_sub(4).max(10),
2068 };
2069 frame.render_widget(Clear, popup);
2070 let block = Block::default()
2071 .title(" Account Assets ")
2072 .borders(Borders::ALL)
2073 .border_style(Style::default().fg(Color::Cyan));
2074 let inner = block.inner(popup);
2075 frame.render_widget(block, popup);
2076
2077 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
2078 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
2079
2080 let mut lines = Vec::with_capacity(assets.len() + 2);
2081 lines.push(Line::from(vec![
2082 Span::styled(
2083 "Asset",
2084 Style::default()
2085 .fg(Color::Cyan)
2086 .add_modifier(Modifier::BOLD),
2087 ),
2088 Span::styled(
2089 " Free",
2090 Style::default()
2091 .fg(Color::Cyan)
2092 .add_modifier(Modifier::BOLD),
2093 ),
2094 ]));
2095 for (asset, qty) in assets {
2096 lines.push(Line::from(vec![
2097 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
2098 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
2099 ]));
2100 }
2101 if lines.len() == 1 {
2102 lines.push(Line::from(Span::styled(
2103 "No assets",
2104 Style::default().fg(Color::DarkGray),
2105 )));
2106 }
2107
2108 frame.render_widget(Paragraph::new(lines), inner);
2109}
2110
2111fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
2112 let area = frame.area();
2113 let popup = Rect {
2114 x: area.x + 2,
2115 y: area.y + 1,
2116 width: area.width.saturating_sub(4).max(40),
2117 height: area.height.saturating_sub(2).max(12),
2118 };
2119 frame.render_widget(Clear, popup);
2120 let block = Block::default()
2121 .title(match bucket {
2122 order_store::HistoryBucket::Day => " History (Day ROI) ",
2123 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
2124 order_store::HistoryBucket::Month => " History (Month ROI) ",
2125 })
2126 .borders(Borders::ALL)
2127 .border_style(Style::default().fg(Color::Cyan));
2128 let inner = block.inner(popup);
2129 frame.render_widget(block, popup);
2130
2131 let max_rows = inner.height.saturating_sub(1) as usize;
2132 let mut visible: Vec<Line> = Vec::new();
2133 for (idx, row) in rows.iter().take(max_rows).enumerate() {
2134 let color = if idx == 0 {
2135 Color::Cyan
2136 } else if row.contains('-') && row.contains('%') {
2137 Color::White
2138 } else {
2139 Color::DarkGray
2140 };
2141 visible.push(Line::from(Span::styled(
2142 row.clone(),
2143 Style::default().fg(color),
2144 )));
2145 }
2146 if visible.is_empty() {
2147 visible.push(Line::from(Span::styled(
2148 "No history rows",
2149 Style::default().fg(Color::DarkGray),
2150 )));
2151 }
2152 frame.render_widget(Paragraph::new(visible), inner);
2153}
2154
2155fn render_selector_popup(
2156 frame: &mut Frame,
2157 title: &str,
2158 items: &[String],
2159 selected: usize,
2160 stats: Option<&HashMap<String, OrderHistoryStats>>,
2161 total_stats: Option<OrderHistoryStats>,
2162 selected_symbol: Option<&str>,
2163) {
2164 let area = frame.area();
2165 let available_width = area.width.saturating_sub(2).max(1);
2166 let width = if stats.is_some() {
2167 let min_width = 44;
2168 let preferred = 84;
2169 preferred
2170 .min(available_width)
2171 .max(min_width.min(available_width))
2172 } else {
2173 let min_width = 24;
2174 let preferred = 48;
2175 preferred
2176 .min(available_width)
2177 .max(min_width.min(available_width))
2178 };
2179 let available_height = area.height.saturating_sub(2).max(1);
2180 let desired_height = if stats.is_some() {
2181 items.len() as u16 + 7
2182 } else {
2183 items.len() as u16 + 4
2184 };
2185 let height = desired_height
2186 .min(available_height)
2187 .max(6.min(available_height));
2188 let popup = Rect {
2189 x: area.x + (area.width.saturating_sub(width)) / 2,
2190 y: area.y + (area.height.saturating_sub(height)) / 2,
2191 width,
2192 height,
2193 };
2194
2195 frame.render_widget(Clear, popup);
2196 let block = Block::default()
2197 .title(title)
2198 .borders(Borders::ALL)
2199 .border_style(Style::default().fg(Color::Cyan));
2200 let inner = block.inner(popup);
2201 frame.render_widget(block, popup);
2202
2203 let mut lines: Vec<Line> = Vec::new();
2204 if stats.is_some() {
2205 if let Some(symbol) = selected_symbol {
2206 lines.push(Line::from(vec![
2207 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
2208 Span::styled(
2209 symbol,
2210 Style::default()
2211 .fg(Color::Green)
2212 .add_modifier(Modifier::BOLD),
2213 ),
2214 ]));
2215 }
2216 lines.push(Line::from(vec![Span::styled(
2217 " Strategy W L T PnL",
2218 Style::default()
2219 .fg(Color::Cyan)
2220 .add_modifier(Modifier::BOLD),
2221 )]));
2222 }
2223
2224 let mut item_lines: Vec<Line> = items
2225 .iter()
2226 .enumerate()
2227 .map(|(idx, item)| {
2228 let item_text = if let Some(stats_map) = stats {
2229 if let Some(s) = strategy_stats_for_item(stats_map, item) {
2230 format!(
2231 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2232 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
2233 )
2234 } else {
2235 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
2236 }
2237 } else {
2238 item.clone()
2239 };
2240 if idx == selected {
2241 Line::from(vec![
2242 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
2243 Span::styled(
2244 item_text,
2245 Style::default()
2246 .fg(Color::White)
2247 .add_modifier(Modifier::BOLD),
2248 ),
2249 ])
2250 } else {
2251 Line::from(vec![
2252 Span::styled(" ", Style::default()),
2253 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
2254 ])
2255 }
2256 })
2257 .collect();
2258 lines.append(&mut item_lines);
2259 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
2260 let mut strategy_sum = OrderHistoryStats::default();
2261 for item in items {
2262 if let Some(s) = strategy_stats_for_item(stats_map, item) {
2263 strategy_sum.trade_count += s.trade_count;
2264 strategy_sum.win_count += s.win_count;
2265 strategy_sum.lose_count += s.lose_count;
2266 strategy_sum.realized_pnl += s.realized_pnl;
2267 }
2268 }
2269 let manual = subtract_stats(t, &strategy_sum);
2270 lines.push(Line::from(vec![Span::styled(
2271 format!(
2272 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2273 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
2274 ),
2275 Style::default().fg(Color::LightBlue),
2276 )]));
2277 }
2278 if let Some(t) = total_stats {
2279 lines.push(Line::from(vec![Span::styled(
2280 format!(
2281 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2282 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
2283 ),
2284 Style::default()
2285 .fg(Color::Yellow)
2286 .add_modifier(Modifier::BOLD),
2287 )]));
2288 }
2289
2290 frame.render_widget(
2291 Paragraph::new(lines).style(Style::default().fg(Color::White)),
2292 inner,
2293 );
2294}
2295
2296fn strategy_stats_for_item<'a>(
2297 stats_map: &'a HashMap<String, OrderHistoryStats>,
2298 item: &str,
2299) -> Option<&'a OrderHistoryStats> {
2300 if let Some(s) = stats_map.get(item) {
2301 return Some(s);
2302 }
2303 let source_tag = source_tag_for_strategy_item(item);
2304 source_tag.and_then(|tag| {
2305 stats_map
2306 .get(&tag)
2307 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
2308 })
2309}
2310
2311fn source_tag_for_strategy_item(item: &str) -> Option<String> {
2312 match item {
2313 "MA(Config)" => return Some("cfg".to_string()),
2314 "MA(Fast 5/20)" => return Some("fst".to_string()),
2315 "MA(Slow 20/60)" => return Some("slw".to_string()),
2316 _ => {}
2317 }
2318 if let Some((_, tail)) = item.rsplit_once('[') {
2319 if let Some(tag) = tail.strip_suffix(']') {
2320 let tag = tag.trim();
2321 if !tag.is_empty() {
2322 return Some(tag.to_ascii_lowercase());
2323 }
2324 }
2325 }
2326 None
2327}
2328
2329fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
2330 let body = client_order_id.strip_prefix("sq-")?;
2331 let (source_tag, _) = body.split_once('-')?;
2332 if source_tag.is_empty() {
2333 None
2334 } else {
2335 Some(source_tag)
2336 }
2337}
2338
2339fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
2340 OrderHistoryStats {
2341 trade_count: total.trade_count.saturating_sub(used.trade_count),
2342 win_count: total.win_count.saturating_sub(used.win_count),
2343 lose_count: total.lose_count.saturating_sub(used.lose_count),
2344 realized_pnl: total.realized_pnl - used.realized_pnl,
2345 }
2346}
2347
2348fn split_symbol_assets(symbol: &str) -> (String, String) {
2349 const QUOTE_SUFFIXES: [&str; 10] = [
2350 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
2351 ];
2352 for q in QUOTE_SUFFIXES {
2353 if let Some(base) = symbol.strip_suffix(q) {
2354 if !base.is_empty() {
2355 return (base.to_string(), q.to_string());
2356 }
2357 }
2358 }
2359 (symbol.to_string(), String::new())
2360}
2361
2362fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
2363 if fills.is_empty() {
2364 return None;
2365 }
2366 let (base_asset, quote_asset) = split_symbol_assets(symbol);
2367 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
2368 let mut notional_quote = 0.0;
2369 let mut fee_quote_equiv = 0.0;
2370 let mut quote_convertible = !quote_asset.is_empty();
2371
2372 for f in fills {
2373 if f.qty > 0.0 && f.price > 0.0 {
2374 notional_quote += f.qty * f.price;
2375 }
2376 if f.commission <= 0.0 {
2377 continue;
2378 }
2379 *fee_by_asset
2380 .entry(f.commission_asset.clone())
2381 .or_insert(0.0) += f.commission;
2382 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
2383 fee_quote_equiv += f.commission;
2384 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
2385 fee_quote_equiv += f.commission * f.price.max(0.0);
2386 } else {
2387 quote_convertible = false;
2388 }
2389 }
2390
2391 if fee_by_asset.is_empty() {
2392 return Some("0".to_string());
2393 }
2394
2395 if quote_convertible && notional_quote > f64::EPSILON {
2396 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
2397 return Some(format!(
2398 "{:.3}% ({:.4} {})",
2399 fee_pct, fee_quote_equiv, quote_asset
2400 ));
2401 }
2402
2403 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
2404 items.sort_by(|a, b| a.0.cmp(&b.0));
2405 if items.len() == 1 {
2406 let (asset, amount) = &items[0];
2407 Some(format!("{:.6} {}", amount, asset))
2408 } else {
2409 Some(format!("mixed fees ({})", items.len()))
2410 }
2411}
2412
2413#[cfg(test)]
2414mod tests {
2415 use super::format_last_applied_fee;
2416 use crate::model::order::Fill;
2417
2418 #[test]
2419 fn fee_summary_from_quote_asset_commission() {
2420 let fills = vec![Fill {
2421 price: 2000.0,
2422 qty: 0.5,
2423 commission: 1.0,
2424 commission_asset: "USDT".to_string(),
2425 }];
2426 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2427 assert_eq!(summary, "0.100% (1.0000 USDT)");
2428 }
2429
2430 #[test]
2431 fn fee_summary_from_base_asset_commission() {
2432 let fills = vec![Fill {
2433 price: 2000.0,
2434 qty: 0.5,
2435 commission: 0.0005,
2436 commission_asset: "ETH".to_string(),
2437 }];
2438 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2439 assert_eq!(summary, "0.100% (1.0000 USDT)");
2440 }
2441}