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