wickra_terminal_core/view.rs
1//! View-models — the renderer-agnostic output of the core.
2//!
3//! A [`Frame`] is what one `tick` produces: a list of [`PanelView`]s, each a
4//! plain data description of what to draw (values, series, sides) — never a
5//! renderer command. The TUI maps a `PanelView` to a ratatui widget; the Web app
6//! maps the same `PanelView` to a canvas draw. Because these are `serde` types,
7//! they are also the exact bytes the cross-language golden corpus pins and the
8//! payload `Terminal::command_json` returns.
9
10use serde::{Deserialize, Serialize};
11
12use crate::source::SourceId;
13
14/// One named output of a multi-output indicator.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct IndicatorField {
17 /// The field name as wickra-core declares it (`macd`, `signal`, `histogram`).
18 pub name: String,
19 /// The field's latest value.
20 pub value: f64,
21}
22
23/// One indicator's latest value (`None` while warming up).
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct IndicatorValue {
26 /// The indicator's display label (`"Sma(20)"`), derived from its spec.
27 pub name: String,
28 /// The primary value, or `None` during warmup. For a multi-output indicator
29 /// this is its first field, so a renderer that only wants one line does not
30 /// have to know which field that is.
31 pub value: Option<f64>,
32 /// Every named output, in declaration order, for the multi-output
33 /// indicators. Empty for single-output ones, and omitted from the JSON
34 /// entirely when empty: a consumer written against the single-output shape
35 /// sees exactly the object it saw before.
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub fields: Vec<IndicatorField>,
38 /// A bounded recent series, oldest first, ending at the current tick, for
39 /// renderers that draw the indicator as a line over the price.
40 ///
41 /// Indicators warm up at different lengths, so this is not always as long as
42 /// the chart's own series. Both end at the same tick, so a renderer aligns
43 /// this to the right. Empty while warming up, and then omitted from the JSON
44 /// entirely rather than serialised as `[]`.
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub series: Vec<f64>,
47}
48
49/// One OHLCV bar, as a chart draws it.
50///
51/// Separate from [`crate::registry::AltBar`], which the bars panel carries: an
52/// alternative bar has a direction and no time, because a Renko brick or a
53/// point-and-figure column advances on price movement. This one is a bar of the
54/// configured timeframe and is placed by its timestamp.
55#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
56pub struct OhlcBar {
57 /// Opening price of the bar.
58 pub open: f64,
59 /// Highest price traded in the bar.
60 pub high: f64,
61 /// Lowest price traded in the bar.
62 pub low: f64,
63 /// Closing price of the bar.
64 pub close: f64,
65 /// Volume traded in the bar.
66 pub volume: f64,
67 /// The bar's opening timestamp (ms since the Unix epoch).
68 pub timestamp: i64,
69}
70
71/// The chart panel's view-model: the bars, a tick-resolution price series, and
72/// the indicator overlays.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ChartView {
75 /// The market shown.
76 pub symbol: String,
77 /// The last traded price.
78 pub last: f64,
79 /// A bounded recent price series, oldest first.
80 ///
81 /// One point per trade rather than per bar, so it is the finer of the two
82 /// and does not wait for a bar to close. A renderer with a handful of
83 /// columns draws this; one with room for candles draws `bars`.
84 pub series: Vec<f64>,
85 /// The closed bars of the configured timeframe, oldest first.
86 ///
87 /// Empty until the first bar closes, and omitted from the JSON entirely
88 /// when empty, so a consumer written against the earlier shape sees exactly
89 /// the object it saw before.
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub bars: Vec<OhlcBar>,
92 /// The bar still accumulating, if a trade has opened one.
93 ///
94 /// Kept apart from `bars` rather than appended to it, because it is the one
95 /// bar that will still change: an indicator never sees it — a reading that
96 /// repainted as its bar filled would be a different number every print —
97 /// but a chart that omitted it would show the market frozen at the last
98 /// close.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub forming: Option<OhlcBar>,
101 /// The indicator overlays.
102 pub indicators: Vec<IndicatorValue>,
103}
104
105/// One order-book level.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct Level {
108 /// Price of the level.
109 pub price: f64,
110 /// Resting quantity at the level.
111 pub quantity: f64,
112}
113
114/// The order-book panel's view-model.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct BookView {
117 /// The market shown.
118 pub symbol: String,
119 /// Bid levels, best (highest) first.
120 pub bids: Vec<Level>,
121 /// Ask levels, best (lowest) first.
122 pub asks: Vec<Level>,
123 /// The spread, or `None` if a side is empty.
124 pub spread: Option<f64>,
125}
126
127/// One tape print in a view-model, with the aggressor side as a semantic hint
128/// (`"buy"` / `"sell"`) the renderer colours.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct TapePrint {
131 /// Execution price.
132 pub price: f64,
133 /// Executed quantity.
134 pub quantity: f64,
135 /// Aggressor side hint: `"buy"` or `"sell"`.
136 pub side: String,
137 /// Venue timestamp (ms since the Unix epoch).
138 pub timestamp: i64,
139}
140
141/// The tape (time-and-sales) panel's view-model.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct TapeView {
144 /// The market shown.
145 pub symbol: String,
146 /// The most recent prints, newest first.
147 pub prints: Vec<TapePrint>,
148}
149
150/// One watchlist row.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct WatchRow {
153 /// The source the market belongs to.
154 pub source: SourceId,
155 /// The market, in `BASE/QUOTE` form.
156 pub symbol: String,
157 /// The last traded price.
158 pub last: f64,
159 /// The venue's best bid, or 0.0 before the first ticker.
160 pub bid: f64,
161 /// The venue's best ask, or 0.0 before the first ticker.
162 pub ask: f64,
163 /// The venue's rolling base-asset volume, or 0.0 before the first ticker.
164 pub volume: f64,
165 /// Percentage change from the first price this terminal folded for the
166 /// market, or 0.0 before there is one.
167 ///
168 /// Computed here rather than left to each renderer: two front-ends deriving
169 /// the same number from the same two fields is two places for it to be
170 /// derived differently, and a watchlist that disagreed with itself between
171 /// the terminal and the browser is the bug that would follow.
172 pub change: f64,
173}
174
175/// The watchlist panel's view-model.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct WatchlistView {
178 /// The tracked markets.
179 pub rows: Vec<WatchRow>,
180}
181
182/// One footprint level: volume traded at a price, split by aggressor side.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct FootprintLevel {
185 /// The price level.
186 pub price: f64,
187 /// Buy-aggressor volume at this price.
188 pub buy: f64,
189 /// Sell-aggressor volume at this price.
190 pub sell: f64,
191}
192
193/// The footprint (volume-profile) panel's view-model.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct FootprintView {
196 /// The market shown.
197 pub symbol: String,
198 /// Price levels, highest price first.
199 pub levels: Vec<FootprintLevel>,
200}
201
202/// One profile's histogram, as a panel row.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct ProfileRow {
205 /// The profile's label, as the spec names it.
206 pub label: String,
207 /// The histogram, in bin order. Empty until the profile has produced one.
208 pub bins: Vec<f64>,
209 /// The lowest price the bins cover, for a distribution over price.
210 ///
211 /// Absent for a distribution over TIME -- day of week, minute of session
212 /// -- which has no price range. Reporting zeros there would be a claim
213 /// about prices that the profile never made.
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub price_low: Option<f64>,
216 /// The highest price the bins cover, for a distribution over price.
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub price_high: Option<f64>,
219}
220
221/// The profile panel's view-model.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct ProfileView {
224 /// The market shown.
225 pub symbol: String,
226 /// The configured profiles, in configured order.
227 pub profiles: Vec<ProfileRow>,
228}
229
230/// One alternative bar stream, as a panel row.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct BarStreamView {
233 /// The stream's label, as the spec names it.
234 pub label: String,
235 /// The most recent completed bars, oldest first.
236 ///
237 /// Empty until the stream completes one, which for a Renko brick or a
238 /// point-and-figure column can take many candles: these charts advance on
239 /// price movement rather than on time.
240 pub bars: Vec<crate::registry::AltBar>,
241}
242
243/// The bars panel's view-model.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct BarsView {
246 /// The market shown.
247 pub symbol: String,
248 /// The configured streams, in configured order.
249 pub streams: Vec<BarStreamView>,
250}
251
252/// One panel's view-model, tagged by kind.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254#[serde(tag = "panel", rename_all = "snake_case")]
255pub enum PanelView {
256 /// A price chart.
257 Chart(ChartView),
258 /// An order book.
259 Book(BookView),
260 /// A time-and-sales tape.
261 Tape(TapeView),
262 /// A multi-market watchlist.
263 Watchlist(WatchlistView),
264 /// A footprint / volume profile.
265 Footprint(FootprintView),
266 /// The configured distributions.
267 Profile(ProfileView),
268 /// The configured alternative charts.
269 Bars(BarsView),
270}
271
272/// The output of one `tick`: every active panel's view-model.
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct Frame {
275 /// The panels, in layout order.
276 pub panels: Vec<PanelView>,
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn panel_view_is_tagged_in_json() {
285 let view = PanelView::Chart(ChartView {
286 symbol: "BTC/USDT".to_string(),
287 last: 100.0,
288 series: vec![99.0, 100.0],
289 bars: vec![OhlcBar {
290 open: 99.0,
291 high: 101.0,
292 low: 98.0,
293 close: 100.0,
294 volume: 3.0,
295 timestamp: 0,
296 }],
297 forming: None,
298 indicators: vec![IndicatorValue {
299 name: "Sma(20)".to_string(),
300 value: None,
301 fields: Vec::new(),
302 series: Vec::new(),
303 }],
304 });
305 let json = serde_json::to_string(&view).unwrap();
306 assert!(json.contains("\"panel\":\"chart\""));
307 assert_eq!(serde_json::from_str::<PanelView>(&json).unwrap(), view);
308 }
309
310 #[test]
311 fn frame_round_trips() {
312 let frame = Frame {
313 panels: vec![PanelView::Watchlist(WatchlistView { rows: vec![] })],
314 };
315 let json = serde_json::to_string(&frame).unwrap();
316 assert_eq!(serde_json::from_str::<Frame>(&json).unwrap(), frame);
317 }
318}