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