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