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