1use 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 let row = Rect { height: 1, ..area };
61 Line::from(self.spans()).render(row, buf);
62 }
63}
64
65impl PositionRow<'_> {
66 #[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
123fn 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 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}