Skip to main content

zero_tui/widgets/
position_row.rs

1//! Single-position row — the reusable primitive behind the
2//! Positions pane and any future verdict overlay that embeds an
3//! open-position snapshot.
4//!
5//! # Layout
6//!
7//! Columns, left to right, with a one-space gutter between each:
8//!
9//! ```text
10//!  BTC     long  size=0.4200  entry=64120.50  mark=64480.00  pnl=+151.13 (+0.82R)  stop=63500  tgt=66000
11//! ```
12//!
13//! Column widths are fixed so the eye can scan down a column
14//! without jitter. Missing optional fields (`mark`, `pnl`, `stop`,
15//! `target`) render as `—` so the row still occupies its slot
16//! and columns don't shift.
17//!
18//! # Color rules (operator-safety)
19//!
20//! - Symbol / side → `theme.primary` (bright, first-glance anchor).
21//! - Numeric fill (`size`, `entry`, `mark`) → `theme.metadata`
22//!   (low-contrast; ops read these after verifying the symbol).
23//! - `pnl` is the one column whose color changes with value:
24//!   - `>= 0`  → `theme.primary` (positive).
25//!   - `<  0`  → `theme.alert` (negative).
26//!   - `None`  → `theme.metadata` (not yet observed).
27//! - `stop` / `target` → `theme.caution` only if the mark has
28//!   crossed the level (stop hit while position open, target hit
29//!   while position open); otherwise `theme.metadata`. This is a
30//!   cheap early-warning cue; full guardrail-proximity coloring
31//!   lands with the risk-overlay pass.
32//!
33//! The widget does **not** flag stale data — freshness is the
34//! job of the parent pane's `Stat<Positions>` badge. Coloring a
35//! single row as stale would hide the fact that the whole feed
36//! is behind.
37
38use ratatui::buffer::Buffer;
39use ratatui::layout::Rect;
40use ratatui::style::{Modifier, Style};
41use ratatui::text::{Line, Span};
42use ratatui::widgets::Widget;
43use zero_engine_client::Position;
44
45use crate::theme::Theme;
46
47#[derive(Debug)]
48pub struct PositionRow<'a> {
49    pub position: &'a Position,
50    pub theme: Theme,
51}
52
53impl Widget for PositionRow<'_> {
54    fn render(self, area: Rect, buf: &mut Buffer) {
55        if area.height == 0 || area.width == 0 {
56            return;
57        }
58        // One row max — callers composing into a pane allocate
59        // their own row rects; this keeps the widget dumb.
60        let row = Rect { height: 1, ..area };
61        Line::from(self.spans()).render(row, buf);
62    }
63}
64
65impl PositionRow<'_> {
66    /// The rendered span vector. Exposed so callers that want to
67    /// embed the row inside another `Line` (e.g. a verdict card
68    /// with a leading chevron) can compose without re-rendering.
69    #[must_use]
70    pub fn spans(&self) -> Vec<Span<'static>> {
71        let p = self.position;
72        let t = &self.theme;
73
74        let sym_style = Style::default().fg(t.primary).add_modifier(Modifier::BOLD);
75        let dim = Style::default().fg(t.metadata);
76
77        let pnl_style = match p.unrealized_pnl {
78            Some(v) if v > 0.0 => Style::default().fg(t.primary),
79            Some(v) if v < 0.0 => Style::default().fg(t.alert),
80            _ => dim,
81        };
82
83        let stop_style = if stop_breached(p) {
84            Style::default().fg(t.caution).add_modifier(Modifier::BOLD)
85        } else {
86            dim
87        };
88        let target_style = if target_reached(p) {
89            Style::default().fg(t.primary).add_modifier(Modifier::BOLD)
90        } else {
91            dim
92        };
93
94        vec![
95            Span::styled(format!(" {:<6}", p.symbol), sym_style),
96            Span::styled(format!(" {:<5}", p.side), dim),
97            Span::styled(format!(" size={:<8.4}", p.size), dim),
98            Span::styled(format!(" entry={:<10}", format!("{:.2}", p.entry)), dim),
99            Span::styled(format!(" mark={:<10}", fmt_opt_price(p.mark)), dim),
100            Span::styled(
101                format!(" pnl={:<10}", fmt_opt_signed(p.unrealized_pnl)),
102                pnl_style,
103            ),
104            Span::styled(format!(" {:<7}", fmt_r(p.unrealized_r)), pnl_style),
105            Span::styled(format!(" stop={:<8}", fmt_opt_price(p.stop)), stop_style),
106            Span::styled(format!(" tgt={:<8}", fmt_opt_price(p.target)), target_style),
107        ]
108    }
109}
110
111fn fmt_opt_price(v: Option<f64>) -> String {
112    v.map_or_else(|| "—".to_string(), |x| format!("{x:.2}"))
113}
114
115fn fmt_opt_signed(v: Option<f64>) -> String {
116    v.map_or_else(|| "—".to_string(), |x| format!("{x:+.2}"))
117}
118
119fn fmt_r(v: Option<f64>) -> String {
120    v.map_or_else(|| "—".to_string(), |x| format!("{x:+.2}R"))
121}
122
123/// True when the position is on the losing side of its stop:
124/// long + mark ≤ stop, or short + mark ≥ stop. Conservative —
125/// we only flag when all three of (stop, mark, side) are known.
126fn stop_breached(p: &Position) -> bool {
127    let (Some(mark), Some(stop)) = (p.mark, p.stop) else {
128        return false;
129    };
130    match p.side.as_str() {
131        "long" => mark <= stop,
132        "short" => mark >= stop,
133        _ => false,
134    }
135}
136
137fn target_reached(p: &Position) -> bool {
138    let (Some(mark), Some(tgt)) = (p.mark, p.target) else {
139        return false;
140    };
141    match p.side.as_str() {
142        "long" => mark >= tgt,
143        "short" => mark <= tgt,
144        _ => false,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use ratatui::Terminal;
152    use ratatui::backend::TestBackend;
153
154    fn render(position: &Position, width: u16) -> String {
155        let backend = TestBackend::new(width, 1);
156        let mut term = Terminal::new(backend).expect("term");
157        term.draw(|f| {
158            let w = PositionRow {
159                position,
160                theme: Theme::default(),
161            };
162            f.render_widget(w, f.area());
163        })
164        .expect("draw");
165        let buf = term.backend().buffer().clone();
166        let mut s = String::new();
167        for x in 0..buf.area.width {
168            s.push_str(buf[(x, 0)].symbol());
169        }
170        s
171    }
172
173    fn btc_long() -> Position {
174        Position {
175            symbol: "BTC".into(),
176            side: "long".into(),
177            size: 0.42,
178            entry: 64_120.50,
179            mark: Some(64_480.00),
180            unrealized_pnl: Some(151.13),
181            unrealized_r: Some(0.82),
182            stop: Some(63_500.0),
183            target: Some(66_000.0),
184            ..Default::default()
185        }
186    }
187
188    #[test]
189    fn renders_key_fields_in_order() {
190        let p = btc_long();
191        let line = render(&p, 120);
192        assert!(line.contains("BTC"), "symbol missing: {line:?}");
193        assert!(line.contains("long"), "side missing: {line:?}");
194        assert!(line.contains("size=0.4200"), "size missing: {line:?}");
195        assert!(line.contains("entry=64120.50"), "entry missing: {line:?}");
196        assert!(line.contains("mark=64480.00"), "mark missing: {line:?}");
197        assert!(line.contains("pnl=+151.13"), "pnl missing: {line:?}");
198        assert!(line.contains("+0.82R"), "R missing: {line:?}");
199        assert!(line.contains("stop=63500"), "stop missing: {line:?}");
200        assert!(line.contains("tgt=66000"), "target missing: {line:?}");
201    }
202
203    #[test]
204    fn missing_optional_fields_render_as_em_dash() {
205        let mut p = btc_long();
206        p.mark = None;
207        p.unrealized_pnl = None;
208        p.unrealized_r = None;
209        p.stop = None;
210        p.target = None;
211        let line = render(&p, 120);
212        for field in ["mark=—", "pnl=—", "stop=—", "tgt=—"] {
213            assert!(line.contains(field), "missing {field}: {line:?}");
214        }
215        // R column renders as a bare em-dash (no `R` suffix) when
216        // absent — the dash alone is the honest empty state.
217        assert!(line.contains(" — "), "expected R column em-dash: {line:?}");
218    }
219
220    #[test]
221    fn stop_breach_detected_for_long_when_mark_at_or_below_stop() {
222        let mut p = btc_long();
223        p.mark = Some(63_500.0);
224        assert!(stop_breached(&p), "long with mark==stop must be breached");
225        p.mark = Some(63_000.0);
226        assert!(stop_breached(&p), "long with mark<stop must be breached");
227        p.mark = Some(64_000.0);
228        assert!(
229            !stop_breached(&p),
230            "long with mark>stop must not be breached"
231        );
232    }
233
234    #[test]
235    fn stop_breach_detected_for_short_when_mark_at_or_above_stop() {
236        let mut p = btc_long();
237        p.side = "short".into();
238        p.stop = Some(64_500.0);
239        p.mark = Some(64_500.0);
240        assert!(stop_breached(&p));
241        p.mark = Some(65_000.0);
242        assert!(stop_breached(&p));
243        p.mark = Some(64_000.0);
244        assert!(!stop_breached(&p));
245    }
246
247    #[test]
248    fn target_reached_mirrors_direction() {
249        let mut p = btc_long();
250        p.mark = Some(66_000.0);
251        assert!(target_reached(&p));
252        p.mark = Some(65_999.0);
253        assert!(!target_reached(&p));
254        p.side = "short".into();
255        p.target = Some(63_000.0);
256        p.mark = Some(63_000.0);
257        assert!(target_reached(&p));
258        p.mark = Some(63_500.0);
259        assert!(!target_reached(&p));
260    }
261
262    #[test]
263    fn unknown_side_never_flags_breach() {
264        let mut p = btc_long();
265        p.side = "flat".into();
266        p.mark = Some(0.0);
267        p.stop = Some(1.0);
268        p.target = Some(2.0);
269        assert!(!stop_breached(&p));
270        assert!(!target_reached(&p));
271    }
272}