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