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