1pub mod chart;
2pub mod dashboard;
3pub mod network_metrics;
4pub mod position_ledger;
5pub mod ui_projection;
6
7use std::collections::HashMap;
8
9use ratatui::layout::{Constraint, Direction, Layout, Rect};
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
13use ratatui::Frame;
14
15use crate::event::{
16 AppEvent, AssetPnlEntry, EvSnapshotEntry, ExitPolicyEntry, LogDomain, LogLevel, LogRecord,
17 PredictorMetricEntry, WsConnectionStatus,
18};
19use crate::model::candle::{Candle, CandleBuilder};
20use crate::model::order::{Fill, OrderSide};
21use crate::model::position::Position;
22use crate::model::signal::Signal;
23use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
24use crate::order_store;
25use crate::risk_module::RateBudgetSnapshot;
26use crate::strategy_catalog::{strategy_kind_categories, strategy_kind_labels};
27use crate::ui::network_metrics::{
28 classify_health, count_since, percentile, rate_per_sec, ratio_pct, NetworkHealth,
29};
30use crate::ui::position_ledger::build_open_order_positions_from_trades;
31
32use chart::{FillMarker, PriceChart};
33use dashboard::{
34 KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar,
35 StrategyMetricsPanel,
36};
37use ui_projection::AssetEntry;
38use ui_projection::UiProjection;
39
40const MAX_LOG_MESSAGES: usize = 200;
41const MAX_FILL_MARKERS: usize = 200;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum GridTab {
45 Assets,
46 Strategies,
47 Risk,
48 Network,
49 History,
50 Positions,
51 Predictors,
52 SystemLog,
53}
54
55#[derive(Debug, Clone)]
56pub struct StrategyLastEvent {
57 pub side: OrderSide,
58 pub price: Option<f64>,
59 pub timestamp_ms: u64,
60 pub is_filled: bool,
61}
62
63#[derive(Debug, Clone)]
64pub struct ViewState {
65 pub is_grid_open: bool,
66 pub selected_grid_tab: GridTab,
67 pub selected_symbol_index: usize,
68 pub selected_strategy_index: usize,
69 pub is_on_panel_selected: bool,
70 pub is_symbol_selector_open: bool,
71 pub selected_symbol_selector_index: usize,
72 pub is_strategy_selector_open: bool,
73 pub selected_strategy_selector_index: usize,
74 pub is_account_popup_open: bool,
75 pub is_history_popup_open: bool,
76 pub is_focus_popup_open: bool,
77 pub is_close_all_confirm_open: bool,
78 pub is_strategy_editor_open: bool,
79}
80
81pub struct AppState {
82 pub symbol: String,
83 pub strategy_label: String,
84 pub candles: Vec<Candle>,
85 pub current_candle: Option<CandleBuilder>,
86 pub candle_interval_ms: u64,
87 pub timeframe: String,
88 pub price_history_len: usize,
89 pub position: Position,
90 pub last_signal: Option<Signal>,
91 pub last_order: Option<OrderUpdate>,
92 pub open_order_history: Vec<String>,
93 pub filled_order_history: Vec<String>,
94 pub fast_sma: Option<f64>,
95 pub slow_sma: Option<f64>,
96 pub ws_connected: bool,
97 pub paused: bool,
98 pub tick_count: u64,
99 pub log_messages: Vec<String>,
100 pub log_records: Vec<LogRecord>,
101 pub balances: HashMap<String, f64>,
102 pub initial_equity_usdt: Option<f64>,
103 pub current_equity_usdt: Option<f64>,
104 pub history_estimated_total_pnl_usdt: Option<f64>,
105 pub fill_markers: Vec<FillMarker>,
106 pub history_trade_count: u32,
107 pub history_win_count: u32,
108 pub history_lose_count: u32,
109 pub history_realized_pnl: f64,
110 pub asset_pnl_by_symbol: HashMap<String, AssetPnlEntry>,
111 pub strategy_stats: HashMap<String, OrderHistoryStats>,
112 pub ev_snapshot_by_scope: HashMap<String, EvSnapshotEntry>,
113 pub exit_policy_by_scope: HashMap<String, ExitPolicyEntry>,
114 pub predictor_metrics_by_scope: HashMap<String, PredictorMetricEntry>,
115 pub history_fills: Vec<OrderHistoryFill>,
116 pub last_price_update_ms: Option<u64>,
117 pub last_price_event_ms: Option<u64>,
118 pub last_price_latency_ms: Option<u64>,
119 pub last_order_history_update_ms: Option<u64>,
120 pub last_order_history_event_ms: Option<u64>,
121 pub last_order_history_latency_ms: Option<u64>,
122 pub trade_stats_reset_warned: bool,
123 pub symbol_selector_open: bool,
124 pub symbol_selector_index: usize,
125 pub symbol_items: Vec<String>,
126 pub strategy_selector_open: bool,
127 pub strategy_selector_index: usize,
128 pub strategy_items: Vec<String>,
129 pub strategy_item_symbols: Vec<String>,
130 pub strategy_item_active: Vec<bool>,
131 pub strategy_item_created_at_ms: Vec<i64>,
132 pub strategy_item_total_running_ms: Vec<u64>,
133 pub account_popup_open: bool,
134 pub history_popup_open: bool,
135 pub focus_popup_open: bool,
136 pub close_all_confirm_open: bool,
137 pub strategy_editor_open: bool,
138 pub strategy_editor_kind_category_selector_open: bool,
139 pub strategy_editor_kind_selector_open: bool,
140 pub strategy_editor_index: usize,
141 pub strategy_editor_field: usize,
142 pub strategy_editor_kind_category_items: Vec<String>,
143 pub strategy_editor_kind_category_index: usize,
144 pub strategy_editor_kind_popup_items: Vec<String>,
145 pub strategy_editor_kind_popup_labels: Vec<Option<String>>,
146 pub strategy_editor_kind_items: Vec<String>,
147 pub strategy_editor_kind_selector_index: usize,
148 pub strategy_editor_kind_index: usize,
149 pub strategy_editor_symbol_index: usize,
150 pub strategy_editor_fast: usize,
151 pub strategy_editor_slow: usize,
152 pub strategy_editor_cooldown: u64,
153 pub grid_symbol_index: usize,
154 pub grid_strategy_index: usize,
155 pub grid_select_on_panel: bool,
156 pub grid_tab: GridTab,
157 pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
158 pub network_tick_drop_count: u64,
159 pub network_reconnect_count: u64,
160 pub network_tick_latencies_ms: Vec<u64>,
161 pub network_fill_latencies_ms: Vec<u64>,
162 pub network_order_sync_latencies_ms: Vec<u64>,
163 pub network_tick_in_timestamps_ms: Vec<u64>,
164 pub network_tick_drop_timestamps_ms: Vec<u64>,
165 pub network_reconnect_timestamps_ms: Vec<u64>,
166 pub network_disconnect_timestamps_ms: Vec<u64>,
167 pub network_last_fill_ms: Option<u64>,
168 pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
169 pub history_rows: Vec<String>,
170 pub history_bucket: order_store::HistoryBucket,
171 pub last_applied_fee: String,
172 pub grid_open: bool,
173 pub ui_projection: UiProjection,
174 pub rate_budget_global: RateBudgetSnapshot,
175 pub rate_budget_orders: RateBudgetSnapshot,
176 pub rate_budget_account: RateBudgetSnapshot,
177 pub rate_budget_market_data: RateBudgetSnapshot,
178 pub close_all_running: bool,
179 pub close_all_job_id: Option<u64>,
180 pub close_all_total: usize,
181 pub close_all_completed: usize,
182 pub close_all_failed: usize,
183 pub close_all_current_symbol: Option<String>,
184 pub close_all_status_expire_at_ms: Option<u64>,
185 pub close_all_row_status_by_symbol: HashMap<String, String>,
186 pub hide_small_positions: bool,
187 pub hide_empty_predictor_rows: bool,
188 pub predictor_scroll_offset: usize,
189}
190
191impl AppState {
192 pub fn new(
193 symbol: &str,
194 strategy_label: &str,
195 price_history_len: usize,
196 candle_interval_ms: u64,
197 timeframe: &str,
198 ) -> Self {
199 Self {
200 symbol: symbol.to_string(),
201 strategy_label: strategy_label.to_string(),
202 candles: Vec::with_capacity(price_history_len),
203 current_candle: None,
204 candle_interval_ms,
205 timeframe: timeframe.to_string(),
206 price_history_len,
207 position: Position::new(symbol.to_string()),
208 last_signal: None,
209 last_order: None,
210 open_order_history: Vec::new(),
211 filled_order_history: Vec::new(),
212 fast_sma: None,
213 slow_sma: None,
214 ws_connected: false,
215 paused: false,
216 tick_count: 0,
217 log_messages: Vec::new(),
218 log_records: Vec::new(),
219 balances: HashMap::new(),
220 initial_equity_usdt: None,
221 current_equity_usdt: None,
222 history_estimated_total_pnl_usdt: None,
223 fill_markers: Vec::new(),
224 history_trade_count: 0,
225 history_win_count: 0,
226 history_lose_count: 0,
227 history_realized_pnl: 0.0,
228 asset_pnl_by_symbol: HashMap::new(),
229 strategy_stats: HashMap::new(),
230 ev_snapshot_by_scope: HashMap::new(),
231 exit_policy_by_scope: HashMap::new(),
232 predictor_metrics_by_scope: HashMap::new(),
233 history_fills: Vec::new(),
234 last_price_update_ms: None,
235 last_price_event_ms: None,
236 last_price_latency_ms: None,
237 last_order_history_update_ms: None,
238 last_order_history_event_ms: None,
239 last_order_history_latency_ms: None,
240 trade_stats_reset_warned: false,
241 symbol_selector_open: false,
242 symbol_selector_index: 0,
243 symbol_items: Vec::new(),
244 strategy_selector_open: false,
245 strategy_selector_index: 0,
246 strategy_items: vec![
247 "MA(Config)".to_string(),
248 "MA(Fast 5/20)".to_string(),
249 "MA(Slow 20/60)".to_string(),
250 "RSA(RSI 14 30/70)".to_string(),
251 ],
252 strategy_item_symbols: vec![
253 symbol.to_ascii_uppercase(),
254 symbol.to_ascii_uppercase(),
255 symbol.to_ascii_uppercase(),
256 symbol.to_ascii_uppercase(),
257 ],
258 strategy_item_active: vec![false, false, false, false],
259 strategy_item_created_at_ms: vec![0, 0, 0, 0],
260 strategy_item_total_running_ms: vec![0, 0, 0, 0],
261 account_popup_open: false,
262 history_popup_open: false,
263 focus_popup_open: false,
264 close_all_confirm_open: false,
265 strategy_editor_open: false,
266 strategy_editor_kind_category_selector_open: false,
267 strategy_editor_kind_selector_open: false,
268 strategy_editor_index: 0,
269 strategy_editor_field: 0,
270 strategy_editor_kind_category_items: strategy_kind_categories(),
271 strategy_editor_kind_category_index: 0,
272 strategy_editor_kind_popup_items: Vec::new(),
273 strategy_editor_kind_popup_labels: Vec::new(),
274 strategy_editor_kind_items: strategy_kind_labels(),
275 strategy_editor_kind_selector_index: 0,
276 strategy_editor_kind_index: 0,
277 strategy_editor_symbol_index: 0,
278 strategy_editor_fast: 5,
279 strategy_editor_slow: 20,
280 strategy_editor_cooldown: 1,
281 grid_symbol_index: 0,
282 grid_strategy_index: 0,
283 grid_select_on_panel: true,
284 grid_tab: GridTab::Strategies,
285 strategy_last_event_by_tag: HashMap::new(),
286 network_tick_drop_count: 0,
287 network_reconnect_count: 0,
288 network_tick_latencies_ms: Vec::new(),
289 network_fill_latencies_ms: Vec::new(),
290 network_order_sync_latencies_ms: Vec::new(),
291 network_tick_in_timestamps_ms: Vec::new(),
292 network_tick_drop_timestamps_ms: Vec::new(),
293 network_reconnect_timestamps_ms: Vec::new(),
294 network_disconnect_timestamps_ms: Vec::new(),
295 network_last_fill_ms: None,
296 network_pending_submit_ms_by_intent: HashMap::new(),
297 history_rows: Vec::new(),
298 history_bucket: order_store::HistoryBucket::Day,
299 last_applied_fee: "---".to_string(),
300 grid_open: false,
301 ui_projection: UiProjection::new(),
302 rate_budget_global: RateBudgetSnapshot {
303 used: 0,
304 limit: 0,
305 reset_in_ms: 0,
306 },
307 rate_budget_orders: RateBudgetSnapshot {
308 used: 0,
309 limit: 0,
310 reset_in_ms: 0,
311 },
312 rate_budget_account: RateBudgetSnapshot {
313 used: 0,
314 limit: 0,
315 reset_in_ms: 0,
316 },
317 rate_budget_market_data: RateBudgetSnapshot {
318 used: 0,
319 limit: 0,
320 reset_in_ms: 0,
321 },
322 close_all_running: false,
323 close_all_job_id: None,
324 close_all_total: 0,
325 close_all_completed: 0,
326 close_all_failed: 0,
327 close_all_current_symbol: None,
328 close_all_status_expire_at_ms: None,
329 close_all_row_status_by_symbol: HashMap::new(),
330 hide_small_positions: true,
331 hide_empty_predictor_rows: true,
332 predictor_scroll_offset: 0,
333 }
334 }
335
336 pub fn last_price(&self) -> Option<f64> {
338 self.current_candle
339 .as_ref()
340 .map(|cb| cb.close)
341 .or_else(|| self.candles.last().map(|c| c.close))
342 }
343
344 pub fn push_log(&mut self, msg: String) {
345 self.log_messages.push(msg);
346 if self.log_messages.len() > MAX_LOG_MESSAGES {
347 self.log_messages.remove(0);
348 }
349 }
350
351 pub fn push_log_record(&mut self, record: LogRecord) {
352 self.log_records.push(record.clone());
353 if self.log_records.len() > MAX_LOG_MESSAGES {
354 self.log_records.remove(0);
355 }
356 self.push_log(format_log_record_compact(&record));
357 }
358
359 fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
360 const MAX_SAMPLES: usize = 200;
361 samples.push(value);
362 if samples.len() > MAX_SAMPLES {
363 let drop_n = samples.len() - MAX_SAMPLES;
364 samples.drain(..drop_n);
365 }
366 }
367
368 fn push_network_event_sample(samples: &mut Vec<u64>, ts_ms: u64) {
369 samples.push(ts_ms);
370 let lower = ts_ms.saturating_sub(60_000);
371 samples.retain(|&v| v >= lower);
372 }
373
374 fn prune_network_event_windows(&mut self, now_ms: u64) {
375 let lower = now_ms.saturating_sub(60_000);
376 self.network_tick_in_timestamps_ms.retain(|&v| v >= lower);
377 self.network_tick_drop_timestamps_ms.retain(|&v| v >= lower);
378 self.network_reconnect_timestamps_ms.retain(|&v| v >= lower);
379 self.network_disconnect_timestamps_ms
380 .retain(|&v| v >= lower);
381 }
382
383 pub fn view_state(&self) -> ViewState {
386 ViewState {
387 is_grid_open: self.grid_open,
388 selected_grid_tab: self.grid_tab,
389 selected_symbol_index: self.grid_symbol_index,
390 selected_strategy_index: self.grid_strategy_index,
391 is_on_panel_selected: self.grid_select_on_panel,
392 is_symbol_selector_open: self.symbol_selector_open,
393 selected_symbol_selector_index: self.symbol_selector_index,
394 is_strategy_selector_open: self.strategy_selector_open,
395 selected_strategy_selector_index: self.strategy_selector_index,
396 is_account_popup_open: self.account_popup_open,
397 is_history_popup_open: self.history_popup_open,
398 is_focus_popup_open: self.focus_popup_open,
399 is_close_all_confirm_open: self.close_all_confirm_open,
400 is_strategy_editor_open: self.strategy_editor_open,
401 }
402 }
403
404 pub fn is_grid_open(&self) -> bool {
405 self.grid_open
406 }
407 pub fn set_grid_open(&mut self, open: bool) {
408 self.grid_open = open;
409 }
410 pub fn grid_tab(&self) -> GridTab {
411 self.grid_tab
412 }
413 pub fn set_grid_tab(&mut self, tab: GridTab) {
414 self.grid_tab = tab;
415 if tab != GridTab::Predictors {
416 self.predictor_scroll_offset = 0;
417 }
418 }
419 pub fn selected_grid_symbol_index(&self) -> usize {
420 self.grid_symbol_index
421 }
422 pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
423 self.grid_symbol_index = idx;
424 }
425 pub fn selected_grid_strategy_index(&self) -> usize {
426 self.grid_strategy_index
427 }
428 pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
429 self.grid_strategy_index = idx;
430 }
431 pub fn is_on_panel_selected(&self) -> bool {
432 self.grid_select_on_panel
433 }
434 pub fn set_on_panel_selected(&mut self, selected: bool) {
435 self.grid_select_on_panel = selected;
436 }
437 pub fn predictor_scroll_offset(&self) -> usize {
438 self.predictor_scroll_offset
439 }
440 pub fn set_predictor_scroll_offset(&mut self, offset: usize) {
441 self.predictor_scroll_offset = offset;
442 }
443 pub fn is_symbol_selector_open(&self) -> bool {
444 self.symbol_selector_open
445 }
446 pub fn set_symbol_selector_open(&mut self, open: bool) {
447 self.symbol_selector_open = open;
448 }
449 pub fn symbol_selector_index(&self) -> usize {
450 self.symbol_selector_index
451 }
452 pub fn set_symbol_selector_index(&mut self, idx: usize) {
453 self.symbol_selector_index = idx;
454 }
455 pub fn is_strategy_selector_open(&self) -> bool {
456 self.strategy_selector_open
457 }
458 pub fn set_strategy_selector_open(&mut self, open: bool) {
459 self.strategy_selector_open = open;
460 }
461 pub fn strategy_selector_index(&self) -> usize {
462 self.strategy_selector_index
463 }
464 pub fn set_strategy_selector_index(&mut self, idx: usize) {
465 self.strategy_selector_index = idx;
466 }
467 pub fn is_account_popup_open(&self) -> bool {
468 self.account_popup_open
469 }
470 pub fn set_account_popup_open(&mut self, open: bool) {
471 self.account_popup_open = open;
472 }
473 pub fn is_history_popup_open(&self) -> bool {
474 self.history_popup_open
475 }
476 pub fn set_history_popup_open(&mut self, open: bool) {
477 self.history_popup_open = open;
478 }
479 pub fn is_focus_popup_open(&self) -> bool {
480 self.focus_popup_open
481 }
482 pub fn set_focus_popup_open(&mut self, open: bool) {
483 self.focus_popup_open = open;
484 }
485 pub fn is_close_all_confirm_open(&self) -> bool {
486 self.close_all_confirm_open
487 }
488 pub fn set_close_all_confirm_open(&mut self, open: bool) {
489 self.close_all_confirm_open = open;
490 }
491 pub fn is_close_all_running(&self) -> bool {
492 self.close_all_running
493 }
494 pub fn close_all_status_text(&self) -> Option<String> {
495 let Some(job_id) = self.close_all_job_id else {
496 return None;
497 };
498 if self.close_all_total == 0 {
499 return Some(format!("close-all #{} RUNNING 0/0", job_id));
500 }
501 let ok = self
502 .close_all_completed
503 .saturating_sub(self.close_all_failed);
504 let current = self
505 .close_all_current_symbol
506 .as_ref()
507 .map(|s| format!(" current={}", s))
508 .unwrap_or_default();
509 let status = if self.close_all_running {
510 "RUNNING"
511 } else if self.close_all_failed == 0 {
512 "DONE"
513 } else if ok == 0 {
514 "FAILED"
515 } else {
516 "PARTIAL"
517 };
518 Some(format!(
519 "close-all #{} {} {}/{} ok:{} fail:{}{}",
520 job_id,
521 status,
522 self.close_all_completed,
523 self.close_all_total,
524 ok,
525 self.close_all_failed,
526 current
527 ))
528 }
529 pub fn is_strategy_editor_open(&self) -> bool {
530 self.strategy_editor_open
531 }
532 pub fn set_strategy_editor_open(&mut self, open: bool) {
533 self.strategy_editor_open = open;
534 }
535 pub fn focus_symbol(&self) -> Option<&str> {
536 self.ui_projection.focus.symbol.as_deref()
537 }
538 pub fn focus_strategy_id(&self) -> Option<&str> {
539 self.ui_projection.focus.strategy_id.as_deref()
540 }
541 pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
542 self.ui_projection.focus.symbol = symbol;
543 }
544 pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
545 self.ui_projection.focus.strategy_id = strategy_id;
546 }
547 pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
548 (
549 self.ui_projection.focus.symbol.clone(),
550 self.ui_projection.focus.strategy_id.clone(),
551 )
552 }
553 pub fn assets_view(&self) -> &[AssetEntry] {
554 &self.ui_projection.assets
555 }
556
557 pub fn refresh_history_rows(&mut self) {
558 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
559 Ok(rows) => {
560 use std::collections::{BTreeMap, BTreeSet};
561
562 let mut date_set: BTreeSet<String> = BTreeSet::new();
563 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
564 for row in rows {
565 date_set.insert(row.date.clone());
566 ticker_map
567 .entry(row.symbol.clone())
568 .or_default()
569 .insert(row.date, row.realized_return_pct);
570 }
571
572 let mut dates: Vec<String> = date_set.into_iter().collect();
574 dates.sort();
575 const MAX_DATE_COLS: usize = 6;
576 if dates.len() > MAX_DATE_COLS {
577 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
578 }
579
580 let mut lines = Vec::new();
581 if dates.is_empty() {
582 lines.push("Ticker (no daily realized roi data)".to_string());
583 self.history_rows = lines;
584 return;
585 }
586
587 let mut header = format!("{:<14}", "Ticker");
588 for d in &dates {
589 header.push_str(&format!(" {:>10}", d));
590 }
591 lines.push(header);
592
593 for (ticker, by_date) in ticker_map {
594 let mut line = format!("{:<14}", ticker);
595 for d in &dates {
596 let cell = by_date
597 .get(d)
598 .map(|v| format!("{:.2}%", v))
599 .unwrap_or_else(|| "-".to_string());
600 line.push_str(&format!(" {:>10}", cell));
601 }
602 lines.push(line);
603 }
604 self.history_rows = lines;
605 }
606 Err(e) => {
607 self.history_rows = vec![
608 "Ticker Date RealizedROI RealizedPnL".to_string(),
609 format!("(failed to load history: {})", e),
610 ];
611 }
612 }
613 }
614
615 fn refresh_equity_usdt(&mut self) {
616 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
617 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
618 let mark_price = self
619 .last_price()
620 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
621 if let Some(price) = mark_price {
622 let total = usdt + btc * price;
623 self.current_equity_usdt = Some(total);
624 self.recompute_initial_equity_from_history();
625 }
626 }
627
628 fn recompute_initial_equity_from_history(&mut self) {
629 if let Some(current) = self.current_equity_usdt {
630 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
631 self.initial_equity_usdt = Some(current - total_pnl);
632 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
633 self.initial_equity_usdt = Some(current);
634 }
635 }
636 }
637
638 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
639 if let Some((idx, _)) = self
640 .candles
641 .iter()
642 .enumerate()
643 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
644 {
645 return Some(idx);
646 }
647 if let Some(cb) = &self.current_candle {
648 if cb.contains(timestamp_ms) {
649 return Some(self.candles.len());
650 }
651 }
652 if let Some((idx, _)) = self
655 .candles
656 .iter()
657 .enumerate()
658 .rev()
659 .find(|(_, c)| c.open_time <= timestamp_ms)
660 {
661 return Some(idx);
662 }
663 None
664 }
665
666 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
667 self.fill_markers.clear();
668 for fill in fills {
669 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
670 self.fill_markers.push(FillMarker {
671 candle_index,
672 price: fill.price,
673 side: fill.side,
674 });
675 }
676 }
677 if self.fill_markers.len() > MAX_FILL_MARKERS {
678 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
679 self.fill_markers.drain(..excess);
680 }
681 }
682
683 fn sync_projection_portfolio_summary(&mut self) {
684 self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
685 self.ui_projection.portfolio.total_realized_pnl_usdt = self.history_realized_pnl;
686 self.ui_projection.portfolio.total_unrealized_pnl_usdt = self.position.unrealized_pnl;
687 self.ui_projection.portfolio.ws_connected = self.ws_connected;
688 }
689
690 fn ensure_projection_focus_defaults(&mut self) {
691 if self.ui_projection.focus.symbol.is_none() {
692 self.ui_projection.focus.symbol = Some(self.symbol.clone());
693 }
694 if self.ui_projection.focus.strategy_id.is_none() {
695 self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
696 }
697 }
698
699 fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
700 let mut next = UiProjection::from_legacy(self);
701 if prev_focus.0.is_some() {
702 next.focus.symbol = prev_focus.0;
703 }
704 if prev_focus.1.is_some() {
705 next.focus.strategy_id = prev_focus.1;
706 }
707 self.ui_projection = next;
708 self.ensure_projection_focus_defaults();
709 }
710
711 pub fn apply(&mut self, event: AppEvent) {
712 let prev_focus = self.focus_pair();
713 let mut rebuild_projection = false;
714 match event {
715 AppEvent::MarketTick(tick) => {
716 rebuild_projection = true;
717 self.tick_count += 1;
718 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
719 self.last_price_update_ms = Some(now_ms);
720 self.last_price_event_ms = Some(tick.timestamp_ms);
721 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
722 Self::push_network_event_sample(&mut self.network_tick_in_timestamps_ms, now_ms);
723 if let Some(lat) = self.last_price_latency_ms {
724 Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
725 }
726
727 let should_new = match &self.current_candle {
729 Some(cb) => !cb.contains(tick.timestamp_ms),
730 None => true,
731 };
732 if should_new {
733 if let Some(cb) = self.current_candle.take() {
734 self.candles.push(cb.finish());
735 if self.candles.len() > self.price_history_len {
736 self.candles.remove(0);
737 self.fill_markers.retain_mut(|m| {
739 if m.candle_index == 0 {
740 false
741 } else {
742 m.candle_index -= 1;
743 true
744 }
745 });
746 }
747 }
748 self.current_candle = Some(CandleBuilder::new(
749 tick.price,
750 tick.timestamp_ms,
751 self.candle_interval_ms,
752 ));
753 } else if let Some(cb) = self.current_candle.as_mut() {
754 cb.update(tick.price);
755 } else {
756 self.current_candle = Some(CandleBuilder::new(
758 tick.price,
759 tick.timestamp_ms,
760 self.candle_interval_ms,
761 ));
762 self.push_log("[WARN] Recovered missing current candle state".to_string());
763 }
764
765 self.position.update_unrealized_pnl(tick.price);
766 self.refresh_equity_usdt();
767 }
768 AppEvent::StrategySignal {
769 ref signal,
770 symbol,
771 source_tag,
772 price,
773 timestamp_ms,
774 } => {
775 self.last_signal = Some(signal.clone());
776 let source_tag = source_tag.to_ascii_lowercase();
777 match signal {
778 Signal::Buy { .. } => {
779 let should_emit = self
780 .strategy_last_event_by_tag
781 .get(&source_tag)
782 .map(|e| {
783 e.side != OrderSide::Buy
784 || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000
785 })
786 .unwrap_or(true);
787 if should_emit {
788 let mut record = LogRecord::new(
789 LogLevel::Info,
790 LogDomain::Strategy,
791 "signal.emit",
792 format!(
793 "side=BUY price={}",
794 price
795 .map(|v| format!("{:.4}", v))
796 .unwrap_or_else(|| "-".to_string())
797 ),
798 );
799 record.symbol = Some(symbol.clone());
800 record.strategy_tag = Some(source_tag.clone());
801 self.push_log_record(record);
802 }
803 self.strategy_last_event_by_tag.insert(
804 source_tag.clone(),
805 StrategyLastEvent {
806 side: OrderSide::Buy,
807 price,
808 timestamp_ms,
809 is_filled: false,
810 },
811 );
812 }
813 Signal::Sell { .. } => {
814 let should_emit = self
815 .strategy_last_event_by_tag
816 .get(&source_tag)
817 .map(|e| {
818 e.side != OrderSide::Sell
819 || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000
820 })
821 .unwrap_or(true);
822 if should_emit {
823 let mut record = LogRecord::new(
824 LogLevel::Info,
825 LogDomain::Strategy,
826 "signal.emit",
827 format!(
828 "side=SELL price={}",
829 price
830 .map(|v| format!("{:.4}", v))
831 .unwrap_or_else(|| "-".to_string())
832 ),
833 );
834 record.symbol = Some(symbol.clone());
835 record.strategy_tag = Some(source_tag.clone());
836 self.push_log_record(record);
837 }
838 self.strategy_last_event_by_tag.insert(
839 source_tag.clone(),
840 StrategyLastEvent {
841 side: OrderSide::Sell,
842 price,
843 timestamp_ms,
844 is_filled: false,
845 },
846 );
847 }
848 Signal::Hold => {}
849 }
850 }
851 AppEvent::StrategyState { fast_sma, slow_sma } => {
852 self.fast_sma = fast_sma;
853 self.slow_sma = slow_sma;
854 }
855 AppEvent::OrderUpdate(ref update) => {
856 rebuild_projection = true;
857 match update {
858 OrderUpdate::Filled {
859 intent_id,
860 client_order_id,
861 side,
862 fills,
863 avg_price,
864 } => {
865 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
866 let source_tag = parse_source_tag_from_client_order_id(client_order_id)
867 .map(|s| s.to_ascii_lowercase());
868 if let Some(submit_ms) =
869 self.network_pending_submit_ms_by_intent.remove(intent_id)
870 {
871 Self::push_latency_sample(
872 &mut self.network_fill_latencies_ms,
873 now_ms.saturating_sub(submit_ms),
874 );
875 } else if let Some(signal_ms) = source_tag
876 .as_deref()
877 .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
878 .map(|e| e.timestamp_ms)
879 {
880 Self::push_latency_sample(
882 &mut self.network_fill_latencies_ms,
883 now_ms.saturating_sub(signal_ms),
884 );
885 }
886 self.network_last_fill_ms = Some(now_ms);
887 if let Some(source_tag) = source_tag {
888 self.strategy_last_event_by_tag.insert(
889 source_tag,
890 StrategyLastEvent {
891 side: *side,
892 price: Some(*avg_price),
893 timestamp_ms: now_ms,
894 is_filled: true,
895 },
896 );
897 }
898 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
899 self.last_applied_fee = summary;
900 }
901 self.position.apply_fill(*side, fills);
902 self.refresh_equity_usdt();
903 let candle_index = if self.current_candle.is_some() {
904 self.candles.len()
905 } else {
906 self.candles.len().saturating_sub(1)
907 };
908 self.fill_markers.push(FillMarker {
909 candle_index,
910 price: *avg_price,
911 side: *side,
912 });
913 if self.fill_markers.len() > MAX_FILL_MARKERS {
914 self.fill_markers.remove(0);
915 }
916 let mut record = LogRecord::new(
917 LogLevel::Info,
918 LogDomain::Order,
919 "fill.received",
920 format!(
921 "side={} client_order_id={} intent_id={} avg_price={:.2}",
922 side, client_order_id, intent_id, avg_price
923 ),
924 );
925 record.symbol = Some(self.symbol.clone());
926 record.strategy_tag =
927 parse_source_tag_from_client_order_id(client_order_id)
928 .map(|s| s.to_ascii_lowercase());
929 self.push_log_record(record);
930 }
931 OrderUpdate::Submitted {
932 intent_id,
933 client_order_id,
934 server_order_id,
935 } => {
936 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
937 self.network_pending_submit_ms_by_intent
938 .insert(intent_id.clone(), now_ms);
939 self.refresh_equity_usdt();
940 let mut record = LogRecord::new(
941 LogLevel::Info,
942 LogDomain::Order,
943 "submit.accepted",
944 format!(
945 "client_order_id={} server_order_id={} intent_id={}",
946 client_order_id, server_order_id, intent_id
947 ),
948 );
949 record.symbol = Some(self.symbol.clone());
950 record.strategy_tag =
951 parse_source_tag_from_client_order_id(client_order_id)
952 .map(|s| s.to_ascii_lowercase());
953 self.push_log_record(record);
954 }
955 OrderUpdate::Rejected {
956 intent_id,
957 client_order_id,
958 reason_code,
959 reason,
960 } => {
961 let level = if reason_code == "risk.qty_too_small" {
962 LogLevel::Warn
963 } else {
964 LogLevel::Error
965 };
966 let mut record = LogRecord::new(
967 level,
968 LogDomain::Order,
969 "reject.received",
970 format!(
971 "client_order_id={} intent_id={} reason_code={} reason={}",
972 client_order_id, intent_id, reason_code, reason
973 ),
974 );
975 record.symbol = Some(self.symbol.clone());
976 record.strategy_tag =
977 parse_source_tag_from_client_order_id(client_order_id)
978 .map(|s| s.to_ascii_lowercase());
979 self.push_log_record(record);
980 }
981 }
982 self.last_order = Some(update.clone());
983 }
984 AppEvent::WsStatus(ref status) => match status {
985 WsConnectionStatus::Connected => {
986 self.ws_connected = true;
987 }
988 WsConnectionStatus::Disconnected => {
989 self.ws_connected = false;
990 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
991 Self::push_network_event_sample(
992 &mut self.network_disconnect_timestamps_ms,
993 now_ms,
994 );
995 self.push_log("[WARN] WebSocket Disconnected".to_string());
996 }
997 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
998 self.ws_connected = false;
999 self.network_reconnect_count += 1;
1000 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1001 Self::push_network_event_sample(
1002 &mut self.network_reconnect_timestamps_ms,
1003 now_ms,
1004 );
1005 self.push_log(format!(
1006 "[WARN] Reconnecting (attempt {}, wait {}ms)",
1007 attempt, delay_ms
1008 ));
1009 }
1010 },
1011 AppEvent::HistoricalCandles {
1012 candles,
1013 interval_ms,
1014 interval,
1015 } => {
1016 rebuild_projection = true;
1017 self.candles = candles;
1018 if self.candles.len() > self.price_history_len {
1019 let excess = self.candles.len() - self.price_history_len;
1020 self.candles.drain(..excess);
1021 }
1022 self.candle_interval_ms = interval_ms;
1023 self.timeframe = interval;
1024 self.current_candle = None;
1025 let fills = self.history_fills.clone();
1026 self.rebuild_fill_markers_from_history(&fills);
1027 self.push_log(format!(
1028 "Switched to {} ({} candles)",
1029 self.timeframe,
1030 self.candles.len()
1031 ));
1032 }
1033 AppEvent::BalanceUpdate(balances) => {
1034 rebuild_projection = true;
1035 self.balances = balances;
1036 self.refresh_equity_usdt();
1037 }
1038 AppEvent::OrderHistoryUpdate(snapshot) => {
1039 rebuild_projection = true;
1040 let mut open = Vec::new();
1041 let mut filled = Vec::new();
1042
1043 for row in snapshot.rows {
1044 let status = row.split_whitespace().nth(1).unwrap_or_default();
1045 if status == "FILLED" {
1046 filled.push(row);
1047 } else {
1048 open.push(row);
1049 }
1050 }
1051
1052 if open.len() > MAX_LOG_MESSAGES {
1053 let excess = open.len() - MAX_LOG_MESSAGES;
1054 open.drain(..excess);
1055 }
1056 if filled.len() > MAX_LOG_MESSAGES {
1057 let excess = filled.len() - MAX_LOG_MESSAGES;
1058 filled.drain(..excess);
1059 }
1060
1061 self.open_order_history = open;
1062 self.filled_order_history = filled;
1063 if snapshot.trade_data_complete {
1064 let stats_looks_reset = snapshot.stats.trade_count == 0
1065 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
1066 if stats_looks_reset {
1067 if !self.trade_stats_reset_warned {
1068 self.push_log(
1069 "[WARN] Ignored transient trade stats reset from order-history sync"
1070 .to_string(),
1071 );
1072 self.trade_stats_reset_warned = true;
1073 }
1074 } else {
1075 self.trade_stats_reset_warned = false;
1076 self.history_trade_count = snapshot.stats.trade_count;
1077 self.history_win_count = snapshot.stats.win_count;
1078 self.history_lose_count = snapshot.stats.lose_count;
1079 self.history_realized_pnl = snapshot.stats.realized_pnl;
1080 if snapshot.open_qty > f64::EPSILON {
1083 self.position.side = Some(OrderSide::Buy);
1084 self.position.qty = snapshot.open_qty;
1085 self.position.entry_price = snapshot.open_entry_price;
1086 if let Some(px) = self.last_price() {
1087 self.position.unrealized_pnl =
1088 (px - snapshot.open_entry_price) * snapshot.open_qty;
1089 }
1090 } else {
1091 self.position.side = None;
1092 self.position.qty = 0.0;
1093 self.position.entry_price = 0.0;
1094 self.position.unrealized_pnl = 0.0;
1095 }
1096 }
1097 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
1098 self.history_fills = snapshot.fills.clone();
1099 self.rebuild_fill_markers_from_history(&snapshot.fills);
1100 }
1101 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
1102 self.recompute_initial_equity_from_history();
1103 }
1104 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
1105 self.last_order_history_event_ms = snapshot.latest_event_ms;
1106 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
1107 Self::push_latency_sample(
1108 &mut self.network_order_sync_latencies_ms,
1109 snapshot.fetch_latency_ms,
1110 );
1111 self.refresh_history_rows();
1112 }
1113 AppEvent::StrategyStatsUpdate { strategy_stats } => {
1114 rebuild_projection = true;
1115 self.strategy_stats = strategy_stats;
1116 }
1117 AppEvent::EvSnapshotUpdate {
1118 symbol,
1119 source_tag,
1120 ev,
1121 entry_ev,
1122 p_win,
1123 gate_mode,
1124 gate_blocked,
1125 } => {
1126 let key = strategy_stats_scope_key(&symbol, &source_tag);
1127 let prev_entry_ev = self.ev_snapshot_by_scope.get(&key).and_then(|v| v.entry_ev);
1128 self.ev_snapshot_by_scope.insert(
1129 key,
1130 EvSnapshotEntry {
1131 ev,
1132 entry_ev: entry_ev.or(prev_entry_ev),
1133 p_win,
1134 gate_mode,
1135 gate_blocked,
1136 updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1137 },
1138 );
1139 }
1140 AppEvent::PredictorMetricsUpdate {
1141 symbol,
1142 market,
1143 predictor,
1144 horizon,
1145 r2,
1146 hit_rate,
1147 mae,
1148 sample_count,
1149 updated_at_ms,
1150 } => {
1151 let key = predictor_metrics_scope_key(&symbol, &market, &predictor, &horizon);
1152 self.predictor_metrics_by_scope.insert(
1153 key,
1154 PredictorMetricEntry {
1155 symbol,
1156 market,
1157 predictor,
1158 horizon,
1159 r2,
1160 hit_rate,
1161 mae,
1162 sample_count,
1163 updated_at_ms,
1164 },
1165 );
1166 }
1167 AppEvent::ExitPolicyUpdate {
1168 symbol,
1169 source_tag,
1170 stop_price,
1171 expected_holding_ms,
1172 protective_stop_ok,
1173 } => {
1174 let key = strategy_stats_scope_key(&symbol, &source_tag);
1175 self.exit_policy_by_scope.insert(
1176 key,
1177 ExitPolicyEntry {
1178 stop_price,
1179 expected_holding_ms,
1180 protective_stop_ok,
1181 updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1182 },
1183 );
1184 }
1185 AppEvent::AssetPnlUpdate { by_symbol } => {
1186 rebuild_projection = true;
1187 self.asset_pnl_by_symbol = by_symbol;
1188 }
1189 AppEvent::RiskRateSnapshot {
1190 global,
1191 orders,
1192 account,
1193 market_data,
1194 } => {
1195 self.rate_budget_global = global;
1196 self.rate_budget_orders = orders;
1197 self.rate_budget_account = account;
1198 self.rate_budget_market_data = market_data;
1199 }
1200 AppEvent::CloseAllRequested {
1201 job_id,
1202 total,
1203 symbols,
1204 } => {
1205 self.close_all_running = true;
1206 self.close_all_job_id = Some(job_id);
1207 self.close_all_total = total;
1208 self.close_all_completed = 0;
1209 self.close_all_failed = 0;
1210 self.close_all_current_symbol = None;
1211 self.close_all_status_expire_at_ms = None;
1212 self.close_all_row_status_by_symbol.clear();
1213 for symbol in symbols {
1214 self.close_all_row_status_by_symbol
1215 .insert(normalize_symbol_for_scope(&symbol), "PENDING".to_string());
1216 }
1217 self.push_log(format!(
1218 "[INFO] close-all #{} started total={}",
1219 job_id, total
1220 ));
1221 }
1222 AppEvent::CloseAllProgress {
1223 job_id,
1224 symbol,
1225 completed,
1226 total,
1227 failed,
1228 reason,
1229 } => {
1230 if self.close_all_job_id != Some(job_id) {
1231 self.close_all_job_id = Some(job_id);
1232 }
1233 self.close_all_running = completed < total;
1234 self.close_all_total = total;
1235 self.close_all_completed = completed;
1236 self.close_all_failed = failed;
1237 self.close_all_current_symbol = Some(symbol.clone());
1238 let symbol_key = normalize_symbol_for_scope(&symbol);
1239 let row_status = if let Some(r) = reason.as_ref() {
1240 if r.contains("too small") || r.contains("No ") || r.contains("Insufficient ") {
1241 "SKIP"
1242 } else {
1243 "FAIL"
1244 }
1245 } else {
1246 "DONE"
1247 };
1248 self.close_all_row_status_by_symbol
1249 .insert(symbol_key, row_status.to_string());
1250 let ok = completed.saturating_sub(failed);
1251 self.push_log(format!(
1252 "[INFO] close-all #{} progress {}/{} ok={} fail={} symbol={}",
1253 job_id, completed, total, ok, failed, symbol
1254 ));
1255 if let Some(r) = reason {
1256 self.push_log(format!(
1257 "[WARN] close-all #{} {} ({}/{}) reason={}",
1258 job_id, symbol, completed, total, r
1259 ));
1260 }
1261 }
1262 AppEvent::CloseAllFinished {
1263 job_id,
1264 completed,
1265 total,
1266 failed,
1267 } => {
1268 self.close_all_running = false;
1269 self.close_all_job_id = Some(job_id);
1270 self.close_all_total = total;
1271 self.close_all_completed = completed;
1272 self.close_all_failed = failed;
1273 self.close_all_status_expire_at_ms =
1274 Some((chrono::Utc::now().timestamp_millis() as u64).saturating_add(5_000));
1275 let ok = completed.saturating_sub(failed);
1276 self.push_log(format!(
1277 "[INFO] close-all #{} finished ok={} fail={} total={}",
1278 job_id, ok, failed, total
1279 ));
1280 }
1281 AppEvent::TickDropped => {
1282 self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1283 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1284 Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1285 }
1286 AppEvent::LogRecord(record) => {
1287 self.push_log_record(record);
1288 }
1289 AppEvent::LogMessage(msg) => {
1290 self.push_log(msg);
1291 }
1292 AppEvent::Error(msg) => {
1293 self.push_log(format!("[ERR] {}", msg));
1294 }
1295 }
1296 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1297 if !self.close_all_running
1298 && self
1299 .close_all_status_expire_at_ms
1300 .map(|ts| now_ms >= ts)
1301 .unwrap_or(false)
1302 {
1303 self.close_all_job_id = None;
1304 self.close_all_total = 0;
1305 self.close_all_completed = 0;
1306 self.close_all_failed = 0;
1307 self.close_all_current_symbol = None;
1308 self.close_all_status_expire_at_ms = None;
1309 self.close_all_row_status_by_symbol.clear();
1310 }
1311 self.prune_network_event_windows(now_ms);
1312 self.sync_projection_portfolio_summary();
1313 if rebuild_projection {
1314 self.rebuild_projection_preserve_focus(prev_focus);
1315 } else {
1316 self.ensure_projection_focus_defaults();
1317 }
1318 }
1319}
1320
1321pub fn render(frame: &mut Frame, state: &AppState) {
1322 let view = state.view_state();
1323 if view.is_grid_open {
1324 render_grid_popup(frame, state);
1325 if view.is_strategy_editor_open {
1326 render_strategy_editor_popup(frame, state);
1327 }
1328 if view.is_close_all_confirm_open {
1329 render_close_all_confirm_popup(frame);
1330 }
1331 return;
1332 }
1333
1334 let area = frame.area();
1335 let status_h = 1u16;
1336 let keybind_h = 1u16;
1337 let min_main_h = 8u16;
1338 let mut order_log_h = 5u16;
1339 let mut order_history_h = 6u16;
1340 let mut system_log_h = 8u16;
1341 let mut lower_total = order_log_h + order_history_h + system_log_h;
1342 let lower_budget = area
1343 .height
1344 .saturating_sub(status_h + keybind_h + min_main_h);
1345 if lower_total > lower_budget {
1346 let mut overflow = lower_total - lower_budget;
1347 while overflow > 0 && system_log_h > 0 {
1348 system_log_h -= 1;
1349 overflow -= 1;
1350 }
1351 while overflow > 0 && order_history_h > 0 {
1352 order_history_h -= 1;
1353 overflow -= 1;
1354 }
1355 while overflow > 0 && order_log_h > 0 {
1356 order_log_h -= 1;
1357 overflow -= 1;
1358 }
1359 lower_total = order_log_h + order_history_h + system_log_h;
1360 if lower_total > lower_budget {
1361 order_log_h = 0;
1363 order_history_h = 0;
1364 system_log_h = 0;
1365 }
1366 }
1367
1368 let outer = Layout::default()
1369 .direction(Direction::Vertical)
1370 .constraints([
1371 Constraint::Length(status_h), Constraint::Min(min_main_h), Constraint::Length(order_log_h), Constraint::Length(order_history_h), Constraint::Length(system_log_h), Constraint::Length(keybind_h), ])
1378 .split(area);
1379
1380 let close_all_status_text = state.close_all_status_text();
1381 frame.render_widget(
1383 StatusBar {
1384 symbol: &state.symbol,
1385 strategy_label: &state.strategy_label,
1386 ws_connected: state.ws_connected,
1387 paused: state.paused,
1388 timeframe: &state.timeframe,
1389 last_price_update_ms: state.last_price_update_ms,
1390 last_price_latency_ms: state.last_price_latency_ms,
1391 last_order_history_update_ms: state.last_order_history_update_ms,
1392 last_order_history_latency_ms: state.last_order_history_latency_ms,
1393 close_all_status: close_all_status_text.as_deref(),
1394 close_all_running: state.close_all_running,
1395 },
1396 outer[0],
1397 );
1398
1399 let main_area = Layout::default()
1401 .direction(Direction::Horizontal)
1402 .constraints([Constraint::Min(40), Constraint::Length(24)])
1403 .split(outer[1]);
1404 let selected_strategy_stats =
1405 strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1406 .cloned()
1407 .unwrap_or_default();
1408
1409 let current_price = state.last_price();
1411 frame.render_widget(
1412 PriceChart::new(&state.candles, &state.symbol)
1413 .current_candle(state.current_candle.as_ref())
1414 .fill_markers(&state.fill_markers)
1415 .fast_sma(state.fast_sma)
1416 .slow_sma(state.slow_sma),
1417 main_area[0],
1418 );
1419
1420 let right_panels = Layout::default()
1422 .direction(Direction::Vertical)
1423 .constraints([Constraint::Min(9), Constraint::Length(8)])
1424 .split(main_area[1]);
1425 frame.render_widget(
1426 PositionPanel::new(
1427 &state.position,
1428 current_price,
1429 &state.last_applied_fee,
1430 ev_snapshot_for_item(
1431 &state.ev_snapshot_by_scope,
1432 &state.strategy_label,
1433 &state.symbol,
1434 ),
1435 exit_policy_for_item(
1436 &state.exit_policy_by_scope,
1437 &state.strategy_label,
1438 &state.symbol,
1439 ),
1440 ),
1441 right_panels[0],
1442 );
1443 frame.render_widget(
1444 StrategyMetricsPanel::new(
1445 &state.strategy_label,
1446 selected_strategy_stats.trade_count,
1447 selected_strategy_stats.win_count,
1448 selected_strategy_stats.lose_count,
1449 selected_strategy_stats.realized_pnl,
1450 ),
1451 right_panels[1],
1452 );
1453
1454 frame.render_widget(
1456 OrderLogPanel::new(
1457 &state.last_signal,
1458 &state.last_order,
1459 state.fast_sma,
1460 state.slow_sma,
1461 selected_strategy_stats.trade_count,
1462 selected_strategy_stats.win_count,
1463 selected_strategy_stats.lose_count,
1464 selected_strategy_stats.realized_pnl,
1465 ),
1466 outer[2],
1467 );
1468
1469 frame.render_widget(
1471 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1472 outer[3],
1473 );
1474
1475 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1477
1478 frame.render_widget(KeybindBar, outer[5]);
1480
1481 if view.is_close_all_confirm_open {
1482 render_close_all_confirm_popup(frame);
1483 } else if view.is_symbol_selector_open {
1484 render_selector_popup(
1485 frame,
1486 " Select Symbol ",
1487 &state.symbol_items,
1488 view.selected_symbol_selector_index,
1489 None,
1490 None,
1491 None,
1492 );
1493 } else if view.is_strategy_selector_open {
1494 let selected_strategy_symbol = state
1495 .strategy_item_symbols
1496 .get(view.selected_strategy_selector_index)
1497 .map(String::as_str)
1498 .unwrap_or(state.symbol.as_str());
1499 render_selector_popup(
1500 frame,
1501 " Select Strategy ",
1502 &state.strategy_items,
1503 view.selected_strategy_selector_index,
1504 Some(&state.strategy_stats),
1505 Some(OrderHistoryStats {
1506 trade_count: state.history_trade_count,
1507 win_count: state.history_win_count,
1508 lose_count: state.history_lose_count,
1509 realized_pnl: state.history_realized_pnl,
1510 }),
1511 Some(selected_strategy_symbol),
1512 );
1513 } else if view.is_account_popup_open {
1514 render_account_popup(frame, &state.balances);
1515 } else if view.is_history_popup_open {
1516 render_history_popup(frame, &state.history_rows, state.history_bucket);
1517 } else if view.is_focus_popup_open {
1518 render_focus_popup(frame, state);
1519 } else if view.is_strategy_editor_open {
1520 render_strategy_editor_popup(frame, state);
1521 }
1522}
1523
1524fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1525 let area = frame.area();
1526 let popup = Rect {
1527 x: area.x + 1,
1528 y: area.y + 1,
1529 width: area.width.saturating_sub(2).max(70),
1530 height: area.height.saturating_sub(2).max(22),
1531 };
1532 frame.render_widget(Clear, popup);
1533 let block = Block::default()
1534 .title(" Focus View (Drill-down) ")
1535 .borders(Borders::ALL)
1536 .border_style(Style::default().fg(Color::Green));
1537 let inner = block.inner(popup);
1538 frame.render_widget(block, popup);
1539
1540 let rows = Layout::default()
1541 .direction(Direction::Vertical)
1542 .constraints([
1543 Constraint::Length(2),
1544 Constraint::Min(8),
1545 Constraint::Length(7),
1546 ])
1547 .split(inner);
1548
1549 let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1550 let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1551 let focus_strategy_stats =
1552 strategy_stats_for_item(&state.strategy_stats, focus_strategy, focus_symbol)
1553 .cloned()
1554 .unwrap_or_default();
1555 frame.render_widget(
1556 Paragraph::new(vec![
1557 Line::from(vec![
1558 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1559 Span::styled(
1560 focus_symbol,
1561 Style::default()
1562 .fg(Color::Cyan)
1563 .add_modifier(Modifier::BOLD),
1564 ),
1565 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
1566 Span::styled(
1567 focus_strategy,
1568 Style::default()
1569 .fg(Color::Magenta)
1570 .add_modifier(Modifier::BOLD),
1571 ),
1572 ]),
1573 Line::from(Span::styled(
1574 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1575 Style::default().fg(Color::DarkGray),
1576 )),
1577 ]),
1578 rows[0],
1579 );
1580
1581 let main_cols = Layout::default()
1582 .direction(Direction::Horizontal)
1583 .constraints([Constraint::Min(48), Constraint::Length(28)])
1584 .split(rows[1]);
1585
1586 frame.render_widget(
1587 PriceChart::new(&state.candles, focus_symbol)
1588 .current_candle(state.current_candle.as_ref())
1589 .fill_markers(&state.fill_markers)
1590 .fast_sma(state.fast_sma)
1591 .slow_sma(state.slow_sma),
1592 main_cols[0],
1593 );
1594 let focus_right = Layout::default()
1595 .direction(Direction::Vertical)
1596 .constraints([Constraint::Min(8), Constraint::Length(8)])
1597 .split(main_cols[1]);
1598 frame.render_widget(
1599 PositionPanel::new(
1600 &state.position,
1601 state.last_price(),
1602 &state.last_applied_fee,
1603 ev_snapshot_for_item(&state.ev_snapshot_by_scope, focus_strategy, focus_symbol),
1604 exit_policy_for_item(&state.exit_policy_by_scope, focus_strategy, focus_symbol),
1605 ),
1606 focus_right[0],
1607 );
1608 frame.render_widget(
1609 StrategyMetricsPanel::new(
1610 focus_strategy,
1611 focus_strategy_stats.trade_count,
1612 focus_strategy_stats.win_count,
1613 focus_strategy_stats.lose_count,
1614 focus_strategy_stats.realized_pnl,
1615 ),
1616 focus_right[1],
1617 );
1618
1619 frame.render_widget(
1620 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1621 rows[2],
1622 );
1623}
1624
1625fn render_close_all_confirm_popup(frame: &mut Frame) {
1626 let area = frame.area();
1627 let popup = Rect {
1628 x: area.x + area.width.saturating_sub(60) / 2,
1629 y: area.y + area.height.saturating_sub(7) / 2,
1630 width: 60.min(area.width.saturating_sub(2)).max(40),
1631 height: 7.min(area.height.saturating_sub(2)).max(5),
1632 };
1633 frame.render_widget(Clear, popup);
1634 let block = Block::default()
1635 .title(" Confirm Close-All ")
1636 .borders(Borders::ALL)
1637 .border_style(Style::default().fg(Color::Red));
1638 let inner = block.inner(popup);
1639 frame.render_widget(block, popup);
1640 let lines = vec![
1641 Line::from(Span::styled(
1642 "Close all open positions now?",
1643 Style::default()
1644 .fg(Color::White)
1645 .add_modifier(Modifier::BOLD),
1646 )),
1647 Line::from(Span::styled(
1648 "[Y/Enter] Confirm [N/Esc] Cancel",
1649 Style::default().fg(Color::DarkGray),
1650 )),
1651 ];
1652 frame.render_widget(Paragraph::new(lines), inner);
1653}
1654
1655fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1656 let view = state.view_state();
1657 let area = frame.area();
1658 let popup = area;
1659 frame.render_widget(Clear, popup);
1660 let block = Block::default()
1661 .title(" Portfolio Grid ")
1662 .borders(Borders::ALL)
1663 .border_style(Style::default().fg(Color::Cyan));
1664 let inner = block.inner(popup);
1665 frame.render_widget(block, popup);
1666
1667 let root = Layout::default()
1668 .direction(Direction::Vertical)
1669 .constraints([Constraint::Length(2), Constraint::Min(1)])
1670 .split(inner);
1671 let tab_area = root[0];
1672 let body_area = root[1];
1673
1674 let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1675 let selected = view.selected_grid_tab == tab;
1676 Span::styled(
1677 format!("[{} {}]", key, label),
1678 if selected {
1679 Style::default()
1680 .fg(Color::Yellow)
1681 .add_modifier(Modifier::BOLD)
1682 } else {
1683 Style::default().fg(Color::DarkGray)
1684 },
1685 )
1686 };
1687 frame.render_widget(
1688 Paragraph::new(Line::from(vec![
1689 tab_span(GridTab::Assets, "1", "Assets"),
1690 Span::raw(" "),
1691 tab_span(GridTab::Strategies, "2", "Strategies"),
1692 Span::raw(" "),
1693 tab_span(GridTab::Positions, "3", "Positions"),
1694 Span::raw(" "),
1695 tab_span(GridTab::Risk, "4", "Risk"),
1696 Span::raw(" "),
1697 tab_span(GridTab::Network, "5", "Network"),
1698 Span::raw(" "),
1699 tab_span(GridTab::History, "6", "History"),
1700 Span::raw(" "),
1701 tab_span(GridTab::Predictors, "7", "Predictors"),
1702 Span::raw(" "),
1703 tab_span(GridTab::SystemLog, "8", "SystemLog"),
1704 ])),
1705 tab_area,
1706 );
1707
1708 let global_pressure =
1709 state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1710 let orders_pressure =
1711 state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1712 let account_pressure =
1713 state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1714 let market_pressure = state.rate_budget_market_data.used as f64
1715 / (state.rate_budget_market_data.limit.max(1) as f64);
1716 let max_pressure = global_pressure
1717 .max(orders_pressure)
1718 .max(account_pressure)
1719 .max(market_pressure);
1720 let (risk_label, risk_color) = if max_pressure >= 0.90 {
1721 ("CRIT", Color::Red)
1722 } else if max_pressure >= 0.70 {
1723 ("WARN", Color::Yellow)
1724 } else {
1725 ("OK", Color::Green)
1726 };
1727
1728 if view.selected_grid_tab == GridTab::Assets {
1729 let spot_assets: Vec<&AssetEntry> = state
1730 .assets_view()
1731 .iter()
1732 .filter(|a| !a.is_futures)
1733 .collect();
1734 let fut_assets: Vec<&AssetEntry> = state
1735 .assets_view()
1736 .iter()
1737 .filter(|a| a.is_futures)
1738 .collect();
1739 let spot_total_rlz: f64 = spot_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1740 let spot_total_unrlz: f64 = spot_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1741 let fut_total_rlz: f64 = fut_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1742 let fut_total_unrlz: f64 = fut_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1743 let total_rlz = spot_total_rlz + fut_total_rlz;
1744 let total_unrlz = spot_total_unrlz + fut_total_unrlz;
1745 let total_pnl = total_rlz + total_unrlz;
1746 let panel_chunks = Layout::default()
1747 .direction(Direction::Vertical)
1748 .constraints([
1749 Constraint::Percentage(46),
1750 Constraint::Percentage(46),
1751 Constraint::Length(3),
1752 Constraint::Length(1),
1753 ])
1754 .split(body_area);
1755
1756 let spot_header = Row::new(vec![
1757 Cell::from("Asset"),
1758 Cell::from("Qty"),
1759 Cell::from("Price"),
1760 Cell::from("RlzPnL"),
1761 Cell::from("UnrPnL"),
1762 ])
1763 .style(Style::default().fg(Color::DarkGray));
1764 let mut spot_rows: Vec<Row> = spot_assets
1765 .iter()
1766 .map(|a| {
1767 Row::new(vec![
1768 Cell::from(a.symbol.clone()),
1769 Cell::from(format!("{:.5}", a.position_qty)),
1770 Cell::from(
1771 a.last_price
1772 .map(|v| format!("{:.2}", v))
1773 .unwrap_or_else(|| "---".to_string()),
1774 ),
1775 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1776 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1777 ])
1778 })
1779 .collect();
1780 if spot_rows.is_empty() {
1781 spot_rows.push(
1782 Row::new(vec![
1783 Cell::from("(no spot assets)"),
1784 Cell::from("-"),
1785 Cell::from("-"),
1786 Cell::from("-"),
1787 Cell::from("-"),
1788 ])
1789 .style(Style::default().fg(Color::DarkGray)),
1790 );
1791 }
1792 frame.render_widget(
1793 Table::new(
1794 spot_rows,
1795 [
1796 Constraint::Length(16),
1797 Constraint::Length(12),
1798 Constraint::Length(10),
1799 Constraint::Length(10),
1800 Constraint::Length(10),
1801 ],
1802 )
1803 .header(spot_header)
1804 .column_spacing(1)
1805 .block(
1806 Block::default()
1807 .title(format!(
1808 " Spot Assets | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1809 spot_assets.len(),
1810 spot_total_rlz + spot_total_unrlz,
1811 spot_total_rlz,
1812 spot_total_unrlz
1813 ))
1814 .borders(Borders::ALL)
1815 .border_style(Style::default().fg(Color::DarkGray)),
1816 ),
1817 panel_chunks[0],
1818 );
1819
1820 let fut_header = Row::new(vec![
1821 Cell::from("Symbol"),
1822 Cell::from("Side"),
1823 Cell::from("PosQty"),
1824 Cell::from("Entry"),
1825 Cell::from("RlzPnL"),
1826 Cell::from("UnrPnL"),
1827 ])
1828 .style(Style::default().fg(Color::DarkGray));
1829 let mut fut_rows: Vec<Row> = fut_assets
1830 .iter()
1831 .map(|a| {
1832 Row::new(vec![
1833 Cell::from(a.symbol.clone()),
1834 Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
1835 Cell::from(format!("{:.5}", a.position_qty)),
1836 Cell::from(
1837 a.entry_price
1838 .map(|v| format!("{:.2}", v))
1839 .unwrap_or_else(|| "---".to_string()),
1840 ),
1841 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1842 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1843 ])
1844 })
1845 .collect();
1846 if fut_rows.is_empty() {
1847 fut_rows.push(
1848 Row::new(vec![
1849 Cell::from("(no futures positions)"),
1850 Cell::from("-"),
1851 Cell::from("-"),
1852 Cell::from("-"),
1853 Cell::from("-"),
1854 Cell::from("-"),
1855 ])
1856 .style(Style::default().fg(Color::DarkGray)),
1857 );
1858 }
1859 frame.render_widget(
1860 Table::new(
1861 fut_rows,
1862 [
1863 Constraint::Length(18),
1864 Constraint::Length(8),
1865 Constraint::Length(10),
1866 Constraint::Length(10),
1867 Constraint::Length(10),
1868 Constraint::Length(10),
1869 ],
1870 )
1871 .header(fut_header)
1872 .column_spacing(1)
1873 .block(
1874 Block::default()
1875 .title(format!(
1876 " Futures Positions | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1877 fut_assets.len(),
1878 fut_total_rlz + fut_total_unrlz,
1879 fut_total_rlz,
1880 fut_total_unrlz
1881 ))
1882 .borders(Borders::ALL)
1883 .border_style(Style::default().fg(Color::DarkGray)),
1884 ),
1885 panel_chunks[1],
1886 );
1887 let total_color = if total_pnl > 0.0 {
1888 Color::Green
1889 } else if total_pnl < 0.0 {
1890 Color::Red
1891 } else {
1892 Color::DarkGray
1893 };
1894 frame.render_widget(
1895 Paragraph::new(Line::from(vec![
1896 Span::styled(" Total PnL: ", Style::default().fg(Color::DarkGray)),
1897 Span::styled(
1898 format!("{:+.4}", total_pnl),
1899 Style::default()
1900 .fg(total_color)
1901 .add_modifier(Modifier::BOLD),
1902 ),
1903 Span::styled(
1904 format!(
1905 " Realized: {:+.4} Unrealized: {:+.4}",
1906 total_rlz, total_unrlz
1907 ),
1908 Style::default().fg(Color::DarkGray),
1909 ),
1910 ]))
1911 .block(
1912 Block::default()
1913 .borders(Borders::ALL)
1914 .border_style(Style::default().fg(Color::DarkGray)),
1915 ),
1916 panel_chunks[2],
1917 );
1918 frame.render_widget(
1919 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
1920 panel_chunks[3],
1921 );
1922 return;
1923 }
1924
1925 if view.selected_grid_tab == GridTab::Risk {
1926 let chunks = Layout::default()
1927 .direction(Direction::Vertical)
1928 .constraints([
1929 Constraint::Length(2),
1930 Constraint::Length(4),
1931 Constraint::Min(3),
1932 Constraint::Length(1),
1933 ])
1934 .split(body_area);
1935 frame.render_widget(
1936 Paragraph::new(Line::from(vec![
1937 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1938 Span::styled(
1939 risk_label,
1940 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1941 ),
1942 Span::styled(
1943 " (70%=WARN, 90%=CRIT)",
1944 Style::default().fg(Color::DarkGray),
1945 ),
1946 ])),
1947 chunks[0],
1948 );
1949 let risk_rows = vec![
1950 Row::new(vec![
1951 Cell::from("GLOBAL"),
1952 Cell::from(format!(
1953 "{}/{}",
1954 state.rate_budget_global.used, state.rate_budget_global.limit
1955 )),
1956 Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1957 ]),
1958 Row::new(vec![
1959 Cell::from("ORDERS"),
1960 Cell::from(format!(
1961 "{}/{}",
1962 state.rate_budget_orders.used, state.rate_budget_orders.limit
1963 )),
1964 Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1965 ]),
1966 Row::new(vec![
1967 Cell::from("ACCOUNT"),
1968 Cell::from(format!(
1969 "{}/{}",
1970 state.rate_budget_account.used, state.rate_budget_account.limit
1971 )),
1972 Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1973 ]),
1974 Row::new(vec![
1975 Cell::from("MARKET"),
1976 Cell::from(format!(
1977 "{}/{}",
1978 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1979 )),
1980 Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1981 ]),
1982 ];
1983 frame.render_widget(
1984 Table::new(
1985 risk_rows,
1986 [
1987 Constraint::Length(10),
1988 Constraint::Length(16),
1989 Constraint::Length(12),
1990 ],
1991 )
1992 .header(Row::new(vec![
1993 Cell::from("Group"),
1994 Cell::from("Used/Limit"),
1995 Cell::from("Reset In"),
1996 ]))
1997 .column_spacing(1)
1998 .block(
1999 Block::default()
2000 .title(" Risk Budgets ")
2001 .borders(Borders::ALL)
2002 .border_style(Style::default().fg(Color::DarkGray)),
2003 ),
2004 chunks[1],
2005 );
2006 let recent_rejections: Vec<&String> = state
2007 .log_messages
2008 .iter()
2009 .filter(|m| m.contains("order.reject.received"))
2010 .rev()
2011 .take(20)
2012 .collect();
2013 let mut lines = vec![Line::from(Span::styled(
2014 "Recent Rejections",
2015 Style::default()
2016 .fg(Color::Cyan)
2017 .add_modifier(Modifier::BOLD),
2018 ))];
2019 for msg in recent_rejections.into_iter().rev() {
2020 lines.push(Line::from(Span::styled(
2021 msg.as_str(),
2022 Style::default().fg(Color::Red),
2023 )));
2024 }
2025 if lines.len() == 1 {
2026 lines.push(Line::from(Span::styled(
2027 "(no rejections yet)",
2028 Style::default().fg(Color::DarkGray),
2029 )));
2030 }
2031 frame.render_widget(
2032 Paragraph::new(lines).block(
2033 Block::default()
2034 .borders(Borders::ALL)
2035 .border_style(Style::default().fg(Color::DarkGray)),
2036 ),
2037 chunks[2],
2038 );
2039 frame.render_widget(
2040 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
2041 chunks[3],
2042 );
2043 return;
2044 }
2045
2046 if view.selected_grid_tab == GridTab::Network {
2047 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2048 let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
2049 let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
2050 let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
2051 let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
2052 let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
2053 let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
2054 let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
2055 let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
2056
2057 let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
2058 let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
2059 let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
2060 let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
2061 let tick_drop_ratio_10s =
2062 ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
2063 let tick_drop_ratio_60s =
2064 ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
2065 let reconnect_rate_60s = reconnect_60s as f64;
2066 let disconnect_rate_60s = disconnect_60s as f64;
2067 let heartbeat_gap_ms = state
2068 .last_price_update_ms
2069 .map(|ts| now_ms.saturating_sub(ts));
2070 let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
2071 let health = classify_health(
2072 state.ws_connected,
2073 tick_drop_ratio_10s,
2074 reconnect_rate_60s,
2075 tick_p95_ms,
2076 heartbeat_gap_ms,
2077 );
2078 let (health_label, health_color) = match health {
2079 NetworkHealth::Ok => ("OK", Color::Green),
2080 NetworkHealth::Warn => ("WARN", Color::Yellow),
2081 NetworkHealth::Crit => ("CRIT", Color::Red),
2082 };
2083
2084 let chunks = Layout::default()
2085 .direction(Direction::Vertical)
2086 .constraints([
2087 Constraint::Length(2),
2088 Constraint::Min(6),
2089 Constraint::Length(6),
2090 Constraint::Length(1),
2091 ])
2092 .split(body_area);
2093 frame.render_widget(
2094 Paragraph::new(Line::from(vec![
2095 Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
2096 Span::styled(
2097 health_label,
2098 Style::default()
2099 .fg(health_color)
2100 .add_modifier(Modifier::BOLD),
2101 ),
2102 Span::styled(" WS: ", Style::default().fg(Color::DarkGray)),
2103 Span::styled(
2104 if state.ws_connected {
2105 "CONNECTED"
2106 } else {
2107 "DISCONNECTED"
2108 },
2109 Style::default().fg(if state.ws_connected {
2110 Color::Green
2111 } else {
2112 Color::Red
2113 }),
2114 ),
2115 Span::styled(
2116 format!(
2117 " in1s={:.1}/s drop10s={:.2}/s ratio10s={:.2}% reconn60s={:.0}/min",
2118 tick_in_rate_1s,
2119 tick_drop_rate_10s,
2120 tick_drop_ratio_10s,
2121 reconnect_rate_60s
2122 ),
2123 Style::default().fg(Color::DarkGray),
2124 ),
2125 ])),
2126 chunks[0],
2127 );
2128
2129 let tick_stats = latency_stats(&state.network_tick_latencies_ms);
2130 let fill_stats = latency_stats(&state.network_fill_latencies_ms);
2131 let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
2132 let last_fill_age = state
2133 .network_last_fill_ms
2134 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2135 .unwrap_or_else(|| "-".to_string());
2136 let rows = vec![
2137 Row::new(vec![
2138 Cell::from("Tick Latency"),
2139 Cell::from(tick_stats.0),
2140 Cell::from(tick_stats.1),
2141 Cell::from(tick_stats.2),
2142 Cell::from(
2143 state
2144 .last_price_latency_ms
2145 .map(|v| format!("{}ms", v))
2146 .unwrap_or_else(|| "-".to_string()),
2147 ),
2148 ]),
2149 Row::new(vec![
2150 Cell::from("Fill Latency"),
2151 Cell::from(fill_stats.0),
2152 Cell::from(fill_stats.1),
2153 Cell::from(fill_stats.2),
2154 Cell::from(last_fill_age),
2155 ]),
2156 Row::new(vec![
2157 Cell::from("Order Sync"),
2158 Cell::from(sync_stats.0),
2159 Cell::from(sync_stats.1),
2160 Cell::from(sync_stats.2),
2161 Cell::from(
2162 state
2163 .last_order_history_latency_ms
2164 .map(|v| format!("{}ms", v))
2165 .unwrap_or_else(|| "-".to_string()),
2166 ),
2167 ]),
2168 ];
2169 frame.render_widget(
2170 Table::new(
2171 rows,
2172 [
2173 Constraint::Length(14),
2174 Constraint::Length(12),
2175 Constraint::Length(12),
2176 Constraint::Length(12),
2177 Constraint::Length(14),
2178 ],
2179 )
2180 .header(Row::new(vec![
2181 Cell::from("Metric"),
2182 Cell::from("p50"),
2183 Cell::from("p95"),
2184 Cell::from("p99"),
2185 Cell::from("last/age"),
2186 ]))
2187 .column_spacing(1)
2188 .block(
2189 Block::default()
2190 .title(" Network Metrics ")
2191 .borders(Borders::ALL)
2192 .border_style(Style::default().fg(Color::DarkGray)),
2193 ),
2194 chunks[1],
2195 );
2196
2197 let summary_rows = vec![
2198 Row::new(vec![
2199 Cell::from("tick_drop_rate_1s"),
2200 Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
2201 Cell::from("tick_drop_rate_60s"),
2202 Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
2203 ]),
2204 Row::new(vec![
2205 Cell::from("drop_ratio_60s"),
2206 Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
2207 Cell::from("disconnect_rate_60s"),
2208 Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
2209 ]),
2210 Row::new(vec![
2211 Cell::from("last_tick_age"),
2212 Cell::from(
2213 heartbeat_gap_ms
2214 .map(format_age_ms)
2215 .unwrap_or_else(|| "-".to_string()),
2216 ),
2217 Cell::from("last_order_update_age"),
2218 Cell::from(
2219 state
2220 .last_order_history_update_ms
2221 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2222 .unwrap_or_else(|| "-".to_string()),
2223 ),
2224 ]),
2225 Row::new(vec![
2226 Cell::from("tick_drop_total"),
2227 Cell::from(state.network_tick_drop_count.to_string()),
2228 Cell::from("reconnect_total"),
2229 Cell::from(state.network_reconnect_count.to_string()),
2230 ]),
2231 ];
2232 frame.render_widget(
2233 Table::new(
2234 summary_rows,
2235 [
2236 Constraint::Length(20),
2237 Constraint::Length(18),
2238 Constraint::Length(20),
2239 Constraint::Length(18),
2240 ],
2241 )
2242 .column_spacing(1)
2243 .block(
2244 Block::default()
2245 .title(" Network Summary ")
2246 .borders(Borders::ALL)
2247 .border_style(Style::default().fg(Color::DarkGray)),
2248 ),
2249 chunks[2],
2250 );
2251 frame.render_widget(
2252 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
2253 chunks[3],
2254 );
2255 return;
2256 }
2257
2258 if view.selected_grid_tab == GridTab::History {
2259 let chunks = Layout::default()
2260 .direction(Direction::Vertical)
2261 .constraints([
2262 Constraint::Length(2),
2263 Constraint::Min(6),
2264 Constraint::Length(1),
2265 ])
2266 .split(body_area);
2267 frame.render_widget(
2268 Paragraph::new(Line::from(vec![
2269 Span::styled("Bucket: ", Style::default().fg(Color::DarkGray)),
2270 Span::styled(
2271 match state.history_bucket {
2272 order_store::HistoryBucket::Day => "Day",
2273 order_store::HistoryBucket::Hour => "Hour",
2274 order_store::HistoryBucket::Month => "Month",
2275 },
2276 Style::default()
2277 .fg(Color::Cyan)
2278 .add_modifier(Modifier::BOLD),
2279 ),
2280 Span::styled(
2281 " (popup hotkeys: D/H/M)",
2282 Style::default().fg(Color::DarkGray),
2283 ),
2284 ])),
2285 chunks[0],
2286 );
2287
2288 let visible = build_history_lines(
2289 &state.history_rows,
2290 chunks[1].height.saturating_sub(2) as usize,
2291 );
2292 frame.render_widget(
2293 Paragraph::new(visible).block(
2294 Block::default()
2295 .title(" History ")
2296 .borders(Borders::ALL)
2297 .border_style(Style::default().fg(Color::DarkGray)),
2298 ),
2299 chunks[1],
2300 );
2301 frame.render_widget(
2302 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
2303 chunks[2],
2304 );
2305 return;
2306 }
2307
2308 if view.selected_grid_tab == GridTab::Positions {
2309 let chunks = Layout::default()
2310 .direction(Direction::Vertical)
2311 .constraints([
2312 Constraint::Length(2),
2313 Constraint::Min(6),
2314 Constraint::Length(1),
2315 ])
2316 .split(body_area);
2317 let persisted = if cfg!(test) {
2318 Vec::new()
2319 } else {
2320 order_store::load_recent_persisted_trades_filtered(None, None, 20_000)
2321 .unwrap_or_default()
2322 };
2323 let open_orders: Vec<_> = build_open_order_positions_from_trades(&persisted)
2324 .into_iter()
2325 .filter(|row| {
2326 let px = asset_last_price_for_symbol(state, &row.symbol).unwrap_or(row.entry_price);
2327 !state.hide_small_positions || (px * row.qty_open).abs() >= 1.0
2328 })
2329 .collect();
2330
2331 frame.render_widget(
2332 Paragraph::new(Line::from(vec![
2333 Span::styled(
2334 "Open Position Orders: ",
2335 Style::default().fg(Color::DarkGray),
2336 ),
2337 Span::styled(
2338 open_orders.len().to_string(),
2339 Style::default()
2340 .fg(Color::Cyan)
2341 .add_modifier(Modifier::BOLD),
2342 ),
2343 Span::styled(
2344 if state.hide_small_positions {
2345 " (order_id scope, filter: >= $1 | [U] toggle)"
2346 } else {
2347 " (order_id scope, filter: OFF | [U] toggle)"
2348 },
2349 Style::default().fg(Color::DarkGray),
2350 ),
2351 ])),
2352 chunks[0],
2353 );
2354
2355 let header = Row::new(vec![
2356 Cell::from("Symbol"),
2357 Cell::from("Source"),
2358 Cell::from("OrderId"),
2359 Cell::from("Close"),
2360 Cell::from("Market"),
2361 Cell::from("Side"),
2362 Cell::from("Qty"),
2363 Cell::from("Entry"),
2364 Cell::from("Last"),
2365 Cell::from("Stop"),
2366 Cell::from("LiveEV"),
2367 Cell::from("EntryEV"),
2368 Cell::from("Score"),
2369 Cell::from("Gate"),
2370 Cell::from("StopType"),
2371 Cell::from("UnrPnL"),
2372 ])
2373 .style(Style::default().fg(Color::DarkGray));
2374 let mut rows: Vec<Row> = if open_orders.is_empty() {
2375 state
2376 .assets_view()
2377 .iter()
2378 .filter(|a| {
2379 let has_pos = a.position_qty.abs() > f64::EPSILON
2380 || a.entry_price.is_some()
2381 || a.side.is_some();
2382 let px = a.last_price.or(a.entry_price).unwrap_or(0.0);
2383 has_pos && (!state.hide_small_positions || (px * a.position_qty.abs()) >= 1.0)
2384 })
2385 .map(|a| {
2386 let ev_snapshot = latest_ev_snapshot_for_symbol_relaxed(
2387 &state.ev_snapshot_by_scope,
2388 &a.symbol,
2389 );
2390 let exit_policy = latest_exit_policy_for_symbol_relaxed(
2391 &state.exit_policy_by_scope,
2392 &a.symbol,
2393 );
2394 Row::new(vec![
2395 Cell::from(a.symbol.clone()),
2396 Cell::from("SYS"),
2397 Cell::from("-"),
2398 Cell::from(
2399 close_all_row_status_for_symbol(state, &a.symbol)
2400 .unwrap_or_else(|| "-".to_string()),
2401 ),
2402 Cell::from(if a.is_futures { "FUT" } else { "SPOT" }),
2403 Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
2404 Cell::from(format!("{:.5}", a.position_qty)),
2405 Cell::from(
2406 a.entry_price
2407 .map(|v| format!("{:.2}", v))
2408 .unwrap_or_else(|| "-".to_string()),
2409 ),
2410 Cell::from(
2411 a.last_price
2412 .map(|v| format!("{:.2}", v))
2413 .unwrap_or_else(|| "-".to_string()),
2414 ),
2415 Cell::from(
2416 exit_policy
2417 .and_then(|p| p.stop_price)
2418 .map(|v| format!("{:.2}", v))
2419 .unwrap_or_else(|| "-".to_string()),
2420 ),
2421 Cell::from(
2422 ev_snapshot
2423 .map(|v| format!("{:+.3}", v.ev))
2424 .unwrap_or_else(|| "-".to_string()),
2425 ),
2426 Cell::from(
2427 ev_snapshot
2428 .and_then(|v| v.entry_ev)
2429 .map(|v| format!("{:+.3}", v))
2430 .unwrap_or_else(|| "-".to_string()),
2431 ),
2432 Cell::from(
2433 ev_snapshot
2434 .map(|v| format!("{:.2}", v.p_win))
2435 .unwrap_or_else(|| "-".to_string()),
2436 ),
2437 Cell::from(
2438 ev_snapshot
2439 .map(|v| {
2440 if v.gate_blocked {
2441 "BLOCK".to_string()
2442 } else {
2443 v.gate_mode.to_ascii_uppercase()
2444 }
2445 })
2446 .unwrap_or_else(|| "-".to_string()),
2447 ),
2448 Cell::from(if exit_policy.and_then(|p| p.stop_price).is_none() {
2449 "-".to_string()
2450 } else if exit_policy.and_then(|p| p.protective_stop_ok) == Some(true) {
2451 "ORDER".to_string()
2452 } else {
2453 "CALC".to_string()
2454 }),
2455 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
2456 ])
2457 })
2458 .collect()
2459 } else {
2460 open_orders
2461 .iter()
2462 .map(|row| {
2463 let symbol_view = display_symbol_for_storage(&row.symbol);
2464 let market_is_fut = row.symbol.ends_with("#FUT");
2465 let asset_last = asset_last_price_for_symbol(state, &row.symbol);
2466 let ev_snapshot =
2467 ev_snapshot_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2468 .or_else(|| {
2469 latest_ev_snapshot_for_symbol_relaxed(
2470 &state.ev_snapshot_by_scope,
2471 &row.symbol,
2472 )
2473 .cloned()
2474 });
2475 let exit_policy =
2476 exit_policy_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2477 .or_else(|| {
2478 latest_exit_policy_for_symbol_relaxed(
2479 &state.exit_policy_by_scope,
2480 &row.symbol,
2481 )
2482 .cloned()
2483 });
2484 let unr = asset_last
2485 .map(|px| (px - row.entry_price) * row.qty_open)
2486 .unwrap_or(0.0);
2487 Row::new(vec![
2488 Cell::from(symbol_view),
2489 Cell::from(row.source_tag.to_ascii_uppercase()),
2490 Cell::from(row.order_id.to_string()),
2491 Cell::from(
2492 close_all_row_status_for_symbol(state, &row.symbol)
2493 .unwrap_or_else(|| "-".to_string()),
2494 ),
2495 Cell::from(if market_is_fut { "FUT" } else { "SPOT" }),
2496 Cell::from("BUY"),
2497 Cell::from(format!("{:.5}", row.qty_open)),
2498 Cell::from(format!("{:.2}", row.entry_price)),
2499 Cell::from(
2500 asset_last
2501 .map(|v| format!("{:.2}", v))
2502 .unwrap_or_else(|| "-".to_string()),
2503 ),
2504 Cell::from(
2505 exit_policy
2506 .as_ref()
2507 .and_then(|p| p.stop_price)
2508 .map(|v| format!("{:.2}", v))
2509 .unwrap_or_else(|| "-".to_string()),
2510 ),
2511 Cell::from(
2512 ev_snapshot
2513 .as_ref()
2514 .map(|v| format!("{:+.3}", v.ev))
2515 .unwrap_or_else(|| "-".to_string()),
2516 ),
2517 Cell::from(
2518 ev_snapshot
2519 .as_ref()
2520 .and_then(|v| v.entry_ev)
2521 .map(|v| format!("{:+.3}", v))
2522 .unwrap_or_else(|| "-".to_string()),
2523 ),
2524 Cell::from(
2525 ev_snapshot
2526 .as_ref()
2527 .map(|v| format!("{:.2}", v.p_win))
2528 .unwrap_or_else(|| "-".to_string()),
2529 ),
2530 Cell::from(
2531 ev_snapshot
2532 .as_ref()
2533 .map(|v| {
2534 if v.gate_blocked {
2535 "BLOCK".to_string()
2536 } else {
2537 v.gate_mode.to_ascii_uppercase()
2538 }
2539 })
2540 .unwrap_or_else(|| "-".to_string()),
2541 ),
2542 Cell::from(
2543 if exit_policy.as_ref().and_then(|p| p.stop_price).is_none() {
2544 "-".to_string()
2545 } else if exit_policy.as_ref().and_then(|p| p.protective_stop_ok)
2546 == Some(true)
2547 {
2548 "ORDER".to_string()
2549 } else {
2550 "CALC".to_string()
2551 },
2552 ),
2553 Cell::from(format!("{:+.4}", unr)),
2554 ])
2555 })
2556 .collect()
2557 };
2558 if rows.is_empty() {
2559 rows.push(
2560 Row::new(vec![
2561 Cell::from("(no open positions)"),
2562 Cell::from("-"),
2563 Cell::from("-"),
2564 Cell::from("-"),
2565 Cell::from("-"),
2566 Cell::from("-"),
2567 Cell::from("-"),
2568 Cell::from("-"),
2569 Cell::from("-"),
2570 Cell::from("-"),
2571 Cell::from("-"),
2572 Cell::from("-"),
2573 Cell::from("-"),
2574 Cell::from("-"),
2575 Cell::from("-"),
2576 Cell::from("-"),
2577 ])
2578 .style(Style::default().fg(Color::DarkGray)),
2579 );
2580 }
2581 frame.render_widget(
2582 Table::new(
2583 rows,
2584 [
2585 Constraint::Length(14),
2586 Constraint::Length(8),
2587 Constraint::Length(12),
2588 Constraint::Length(8),
2589 Constraint::Length(7),
2590 Constraint::Length(8),
2591 Constraint::Length(11),
2592 Constraint::Length(10),
2593 Constraint::Length(10),
2594 Constraint::Length(10),
2595 Constraint::Length(8),
2596 Constraint::Length(7),
2597 Constraint::Length(8),
2598 Constraint::Length(9),
2599 Constraint::Length(11),
2600 ],
2601 )
2602 .header(header)
2603 .column_spacing(1)
2604 .block(
2605 Block::default()
2606 .title(" Positions ")
2607 .borders(Borders::ALL)
2608 .border_style(Style::default().fg(Color::DarkGray)),
2609 ),
2610 chunks[1],
2611 );
2612 frame.render_widget(
2613 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
2614 chunks[2],
2615 );
2616 return;
2617 }
2618
2619 if view.selected_grid_tab == GridTab::Predictors {
2620 let chunks = Layout::default()
2621 .direction(Direction::Vertical)
2622 .constraints([
2623 Constraint::Length(2),
2624 Constraint::Min(6),
2625 Constraint::Length(1),
2626 ])
2627 .split(body_area);
2628 let mut entries: Vec<&PredictorMetricEntry> = state
2629 .predictor_metrics_by_scope
2630 .values()
2631 .filter(|e| !state.hide_empty_predictor_rows || e.sample_count > 0)
2632 .collect();
2633 entries.sort_by(|a, b| match (a.r2, b.r2) {
2634 (Some(ra), Some(rb)) => rb
2635 .partial_cmp(&ra)
2636 .unwrap_or(std::cmp::Ordering::Equal)
2637 .then_with(|| b.sample_count.cmp(&a.sample_count))
2638 .then_with(|| b.updated_at_ms.cmp(&a.updated_at_ms)),
2639 (Some(_), None) => std::cmp::Ordering::Less,
2640 (None, Some(_)) => std::cmp::Ordering::Greater,
2641 (None, None) => b
2642 .sample_count
2643 .cmp(&a.sample_count)
2644 .then_with(|| b.updated_at_ms.cmp(&a.updated_at_ms)),
2645 });
2646 let visible_rows = chunks[1].height.saturating_sub(3).max(1) as usize;
2647 let max_start = entries.len().saturating_sub(visible_rows);
2648 let start = state.predictor_scroll_offset.min(max_start);
2649 let end = (start + visible_rows).min(entries.len());
2650 let visible_entries = &entries[start..end];
2651 frame.render_widget(
2652 Paragraph::new(Line::from(vec![
2653 Span::styled("Predictor Rows: ", Style::default().fg(Color::DarkGray)),
2654 Span::styled(
2655 entries.len().to_string(),
2656 Style::default()
2657 .fg(Color::Cyan)
2658 .add_modifier(Modifier::BOLD),
2659 ),
2660 Span::styled(
2661 if state.hide_empty_predictor_rows {
2662 " (N>0 only | [U] toggle)"
2663 } else {
2664 " (all rows | [U] toggle)"
2665 },
2666 Style::default().fg(Color::DarkGray),
2667 ),
2668 Span::styled(
2669 format!(" view {}-{}", start.saturating_add(1), end.max(start)),
2670 Style::default().fg(Color::DarkGray),
2671 ),
2672 ])),
2673 chunks[0],
2674 );
2675
2676 let header = Row::new(vec![
2677 Cell::from("Symbol"),
2678 Cell::from("Market"),
2679 Cell::from("Predictor"),
2680 Cell::from("Horizon"),
2681 Cell::from("R2"),
2682 Cell::from("MAE"),
2683 Cell::from("N"),
2684 ])
2685 .style(Style::default().fg(Color::DarkGray));
2686 let mut rows: Vec<Row> = visible_entries
2687 .iter()
2688 .map(|e| {
2689 Row::new(vec![
2690 Cell::from(display_symbol_for_storage(&e.symbol)),
2691 Cell::from(e.market.to_ascii_uppercase()),
2692 Cell::from(e.predictor.clone()),
2693 Cell::from(e.horizon.clone()),
2694 Cell::from(e.r2.map(|v| format!("{:+.3}", v)).unwrap_or_else(|| {
2695 if e.sample_count > 0 {
2696 "WARMUP".to_string()
2697 } else {
2698 "-".to_string()
2699 }
2700 })),
2701 Cell::from(
2702 e.mae
2703 .map(|v| {
2704 if v.abs() < 1e-5 {
2705 format!("{:.2e}", v)
2706 } else {
2707 format!("{:.5}", v)
2708 }
2709 })
2710 .unwrap_or_else(|| "-".to_string()),
2711 ),
2712 Cell::from(e.sample_count.to_string()),
2713 ])
2714 })
2715 .collect();
2716 if rows.is_empty() {
2717 rows.push(
2718 Row::new(vec![
2719 Cell::from("(no predictor metrics)"),
2720 Cell::from("-"),
2721 Cell::from("-"),
2722 Cell::from("-"),
2723 Cell::from("-"),
2724 Cell::from("-"),
2725 Cell::from("-"),
2726 ])
2727 .style(Style::default().fg(Color::DarkGray)),
2728 );
2729 }
2730 frame.render_widget(
2731 Table::new(
2732 rows,
2733 [
2734 Constraint::Length(14),
2735 Constraint::Length(7),
2736 Constraint::Length(14),
2737 Constraint::Length(8),
2738 Constraint::Length(8),
2739 Constraint::Length(10),
2740 Constraint::Length(6),
2741 ],
2742 )
2743 .header(header)
2744 .column_spacing(1)
2745 .block(
2746 Block::default()
2747 .title(" Predictors ")
2748 .borders(Borders::ALL)
2749 .border_style(Style::default().fg(Color::DarkGray)),
2750 ),
2751 chunks[1],
2752 );
2753 frame.render_widget(
2754 Paragraph::new(
2755 "[1/2/3/4/5/6/7/8] tab [J/K] scroll [U] <$1 filter [Z] close-all [G/Esc] close",
2756 ),
2757 chunks[2],
2758 );
2759 return;
2760 }
2761
2762 if view.selected_grid_tab == GridTab::SystemLog {
2763 let chunks = Layout::default()
2764 .direction(Direction::Vertical)
2765 .constraints([Constraint::Min(6), Constraint::Length(1)])
2766 .split(body_area);
2767 let max_rows = chunks[0].height.saturating_sub(2) as usize;
2768 let mut log_rows: Vec<Row> = state
2769 .log_messages
2770 .iter()
2771 .rev()
2772 .take(max_rows.max(1))
2773 .rev()
2774 .map(|line| Row::new(vec![Cell::from(line.clone())]))
2775 .collect();
2776 if log_rows.is_empty() {
2777 log_rows.push(
2778 Row::new(vec![Cell::from("(no system logs yet)")])
2779 .style(Style::default().fg(Color::DarkGray)),
2780 );
2781 }
2782 frame.render_widget(
2783 Table::new(log_rows, [Constraint::Min(1)])
2784 .header(
2785 Row::new(vec![Cell::from("Message")])
2786 .style(Style::default().fg(Color::DarkGray)),
2787 )
2788 .column_spacing(1)
2789 .block(
2790 Block::default()
2791 .title(" System Log ")
2792 .borders(Borders::ALL)
2793 .border_style(Style::default().fg(Color::DarkGray)),
2794 ),
2795 chunks[0],
2796 );
2797 frame.render_widget(
2798 Paragraph::new("[1/2/3/4/5/6/7/8] tab [U] <$1 filter [Z] close-all [G/Esc] close"),
2799 chunks[1],
2800 );
2801 return;
2802 }
2803
2804 let selected_symbol = state
2805 .symbol_items
2806 .get(view.selected_symbol_index)
2807 .map(String::as_str)
2808 .unwrap_or(state.symbol.as_str());
2809 let strategy_chunks = Layout::default()
2810 .direction(Direction::Vertical)
2811 .constraints([
2812 Constraint::Length(2),
2813 Constraint::Length(3),
2814 Constraint::Min(12),
2815 Constraint::Length(1),
2816 ])
2817 .split(body_area);
2818
2819 let mut on_indices: Vec<usize> = Vec::new();
2820 let mut off_indices: Vec<usize> = Vec::new();
2821 for idx in 0..state.strategy_items.len() {
2822 if state
2823 .strategy_item_active
2824 .get(idx)
2825 .copied()
2826 .unwrap_or(false)
2827 {
2828 on_indices.push(idx);
2829 } else {
2830 off_indices.push(idx);
2831 }
2832 }
2833 let on_weight = on_indices.len().max(1) as u32;
2834 let off_weight = off_indices.len().max(1) as u32;
2835
2836 frame.render_widget(
2837 Paragraph::new(Line::from(vec![
2838 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
2839 Span::styled(
2840 risk_label,
2841 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
2842 ),
2843 Span::styled(" GLOBAL ", Style::default().fg(Color::DarkGray)),
2844 Span::styled(
2845 format!(
2846 "{}/{}",
2847 state.rate_budget_global.used, state.rate_budget_global.limit
2848 ),
2849 Style::default().fg(if global_pressure >= 0.9 {
2850 Color::Red
2851 } else if global_pressure >= 0.7 {
2852 Color::Yellow
2853 } else {
2854 Color::Cyan
2855 }),
2856 ),
2857 Span::styled(" ORD ", Style::default().fg(Color::DarkGray)),
2858 Span::styled(
2859 format!(
2860 "{}/{}",
2861 state.rate_budget_orders.used, state.rate_budget_orders.limit
2862 ),
2863 Style::default().fg(if orders_pressure >= 0.9 {
2864 Color::Red
2865 } else if orders_pressure >= 0.7 {
2866 Color::Yellow
2867 } else {
2868 Color::Cyan
2869 }),
2870 ),
2871 Span::styled(" ACC ", Style::default().fg(Color::DarkGray)),
2872 Span::styled(
2873 format!(
2874 "{}/{}",
2875 state.rate_budget_account.used, state.rate_budget_account.limit
2876 ),
2877 Style::default().fg(if account_pressure >= 0.9 {
2878 Color::Red
2879 } else if account_pressure >= 0.7 {
2880 Color::Yellow
2881 } else {
2882 Color::Cyan
2883 }),
2884 ),
2885 Span::styled(" MKT ", Style::default().fg(Color::DarkGray)),
2886 Span::styled(
2887 format!(
2888 "{}/{}",
2889 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
2890 ),
2891 Style::default().fg(if market_pressure >= 0.9 {
2892 Color::Red
2893 } else if market_pressure >= 0.7 {
2894 Color::Yellow
2895 } else {
2896 Color::Cyan
2897 }),
2898 ),
2899 ])),
2900 strategy_chunks[0],
2901 );
2902
2903 let strategy_area = strategy_chunks[2];
2904 let min_panel_height: u16 = 6;
2905 let total_height = strategy_area.height;
2906 let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
2907 let total_weight = on_weight + off_weight;
2908 let mut on_h =
2909 ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
2910 let max_on_h = total_height.saturating_sub(min_panel_height);
2911 if on_h > max_on_h {
2912 on_h = max_on_h;
2913 }
2914 let off_h = total_height.saturating_sub(on_h);
2915 (on_h, off_h)
2916 } else {
2917 let on_h = (total_height / 2).max(1);
2918 let off_h = total_height.saturating_sub(on_h).max(1);
2919 (on_h, off_h)
2920 };
2921 let on_area = Rect {
2922 x: strategy_area.x,
2923 y: strategy_area.y,
2924 width: strategy_area.width,
2925 height: on_height,
2926 };
2927 let off_area = Rect {
2928 x: strategy_area.x,
2929 y: strategy_area.y.saturating_add(on_height),
2930 width: strategy_area.width,
2931 height: off_height,
2932 };
2933
2934 let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
2935 indices
2936 .iter()
2937 .map(|idx| {
2938 let item = state
2939 .strategy_items
2940 .get(*idx)
2941 .map(String::as_str)
2942 .unwrap_or("-");
2943 let row_symbol = state
2944 .strategy_item_symbols
2945 .get(*idx)
2946 .map(String::as_str)
2947 .unwrap_or(state.symbol.as_str());
2948 strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
2949 .map(|s| s.realized_pnl)
2950 .unwrap_or(0.0)
2951 })
2952 .sum()
2953 };
2954 let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
2955 let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
2956 let total_pnl_sum = on_pnl_sum + off_pnl_sum;
2957
2958 let total_row = Row::new(vec![
2959 Cell::from("ON Total"),
2960 Cell::from(on_indices.len().to_string()),
2961 Cell::from(format!("{:+.4}", on_pnl_sum)),
2962 Cell::from("OFF Total"),
2963 Cell::from(off_indices.len().to_string()),
2964 Cell::from(format!("{:+.4}", off_pnl_sum)),
2965 Cell::from("All Total"),
2966 Cell::from(format!("{:+.4}", total_pnl_sum)),
2967 ]);
2968 let total_table = Table::new(
2969 vec![total_row],
2970 [
2971 Constraint::Length(10),
2972 Constraint::Length(5),
2973 Constraint::Length(12),
2974 Constraint::Length(10),
2975 Constraint::Length(5),
2976 Constraint::Length(12),
2977 Constraint::Length(10),
2978 Constraint::Length(12),
2979 ],
2980 )
2981 .column_spacing(1)
2982 .block(
2983 Block::default()
2984 .title(" Total ")
2985 .borders(Borders::ALL)
2986 .border_style(Style::default().fg(Color::DarkGray)),
2987 );
2988 frame.render_widget(total_table, strategy_chunks[1]);
2989
2990 let render_strategy_window = |frame: &mut Frame,
2991 area: Rect,
2992 title: &str,
2993 indices: &[usize],
2994 state: &AppState,
2995 pnl_sum: f64,
2996 selected_panel: bool| {
2997 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2998 let inner_height = area.height.saturating_sub(2);
2999 let row_capacity = inner_height.saturating_sub(1) as usize;
3000 let selected_pos = indices
3001 .iter()
3002 .position(|idx| *idx == view.selected_strategy_index);
3003 let window_start = if row_capacity == 0 {
3004 0
3005 } else if let Some(pos) = selected_pos {
3006 pos.saturating_sub(row_capacity.saturating_sub(1))
3007 } else {
3008 0
3009 };
3010 let window_end = if row_capacity == 0 {
3011 0
3012 } else {
3013 (window_start + row_capacity).min(indices.len())
3014 };
3015 let visible_indices = if indices.is_empty() || row_capacity == 0 {
3016 &indices[0..0]
3017 } else {
3018 &indices[window_start..window_end]
3019 };
3020 let header = Row::new(vec![
3021 Cell::from(" "),
3022 Cell::from("Symbol"),
3023 Cell::from("Strategy"),
3024 Cell::from("Run"),
3025 Cell::from("Last"),
3026 Cell::from("Px"),
3027 Cell::from("Age"),
3028 Cell::from("W"),
3029 Cell::from("L"),
3030 Cell::from("T"),
3031 Cell::from("PnL"),
3032 ])
3033 .style(Style::default().fg(Color::DarkGray));
3034 let mut rows: Vec<Row> = visible_indices
3035 .iter()
3036 .map(|idx| {
3037 let row_symbol = state
3038 .strategy_item_symbols
3039 .get(*idx)
3040 .map(String::as_str)
3041 .unwrap_or("-");
3042 let item = state
3043 .strategy_items
3044 .get(*idx)
3045 .cloned()
3046 .unwrap_or_else(|| "-".to_string());
3047 let running = state
3048 .strategy_item_total_running_ms
3049 .get(*idx)
3050 .copied()
3051 .map(format_running_time)
3052 .unwrap_or_else(|| "-".to_string());
3053 let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
3054 let source_tag = source_tag_for_strategy_item(&item);
3055 let last_evt = source_tag
3056 .as_ref()
3057 .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
3058 let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
3059 let age = now_ms.saturating_sub(evt.timestamp_ms);
3060 let age_txt = if age < 1_000 {
3061 format!("{}ms", age)
3062 } else if age < 60_000 {
3063 format!("{}s", age / 1_000)
3064 } else {
3065 format!("{}m", age / 60_000)
3066 };
3067 let side_txt = match evt.side {
3068 OrderSide::Buy => "BUY",
3069 OrderSide::Sell => "SELL",
3070 };
3071 let px_txt = evt
3072 .price
3073 .map(|v| format!("{:.2}", v))
3074 .unwrap_or_else(|| "-".to_string());
3075 let style = match evt.side {
3076 OrderSide::Buy => Style::default()
3077 .fg(Color::Green)
3078 .add_modifier(Modifier::BOLD),
3079 OrderSide::Sell => {
3080 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
3081 }
3082 };
3083 (side_txt.to_string(), px_txt, age_txt, style)
3084 } else {
3085 (
3086 "-".to_string(),
3087 "-".to_string(),
3088 "-".to_string(),
3089 Style::default().fg(Color::DarkGray),
3090 )
3091 };
3092 let (w, l, t, pnl) = if let Some(s) = stats {
3093 (
3094 s.win_count.to_string(),
3095 s.lose_count.to_string(),
3096 s.trade_count.to_string(),
3097 format!("{:+.4}", s.realized_pnl),
3098 )
3099 } else {
3100 (
3101 "0".to_string(),
3102 "0".to_string(),
3103 "0".to_string(),
3104 "+0.0000".to_string(),
3105 )
3106 };
3107 let marker = if *idx == view.selected_strategy_index {
3108 "▶"
3109 } else {
3110 " "
3111 };
3112 let mut row = Row::new(vec![
3113 Cell::from(marker),
3114 Cell::from(row_symbol.to_string()),
3115 Cell::from(item),
3116 Cell::from(running),
3117 Cell::from(last_label).style(last_style),
3118 Cell::from(last_px),
3119 Cell::from(last_age),
3120 Cell::from(w),
3121 Cell::from(l),
3122 Cell::from(t),
3123 Cell::from(pnl),
3124 ]);
3125 if *idx == view.selected_strategy_index {
3126 row = row.style(
3127 Style::default()
3128 .fg(Color::Yellow)
3129 .add_modifier(Modifier::BOLD),
3130 );
3131 }
3132 row
3133 })
3134 .collect();
3135
3136 if rows.is_empty() {
3137 rows.push(
3138 Row::new(vec![
3139 Cell::from(" "),
3140 Cell::from("-"),
3141 Cell::from("(empty)"),
3142 Cell::from("-"),
3143 Cell::from("-"),
3144 Cell::from("-"),
3145 Cell::from("-"),
3146 Cell::from("-"),
3147 Cell::from("-"),
3148 Cell::from("-"),
3149 Cell::from("-"),
3150 ])
3151 .style(Style::default().fg(Color::DarkGray)),
3152 );
3153 }
3154
3155 let table = Table::new(
3156 rows,
3157 [
3158 Constraint::Length(2),
3159 Constraint::Length(12),
3160 Constraint::Min(14),
3161 Constraint::Length(9),
3162 Constraint::Length(5),
3163 Constraint::Length(9),
3164 Constraint::Length(6),
3165 Constraint::Length(3),
3166 Constraint::Length(3),
3167 Constraint::Length(4),
3168 Constraint::Length(11),
3169 ],
3170 )
3171 .header(header)
3172 .column_spacing(1)
3173 .block(
3174 Block::default()
3175 .title(format!(
3176 "{} | Total {:+.4} | {}/{}",
3177 title,
3178 pnl_sum,
3179 visible_indices.len(),
3180 indices.len()
3181 ))
3182 .borders(Borders::ALL)
3183 .border_style(if selected_panel {
3184 Style::default().fg(Color::Yellow)
3185 } else if risk_label == "CRIT" {
3186 Style::default().fg(Color::Red)
3187 } else if risk_label == "WARN" {
3188 Style::default().fg(Color::Yellow)
3189 } else {
3190 Style::default().fg(Color::DarkGray)
3191 }),
3192 );
3193 frame.render_widget(table, area);
3194 };
3195
3196 render_strategy_window(
3197 frame,
3198 on_area,
3199 " ON Strategies ",
3200 &on_indices,
3201 state,
3202 on_pnl_sum,
3203 view.is_on_panel_selected,
3204 );
3205 render_strategy_window(
3206 frame,
3207 off_area,
3208 " OFF Strategies ",
3209 &off_indices,
3210 state,
3211 off_pnl_sum,
3212 !view.is_on_panel_selected,
3213 );
3214 frame.render_widget(
3215 Paragraph::new(Line::from(vec![
3216 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
3217 Span::styled(
3218 selected_symbol,
3219 Style::default()
3220 .fg(Color::Green)
3221 .add_modifier(Modifier::BOLD),
3222 ),
3223 Span::styled(
3224 " [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",
3225 Style::default().fg(Color::DarkGray),
3226 ),
3227 ])),
3228 strategy_chunks[3],
3229 );
3230}
3231
3232fn format_running_time(total_running_ms: u64) -> String {
3233 let total_sec = total_running_ms / 1000;
3234 let days = total_sec / 86_400;
3235 let hours = (total_sec % 86_400) / 3_600;
3236 let minutes = (total_sec % 3_600) / 60;
3237 if days > 0 {
3238 format!("{}d {:02}h", days, hours)
3239 } else {
3240 format!("{:02}h {:02}m", hours, minutes)
3241 }
3242}
3243
3244fn format_age_ms(age_ms: u64) -> String {
3245 if age_ms < 1_000 {
3246 format!("{}ms", age_ms)
3247 } else if age_ms < 60_000 {
3248 format!("{}s", age_ms / 1_000)
3249 } else {
3250 format!("{}m", age_ms / 60_000)
3251 }
3252}
3253
3254fn latency_stats(samples: &[u64]) -> (String, String, String) {
3255 let p50 = percentile(samples, 50);
3256 let p95 = percentile(samples, 95);
3257 let p99 = percentile(samples, 99);
3258 (
3259 p50.map(|v| format!("{}ms", v))
3260 .unwrap_or_else(|| "-".to_string()),
3261 p95.map(|v| format!("{}ms", v))
3262 .unwrap_or_else(|| "-".to_string()),
3263 p99.map(|v| format!("{}ms", v))
3264 .unwrap_or_else(|| "-".to_string()),
3265 )
3266}
3267
3268fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
3269 let area = frame.area();
3270 let popup = Rect {
3271 x: area.x + 8,
3272 y: area.y + 4,
3273 width: area.width.saturating_sub(16).max(50),
3274 height: area.height.saturating_sub(8).max(12),
3275 };
3276 frame.render_widget(Clear, popup);
3277 let block = Block::default()
3278 .title(" Strategy Config ")
3279 .borders(Borders::ALL)
3280 .border_style(Style::default().fg(Color::Yellow));
3281 let inner = block.inner(popup);
3282 frame.render_widget(block, popup);
3283 let selected_name = state
3284 .strategy_items
3285 .get(state.strategy_editor_index)
3286 .map(String::as_str)
3287 .unwrap_or("Unknown");
3288 let strategy_kind = state
3289 .strategy_editor_kind_items
3290 .get(state.strategy_editor_kind_index)
3291 .map(String::as_str)
3292 .unwrap_or("MA");
3293 let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
3294 let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
3295 let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
3296 let period_1_label = if is_rsa {
3297 "RSI Period"
3298 } else if is_atr {
3299 "ATR Period"
3300 } else if is_chb {
3301 "Entry Window"
3302 } else {
3303 "Fast Period"
3304 };
3305 let period_2_label = if is_rsa {
3306 "Upper RSI"
3307 } else if is_atr {
3308 "Threshold x100"
3309 } else if is_chb {
3310 "Exit Window"
3311 } else {
3312 "Slow Period"
3313 };
3314 let rows = [
3315 ("Strategy", strategy_kind.to_string()),
3316 (
3317 "Symbol",
3318 state
3319 .symbol_items
3320 .get(state.strategy_editor_symbol_index)
3321 .cloned()
3322 .unwrap_or_else(|| state.symbol.clone()),
3323 ),
3324 (period_1_label, state.strategy_editor_fast.to_string()),
3325 (period_2_label, state.strategy_editor_slow.to_string()),
3326 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
3327 ];
3328 let mut lines = vec![
3329 Line::from(vec![
3330 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
3331 Span::styled(
3332 selected_name,
3333 Style::default()
3334 .fg(Color::White)
3335 .add_modifier(Modifier::BOLD),
3336 ),
3337 ]),
3338 Line::from(Span::styled(
3339 "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
3340 Style::default().fg(Color::DarkGray),
3341 )),
3342 ];
3343 if is_rsa {
3344 let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
3345 lines.push(Line::from(Span::styled(
3346 format!("RSA lower threshold auto-derived: {}", lower),
3347 Style::default().fg(Color::DarkGray),
3348 )));
3349 } else if is_atr {
3350 let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
3351 lines.push(Line::from(Span::styled(
3352 format!(
3353 "ATR expansion threshold: {:.2}x",
3354 threshold_x100 as f64 / 100.0
3355 ),
3356 Style::default().fg(Color::DarkGray),
3357 )));
3358 } else if is_chb {
3359 lines.push(Line::from(Span::styled(
3360 "CHB breakout: buy on entry high break, sell on exit low break",
3361 Style::default().fg(Color::DarkGray),
3362 )));
3363 }
3364 for (idx, (name, value)) in rows.iter().enumerate() {
3365 let marker = if idx == state.strategy_editor_field {
3366 "▶ "
3367 } else {
3368 " "
3369 };
3370 let style = if idx == state.strategy_editor_field {
3371 Style::default()
3372 .fg(Color::Yellow)
3373 .add_modifier(Modifier::BOLD)
3374 } else {
3375 Style::default().fg(Color::White)
3376 };
3377 lines.push(Line::from(vec![
3378 Span::styled(marker, Style::default().fg(Color::Yellow)),
3379 Span::styled(format!("{:<14}", name), style),
3380 Span::styled(value, style),
3381 ]));
3382 }
3383 frame.render_widget(Paragraph::new(lines), inner);
3384 if state.strategy_editor_kind_category_selector_open {
3385 render_selector_popup(
3386 frame,
3387 " Select Strategy Category ",
3388 &state.strategy_editor_kind_category_items,
3389 state.strategy_editor_kind_category_index.min(
3390 state
3391 .strategy_editor_kind_category_items
3392 .len()
3393 .saturating_sub(1),
3394 ),
3395 None,
3396 None,
3397 None,
3398 );
3399 } else if state.strategy_editor_kind_selector_open {
3400 render_selector_popup(
3401 frame,
3402 " Select Strategy Type ",
3403 &state.strategy_editor_kind_popup_items,
3404 state.strategy_editor_kind_selector_index.min(
3405 state
3406 .strategy_editor_kind_popup_items
3407 .len()
3408 .saturating_sub(1),
3409 ),
3410 None,
3411 None,
3412 None,
3413 );
3414 }
3415}
3416
3417fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
3418 let area = frame.area();
3419 let popup = Rect {
3420 x: area.x + 4,
3421 y: area.y + 2,
3422 width: area.width.saturating_sub(8).max(30),
3423 height: area.height.saturating_sub(4).max(10),
3424 };
3425 frame.render_widget(Clear, popup);
3426 let block = Block::default()
3427 .title(" Account Assets ")
3428 .borders(Borders::ALL)
3429 .border_style(Style::default().fg(Color::Cyan));
3430 let inner = block.inner(popup);
3431 frame.render_widget(block, popup);
3432
3433 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
3434 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
3435
3436 let mut lines = Vec::with_capacity(assets.len() + 2);
3437 lines.push(Line::from(vec![
3438 Span::styled(
3439 "Asset",
3440 Style::default()
3441 .fg(Color::Cyan)
3442 .add_modifier(Modifier::BOLD),
3443 ),
3444 Span::styled(
3445 " Free",
3446 Style::default()
3447 .fg(Color::Cyan)
3448 .add_modifier(Modifier::BOLD),
3449 ),
3450 ]));
3451 for (asset, qty) in assets {
3452 lines.push(Line::from(vec![
3453 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
3454 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
3455 ]));
3456 }
3457 if lines.len() == 1 {
3458 lines.push(Line::from(Span::styled(
3459 "No assets",
3460 Style::default().fg(Color::DarkGray),
3461 )));
3462 }
3463
3464 frame.render_widget(Paragraph::new(lines), inner);
3465}
3466
3467fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
3468 let area = frame.area();
3469 let popup = Rect {
3470 x: area.x + 2,
3471 y: area.y + 1,
3472 width: area.width.saturating_sub(4).max(40),
3473 height: area.height.saturating_sub(2).max(12),
3474 };
3475 frame.render_widget(Clear, popup);
3476 let block = Block::default()
3477 .title(match bucket {
3478 order_store::HistoryBucket::Day => " History (Day ROI) ",
3479 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
3480 order_store::HistoryBucket::Month => " History (Month ROI) ",
3481 })
3482 .borders(Borders::ALL)
3483 .border_style(Style::default().fg(Color::Cyan));
3484 let inner = block.inner(popup);
3485 frame.render_widget(block, popup);
3486
3487 let max_rows = inner.height.saturating_sub(1) as usize;
3488 let visible = build_history_lines(rows, max_rows);
3489 frame.render_widget(Paragraph::new(visible), inner);
3490}
3491
3492fn build_history_lines(rows: &[String], max_rows: usize) -> Vec<Line<'_>> {
3493 let mut visible: Vec<Line> = Vec::new();
3494 for (idx, row) in rows.iter().take(max_rows).enumerate() {
3495 let color = if idx == 0 {
3496 Color::Cyan
3497 } else if row.contains('-') && row.contains('%') {
3498 Color::White
3499 } else {
3500 Color::DarkGray
3501 };
3502 visible.push(Line::from(Span::styled(
3503 row.as_str(),
3504 Style::default().fg(color),
3505 )));
3506 }
3507 if visible.is_empty() {
3508 visible.push(Line::from(Span::styled(
3509 "No history rows",
3510 Style::default().fg(Color::DarkGray),
3511 )));
3512 }
3513 visible
3514}
3515
3516fn render_selector_popup(
3517 frame: &mut Frame,
3518 title: &str,
3519 items: &[String],
3520 selected: usize,
3521 stats: Option<&HashMap<String, OrderHistoryStats>>,
3522 total_stats: Option<OrderHistoryStats>,
3523 selected_symbol: Option<&str>,
3524) {
3525 let area = frame.area();
3526 let available_width = area.width.saturating_sub(2).max(1);
3527 let width = if stats.is_some() {
3528 let min_width = 44;
3529 let preferred = 84;
3530 preferred
3531 .min(available_width)
3532 .max(min_width.min(available_width))
3533 } else {
3534 let min_width = 24;
3535 let preferred = 48;
3536 preferred
3537 .min(available_width)
3538 .max(min_width.min(available_width))
3539 };
3540 let available_height = area.height.saturating_sub(2).max(1);
3541 let desired_height = if stats.is_some() {
3542 items.len() as u16 + 7
3543 } else {
3544 items.len() as u16 + 4
3545 };
3546 let height = desired_height
3547 .min(available_height)
3548 .max(6.min(available_height));
3549 let popup = Rect {
3550 x: area.x + (area.width.saturating_sub(width)) / 2,
3551 y: area.y + (area.height.saturating_sub(height)) / 2,
3552 width,
3553 height,
3554 };
3555
3556 frame.render_widget(Clear, popup);
3557 let block = Block::default()
3558 .title(title)
3559 .borders(Borders::ALL)
3560 .border_style(Style::default().fg(Color::Cyan));
3561 let inner = block.inner(popup);
3562 frame.render_widget(block, popup);
3563
3564 let mut lines: Vec<Line> = Vec::new();
3565 if stats.is_some() {
3566 if let Some(symbol) = selected_symbol {
3567 lines.push(Line::from(vec![
3568 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
3569 Span::styled(
3570 symbol,
3571 Style::default()
3572 .fg(Color::Green)
3573 .add_modifier(Modifier::BOLD),
3574 ),
3575 ]));
3576 }
3577 lines.push(Line::from(vec![Span::styled(
3578 " Strategy W L T PnL",
3579 Style::default()
3580 .fg(Color::Cyan)
3581 .add_modifier(Modifier::BOLD),
3582 )]));
3583 }
3584
3585 let mut item_lines: Vec<Line> = items
3586 .iter()
3587 .enumerate()
3588 .map(|(idx, item)| {
3589 let item_text = if let Some(stats_map) = stats {
3590 let symbol = selected_symbol.unwrap_or("-");
3591 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3592 format!(
3593 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3594 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
3595 )
3596 } else {
3597 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
3598 }
3599 } else {
3600 item.clone()
3601 };
3602 if idx == selected {
3603 Line::from(vec![
3604 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
3605 Span::styled(
3606 item_text,
3607 Style::default()
3608 .fg(Color::White)
3609 .add_modifier(Modifier::BOLD),
3610 ),
3611 ])
3612 } else {
3613 Line::from(vec![
3614 Span::styled(" ", Style::default()),
3615 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
3616 ])
3617 }
3618 })
3619 .collect();
3620 lines.append(&mut item_lines);
3621 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
3622 let mut strategy_sum = OrderHistoryStats::default();
3623 for item in items {
3624 let symbol = selected_symbol.unwrap_or("-");
3625 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3626 strategy_sum.trade_count += s.trade_count;
3627 strategy_sum.win_count += s.win_count;
3628 strategy_sum.lose_count += s.lose_count;
3629 strategy_sum.realized_pnl += s.realized_pnl;
3630 }
3631 }
3632 let manual = subtract_stats(t, &strategy_sum);
3633 lines.push(Line::from(vec![Span::styled(
3634 format!(
3635 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3636 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
3637 ),
3638 Style::default().fg(Color::LightBlue),
3639 )]));
3640 }
3641 if let Some(t) = total_stats {
3642 lines.push(Line::from(vec![Span::styled(
3643 format!(
3644 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3645 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
3646 ),
3647 Style::default()
3648 .fg(Color::Yellow)
3649 .add_modifier(Modifier::BOLD),
3650 )]));
3651 }
3652
3653 frame.render_widget(
3654 Paragraph::new(lines).style(Style::default().fg(Color::White)),
3655 inner,
3656 );
3657}
3658
3659fn parse_scope_key(scope_key: &str) -> Option<(String, String)> {
3660 let (symbol, tag) = scope_key.split_once("::")?;
3661 let symbol = symbol.trim().to_ascii_uppercase();
3662 let source_tag = tag.trim().to_ascii_lowercase();
3663 if symbol.is_empty() || source_tag.is_empty() {
3664 None
3665 } else {
3666 Some((symbol, source_tag))
3667 }
3668}
3669
3670fn ev_snapshot_for_symbol_and_tag(
3671 state: &AppState,
3672 symbol: &str,
3673 source_tag: &str,
3674) -> Option<EvSnapshotEntry> {
3675 let tag = source_tag.trim().to_ascii_lowercase();
3676 let candidates = symbol_scope_candidates(symbol);
3677 state
3678 .ev_snapshot_by_scope
3679 .iter()
3680 .filter_map(|(k, v)| {
3681 let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3682 let symbol_ok = candidates.iter().any(|prefix| {
3683 prefix
3684 .trim_end_matches("::")
3685 .eq_ignore_ascii_case(&scope_symbol)
3686 });
3687 if symbol_ok && scope_tag == tag {
3688 Some(v)
3689 } else {
3690 None
3691 }
3692 })
3693 .max_by_key(|v| v.updated_at_ms)
3694 .cloned()
3695}
3696
3697fn exit_policy_for_symbol_and_tag(
3698 state: &AppState,
3699 symbol: &str,
3700 source_tag: &str,
3701) -> Option<ExitPolicyEntry> {
3702 let tag = source_tag.trim().to_ascii_lowercase();
3703 let candidates = symbol_scope_candidates(symbol);
3704 state
3705 .exit_policy_by_scope
3706 .iter()
3707 .filter_map(|(k, v)| {
3708 let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3709 let symbol_ok = candidates.iter().any(|prefix| {
3710 prefix
3711 .trim_end_matches("::")
3712 .eq_ignore_ascii_case(&scope_symbol)
3713 });
3714 if symbol_ok && scope_tag == tag {
3715 Some(v)
3716 } else {
3717 None
3718 }
3719 })
3720 .max_by_key(|v| v.updated_at_ms)
3721 .cloned()
3722}
3723
3724fn display_symbol_for_storage(symbol: &str) -> String {
3725 let upper = symbol.trim().to_ascii_uppercase();
3726 if let Some(base) = upper.strip_suffix("#FUT") {
3727 format!("{} (FUT)", base)
3728 } else {
3729 upper
3730 }
3731}
3732
3733fn asset_last_price_for_symbol(state: &AppState, symbol: &str) -> Option<f64> {
3734 let target = normalize_symbol_for_scope(symbol);
3735 state
3736 .assets_view()
3737 .iter()
3738 .find(|a| normalize_symbol_for_scope(&a.symbol) == target)
3739 .and_then(|a| a.last_price)
3740}
3741
3742fn close_all_row_status_for_symbol(state: &AppState, symbol: &str) -> Option<String> {
3743 let key = normalize_symbol_for_scope(symbol);
3744 if state.close_all_running {
3745 if let Some(found) = state.close_all_row_status_by_symbol.get(&key) {
3746 if found == "PENDING" {
3747 return Some("RUNNING".to_string());
3748 }
3749 return Some(found.clone());
3750 }
3751 }
3752 state.close_all_row_status_by_symbol.get(&key).cloned()
3753}
3754
3755fn strategy_stats_for_item<'a>(
3756 stats_map: &'a HashMap<String, OrderHistoryStats>,
3757 item: &str,
3758 symbol: &str,
3759) -> Option<&'a OrderHistoryStats> {
3760 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3761 let scoped = strategy_stats_scope_key(symbol, &source_tag);
3762 if let Some(s) = stats_map.get(&scoped) {
3763 return Some(s);
3764 }
3765 }
3766 if let Some(s) = stats_map.get(item) {
3767 return Some(s);
3768 }
3769 let source_tag = source_tag_for_strategy_item(item);
3770 source_tag.and_then(|tag| {
3771 stats_map
3772 .get(&tag)
3773 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
3774 })
3775}
3776
3777fn ev_snapshot_for_item<'a>(
3778 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3779 item: &str,
3780 symbol: &str,
3781) -> Option<&'a EvSnapshotEntry> {
3782 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3783 if let Some(found) = ev_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3784 return Some(found);
3785 }
3786 }
3787 latest_ev_snapshot_for_symbol(ev_map, symbol)
3788}
3789
3790fn exit_policy_for_item<'a>(
3791 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3792 item: &str,
3793 symbol: &str,
3794) -> Option<&'a ExitPolicyEntry> {
3795 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3796 if let Some(found) = policy_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3797 return Some(found);
3798 }
3799 }
3800 latest_exit_policy_for_symbol(policy_map, symbol)
3801}
3802
3803fn latest_ev_snapshot_for_symbol<'a>(
3804 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3805 symbol: &str,
3806) -> Option<&'a EvSnapshotEntry> {
3807 let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3808 ev_map
3809 .iter()
3810 .filter(|(k, _)| k.starts_with(&prefix))
3811 .max_by_key(|(_, v)| v.updated_at_ms)
3812 .map(|(_, v)| v)
3813}
3814
3815fn latest_exit_policy_for_symbol<'a>(
3816 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3817 symbol: &str,
3818) -> Option<&'a ExitPolicyEntry> {
3819 let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3820 policy_map
3821 .iter()
3822 .filter(|(k, _)| k.starts_with(&prefix))
3823 .max_by_key(|(_, v)| v.updated_at_ms)
3824 .map(|(_, v)| v)
3825}
3826
3827fn latest_ev_snapshot_for_symbol_relaxed<'a>(
3828 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3829 symbol: &str,
3830) -> Option<&'a EvSnapshotEntry> {
3831 let candidates = symbol_scope_candidates(symbol);
3832 ev_map
3833 .iter()
3834 .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3835 .max_by_key(|(_, v)| v.updated_at_ms)
3836 .map(|(_, v)| v)
3837}
3838
3839fn latest_exit_policy_for_symbol_relaxed<'a>(
3840 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3841 symbol: &str,
3842) -> Option<&'a ExitPolicyEntry> {
3843 let candidates = symbol_scope_candidates(symbol);
3844 policy_map
3845 .iter()
3846 .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3847 .max_by_key(|(_, v)| v.updated_at_ms)
3848 .map(|(_, v)| v)
3849}
3850
3851fn symbol_scope_candidates(symbol: &str) -> Vec<String> {
3852 let mut variants: Vec<String> = Vec::new();
3853 let upper = symbol.trim().to_ascii_uppercase();
3854 let base = if let Some(raw) = upper.strip_suffix(" (FUT)") {
3855 raw.trim().to_string()
3856 } else if let Some(raw) = upper.strip_suffix("#FUT") {
3857 raw.trim().to_string()
3858 } else {
3859 upper.clone()
3860 };
3861
3862 if !base.is_empty() {
3863 variants.push(base.clone());
3864 variants.push(format!("{} (FUT)", base));
3865 variants.push(format!("{}#FUT", base));
3866 }
3867 if !upper.is_empty() {
3868 variants.push(upper);
3869 }
3870 variants.sort();
3871 variants.dedup();
3872 variants.into_iter().map(|v| format!("{}::", v)).collect()
3873}
3874
3875fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
3876 format!(
3877 "{}::{}",
3878 symbol.trim().to_ascii_uppercase(),
3879 source_tag.trim().to_ascii_lowercase()
3880 )
3881}
3882
3883fn predictor_metrics_scope_key(
3884 symbol: &str,
3885 market: &str,
3886 predictor: &str,
3887 horizon: &str,
3888) -> String {
3889 format!(
3890 "{}::{}::{}::{}",
3891 symbol.trim().to_ascii_uppercase(),
3892 market.trim().to_ascii_lowercase(),
3893 predictor.trim().to_ascii_lowercase(),
3894 horizon.trim().to_ascii_lowercase()
3895 )
3896}
3897
3898fn source_tag_for_strategy_item(item: &str) -> Option<String> {
3899 match item {
3900 "MA(Config)" => return Some("cfg".to_string()),
3901 "MA(Fast 5/20)" => return Some("fst".to_string()),
3902 "MA(Slow 20/60)" => return Some("slw".to_string()),
3903 "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
3904 "DCT(Donchian 20/10)" => return Some("dct".to_string()),
3905 "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
3906 "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
3907 "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
3908 "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
3909 "ORB(Opening 12/8)" => return Some("orb".to_string()),
3910 "REG(Regime 10/30)" => return Some("reg".to_string()),
3911 "ENS(Vote 10/30)" => return Some("ens".to_string()),
3912 "MAC(MACD 12/26)" => return Some("mac".to_string()),
3913 "ROC(ROC 10 0.20%)" => return Some("roc".to_string()),
3914 "ARN(Aroon 14 70)" => return Some("arn".to_string()),
3915 _ => {}
3916 }
3917 if let Some((_, tail)) = item.rsplit_once('[') {
3918 if let Some(tag) = tail.strip_suffix(']') {
3919 let tag = tag.trim();
3920 if !tag.is_empty() {
3921 return Some(tag.to_ascii_lowercase());
3922 }
3923 }
3924 }
3925 None
3926}
3927
3928fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
3929 let body = client_order_id.strip_prefix("sq-")?;
3930 let (source_tag, _) = body.split_once('-')?;
3931 if source_tag.is_empty() {
3932 None
3933 } else {
3934 Some(source_tag)
3935 }
3936}
3937
3938fn normalize_symbol_for_scope(symbol: &str) -> String {
3939 let upper = symbol.trim().to_ascii_uppercase();
3940 if let Some(raw) = upper.strip_suffix(" (FUT)") {
3941 return raw.trim().to_string();
3942 }
3943 if let Some(raw) = upper.strip_suffix("#FUT") {
3944 return raw.trim().to_string();
3945 }
3946 upper
3947}
3948
3949fn format_log_record_compact(record: &LogRecord) -> String {
3950 let level = match record.level {
3951 LogLevel::Debug => "DEBUG",
3952 LogLevel::Info => "INFO",
3953 LogLevel::Warn => "WARN",
3954 LogLevel::Error => "ERR",
3955 };
3956 let domain = match record.domain {
3957 LogDomain::Ws => "ws",
3958 LogDomain::Strategy => "strategy",
3959 LogDomain::Risk => "risk",
3960 LogDomain::Order => "order",
3961 LogDomain::Portfolio => "portfolio",
3962 LogDomain::Ui => "ui",
3963 LogDomain::System => "system",
3964 };
3965 let symbol = record.symbol.as_deref().unwrap_or("-");
3966 let strategy = record.strategy_tag.as_deref().unwrap_or("-");
3967 format!(
3968 "[{}] {}.{} {} {} {}",
3969 level, domain, record.event, symbol, strategy, record.msg
3970 )
3971}
3972
3973fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
3974 OrderHistoryStats {
3975 trade_count: total.trade_count.saturating_sub(used.trade_count),
3976 win_count: total.win_count.saturating_sub(used.win_count),
3977 lose_count: total.lose_count.saturating_sub(used.lose_count),
3978 realized_pnl: total.realized_pnl - used.realized_pnl,
3979 }
3980}
3981
3982fn split_symbol_assets(symbol: &str) -> (String, String) {
3983 const QUOTE_SUFFIXES: [&str; 10] = [
3984 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
3985 ];
3986 for q in QUOTE_SUFFIXES {
3987 if let Some(base) = symbol.strip_suffix(q) {
3988 if !base.is_empty() {
3989 return (base.to_string(), q.to_string());
3990 }
3991 }
3992 }
3993 (symbol.to_string(), String::new())
3994}
3995
3996fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
3997 if fills.is_empty() {
3998 return None;
3999 }
4000 let (base_asset, quote_asset) = split_symbol_assets(symbol);
4001 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
4002 let mut notional_quote = 0.0;
4003 let mut fee_quote_equiv = 0.0;
4004 let mut quote_convertible = !quote_asset.is_empty();
4005
4006 for f in fills {
4007 if f.qty > 0.0 && f.price > 0.0 {
4008 notional_quote += f.qty * f.price;
4009 }
4010 if f.commission <= 0.0 {
4011 continue;
4012 }
4013 *fee_by_asset
4014 .entry(f.commission_asset.clone())
4015 .or_insert(0.0) += f.commission;
4016 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
4017 fee_quote_equiv += f.commission;
4018 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
4019 fee_quote_equiv += f.commission * f.price.max(0.0);
4020 } else {
4021 quote_convertible = false;
4022 }
4023 }
4024
4025 if fee_by_asset.is_empty() {
4026 return Some("0".to_string());
4027 }
4028
4029 if quote_convertible && notional_quote > f64::EPSILON {
4030 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
4031 return Some(format!(
4032 "{:.3}% ({:.4} {})",
4033 fee_pct, fee_quote_equiv, quote_asset
4034 ));
4035 }
4036
4037 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
4038 items.sort_by(|a, b| a.0.cmp(&b.0));
4039 if items.len() == 1 {
4040 let (asset, amount) = &items[0];
4041 Some(format!("{:.6} {}", amount, asset))
4042 } else {
4043 Some(format!("mixed fees ({})", items.len()))
4044 }
4045}
4046
4047#[cfg(test)]
4048mod tests {
4049 use super::{format_last_applied_fee, symbol_scope_candidates};
4050 use crate::model::order::Fill;
4051
4052 #[test]
4053 fn fee_summary_from_quote_asset_commission() {
4054 let fills = vec![Fill {
4055 price: 2000.0,
4056 qty: 0.5,
4057 commission: 1.0,
4058 commission_asset: "USDT".to_string(),
4059 }];
4060 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
4061 assert_eq!(summary, "0.100% (1.0000 USDT)");
4062 }
4063
4064 #[test]
4065 fn fee_summary_from_base_asset_commission() {
4066 let fills = vec![Fill {
4067 price: 2000.0,
4068 qty: 0.5,
4069 commission: 0.0005,
4070 commission_asset: "ETH".to_string(),
4071 }];
4072 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
4073 assert_eq!(summary, "0.100% (1.0000 USDT)");
4074 }
4075
4076 #[test]
4077 fn symbol_scope_candidates_include_spot_and_futures_variants() {
4078 let mut from_spot = symbol_scope_candidates("btcusdt");
4079 from_spot.sort();
4080 assert!(from_spot.contains(&"BTCUSDT::".to_string()));
4081 assert!(from_spot.contains(&"BTCUSDT (FUT)::".to_string()));
4082 assert!(from_spot.contains(&"BTCUSDT#FUT::".to_string()));
4083
4084 let mut from_fut_label = symbol_scope_candidates("BTCUSDT (FUT)");
4085 from_fut_label.sort();
4086 assert!(from_fut_label.contains(&"BTCUSDT::".to_string()));
4087 assert!(from_fut_label.contains(&"BTCUSDT (FUT)::".to_string()));
4088 assert!(from_fut_label.contains(&"BTCUSDT#FUT::".to_string()));
4089
4090 let mut from_hash_fut = symbol_scope_candidates("BTCUSDT#FUT");
4091 from_hash_fut.sort();
4092 assert!(from_hash_fut.contains(&"BTCUSDT::".to_string()));
4093 assert!(from_hash_fut.contains(&"BTCUSDT (FUT)::".to_string()));
4094 assert!(from_hash_fut.contains(&"BTCUSDT#FUT::".to_string()));
4095 }
4096}