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