Skip to main content

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}