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