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