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