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 p_win,
1076 gate_mode,
1077 gate_blocked,
1078 } => {
1079 let key = strategy_stats_scope_key(&symbol, &source_tag);
1080 self.ev_snapshot_by_scope.insert(
1081 key,
1082 EvSnapshotEntry {
1083 ev,
1084 p_win,
1085 gate_mode,
1086 gate_blocked,
1087 updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1088 },
1089 );
1090 }
1091 AppEvent::ExitPolicyUpdate {
1092 symbol,
1093 source_tag,
1094 stop_price,
1095 expected_holding_ms,
1096 protective_stop_ok,
1097 } => {
1098 let key = strategy_stats_scope_key(&symbol, &source_tag);
1099 self.exit_policy_by_scope.insert(
1100 key,
1101 ExitPolicyEntry {
1102 stop_price,
1103 expected_holding_ms,
1104 protective_stop_ok,
1105 updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1106 },
1107 );
1108 }
1109 AppEvent::AssetPnlUpdate { by_symbol } => {
1110 rebuild_projection = true;
1111 self.asset_pnl_by_symbol = by_symbol;
1112 }
1113 AppEvent::RiskRateSnapshot {
1114 global,
1115 orders,
1116 account,
1117 market_data,
1118 } => {
1119 self.rate_budget_global = global;
1120 self.rate_budget_orders = orders;
1121 self.rate_budget_account = account;
1122 self.rate_budget_market_data = market_data;
1123 }
1124 AppEvent::CloseAllRequested {
1125 job_id,
1126 total,
1127 symbols,
1128 } => {
1129 self.close_all_running = true;
1130 self.close_all_job_id = Some(job_id);
1131 self.close_all_total = total;
1132 self.close_all_completed = 0;
1133 self.close_all_failed = 0;
1134 self.close_all_current_symbol = None;
1135 self.close_all_status_expire_at_ms = None;
1136 self.close_all_row_status_by_symbol.clear();
1137 for symbol in symbols {
1138 self.close_all_row_status_by_symbol
1139 .insert(normalize_symbol_for_scope(&symbol), "PENDING".to_string());
1140 }
1141 self.push_log(format!("[INFO] close-all #{} started total={}", job_id, total));
1142 }
1143 AppEvent::CloseAllProgress {
1144 job_id,
1145 symbol,
1146 completed,
1147 total,
1148 failed,
1149 reason,
1150 } => {
1151 if self.close_all_job_id != Some(job_id) {
1152 self.close_all_job_id = Some(job_id);
1153 }
1154 self.close_all_running = completed < total;
1155 self.close_all_total = total;
1156 self.close_all_completed = completed;
1157 self.close_all_failed = failed;
1158 self.close_all_current_symbol = Some(symbol.clone());
1159 let symbol_key = normalize_symbol_for_scope(&symbol);
1160 let row_status = if let Some(r) = reason.as_ref() {
1161 if r.contains("too small")
1162 || r.contains("No ")
1163 || r.contains("Insufficient ")
1164 {
1165 "SKIP"
1166 } else {
1167 "FAIL"
1168 }
1169 } else {
1170 "DONE"
1171 };
1172 self.close_all_row_status_by_symbol
1173 .insert(symbol_key, row_status.to_string());
1174 let ok = completed.saturating_sub(failed);
1175 self.push_log(format!(
1176 "[INFO] close-all #{} progress {}/{} ok={} fail={} symbol={}",
1177 job_id, completed, total, ok, failed, symbol
1178 ));
1179 if let Some(r) = reason {
1180 self.push_log(format!(
1181 "[WARN] close-all #{} {} ({}/{}) reason={}",
1182 job_id, symbol, completed, total, r
1183 ));
1184 }
1185 }
1186 AppEvent::CloseAllFinished {
1187 job_id,
1188 completed,
1189 total,
1190 failed,
1191 } => {
1192 self.close_all_running = false;
1193 self.close_all_job_id = Some(job_id);
1194 self.close_all_total = total;
1195 self.close_all_completed = completed;
1196 self.close_all_failed = failed;
1197 self.close_all_status_expire_at_ms =
1198 Some((chrono::Utc::now().timestamp_millis() as u64).saturating_add(5_000));
1199 let ok = completed.saturating_sub(failed);
1200 self.push_log(format!(
1201 "[INFO] close-all #{} finished ok={} fail={} total={}",
1202 job_id, ok, failed, total
1203 ));
1204 }
1205 AppEvent::TickDropped => {
1206 self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1207 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1208 Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1209 }
1210 AppEvent::LogRecord(record) => {
1211 self.push_log_record(record);
1212 }
1213 AppEvent::LogMessage(msg) => {
1214 self.push_log(msg);
1215 }
1216 AppEvent::Error(msg) => {
1217 self.push_log(format!("[ERR] {}", msg));
1218 }
1219 }
1220 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1221 if !self.close_all_running
1222 && self
1223 .close_all_status_expire_at_ms
1224 .map(|ts| now_ms >= ts)
1225 .unwrap_or(false)
1226 {
1227 self.close_all_job_id = None;
1228 self.close_all_total = 0;
1229 self.close_all_completed = 0;
1230 self.close_all_failed = 0;
1231 self.close_all_current_symbol = None;
1232 self.close_all_status_expire_at_ms = None;
1233 self.close_all_row_status_by_symbol.clear();
1234 }
1235 self.prune_network_event_windows(now_ms);
1236 self.sync_projection_portfolio_summary();
1237 if rebuild_projection {
1238 self.rebuild_projection_preserve_focus(prev_focus);
1239 } else {
1240 self.ensure_projection_focus_defaults();
1241 }
1242 }
1243}
1244
1245pub fn render(frame: &mut Frame, state: &AppState) {
1246 let view = state.view_state();
1247 if view.is_grid_open {
1248 render_grid_popup(frame, state);
1249 if view.is_strategy_editor_open {
1250 render_strategy_editor_popup(frame, state);
1251 }
1252 if view.is_close_all_confirm_open {
1253 render_close_all_confirm_popup(frame);
1254 }
1255 return;
1256 }
1257
1258 let area = frame.area();
1259 let status_h = 1u16;
1260 let keybind_h = 1u16;
1261 let min_main_h = 8u16;
1262 let mut order_log_h = 5u16;
1263 let mut order_history_h = 6u16;
1264 let mut system_log_h = 8u16;
1265 let mut lower_total = order_log_h + order_history_h + system_log_h;
1266 let lower_budget = area
1267 .height
1268 .saturating_sub(status_h + keybind_h + min_main_h);
1269 if lower_total > lower_budget {
1270 let mut overflow = lower_total - lower_budget;
1271 while overflow > 0 && system_log_h > 0 {
1272 system_log_h -= 1;
1273 overflow -= 1;
1274 }
1275 while overflow > 0 && order_history_h > 0 {
1276 order_history_h -= 1;
1277 overflow -= 1;
1278 }
1279 while overflow > 0 && order_log_h > 0 {
1280 order_log_h -= 1;
1281 overflow -= 1;
1282 }
1283 lower_total = order_log_h + order_history_h + system_log_h;
1284 if lower_total > lower_budget {
1285 order_log_h = 0;
1287 order_history_h = 0;
1288 system_log_h = 0;
1289 }
1290 }
1291
1292 let outer = Layout::default()
1293 .direction(Direction::Vertical)
1294 .constraints([
1295 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), ])
1302 .split(area);
1303
1304 let close_all_status_text = state.close_all_status_text();
1305 frame.render_widget(
1307 StatusBar {
1308 symbol: &state.symbol,
1309 strategy_label: &state.strategy_label,
1310 ws_connected: state.ws_connected,
1311 paused: state.paused,
1312 timeframe: &state.timeframe,
1313 last_price_update_ms: state.last_price_update_ms,
1314 last_price_latency_ms: state.last_price_latency_ms,
1315 last_order_history_update_ms: state.last_order_history_update_ms,
1316 last_order_history_latency_ms: state.last_order_history_latency_ms,
1317 close_all_status: close_all_status_text.as_deref(),
1318 close_all_running: state.close_all_running,
1319 },
1320 outer[0],
1321 );
1322
1323 let main_area = Layout::default()
1325 .direction(Direction::Horizontal)
1326 .constraints([Constraint::Min(40), Constraint::Length(24)])
1327 .split(outer[1]);
1328 let selected_strategy_stats =
1329 strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1330 .cloned()
1331 .unwrap_or_default();
1332
1333 let current_price = state.last_price();
1335 frame.render_widget(
1336 PriceChart::new(&state.candles, &state.symbol)
1337 .current_candle(state.current_candle.as_ref())
1338 .fill_markers(&state.fill_markers)
1339 .fast_sma(state.fast_sma)
1340 .slow_sma(state.slow_sma),
1341 main_area[0],
1342 );
1343
1344 let right_panels = Layout::default()
1346 .direction(Direction::Vertical)
1347 .constraints([Constraint::Min(9), Constraint::Length(8)])
1348 .split(main_area[1]);
1349 frame.render_widget(
1350 PositionPanel::new(
1351 &state.position,
1352 current_price,
1353 &state.last_applied_fee,
1354 ev_snapshot_for_item(&state.ev_snapshot_by_scope, &state.strategy_label, &state.symbol),
1355 exit_policy_for_item(&state.exit_policy_by_scope, &state.strategy_label, &state.symbol),
1356 ),
1357 right_panels[0],
1358 );
1359 frame.render_widget(
1360 StrategyMetricsPanel::new(
1361 &state.strategy_label,
1362 selected_strategy_stats.trade_count,
1363 selected_strategy_stats.win_count,
1364 selected_strategy_stats.lose_count,
1365 selected_strategy_stats.realized_pnl,
1366 ),
1367 right_panels[1],
1368 );
1369
1370 frame.render_widget(
1372 OrderLogPanel::new(
1373 &state.last_signal,
1374 &state.last_order,
1375 state.fast_sma,
1376 state.slow_sma,
1377 selected_strategy_stats.trade_count,
1378 selected_strategy_stats.win_count,
1379 selected_strategy_stats.lose_count,
1380 selected_strategy_stats.realized_pnl,
1381 ),
1382 outer[2],
1383 );
1384
1385 frame.render_widget(
1387 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1388 outer[3],
1389 );
1390
1391 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1393
1394 frame.render_widget(KeybindBar, outer[5]);
1396
1397 if view.is_close_all_confirm_open {
1398 render_close_all_confirm_popup(frame);
1399 } else if view.is_symbol_selector_open {
1400 render_selector_popup(
1401 frame,
1402 " Select Symbol ",
1403 &state.symbol_items,
1404 view.selected_symbol_selector_index,
1405 None,
1406 None,
1407 None,
1408 );
1409 } else if view.is_strategy_selector_open {
1410 let selected_strategy_symbol = state
1411 .strategy_item_symbols
1412 .get(view.selected_strategy_selector_index)
1413 .map(String::as_str)
1414 .unwrap_or(state.symbol.as_str());
1415 render_selector_popup(
1416 frame,
1417 " Select Strategy ",
1418 &state.strategy_items,
1419 view.selected_strategy_selector_index,
1420 Some(&state.strategy_stats),
1421 Some(OrderHistoryStats {
1422 trade_count: state.history_trade_count,
1423 win_count: state.history_win_count,
1424 lose_count: state.history_lose_count,
1425 realized_pnl: state.history_realized_pnl,
1426 }),
1427 Some(selected_strategy_symbol),
1428 );
1429 } else if view.is_account_popup_open {
1430 render_account_popup(frame, &state.balances);
1431 } else if view.is_history_popup_open {
1432 render_history_popup(frame, &state.history_rows, state.history_bucket);
1433 } else if view.is_focus_popup_open {
1434 render_focus_popup(frame, state);
1435 } else if view.is_strategy_editor_open {
1436 render_strategy_editor_popup(frame, state);
1437 }
1438}
1439
1440fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1441 let area = frame.area();
1442 let popup = Rect {
1443 x: area.x + 1,
1444 y: area.y + 1,
1445 width: area.width.saturating_sub(2).max(70),
1446 height: area.height.saturating_sub(2).max(22),
1447 };
1448 frame.render_widget(Clear, popup);
1449 let block = Block::default()
1450 .title(" Focus View (Drill-down) ")
1451 .borders(Borders::ALL)
1452 .border_style(Style::default().fg(Color::Green));
1453 let inner = block.inner(popup);
1454 frame.render_widget(block, popup);
1455
1456 let rows = Layout::default()
1457 .direction(Direction::Vertical)
1458 .constraints([
1459 Constraint::Length(2),
1460 Constraint::Min(8),
1461 Constraint::Length(7),
1462 ])
1463 .split(inner);
1464
1465 let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1466 let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1467 let focus_strategy_stats = strategy_stats_for_item(
1468 &state.strategy_stats,
1469 focus_strategy,
1470 focus_symbol,
1471 )
1472 .cloned()
1473 .unwrap_or_default();
1474 frame.render_widget(
1475 Paragraph::new(vec![
1476 Line::from(vec![
1477 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1478 Span::styled(
1479 focus_symbol,
1480 Style::default()
1481 .fg(Color::Cyan)
1482 .add_modifier(Modifier::BOLD),
1483 ),
1484 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
1485 Span::styled(
1486 focus_strategy,
1487 Style::default()
1488 .fg(Color::Magenta)
1489 .add_modifier(Modifier::BOLD),
1490 ),
1491 ]),
1492 Line::from(Span::styled(
1493 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1494 Style::default().fg(Color::DarkGray),
1495 )),
1496 ]),
1497 rows[0],
1498 );
1499
1500 let main_cols = Layout::default()
1501 .direction(Direction::Horizontal)
1502 .constraints([Constraint::Min(48), Constraint::Length(28)])
1503 .split(rows[1]);
1504
1505 frame.render_widget(
1506 PriceChart::new(&state.candles, focus_symbol)
1507 .current_candle(state.current_candle.as_ref())
1508 .fill_markers(&state.fill_markers)
1509 .fast_sma(state.fast_sma)
1510 .slow_sma(state.slow_sma),
1511 main_cols[0],
1512 );
1513 let focus_right = Layout::default()
1514 .direction(Direction::Vertical)
1515 .constraints([Constraint::Min(8), Constraint::Length(8)])
1516 .split(main_cols[1]);
1517 frame.render_widget(
1518 PositionPanel::new(
1519 &state.position,
1520 state.last_price(),
1521 &state.last_applied_fee,
1522 ev_snapshot_for_item(&state.ev_snapshot_by_scope, focus_strategy, focus_symbol),
1523 exit_policy_for_item(&state.exit_policy_by_scope, focus_strategy, focus_symbol),
1524 ),
1525 focus_right[0],
1526 );
1527 frame.render_widget(
1528 StrategyMetricsPanel::new(
1529 focus_strategy,
1530 focus_strategy_stats.trade_count,
1531 focus_strategy_stats.win_count,
1532 focus_strategy_stats.lose_count,
1533 focus_strategy_stats.realized_pnl,
1534 ),
1535 focus_right[1],
1536 );
1537
1538 frame.render_widget(
1539 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1540 rows[2],
1541 );
1542}
1543
1544fn render_close_all_confirm_popup(frame: &mut Frame) {
1545 let area = frame.area();
1546 let popup = Rect {
1547 x: area.x + area.width.saturating_sub(60) / 2,
1548 y: area.y + area.height.saturating_sub(7) / 2,
1549 width: 60.min(area.width.saturating_sub(2)).max(40),
1550 height: 7.min(area.height.saturating_sub(2)).max(5),
1551 };
1552 frame.render_widget(Clear, popup);
1553 let block = Block::default()
1554 .title(" Confirm Close-All ")
1555 .borders(Borders::ALL)
1556 .border_style(Style::default().fg(Color::Red));
1557 let inner = block.inner(popup);
1558 frame.render_widget(block, popup);
1559 let lines = vec![
1560 Line::from(Span::styled(
1561 "Close all open positions now?",
1562 Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
1563 )),
1564 Line::from(Span::styled(
1565 "[Y/Enter] Confirm [N/Esc] Cancel",
1566 Style::default().fg(Color::DarkGray),
1567 )),
1568 ];
1569 frame.render_widget(Paragraph::new(lines), inner);
1570}
1571
1572
1573fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1574 let view = state.view_state();
1575 let area = frame.area();
1576 let popup = area;
1577 frame.render_widget(Clear, popup);
1578 let block = Block::default()
1579 .title(" Portfolio Grid ")
1580 .borders(Borders::ALL)
1581 .border_style(Style::default().fg(Color::Cyan));
1582 let inner = block.inner(popup);
1583 frame.render_widget(block, popup);
1584
1585 let root = Layout::default()
1586 .direction(Direction::Vertical)
1587 .constraints([Constraint::Length(2), Constraint::Min(1)])
1588 .split(inner);
1589 let tab_area = root[0];
1590 let body_area = root[1];
1591
1592 let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1593 let selected = view.selected_grid_tab == tab;
1594 Span::styled(
1595 format!("[{} {}]", key, label),
1596 if selected {
1597 Style::default()
1598 .fg(Color::Yellow)
1599 .add_modifier(Modifier::BOLD)
1600 } else {
1601 Style::default().fg(Color::DarkGray)
1602 },
1603 )
1604 };
1605 frame.render_widget(
1606 Paragraph::new(Line::from(vec![
1607 tab_span(GridTab::Assets, "1", "Assets"),
1608 Span::raw(" "),
1609 tab_span(GridTab::Strategies, "2", "Strategies"),
1610 Span::raw(" "),
1611 tab_span(GridTab::Positions, "3", "Positions"),
1612 Span::raw(" "),
1613 tab_span(GridTab::Risk, "4", "Risk"),
1614 Span::raw(" "),
1615 tab_span(GridTab::Network, "5", "Network"),
1616 Span::raw(" "),
1617 tab_span(GridTab::History, "6", "History"),
1618 Span::raw(" "),
1619 tab_span(GridTab::SystemLog, "7", "SystemLog"),
1620 ])),
1621 tab_area,
1622 );
1623
1624 let global_pressure =
1625 state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1626 let orders_pressure =
1627 state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1628 let account_pressure =
1629 state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1630 let market_pressure = state.rate_budget_market_data.used as f64
1631 / (state.rate_budget_market_data.limit.max(1) as f64);
1632 let max_pressure = global_pressure
1633 .max(orders_pressure)
1634 .max(account_pressure)
1635 .max(market_pressure);
1636 let (risk_label, risk_color) = if max_pressure >= 0.90 {
1637 ("CRIT", Color::Red)
1638 } else if max_pressure >= 0.70 {
1639 ("WARN", Color::Yellow)
1640 } else {
1641 ("OK", Color::Green)
1642 };
1643
1644 if view.selected_grid_tab == GridTab::Assets {
1645 let spot_assets: Vec<&AssetEntry> = state
1646 .assets_view()
1647 .iter()
1648 .filter(|a| !a.is_futures)
1649 .collect();
1650 let fut_assets: Vec<&AssetEntry> = state
1651 .assets_view()
1652 .iter()
1653 .filter(|a| a.is_futures)
1654 .collect();
1655 let spot_total_rlz: f64 = spot_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1656 let spot_total_unrlz: f64 = spot_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1657 let fut_total_rlz: f64 = fut_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1658 let fut_total_unrlz: f64 = fut_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1659 let total_rlz = spot_total_rlz + fut_total_rlz;
1660 let total_unrlz = spot_total_unrlz + fut_total_unrlz;
1661 let total_pnl = total_rlz + total_unrlz;
1662 let panel_chunks = Layout::default()
1663 .direction(Direction::Vertical)
1664 .constraints([
1665 Constraint::Percentage(46),
1666 Constraint::Percentage(46),
1667 Constraint::Length(3),
1668 Constraint::Length(1),
1669 ])
1670 .split(body_area);
1671
1672 let spot_header = Row::new(vec![
1673 Cell::from("Asset"),
1674 Cell::from("Qty"),
1675 Cell::from("Price"),
1676 Cell::from("RlzPnL"),
1677 Cell::from("UnrPnL"),
1678 ])
1679 .style(Style::default().fg(Color::DarkGray));
1680 let mut spot_rows: Vec<Row> = spot_assets
1681 .iter()
1682 .map(|a| {
1683 Row::new(vec![
1684 Cell::from(a.symbol.clone()),
1685 Cell::from(format!("{:.5}", a.position_qty)),
1686 Cell::from(
1687 a.last_price
1688 .map(|v| format!("{:.2}", v))
1689 .unwrap_or_else(|| "---".to_string()),
1690 ),
1691 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1692 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1693 ])
1694 })
1695 .collect();
1696 if spot_rows.is_empty() {
1697 spot_rows.push(
1698 Row::new(vec![
1699 Cell::from("(no spot assets)"),
1700 Cell::from("-"),
1701 Cell::from("-"),
1702 Cell::from("-"),
1703 Cell::from("-"),
1704 ])
1705 .style(Style::default().fg(Color::DarkGray)),
1706 );
1707 }
1708 frame.render_widget(
1709 Table::new(
1710 spot_rows,
1711 [
1712 Constraint::Length(16),
1713 Constraint::Length(12),
1714 Constraint::Length(10),
1715 Constraint::Length(10),
1716 Constraint::Length(10),
1717 ],
1718 )
1719 .header(spot_header)
1720 .column_spacing(1)
1721 .block(
1722 Block::default()
1723 .title(format!(
1724 " Spot Assets | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1725 spot_assets.len(),
1726 spot_total_rlz + spot_total_unrlz,
1727 spot_total_rlz,
1728 spot_total_unrlz
1729 ))
1730 .borders(Borders::ALL)
1731 .border_style(Style::default().fg(Color::DarkGray)),
1732 ),
1733 panel_chunks[0],
1734 );
1735
1736 let fut_header = Row::new(vec![
1737 Cell::from("Symbol"),
1738 Cell::from("Side"),
1739 Cell::from("PosQty"),
1740 Cell::from("Entry"),
1741 Cell::from("RlzPnL"),
1742 Cell::from("UnrPnL"),
1743 ])
1744 .style(Style::default().fg(Color::DarkGray));
1745 let mut fut_rows: Vec<Row> = fut_assets
1746 .iter()
1747 .map(|a| {
1748 Row::new(vec![
1749 Cell::from(a.symbol.clone()),
1750 Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
1751 Cell::from(format!("{:.5}", a.position_qty)),
1752 Cell::from(
1753 a.entry_price
1754 .map(|v| format!("{:.2}", v))
1755 .unwrap_or_else(|| "---".to_string()),
1756 ),
1757 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1758 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1759 ])
1760 })
1761 .collect();
1762 if fut_rows.is_empty() {
1763 fut_rows.push(
1764 Row::new(vec![
1765 Cell::from("(no futures positions)"),
1766 Cell::from("-"),
1767 Cell::from("-"),
1768 Cell::from("-"),
1769 Cell::from("-"),
1770 Cell::from("-"),
1771 ])
1772 .style(Style::default().fg(Color::DarkGray)),
1773 );
1774 }
1775 frame.render_widget(
1776 Table::new(
1777 fut_rows,
1778 [
1779 Constraint::Length(18),
1780 Constraint::Length(8),
1781 Constraint::Length(10),
1782 Constraint::Length(10),
1783 Constraint::Length(10),
1784 Constraint::Length(10),
1785 ],
1786 )
1787 .header(fut_header)
1788 .column_spacing(1)
1789 .block(
1790 Block::default()
1791 .title(format!(
1792 " Futures Positions | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1793 fut_assets.len(),
1794 fut_total_rlz + fut_total_unrlz,
1795 fut_total_rlz,
1796 fut_total_unrlz
1797 ))
1798 .borders(Borders::ALL)
1799 .border_style(Style::default().fg(Color::DarkGray)),
1800 ),
1801 panel_chunks[1],
1802 );
1803 let total_color = if total_pnl > 0.0 {
1804 Color::Green
1805 } else if total_pnl < 0.0 {
1806 Color::Red
1807 } else {
1808 Color::DarkGray
1809 };
1810 frame.render_widget(
1811 Paragraph::new(Line::from(vec![
1812 Span::styled(" Total PnL: ", Style::default().fg(Color::DarkGray)),
1813 Span::styled(
1814 format!("{:+.4}", total_pnl),
1815 Style::default().fg(total_color).add_modifier(Modifier::BOLD),
1816 ),
1817 Span::styled(
1818 format!(" Realized: {:+.4} Unrealized: {:+.4}", total_rlz, total_unrlz),
1819 Style::default().fg(Color::DarkGray),
1820 ),
1821 ]))
1822 .block(
1823 Block::default()
1824 .borders(Borders::ALL)
1825 .border_style(Style::default().fg(Color::DarkGray)),
1826 ),
1827 panel_chunks[2],
1828 );
1829 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]);
1830 return;
1831 }
1832
1833 if view.selected_grid_tab == GridTab::Risk {
1834 let chunks = Layout::default()
1835 .direction(Direction::Vertical)
1836 .constraints([
1837 Constraint::Length(2),
1838 Constraint::Length(4),
1839 Constraint::Min(3),
1840 Constraint::Length(1),
1841 ])
1842 .split(body_area);
1843 frame.render_widget(
1844 Paragraph::new(Line::from(vec![
1845 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1846 Span::styled(
1847 risk_label,
1848 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1849 ),
1850 Span::styled(
1851 " (70%=WARN, 90%=CRIT)",
1852 Style::default().fg(Color::DarkGray),
1853 ),
1854 ])),
1855 chunks[0],
1856 );
1857 let risk_rows = vec![
1858 Row::new(vec![
1859 Cell::from("GLOBAL"),
1860 Cell::from(format!(
1861 "{}/{}",
1862 state.rate_budget_global.used, state.rate_budget_global.limit
1863 )),
1864 Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1865 ]),
1866 Row::new(vec![
1867 Cell::from("ORDERS"),
1868 Cell::from(format!(
1869 "{}/{}",
1870 state.rate_budget_orders.used, state.rate_budget_orders.limit
1871 )),
1872 Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1873 ]),
1874 Row::new(vec![
1875 Cell::from("ACCOUNT"),
1876 Cell::from(format!(
1877 "{}/{}",
1878 state.rate_budget_account.used, state.rate_budget_account.limit
1879 )),
1880 Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1881 ]),
1882 Row::new(vec![
1883 Cell::from("MARKET"),
1884 Cell::from(format!(
1885 "{}/{}",
1886 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1887 )),
1888 Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1889 ]),
1890 ];
1891 frame.render_widget(
1892 Table::new(
1893 risk_rows,
1894 [
1895 Constraint::Length(10),
1896 Constraint::Length(16),
1897 Constraint::Length(12),
1898 ],
1899 )
1900 .header(Row::new(vec![
1901 Cell::from("Group"),
1902 Cell::from("Used/Limit"),
1903 Cell::from("Reset In"),
1904 ]))
1905 .column_spacing(1)
1906 .block(
1907 Block::default()
1908 .title(" Risk Budgets ")
1909 .borders(Borders::ALL)
1910 .border_style(Style::default().fg(Color::DarkGray)),
1911 ),
1912 chunks[1],
1913 );
1914 let recent_rejections: Vec<&String> = state
1915 .log_messages
1916 .iter()
1917 .filter(|m| m.contains("order.reject.received"))
1918 .rev()
1919 .take(20)
1920 .collect();
1921 let mut lines = vec![Line::from(Span::styled(
1922 "Recent Rejections",
1923 Style::default()
1924 .fg(Color::Cyan)
1925 .add_modifier(Modifier::BOLD),
1926 ))];
1927 for msg in recent_rejections.into_iter().rev() {
1928 lines.push(Line::from(Span::styled(
1929 msg.as_str(),
1930 Style::default().fg(Color::Red),
1931 )));
1932 }
1933 if lines.len() == 1 {
1934 lines.push(Line::from(Span::styled(
1935 "(no rejections yet)",
1936 Style::default().fg(Color::DarkGray),
1937 )));
1938 }
1939 frame.render_widget(
1940 Paragraph::new(lines).block(
1941 Block::default()
1942 .borders(Borders::ALL)
1943 .border_style(Style::default().fg(Color::DarkGray)),
1944 ),
1945 chunks[2],
1946 );
1947 frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab [U] <$1 filter [Z] close-all [G/Esc] close"), chunks[3]);
1948 return;
1949 }
1950
1951 if view.selected_grid_tab == GridTab::Network {
1952 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1953 let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1954 let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1955 let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1956 let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1957 let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1958 let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1959 let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1960 let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1961
1962 let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1963 let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1964 let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1965 let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1966 let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1967 let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1968 let reconnect_rate_60s = reconnect_60s as f64;
1969 let disconnect_rate_60s = disconnect_60s as f64;
1970 let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1971 let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1972 let health = classify_health(
1973 state.ws_connected,
1974 tick_drop_ratio_10s,
1975 reconnect_rate_60s,
1976 tick_p95_ms,
1977 heartbeat_gap_ms,
1978 );
1979 let (health_label, health_color) = match health {
1980 NetworkHealth::Ok => ("OK", Color::Green),
1981 NetworkHealth::Warn => ("WARN", Color::Yellow),
1982 NetworkHealth::Crit => ("CRIT", Color::Red),
1983 };
1984
1985 let chunks = Layout::default()
1986 .direction(Direction::Vertical)
1987 .constraints([
1988 Constraint::Length(2),
1989 Constraint::Min(6),
1990 Constraint::Length(6),
1991 Constraint::Length(1),
1992 ])
1993 .split(body_area);
1994 frame.render_widget(
1995 Paragraph::new(Line::from(vec![
1996 Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1997 Span::styled(
1998 health_label,
1999 Style::default()
2000 .fg(health_color)
2001 .add_modifier(Modifier::BOLD),
2002 ),
2003 Span::styled(" WS: ", Style::default().fg(Color::DarkGray)),
2004 Span::styled(
2005 if state.ws_connected {
2006 "CONNECTED"
2007 } else {
2008 "DISCONNECTED"
2009 },
2010 Style::default().fg(if state.ws_connected {
2011 Color::Green
2012 } else {
2013 Color::Red
2014 }),
2015 ),
2016 Span::styled(
2017 format!(
2018 " in1s={:.1}/s drop10s={:.2}/s ratio10s={:.2}% reconn60s={:.0}/min",
2019 tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
2020 ),
2021 Style::default().fg(Color::DarkGray),
2022 ),
2023 ])),
2024 chunks[0],
2025 );
2026
2027 let tick_stats = latency_stats(&state.network_tick_latencies_ms);
2028 let fill_stats = latency_stats(&state.network_fill_latencies_ms);
2029 let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
2030 let last_fill_age = state
2031 .network_last_fill_ms
2032 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2033 .unwrap_or_else(|| "-".to_string());
2034 let rows = vec![
2035 Row::new(vec![
2036 Cell::from("Tick Latency"),
2037 Cell::from(tick_stats.0),
2038 Cell::from(tick_stats.1),
2039 Cell::from(tick_stats.2),
2040 Cell::from(
2041 state
2042 .last_price_latency_ms
2043 .map(|v| format!("{}ms", v))
2044 .unwrap_or_else(|| "-".to_string()),
2045 ),
2046 ]),
2047 Row::new(vec![
2048 Cell::from("Fill Latency"),
2049 Cell::from(fill_stats.0),
2050 Cell::from(fill_stats.1),
2051 Cell::from(fill_stats.2),
2052 Cell::from(last_fill_age),
2053 ]),
2054 Row::new(vec![
2055 Cell::from("Order Sync"),
2056 Cell::from(sync_stats.0),
2057 Cell::from(sync_stats.1),
2058 Cell::from(sync_stats.2),
2059 Cell::from(
2060 state
2061 .last_order_history_latency_ms
2062 .map(|v| format!("{}ms", v))
2063 .unwrap_or_else(|| "-".to_string()),
2064 ),
2065 ]),
2066 ];
2067 frame.render_widget(
2068 Table::new(
2069 rows,
2070 [
2071 Constraint::Length(14),
2072 Constraint::Length(12),
2073 Constraint::Length(12),
2074 Constraint::Length(12),
2075 Constraint::Length(14),
2076 ],
2077 )
2078 .header(Row::new(vec![
2079 Cell::from("Metric"),
2080 Cell::from("p50"),
2081 Cell::from("p95"),
2082 Cell::from("p99"),
2083 Cell::from("last/age"),
2084 ]))
2085 .column_spacing(1)
2086 .block(
2087 Block::default()
2088 .title(" Network Metrics ")
2089 .borders(Borders::ALL)
2090 .border_style(Style::default().fg(Color::DarkGray)),
2091 ),
2092 chunks[1],
2093 );
2094
2095 let summary_rows = vec![
2096 Row::new(vec![
2097 Cell::from("tick_drop_rate_1s"),
2098 Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
2099 Cell::from("tick_drop_rate_60s"),
2100 Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
2101 ]),
2102 Row::new(vec![
2103 Cell::from("drop_ratio_60s"),
2104 Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
2105 Cell::from("disconnect_rate_60s"),
2106 Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
2107 ]),
2108 Row::new(vec![
2109 Cell::from("last_tick_age"),
2110 Cell::from(
2111 heartbeat_gap_ms
2112 .map(format_age_ms)
2113 .unwrap_or_else(|| "-".to_string()),
2114 ),
2115 Cell::from("last_order_update_age"),
2116 Cell::from(
2117 state
2118 .last_order_history_update_ms
2119 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2120 .unwrap_or_else(|| "-".to_string()),
2121 ),
2122 ]),
2123 Row::new(vec![
2124 Cell::from("tick_drop_total"),
2125 Cell::from(state.network_tick_drop_count.to_string()),
2126 Cell::from("reconnect_total"),
2127 Cell::from(state.network_reconnect_count.to_string()),
2128 ]),
2129 ];
2130 frame.render_widget(
2131 Table::new(
2132 summary_rows,
2133 [
2134 Constraint::Length(20),
2135 Constraint::Length(18),
2136 Constraint::Length(20),
2137 Constraint::Length(18),
2138 ],
2139 )
2140 .column_spacing(1)
2141 .block(
2142 Block::default()
2143 .title(" Network Summary ")
2144 .borders(Borders::ALL)
2145 .border_style(Style::default().fg(Color::DarkGray)),
2146 ),
2147 chunks[2],
2148 );
2149 frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab [U] <$1 filter [Z] close-all [G/Esc] close"), chunks[3]);
2150 return;
2151 }
2152
2153 if view.selected_grid_tab == GridTab::History {
2154 let chunks = Layout::default()
2155 .direction(Direction::Vertical)
2156 .constraints([Constraint::Length(2), Constraint::Min(6), Constraint::Length(1)])
2157 .split(body_area);
2158 frame.render_widget(
2159 Paragraph::new(Line::from(vec![
2160 Span::styled("Bucket: ", Style::default().fg(Color::DarkGray)),
2161 Span::styled(
2162 match state.history_bucket {
2163 order_store::HistoryBucket::Day => "Day",
2164 order_store::HistoryBucket::Hour => "Hour",
2165 order_store::HistoryBucket::Month => "Month",
2166 },
2167 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2168 ),
2169 Span::styled(
2170 " (popup hotkeys: D/H/M)",
2171 Style::default().fg(Color::DarkGray),
2172 ),
2173 ])),
2174 chunks[0],
2175 );
2176
2177 let visible = build_history_lines(&state.history_rows, chunks[1].height.saturating_sub(2) as usize);
2178 frame.render_widget(
2179 Paragraph::new(visible).block(
2180 Block::default()
2181 .title(" History ")
2182 .borders(Borders::ALL)
2183 .border_style(Style::default().fg(Color::DarkGray)),
2184 ),
2185 chunks[1],
2186 );
2187 frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab [U] <$1 filter [Z] close-all [G/Esc] close"), chunks[2]);
2188 return;
2189 }
2190
2191 if view.selected_grid_tab == GridTab::Positions {
2192 let chunks = Layout::default()
2193 .direction(Direction::Vertical)
2194 .constraints([Constraint::Length(2), Constraint::Min(6), Constraint::Length(1)])
2195 .split(body_area);
2196 let persisted = if cfg!(test) {
2197 Vec::new()
2198 } else {
2199 order_store::load_recent_persisted_trades_filtered(None, None, 20_000)
2200 .unwrap_or_default()
2201 };
2202 let open_orders: Vec<_> = build_open_order_positions_from_trades(&persisted)
2203 .into_iter()
2204 .filter(|row| {
2205 let px = asset_last_price_for_symbol(state, &row.symbol).unwrap_or(row.entry_price);
2206 !state.hide_small_positions || (px * row.qty_open).abs() >= 1.0
2207 })
2208 .collect();
2209
2210 frame.render_widget(
2211 Paragraph::new(Line::from(vec![
2212 Span::styled("Open Position Orders: ", Style::default().fg(Color::DarkGray)),
2213 Span::styled(
2214 open_orders.len().to_string(),
2215 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2216 ),
2217 Span::styled(
2218 if state.hide_small_positions {
2219 " (order_id scope, filter: >= $1 | [U] toggle)"
2220 } else {
2221 " (order_id scope, filter: OFF | [U] toggle)"
2222 },
2223 Style::default().fg(Color::DarkGray),
2224 ),
2225 ])),
2226 chunks[0],
2227 );
2228
2229 let header = Row::new(vec![
2230 Cell::from("Symbol"),
2231 Cell::from("Source"),
2232 Cell::from("OrderId"),
2233 Cell::from("Close"),
2234 Cell::from("Market"),
2235 Cell::from("Side"),
2236 Cell::from("Qty"),
2237 Cell::from("Entry"),
2238 Cell::from("Last"),
2239 Cell::from("Stop"),
2240 Cell::from("EV"),
2241 Cell::from("Score"),
2242 Cell::from("Gate"),
2243 Cell::from("StopType"),
2244 Cell::from("UnrPnL"),
2245 ])
2246 .style(Style::default().fg(Color::DarkGray));
2247 let mut rows: Vec<Row> = if open_orders.is_empty() {
2248 state
2249 .assets_view()
2250 .iter()
2251 .filter(|a| {
2252 let has_pos =
2253 a.position_qty.abs() > f64::EPSILON || a.entry_price.is_some() || a.side.is_some();
2254 let px = a
2255 .last_price
2256 .or(a.entry_price)
2257 .unwrap_or(0.0);
2258 has_pos && (!state.hide_small_positions || (px * a.position_qty.abs()) >= 1.0)
2259 })
2260 .map(|a| {
2261 let ev_snapshot =
2262 latest_ev_snapshot_for_symbol_relaxed(&state.ev_snapshot_by_scope, &a.symbol);
2263 let exit_policy =
2264 latest_exit_policy_for_symbol_relaxed(&state.exit_policy_by_scope, &a.symbol);
2265 Row::new(vec![
2266 Cell::from(a.symbol.clone()),
2267 Cell::from("SYS"),
2268 Cell::from("-"),
2269 Cell::from(
2270 close_all_row_status_for_symbol(state, &a.symbol).unwrap_or_else(|| "-".to_string()),
2271 ),
2272 Cell::from(if a.is_futures { "FUT" } else { "SPOT" }),
2273 Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
2274 Cell::from(format!("{:.5}", a.position_qty)),
2275 Cell::from(
2276 a.entry_price
2277 .map(|v| format!("{:.2}", v))
2278 .unwrap_or_else(|| "-".to_string()),
2279 ),
2280 Cell::from(
2281 a.last_price
2282 .map(|v| format!("{:.2}", v))
2283 .unwrap_or_else(|| "-".to_string()),
2284 ),
2285 Cell::from(
2286 exit_policy
2287 .and_then(|p| p.stop_price)
2288 .map(|v| format!("{:.2}", v))
2289 .unwrap_or_else(|| "-".to_string()),
2290 ),
2291 Cell::from(
2292 ev_snapshot
2293 .map(|v| format!("{:+.3}", v.ev))
2294 .unwrap_or_else(|| "-".to_string()),
2295 ),
2296 Cell::from(
2297 ev_snapshot
2298 .map(|v| format!("{:.2}", v.p_win))
2299 .unwrap_or_else(|| "-".to_string()),
2300 ),
2301 Cell::from(
2302 ev_snapshot
2303 .map(|v| {
2304 if v.gate_blocked {
2305 "BLOCK".to_string()
2306 } else {
2307 v.gate_mode.to_ascii_uppercase()
2308 }
2309 })
2310 .unwrap_or_else(|| "-".to_string()),
2311 ),
2312 Cell::from(
2313 if exit_policy.and_then(|p| p.stop_price).is_none() {
2314 "-".to_string()
2315 } else if exit_policy.and_then(|p| p.protective_stop_ok) == Some(true) {
2316 "ORDER".to_string()
2317 } else {
2318 "CALC".to_string()
2319 },
2320 ),
2321 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
2322 ])
2323 })
2324 .collect()
2325 } else {
2326 open_orders
2327 .iter()
2328 .map(|row| {
2329 let symbol_view = display_symbol_for_storage(&row.symbol);
2330 let market_is_fut = row.symbol.ends_with("#FUT");
2331 let asset_last = asset_last_price_for_symbol(state, &row.symbol);
2332 let ev_snapshot =
2333 ev_snapshot_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2334 .or_else(|| {
2335 latest_ev_snapshot_for_symbol_relaxed(
2336 &state.ev_snapshot_by_scope,
2337 &row.symbol,
2338 )
2339 .cloned()
2340 });
2341 let exit_policy =
2342 exit_policy_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2343 .or_else(|| {
2344 latest_exit_policy_for_symbol_relaxed(
2345 &state.exit_policy_by_scope,
2346 &row.symbol,
2347 )
2348 .cloned()
2349 });
2350 let unr = asset_last
2351 .map(|px| (px - row.entry_price) * row.qty_open)
2352 .unwrap_or(0.0);
2353 Row::new(vec![
2354 Cell::from(symbol_view),
2355 Cell::from(row.source_tag.to_ascii_uppercase()),
2356 Cell::from(row.order_id.to_string()),
2357 Cell::from(
2358 close_all_row_status_for_symbol(state, &row.symbol)
2359 .unwrap_or_else(|| "-".to_string()),
2360 ),
2361 Cell::from(if market_is_fut { "FUT" } else { "SPOT" }),
2362 Cell::from("BUY"),
2363 Cell::from(format!("{:.5}", row.qty_open)),
2364 Cell::from(format!("{:.2}", row.entry_price)),
2365 Cell::from(asset_last.map(|v| format!("{:.2}", v)).unwrap_or_else(|| "-".to_string())),
2366 Cell::from(
2367 exit_policy
2368 .as_ref()
2369 .and_then(|p| p.stop_price)
2370 .map(|v| format!("{:.2}", v))
2371 .unwrap_or_else(|| "-".to_string()),
2372 ),
2373 Cell::from(
2374 ev_snapshot
2375 .as_ref()
2376 .map(|v| format!("{:+.3}", v.ev))
2377 .unwrap_or_else(|| "-".to_string()),
2378 ),
2379 Cell::from(
2380 ev_snapshot
2381 .as_ref()
2382 .map(|v| format!("{:.2}", v.p_win))
2383 .unwrap_or_else(|| "-".to_string()),
2384 ),
2385 Cell::from(
2386 ev_snapshot
2387 .as_ref()
2388 .map(|v| {
2389 if v.gate_blocked {
2390 "BLOCK".to_string()
2391 } else {
2392 v.gate_mode.to_ascii_uppercase()
2393 }
2394 })
2395 .unwrap_or_else(|| "-".to_string()),
2396 ),
2397 Cell::from(
2398 if exit_policy.as_ref().and_then(|p| p.stop_price).is_none() {
2399 "-".to_string()
2400 } else if exit_policy.as_ref().and_then(|p| p.protective_stop_ok) == Some(true) {
2401 "ORDER".to_string()
2402 } else {
2403 "CALC".to_string()
2404 },
2405 ),
2406 Cell::from(format!("{:+.4}", unr)),
2407 ])
2408 })
2409 .collect()
2410 };
2411 if rows.is_empty() {
2412 rows.push(
2413 Row::new(vec![
2414 Cell::from("(no open positions)"),
2415 Cell::from("-"),
2416 Cell::from("-"),
2417 Cell::from("-"),
2418 Cell::from("-"),
2419 Cell::from("-"),
2420 Cell::from("-"),
2421 Cell::from("-"),
2422 Cell::from("-"),
2423 Cell::from("-"),
2424 Cell::from("-"),
2425 Cell::from("-"),
2426 Cell::from("-"),
2427 Cell::from("-"),
2428 Cell::from("-"),
2429 ])
2430 .style(Style::default().fg(Color::DarkGray)),
2431 );
2432 }
2433 frame.render_widget(
2434 Table::new(
2435 rows,
2436 [
2437 Constraint::Length(14),
2438 Constraint::Length(8),
2439 Constraint::Length(12),
2440 Constraint::Length(8),
2441 Constraint::Length(7),
2442 Constraint::Length(8),
2443 Constraint::Length(11),
2444 Constraint::Length(10),
2445 Constraint::Length(10),
2446 Constraint::Length(10),
2447 Constraint::Length(8),
2448 Constraint::Length(7),
2449 Constraint::Length(8),
2450 Constraint::Length(9),
2451 Constraint::Length(11),
2452 ],
2453 )
2454 .header(header)
2455 .column_spacing(1)
2456 .block(
2457 Block::default()
2458 .title(" Positions ")
2459 .borders(Borders::ALL)
2460 .border_style(Style::default().fg(Color::DarkGray)),
2461 ),
2462 chunks[1],
2463 );
2464 frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab [U] <$1 filter [Z] close-all [G/Esc] close"), chunks[2]);
2465 return;
2466 }
2467
2468 if view.selected_grid_tab == GridTab::SystemLog {
2469 let chunks = Layout::default()
2470 .direction(Direction::Vertical)
2471 .constraints([Constraint::Min(6), Constraint::Length(1)])
2472 .split(body_area);
2473 let max_rows = chunks[0].height.saturating_sub(2) as usize;
2474 let mut log_rows: Vec<Row> = state
2475 .log_messages
2476 .iter()
2477 .rev()
2478 .take(max_rows.max(1))
2479 .rev()
2480 .map(|line| Row::new(vec![Cell::from(line.clone())]))
2481 .collect();
2482 if log_rows.is_empty() {
2483 log_rows.push(
2484 Row::new(vec![Cell::from("(no system logs yet)")])
2485 .style(Style::default().fg(Color::DarkGray)),
2486 );
2487 }
2488 frame.render_widget(
2489 Table::new(log_rows, [Constraint::Min(1)])
2490 .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
2491 .column_spacing(1)
2492 .block(
2493 Block::default()
2494 .title(" System Log ")
2495 .borders(Borders::ALL)
2496 .border_style(Style::default().fg(Color::DarkGray)),
2497 ),
2498 chunks[0],
2499 );
2500 frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab [U] <$1 filter [Z] close-all [G/Esc] close"), chunks[1]);
2501 return;
2502 }
2503
2504 let selected_symbol = state
2505 .symbol_items
2506 .get(view.selected_symbol_index)
2507 .map(String::as_str)
2508 .unwrap_or(state.symbol.as_str());
2509 let strategy_chunks = Layout::default()
2510 .direction(Direction::Vertical)
2511 .constraints([
2512 Constraint::Length(2),
2513 Constraint::Length(3),
2514 Constraint::Min(12),
2515 Constraint::Length(1),
2516 ])
2517 .split(body_area);
2518
2519 let mut on_indices: Vec<usize> = Vec::new();
2520 let mut off_indices: Vec<usize> = Vec::new();
2521 for idx in 0..state.strategy_items.len() {
2522 if state
2523 .strategy_item_active
2524 .get(idx)
2525 .copied()
2526 .unwrap_or(false)
2527 {
2528 on_indices.push(idx);
2529 } else {
2530 off_indices.push(idx);
2531 }
2532 }
2533 let on_weight = on_indices.len().max(1) as u32;
2534 let off_weight = off_indices.len().max(1) as u32;
2535
2536 frame.render_widget(
2537 Paragraph::new(Line::from(vec![
2538 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
2539 Span::styled(
2540 risk_label,
2541 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
2542 ),
2543 Span::styled(" GLOBAL ", Style::default().fg(Color::DarkGray)),
2544 Span::styled(
2545 format!(
2546 "{}/{}",
2547 state.rate_budget_global.used, state.rate_budget_global.limit
2548 ),
2549 Style::default().fg(if global_pressure >= 0.9 {
2550 Color::Red
2551 } else if global_pressure >= 0.7 {
2552 Color::Yellow
2553 } else {
2554 Color::Cyan
2555 }),
2556 ),
2557 Span::styled(" ORD ", Style::default().fg(Color::DarkGray)),
2558 Span::styled(
2559 format!(
2560 "{}/{}",
2561 state.rate_budget_orders.used, state.rate_budget_orders.limit
2562 ),
2563 Style::default().fg(if orders_pressure >= 0.9 {
2564 Color::Red
2565 } else if orders_pressure >= 0.7 {
2566 Color::Yellow
2567 } else {
2568 Color::Cyan
2569 }),
2570 ),
2571 Span::styled(" ACC ", Style::default().fg(Color::DarkGray)),
2572 Span::styled(
2573 format!(
2574 "{}/{}",
2575 state.rate_budget_account.used, state.rate_budget_account.limit
2576 ),
2577 Style::default().fg(if account_pressure >= 0.9 {
2578 Color::Red
2579 } else if account_pressure >= 0.7 {
2580 Color::Yellow
2581 } else {
2582 Color::Cyan
2583 }),
2584 ),
2585 Span::styled(" MKT ", Style::default().fg(Color::DarkGray)),
2586 Span::styled(
2587 format!(
2588 "{}/{}",
2589 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
2590 ),
2591 Style::default().fg(if market_pressure >= 0.9 {
2592 Color::Red
2593 } else if market_pressure >= 0.7 {
2594 Color::Yellow
2595 } else {
2596 Color::Cyan
2597 }),
2598 ),
2599 ])),
2600 strategy_chunks[0],
2601 );
2602
2603 let strategy_area = strategy_chunks[2];
2604 let min_panel_height: u16 = 6;
2605 let total_height = strategy_area.height;
2606 let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
2607 let total_weight = on_weight + off_weight;
2608 let mut on_h =
2609 ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
2610 let max_on_h = total_height.saturating_sub(min_panel_height);
2611 if on_h > max_on_h {
2612 on_h = max_on_h;
2613 }
2614 let off_h = total_height.saturating_sub(on_h);
2615 (on_h, off_h)
2616 } else {
2617 let on_h = (total_height / 2).max(1);
2618 let off_h = total_height.saturating_sub(on_h).max(1);
2619 (on_h, off_h)
2620 };
2621 let on_area = Rect {
2622 x: strategy_area.x,
2623 y: strategy_area.y,
2624 width: strategy_area.width,
2625 height: on_height,
2626 };
2627 let off_area = Rect {
2628 x: strategy_area.x,
2629 y: strategy_area.y.saturating_add(on_height),
2630 width: strategy_area.width,
2631 height: off_height,
2632 };
2633
2634 let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
2635 indices
2636 .iter()
2637 .map(|idx| {
2638 let item = state
2639 .strategy_items
2640 .get(*idx)
2641 .map(String::as_str)
2642 .unwrap_or("-");
2643 let row_symbol = state
2644 .strategy_item_symbols
2645 .get(*idx)
2646 .map(String::as_str)
2647 .unwrap_or(state.symbol.as_str());
2648 strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
2649 .map(|s| s.realized_pnl)
2650 .unwrap_or(0.0)
2651 })
2652 .sum()
2653 };
2654 let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
2655 let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
2656 let total_pnl_sum = on_pnl_sum + off_pnl_sum;
2657
2658 let total_row = Row::new(vec![
2659 Cell::from("ON Total"),
2660 Cell::from(on_indices.len().to_string()),
2661 Cell::from(format!("{:+.4}", on_pnl_sum)),
2662 Cell::from("OFF Total"),
2663 Cell::from(off_indices.len().to_string()),
2664 Cell::from(format!("{:+.4}", off_pnl_sum)),
2665 Cell::from("All Total"),
2666 Cell::from(format!("{:+.4}", total_pnl_sum)),
2667 ]);
2668 let total_table = Table::new(
2669 vec![total_row],
2670 [
2671 Constraint::Length(10),
2672 Constraint::Length(5),
2673 Constraint::Length(12),
2674 Constraint::Length(10),
2675 Constraint::Length(5),
2676 Constraint::Length(12),
2677 Constraint::Length(10),
2678 Constraint::Length(12),
2679 ],
2680 )
2681 .column_spacing(1)
2682 .block(
2683 Block::default()
2684 .title(" Total ")
2685 .borders(Borders::ALL)
2686 .border_style(Style::default().fg(Color::DarkGray)),
2687 );
2688 frame.render_widget(total_table, strategy_chunks[1]);
2689
2690 let render_strategy_window = |frame: &mut Frame,
2691 area: Rect,
2692 title: &str,
2693 indices: &[usize],
2694 state: &AppState,
2695 pnl_sum: f64,
2696 selected_panel: bool| {
2697 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2698 let inner_height = area.height.saturating_sub(2);
2699 let row_capacity = inner_height.saturating_sub(1) as usize;
2700 let selected_pos = indices
2701 .iter()
2702 .position(|idx| *idx == view.selected_strategy_index);
2703 let window_start = if row_capacity == 0 {
2704 0
2705 } else if let Some(pos) = selected_pos {
2706 pos.saturating_sub(row_capacity.saturating_sub(1))
2707 } else {
2708 0
2709 };
2710 let window_end = if row_capacity == 0 {
2711 0
2712 } else {
2713 (window_start + row_capacity).min(indices.len())
2714 };
2715 let visible_indices = if indices.is_empty() || row_capacity == 0 {
2716 &indices[0..0]
2717 } else {
2718 &indices[window_start..window_end]
2719 };
2720 let header = Row::new(vec![
2721 Cell::from(" "),
2722 Cell::from("Symbol"),
2723 Cell::from("Strategy"),
2724 Cell::from("Run"),
2725 Cell::from("Last"),
2726 Cell::from("Px"),
2727 Cell::from("Age"),
2728 Cell::from("W"),
2729 Cell::from("L"),
2730 Cell::from("T"),
2731 Cell::from("PnL"),
2732 ])
2733 .style(Style::default().fg(Color::DarkGray));
2734 let mut rows: Vec<Row> = visible_indices
2735 .iter()
2736 .map(|idx| {
2737 let row_symbol = state
2738 .strategy_item_symbols
2739 .get(*idx)
2740 .map(String::as_str)
2741 .unwrap_or("-");
2742 let item = state
2743 .strategy_items
2744 .get(*idx)
2745 .cloned()
2746 .unwrap_or_else(|| "-".to_string());
2747 let running = state
2748 .strategy_item_total_running_ms
2749 .get(*idx)
2750 .copied()
2751 .map(format_running_time)
2752 .unwrap_or_else(|| "-".to_string());
2753 let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
2754 let source_tag = source_tag_for_strategy_item(&item);
2755 let last_evt = source_tag
2756 .as_ref()
2757 .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2758 let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2759 let age = now_ms.saturating_sub(evt.timestamp_ms);
2760 let age_txt = if age < 1_000 {
2761 format!("{}ms", age)
2762 } else if age < 60_000 {
2763 format!("{}s", age / 1_000)
2764 } else {
2765 format!("{}m", age / 60_000)
2766 };
2767 let side_txt = match evt.side {
2768 OrderSide::Buy => "BUY",
2769 OrderSide::Sell => "SELL",
2770 };
2771 let px_txt = evt
2772 .price
2773 .map(|v| format!("{:.2}", v))
2774 .unwrap_or_else(|| "-".to_string());
2775 let style = match evt.side {
2776 OrderSide::Buy => Style::default()
2777 .fg(Color::Green)
2778 .add_modifier(Modifier::BOLD),
2779 OrderSide::Sell => {
2780 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2781 }
2782 };
2783 (side_txt.to_string(), px_txt, age_txt, style)
2784 } else {
2785 (
2786 "-".to_string(),
2787 "-".to_string(),
2788 "-".to_string(),
2789 Style::default().fg(Color::DarkGray),
2790 )
2791 };
2792 let (w, l, t, pnl) = if let Some(s) = stats {
2793 (
2794 s.win_count.to_string(),
2795 s.lose_count.to_string(),
2796 s.trade_count.to_string(),
2797 format!("{:+.4}", s.realized_pnl),
2798 )
2799 } else {
2800 (
2801 "0".to_string(),
2802 "0".to_string(),
2803 "0".to_string(),
2804 "+0.0000".to_string(),
2805 )
2806 };
2807 let marker = if *idx == view.selected_strategy_index {
2808 "▶"
2809 } else {
2810 " "
2811 };
2812 let mut row = Row::new(vec![
2813 Cell::from(marker),
2814 Cell::from(row_symbol.to_string()),
2815 Cell::from(item),
2816 Cell::from(running),
2817 Cell::from(last_label).style(last_style),
2818 Cell::from(last_px),
2819 Cell::from(last_age),
2820 Cell::from(w),
2821 Cell::from(l),
2822 Cell::from(t),
2823 Cell::from(pnl),
2824 ]);
2825 if *idx == view.selected_strategy_index {
2826 row = row.style(
2827 Style::default()
2828 .fg(Color::Yellow)
2829 .add_modifier(Modifier::BOLD),
2830 );
2831 }
2832 row
2833 })
2834 .collect();
2835
2836 if rows.is_empty() {
2837 rows.push(
2838 Row::new(vec![
2839 Cell::from(" "),
2840 Cell::from("-"),
2841 Cell::from("(empty)"),
2842 Cell::from("-"),
2843 Cell::from("-"),
2844 Cell::from("-"),
2845 Cell::from("-"),
2846 Cell::from("-"),
2847 Cell::from("-"),
2848 Cell::from("-"),
2849 Cell::from("-"),
2850 ])
2851 .style(Style::default().fg(Color::DarkGray)),
2852 );
2853 }
2854
2855 let table = Table::new(
2856 rows,
2857 [
2858 Constraint::Length(2),
2859 Constraint::Length(12),
2860 Constraint::Min(14),
2861 Constraint::Length(9),
2862 Constraint::Length(5),
2863 Constraint::Length(9),
2864 Constraint::Length(6),
2865 Constraint::Length(3),
2866 Constraint::Length(3),
2867 Constraint::Length(4),
2868 Constraint::Length(11),
2869 ],
2870 )
2871 .header(header)
2872 .column_spacing(1)
2873 .block(
2874 Block::default()
2875 .title(format!(
2876 "{} | Total {:+.4} | {}/{}",
2877 title,
2878 pnl_sum,
2879 visible_indices.len(),
2880 indices.len()
2881 ))
2882 .borders(Borders::ALL)
2883 .border_style(if selected_panel {
2884 Style::default().fg(Color::Yellow)
2885 } else if risk_label == "CRIT" {
2886 Style::default().fg(Color::Red)
2887 } else if risk_label == "WARN" {
2888 Style::default().fg(Color::Yellow)
2889 } else {
2890 Style::default().fg(Color::DarkGray)
2891 }),
2892 );
2893 frame.render_widget(table, area);
2894 };
2895
2896 render_strategy_window(
2897 frame,
2898 on_area,
2899 " ON Strategies ",
2900 &on_indices,
2901 state,
2902 on_pnl_sum,
2903 view.is_on_panel_selected,
2904 );
2905 render_strategy_window(
2906 frame,
2907 off_area,
2908 " OFF Strategies ",
2909 &off_indices,
2910 state,
2911 off_pnl_sum,
2912 !view.is_on_panel_selected,
2913 );
2914 frame.render_widget(
2915 Paragraph::new(Line::from(vec![
2916 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2917 Span::styled(
2918 selected_symbol,
2919 Style::default()
2920 .fg(Color::Green)
2921 .add_modifier(Modifier::BOLD),
2922 ),
2923 Span::styled(
2924 " [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",
2925 Style::default().fg(Color::DarkGray),
2926 ),
2927 ])),
2928 strategy_chunks[3],
2929 );
2930}
2931
2932fn format_running_time(total_running_ms: u64) -> String {
2933 let total_sec = total_running_ms / 1000;
2934 let days = total_sec / 86_400;
2935 let hours = (total_sec % 86_400) / 3_600;
2936 let minutes = (total_sec % 3_600) / 60;
2937 if days > 0 {
2938 format!("{}d {:02}h", days, hours)
2939 } else {
2940 format!("{:02}h {:02}m", hours, minutes)
2941 }
2942}
2943
2944fn format_age_ms(age_ms: u64) -> String {
2945 if age_ms < 1_000 {
2946 format!("{}ms", age_ms)
2947 } else if age_ms < 60_000 {
2948 format!("{}s", age_ms / 1_000)
2949 } else {
2950 format!("{}m", age_ms / 60_000)
2951 }
2952}
2953
2954fn latency_stats(samples: &[u64]) -> (String, String, String) {
2955 let p50 = percentile(samples, 50);
2956 let p95 = percentile(samples, 95);
2957 let p99 = percentile(samples, 99);
2958 (
2959 p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2960 p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2961 p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2962 )
2963}
2964
2965fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2966 let area = frame.area();
2967 let popup = Rect {
2968 x: area.x + 8,
2969 y: area.y + 4,
2970 width: area.width.saturating_sub(16).max(50),
2971 height: area.height.saturating_sub(8).max(12),
2972 };
2973 frame.render_widget(Clear, popup);
2974 let block = Block::default()
2975 .title(" Strategy Config ")
2976 .borders(Borders::ALL)
2977 .border_style(Style::default().fg(Color::Yellow));
2978 let inner = block.inner(popup);
2979 frame.render_widget(block, popup);
2980 let selected_name = state
2981 .strategy_items
2982 .get(state.strategy_editor_index)
2983 .map(String::as_str)
2984 .unwrap_or("Unknown");
2985 let strategy_kind = state
2986 .strategy_editor_kind_items
2987 .get(state.strategy_editor_kind_index)
2988 .map(String::as_str)
2989 .unwrap_or("MA");
2990 let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
2991 let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
2992 let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
2993 let period_1_label = if is_rsa {
2994 "RSI Period"
2995 } else if is_atr {
2996 "ATR Period"
2997 } else if is_chb {
2998 "Entry Window"
2999 } else {
3000 "Fast Period"
3001 };
3002 let period_2_label = if is_rsa {
3003 "Upper RSI"
3004 } else if is_atr {
3005 "Threshold x100"
3006 } else if is_chb {
3007 "Exit Window"
3008 } else {
3009 "Slow Period"
3010 };
3011 let rows = [
3012 ("Strategy", strategy_kind.to_string()),
3013 (
3014 "Symbol",
3015 state
3016 .symbol_items
3017 .get(state.strategy_editor_symbol_index)
3018 .cloned()
3019 .unwrap_or_else(|| state.symbol.clone()),
3020 ),
3021 (period_1_label, state.strategy_editor_fast.to_string()),
3022 (period_2_label, state.strategy_editor_slow.to_string()),
3023 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
3024 ];
3025 let mut lines = vec![
3026 Line::from(vec![
3027 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
3028 Span::styled(
3029 selected_name,
3030 Style::default()
3031 .fg(Color::White)
3032 .add_modifier(Modifier::BOLD),
3033 ),
3034 ]),
3035 Line::from(Span::styled(
3036 "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
3037 Style::default().fg(Color::DarkGray),
3038 )),
3039 ];
3040 if is_rsa {
3041 let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
3042 lines.push(Line::from(Span::styled(
3043 format!("RSA lower threshold auto-derived: {}", lower),
3044 Style::default().fg(Color::DarkGray),
3045 )));
3046 } else if is_atr {
3047 let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
3048 lines.push(Line::from(Span::styled(
3049 format!("ATR expansion threshold: {:.2}x", threshold_x100 as f64 / 100.0),
3050 Style::default().fg(Color::DarkGray),
3051 )));
3052 } else if is_chb {
3053 lines.push(Line::from(Span::styled(
3054 "CHB breakout: buy on entry high break, sell on exit low break",
3055 Style::default().fg(Color::DarkGray),
3056 )));
3057 }
3058 for (idx, (name, value)) in rows.iter().enumerate() {
3059 let marker = if idx == state.strategy_editor_field {
3060 "▶ "
3061 } else {
3062 " "
3063 };
3064 let style = if idx == state.strategy_editor_field {
3065 Style::default()
3066 .fg(Color::Yellow)
3067 .add_modifier(Modifier::BOLD)
3068 } else {
3069 Style::default().fg(Color::White)
3070 };
3071 lines.push(Line::from(vec![
3072 Span::styled(marker, Style::default().fg(Color::Yellow)),
3073 Span::styled(format!("{:<14}", name), style),
3074 Span::styled(value, style),
3075 ]));
3076 }
3077 frame.render_widget(Paragraph::new(lines), inner);
3078 if state.strategy_editor_kind_category_selector_open {
3079 render_selector_popup(
3080 frame,
3081 " Select Strategy Category ",
3082 &state.strategy_editor_kind_category_items,
3083 state
3084 .strategy_editor_kind_category_index
3085 .min(state.strategy_editor_kind_category_items.len().saturating_sub(1)),
3086 None,
3087 None,
3088 None,
3089 );
3090 } else if state.strategy_editor_kind_selector_open {
3091 render_selector_popup(
3092 frame,
3093 " Select Strategy Type ",
3094 &state.strategy_editor_kind_popup_items,
3095 state
3096 .strategy_editor_kind_selector_index
3097 .min(state.strategy_editor_kind_popup_items.len().saturating_sub(1)),
3098 None,
3099 None,
3100 None,
3101 );
3102 }
3103}
3104
3105fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
3106 let area = frame.area();
3107 let popup = Rect {
3108 x: area.x + 4,
3109 y: area.y + 2,
3110 width: area.width.saturating_sub(8).max(30),
3111 height: area.height.saturating_sub(4).max(10),
3112 };
3113 frame.render_widget(Clear, popup);
3114 let block = Block::default()
3115 .title(" Account Assets ")
3116 .borders(Borders::ALL)
3117 .border_style(Style::default().fg(Color::Cyan));
3118 let inner = block.inner(popup);
3119 frame.render_widget(block, popup);
3120
3121 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
3122 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
3123
3124 let mut lines = Vec::with_capacity(assets.len() + 2);
3125 lines.push(Line::from(vec![
3126 Span::styled(
3127 "Asset",
3128 Style::default()
3129 .fg(Color::Cyan)
3130 .add_modifier(Modifier::BOLD),
3131 ),
3132 Span::styled(
3133 " Free",
3134 Style::default()
3135 .fg(Color::Cyan)
3136 .add_modifier(Modifier::BOLD),
3137 ),
3138 ]));
3139 for (asset, qty) in assets {
3140 lines.push(Line::from(vec![
3141 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
3142 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
3143 ]));
3144 }
3145 if lines.len() == 1 {
3146 lines.push(Line::from(Span::styled(
3147 "No assets",
3148 Style::default().fg(Color::DarkGray),
3149 )));
3150 }
3151
3152 frame.render_widget(Paragraph::new(lines), inner);
3153}
3154
3155fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
3156 let area = frame.area();
3157 let popup = Rect {
3158 x: area.x + 2,
3159 y: area.y + 1,
3160 width: area.width.saturating_sub(4).max(40),
3161 height: area.height.saturating_sub(2).max(12),
3162 };
3163 frame.render_widget(Clear, popup);
3164 let block = Block::default()
3165 .title(match bucket {
3166 order_store::HistoryBucket::Day => " History (Day ROI) ",
3167 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
3168 order_store::HistoryBucket::Month => " History (Month ROI) ",
3169 })
3170 .borders(Borders::ALL)
3171 .border_style(Style::default().fg(Color::Cyan));
3172 let inner = block.inner(popup);
3173 frame.render_widget(block, popup);
3174
3175 let max_rows = inner.height.saturating_sub(1) as usize;
3176 let visible = build_history_lines(rows, max_rows);
3177 frame.render_widget(Paragraph::new(visible), inner);
3178}
3179
3180fn build_history_lines(rows: &[String], max_rows: usize) -> Vec<Line<'_>> {
3181 let mut visible: Vec<Line> = Vec::new();
3182 for (idx, row) in rows.iter().take(max_rows).enumerate() {
3183 let color = if idx == 0 {
3184 Color::Cyan
3185 } else if row.contains('-') && row.contains('%') {
3186 Color::White
3187 } else {
3188 Color::DarkGray
3189 };
3190 visible.push(Line::from(Span::styled(
3191 row.as_str(),
3192 Style::default().fg(color),
3193 )));
3194 }
3195 if visible.is_empty() {
3196 visible.push(Line::from(Span::styled(
3197 "No history rows",
3198 Style::default().fg(Color::DarkGray),
3199 )));
3200 }
3201 visible
3202}
3203
3204fn render_selector_popup(
3205 frame: &mut Frame,
3206 title: &str,
3207 items: &[String],
3208 selected: usize,
3209 stats: Option<&HashMap<String, OrderHistoryStats>>,
3210 total_stats: Option<OrderHistoryStats>,
3211 selected_symbol: Option<&str>,
3212) {
3213 let area = frame.area();
3214 let available_width = area.width.saturating_sub(2).max(1);
3215 let width = if stats.is_some() {
3216 let min_width = 44;
3217 let preferred = 84;
3218 preferred
3219 .min(available_width)
3220 .max(min_width.min(available_width))
3221 } else {
3222 let min_width = 24;
3223 let preferred = 48;
3224 preferred
3225 .min(available_width)
3226 .max(min_width.min(available_width))
3227 };
3228 let available_height = area.height.saturating_sub(2).max(1);
3229 let desired_height = if stats.is_some() {
3230 items.len() as u16 + 7
3231 } else {
3232 items.len() as u16 + 4
3233 };
3234 let height = desired_height
3235 .min(available_height)
3236 .max(6.min(available_height));
3237 let popup = Rect {
3238 x: area.x + (area.width.saturating_sub(width)) / 2,
3239 y: area.y + (area.height.saturating_sub(height)) / 2,
3240 width,
3241 height,
3242 };
3243
3244 frame.render_widget(Clear, popup);
3245 let block = Block::default()
3246 .title(title)
3247 .borders(Borders::ALL)
3248 .border_style(Style::default().fg(Color::Cyan));
3249 let inner = block.inner(popup);
3250 frame.render_widget(block, popup);
3251
3252 let mut lines: Vec<Line> = Vec::new();
3253 if stats.is_some() {
3254 if let Some(symbol) = selected_symbol {
3255 lines.push(Line::from(vec![
3256 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
3257 Span::styled(
3258 symbol,
3259 Style::default()
3260 .fg(Color::Green)
3261 .add_modifier(Modifier::BOLD),
3262 ),
3263 ]));
3264 }
3265 lines.push(Line::from(vec![Span::styled(
3266 " Strategy W L T PnL",
3267 Style::default()
3268 .fg(Color::Cyan)
3269 .add_modifier(Modifier::BOLD),
3270 )]));
3271 }
3272
3273 let mut item_lines: Vec<Line> = items
3274 .iter()
3275 .enumerate()
3276 .map(|(idx, item)| {
3277 let item_text = if let Some(stats_map) = stats {
3278 let symbol = selected_symbol.unwrap_or("-");
3279 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3280 format!(
3281 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3282 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
3283 )
3284 } else {
3285 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
3286 }
3287 } else {
3288 item.clone()
3289 };
3290 if idx == selected {
3291 Line::from(vec![
3292 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
3293 Span::styled(
3294 item_text,
3295 Style::default()
3296 .fg(Color::White)
3297 .add_modifier(Modifier::BOLD),
3298 ),
3299 ])
3300 } else {
3301 Line::from(vec![
3302 Span::styled(" ", Style::default()),
3303 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
3304 ])
3305 }
3306 })
3307 .collect();
3308 lines.append(&mut item_lines);
3309 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
3310 let mut strategy_sum = OrderHistoryStats::default();
3311 for item in items {
3312 let symbol = selected_symbol.unwrap_or("-");
3313 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3314 strategy_sum.trade_count += s.trade_count;
3315 strategy_sum.win_count += s.win_count;
3316 strategy_sum.lose_count += s.lose_count;
3317 strategy_sum.realized_pnl += s.realized_pnl;
3318 }
3319 }
3320 let manual = subtract_stats(t, &strategy_sum);
3321 lines.push(Line::from(vec![Span::styled(
3322 format!(
3323 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3324 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
3325 ),
3326 Style::default().fg(Color::LightBlue),
3327 )]));
3328 }
3329 if let Some(t) = total_stats {
3330 lines.push(Line::from(vec![Span::styled(
3331 format!(
3332 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3333 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
3334 ),
3335 Style::default()
3336 .fg(Color::Yellow)
3337 .add_modifier(Modifier::BOLD),
3338 )]));
3339 }
3340
3341 frame.render_widget(
3342 Paragraph::new(lines).style(Style::default().fg(Color::White)),
3343 inner,
3344 );
3345}
3346
3347fn parse_scope_key(scope_key: &str) -> Option<(String, String)> {
3348 let (symbol, tag) = scope_key.split_once("::")?;
3349 let symbol = symbol.trim().to_ascii_uppercase();
3350 let source_tag = tag.trim().to_ascii_lowercase();
3351 if symbol.is_empty() || source_tag.is_empty() {
3352 None
3353 } else {
3354 Some((symbol, source_tag))
3355 }
3356}
3357
3358fn ev_snapshot_for_symbol_and_tag(
3359 state: &AppState,
3360 symbol: &str,
3361 source_tag: &str,
3362) -> Option<EvSnapshotEntry> {
3363 let tag = source_tag.trim().to_ascii_lowercase();
3364 let candidates = symbol_scope_candidates(symbol);
3365 state
3366 .ev_snapshot_by_scope
3367 .iter()
3368 .filter_map(|(k, v)| {
3369 let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3370 let symbol_ok = candidates
3371 .iter()
3372 .any(|prefix| prefix.trim_end_matches("::").eq_ignore_ascii_case(&scope_symbol));
3373 if symbol_ok && scope_tag == tag {
3374 Some(v)
3375 } else {
3376 None
3377 }
3378 })
3379 .max_by_key(|v| v.updated_at_ms)
3380 .cloned()
3381}
3382
3383fn exit_policy_for_symbol_and_tag(
3384 state: &AppState,
3385 symbol: &str,
3386 source_tag: &str,
3387) -> Option<ExitPolicyEntry> {
3388 let tag = source_tag.trim().to_ascii_lowercase();
3389 let candidates = symbol_scope_candidates(symbol);
3390 state
3391 .exit_policy_by_scope
3392 .iter()
3393 .filter_map(|(k, v)| {
3394 let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3395 let symbol_ok = candidates
3396 .iter()
3397 .any(|prefix| prefix.trim_end_matches("::").eq_ignore_ascii_case(&scope_symbol));
3398 if symbol_ok && scope_tag == tag {
3399 Some(v)
3400 } else {
3401 None
3402 }
3403 })
3404 .max_by_key(|v| v.updated_at_ms)
3405 .cloned()
3406}
3407
3408fn display_symbol_for_storage(symbol: &str) -> String {
3409 let upper = symbol.trim().to_ascii_uppercase();
3410 if let Some(base) = upper.strip_suffix("#FUT") {
3411 format!("{} (FUT)", base)
3412 } else {
3413 upper
3414 }
3415}
3416
3417fn asset_last_price_for_symbol(state: &AppState, symbol: &str) -> Option<f64> {
3418 let target = normalize_symbol_for_scope(symbol);
3419 state
3420 .assets_view()
3421 .iter()
3422 .find(|a| normalize_symbol_for_scope(&a.symbol) == target)
3423 .and_then(|a| a.last_price)
3424}
3425
3426fn close_all_row_status_for_symbol(state: &AppState, symbol: &str) -> Option<String> {
3427 let key = normalize_symbol_for_scope(symbol);
3428 if state.close_all_running {
3429 if let Some(found) = state.close_all_row_status_by_symbol.get(&key) {
3430 if found == "PENDING" {
3431 return Some("RUNNING".to_string());
3432 }
3433 return Some(found.clone());
3434 }
3435 }
3436 state.close_all_row_status_by_symbol.get(&key).cloned()
3437}
3438
3439fn strategy_stats_for_item<'a>(
3440 stats_map: &'a HashMap<String, OrderHistoryStats>,
3441 item: &str,
3442 symbol: &str,
3443) -> Option<&'a OrderHistoryStats> {
3444 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3445 let scoped = strategy_stats_scope_key(symbol, &source_tag);
3446 if let Some(s) = stats_map.get(&scoped) {
3447 return Some(s);
3448 }
3449 }
3450 if let Some(s) = stats_map.get(item) {
3451 return Some(s);
3452 }
3453 let source_tag = source_tag_for_strategy_item(item);
3454 source_tag.and_then(|tag| {
3455 stats_map
3456 .get(&tag)
3457 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
3458 })
3459}
3460
3461fn ev_snapshot_for_item<'a>(
3462 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3463 item: &str,
3464 symbol: &str,
3465) -> Option<&'a EvSnapshotEntry> {
3466 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3467 if let Some(found) = ev_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3468 return Some(found);
3469 }
3470 }
3471 latest_ev_snapshot_for_symbol(ev_map, symbol)
3472}
3473
3474fn exit_policy_for_item<'a>(
3475 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3476 item: &str,
3477 symbol: &str,
3478) -> Option<&'a ExitPolicyEntry> {
3479 if let Some(source_tag) = source_tag_for_strategy_item(item) {
3480 if let Some(found) = policy_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3481 return Some(found);
3482 }
3483 }
3484 latest_exit_policy_for_symbol(policy_map, symbol)
3485}
3486
3487fn latest_ev_snapshot_for_symbol<'a>(
3488 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3489 symbol: &str,
3490) -> Option<&'a EvSnapshotEntry> {
3491 let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3492 ev_map
3493 .iter()
3494 .filter(|(k, _)| k.starts_with(&prefix))
3495 .max_by_key(|(_, v)| v.updated_at_ms)
3496 .map(|(_, v)| v)
3497}
3498
3499fn latest_exit_policy_for_symbol<'a>(
3500 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3501 symbol: &str,
3502) -> Option<&'a ExitPolicyEntry> {
3503 let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3504 policy_map
3505 .iter()
3506 .filter(|(k, _)| k.starts_with(&prefix))
3507 .max_by_key(|(_, v)| v.updated_at_ms)
3508 .map(|(_, v)| v)
3509}
3510
3511fn latest_ev_snapshot_for_symbol_relaxed<'a>(
3512 ev_map: &'a HashMap<String, EvSnapshotEntry>,
3513 symbol: &str,
3514) -> Option<&'a EvSnapshotEntry> {
3515 let candidates = symbol_scope_candidates(symbol);
3516 ev_map
3517 .iter()
3518 .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3519 .max_by_key(|(_, v)| v.updated_at_ms)
3520 .map(|(_, v)| v)
3521}
3522
3523fn latest_exit_policy_for_symbol_relaxed<'a>(
3524 policy_map: &'a HashMap<String, ExitPolicyEntry>,
3525 symbol: &str,
3526) -> Option<&'a ExitPolicyEntry> {
3527 let candidates = symbol_scope_candidates(symbol);
3528 policy_map
3529 .iter()
3530 .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3531 .max_by_key(|(_, v)| v.updated_at_ms)
3532 .map(|(_, v)| v)
3533}
3534
3535fn symbol_scope_candidates(symbol: &str) -> Vec<String> {
3536 let mut variants: Vec<String> = Vec::new();
3537 let upper = symbol.trim().to_ascii_uppercase();
3538 let base = if let Some(raw) = upper.strip_suffix(" (FUT)") {
3539 raw.trim().to_string()
3540 } else if let Some(raw) = upper.strip_suffix("#FUT") {
3541 raw.trim().to_string()
3542 } else {
3543 upper.clone()
3544 };
3545
3546 if !base.is_empty() {
3547 variants.push(base.clone());
3548 variants.push(format!("{} (FUT)", base));
3549 variants.push(format!("{}#FUT", base));
3550 }
3551 if !upper.is_empty() {
3552 variants.push(upper);
3553 }
3554 variants.sort();
3555 variants.dedup();
3556 variants
3557 .into_iter()
3558 .map(|v| format!("{}::", v))
3559 .collect()
3560}
3561
3562fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
3563 format!(
3564 "{}::{}",
3565 symbol.trim().to_ascii_uppercase(),
3566 source_tag.trim().to_ascii_lowercase()
3567 )
3568}
3569
3570fn source_tag_for_strategy_item(item: &str) -> Option<String> {
3571 match item {
3572 "MA(Config)" => return Some("cfg".to_string()),
3573 "MA(Fast 5/20)" => return Some("fst".to_string()),
3574 "MA(Slow 20/60)" => return Some("slw".to_string()),
3575 "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
3576 "DCT(Donchian 20/10)" => return Some("dct".to_string()),
3577 "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
3578 "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
3579 "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
3580 "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
3581 "ORB(Opening 12/8)" => return Some("orb".to_string()),
3582 "REG(Regime 10/30)" => return Some("reg".to_string()),
3583 "ENS(Vote 10/30)" => return Some("ens".to_string()),
3584 "MAC(MACD 12/26)" => return Some("mac".to_string()),
3585 "ROC(ROC 10 0.20%)" => return Some("roc".to_string()),
3586 "ARN(Aroon 14 70)" => return Some("arn".to_string()),
3587 _ => {}
3588 }
3589 if let Some((_, tail)) = item.rsplit_once('[') {
3590 if let Some(tag) = tail.strip_suffix(']') {
3591 let tag = tag.trim();
3592 if !tag.is_empty() {
3593 return Some(tag.to_ascii_lowercase());
3594 }
3595 }
3596 }
3597 None
3598}
3599
3600fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
3601 let body = client_order_id.strip_prefix("sq-")?;
3602 let (source_tag, _) = body.split_once('-')?;
3603 if source_tag.is_empty() {
3604 None
3605 } else {
3606 Some(source_tag)
3607 }
3608}
3609
3610fn normalize_symbol_for_scope(symbol: &str) -> String {
3611 let upper = symbol.trim().to_ascii_uppercase();
3612 if let Some(raw) = upper.strip_suffix(" (FUT)") {
3613 return raw.trim().to_string();
3614 }
3615 if let Some(raw) = upper.strip_suffix("#FUT") {
3616 return raw.trim().to_string();
3617 }
3618 upper
3619}
3620
3621fn format_log_record_compact(record: &LogRecord) -> String {
3622 let level = match record.level {
3623 LogLevel::Debug => "DEBUG",
3624 LogLevel::Info => "INFO",
3625 LogLevel::Warn => "WARN",
3626 LogLevel::Error => "ERR",
3627 };
3628 let domain = match record.domain {
3629 LogDomain::Ws => "ws",
3630 LogDomain::Strategy => "strategy",
3631 LogDomain::Risk => "risk",
3632 LogDomain::Order => "order",
3633 LogDomain::Portfolio => "portfolio",
3634 LogDomain::Ui => "ui",
3635 LogDomain::System => "system",
3636 };
3637 let symbol = record.symbol.as_deref().unwrap_or("-");
3638 let strategy = record.strategy_tag.as_deref().unwrap_or("-");
3639 format!(
3640 "[{}] {}.{} {} {} {}",
3641 level, domain, record.event, symbol, strategy, record.msg
3642 )
3643}
3644
3645fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
3646 OrderHistoryStats {
3647 trade_count: total.trade_count.saturating_sub(used.trade_count),
3648 win_count: total.win_count.saturating_sub(used.win_count),
3649 lose_count: total.lose_count.saturating_sub(used.lose_count),
3650 realized_pnl: total.realized_pnl - used.realized_pnl,
3651 }
3652}
3653
3654fn split_symbol_assets(symbol: &str) -> (String, String) {
3655 const QUOTE_SUFFIXES: [&str; 10] = [
3656 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
3657 ];
3658 for q in QUOTE_SUFFIXES {
3659 if let Some(base) = symbol.strip_suffix(q) {
3660 if !base.is_empty() {
3661 return (base.to_string(), q.to_string());
3662 }
3663 }
3664 }
3665 (symbol.to_string(), String::new())
3666}
3667
3668fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
3669 if fills.is_empty() {
3670 return None;
3671 }
3672 let (base_asset, quote_asset) = split_symbol_assets(symbol);
3673 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
3674 let mut notional_quote = 0.0;
3675 let mut fee_quote_equiv = 0.0;
3676 let mut quote_convertible = !quote_asset.is_empty();
3677
3678 for f in fills {
3679 if f.qty > 0.0 && f.price > 0.0 {
3680 notional_quote += f.qty * f.price;
3681 }
3682 if f.commission <= 0.0 {
3683 continue;
3684 }
3685 *fee_by_asset
3686 .entry(f.commission_asset.clone())
3687 .or_insert(0.0) += f.commission;
3688 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
3689 fee_quote_equiv += f.commission;
3690 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
3691 fee_quote_equiv += f.commission * f.price.max(0.0);
3692 } else {
3693 quote_convertible = false;
3694 }
3695 }
3696
3697 if fee_by_asset.is_empty() {
3698 return Some("0".to_string());
3699 }
3700
3701 if quote_convertible && notional_quote > f64::EPSILON {
3702 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
3703 return Some(format!(
3704 "{:.3}% ({:.4} {})",
3705 fee_pct, fee_quote_equiv, quote_asset
3706 ));
3707 }
3708
3709 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
3710 items.sort_by(|a, b| a.0.cmp(&b.0));
3711 if items.len() == 1 {
3712 let (asset, amount) = &items[0];
3713 Some(format!("{:.6} {}", amount, asset))
3714 } else {
3715 Some(format!("mixed fees ({})", items.len()))
3716 }
3717}
3718
3719#[cfg(test)]
3720mod tests {
3721 use super::{format_last_applied_fee, symbol_scope_candidates};
3722 use crate::model::order::Fill;
3723
3724 #[test]
3725 fn fee_summary_from_quote_asset_commission() {
3726 let fills = vec![Fill {
3727 price: 2000.0,
3728 qty: 0.5,
3729 commission: 1.0,
3730 commission_asset: "USDT".to_string(),
3731 }];
3732 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
3733 assert_eq!(summary, "0.100% (1.0000 USDT)");
3734 }
3735
3736 #[test]
3737 fn fee_summary_from_base_asset_commission() {
3738 let fills = vec![Fill {
3739 price: 2000.0,
3740 qty: 0.5,
3741 commission: 0.0005,
3742 commission_asset: "ETH".to_string(),
3743 }];
3744 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
3745 assert_eq!(summary, "0.100% (1.0000 USDT)");
3746 }
3747
3748 #[test]
3749 fn symbol_scope_candidates_include_spot_and_futures_variants() {
3750 let mut from_spot = symbol_scope_candidates("btcusdt");
3751 from_spot.sort();
3752 assert!(from_spot.contains(&"BTCUSDT::".to_string()));
3753 assert!(from_spot.contains(&"BTCUSDT (FUT)::".to_string()));
3754 assert!(from_spot.contains(&"BTCUSDT#FUT::".to_string()));
3755
3756 let mut from_fut_label = symbol_scope_candidates("BTCUSDT (FUT)");
3757 from_fut_label.sort();
3758 assert!(from_fut_label.contains(&"BTCUSDT::".to_string()));
3759 assert!(from_fut_label.contains(&"BTCUSDT (FUT)::".to_string()));
3760 assert!(from_fut_label.contains(&"BTCUSDT#FUT::".to_string()));
3761
3762 let mut from_hash_fut = symbol_scope_candidates("BTCUSDT#FUT");
3763 from_hash_fut.sort();
3764 assert!(from_hash_fut.contains(&"BTCUSDT::".to_string()));
3765 assert!(from_hash_fut.contains(&"BTCUSDT (FUT)::".to_string()));
3766 assert!(from_hash_fut.contains(&"BTCUSDT#FUT::".to_string()));
3767 }
3768}