Skip to main content

digdigdig3_station/data/
ticker_ext.rs

1//! Extended Ticker DataPoint types for Indicators and Full depth.
2//!
3//! `TickerPoint` (Compact, 72 B) is unchanged — see `ticker.rs`.
4//!
5//! Layout convention: all fields little-endian. `f64::NAN` for absent
6//! `Option<f64>`, `i64::MIN` as u64 sentinel for absent `Option<i64>`.
7
8use digdigdig3::core::types::{StreamEvent, Ticker};
9use serde::{Deserialize, Serialize};
10
11use crate::series::DataPoint;
12
13// ─── helpers ─────────────────────────────────────────────────────────────────
14
15#[inline]
16fn opt_f64(v: Option<f64>) -> f64 {
17    v.unwrap_or(f64::NAN)
18}
19
20#[inline]
21fn opt_i64_enc(v: Option<i64>) -> u64 {
22    match v {
23        Some(x) => x as u64,
24        None => i64::MIN as u64,
25    }
26}
27
28#[inline]
29fn opt_i64_dec(raw: u64) -> Option<i64> {
30    let v = raw as i64;
31    if v == i64::MIN { None } else { Some(v) }
32}
33
34// ─── TickerIndicatorsPoint ───────────────────────────────────────────────────
35
36/// 160 B ticker record for Indicators depth.
37///
38/// Fields (all LE):
39///   u64 ts_ms (8)
40///   f64 last, bid, ask, bid_qty, ask_qty              (5 × 8 = 40)
41///   f64 high_24h, low_24h                             (2 × 8 = 16)
42///   f64 open_price, prev_close_price                  (2 × 8 = 16)
43///   f64 vol_24h, quote_vol_24h                        (2 × 8 = 16)
44///   f64 change_pct_24h                                (8)
45///   f64 weighted_avg_price                            (8)
46///   f64 last_qty                                      (8)
47///   f64 mark_price, index_price, open_interest        (3 × 8 = 24)
48///   f64 funding_rate                                  (8)
49///   u64 count (sentinel i64::MIN)                     (8)
50///
51/// Total: 8 + 40 + 16 + 16 + 16 + 8 + 8 + 8 + 24 + 8 + 8 = 160 B
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TickerIndicatorsPoint {
54    pub ts_ms: i64,
55    pub last: f64,
56    pub bid: f64,
57    pub ask: f64,
58    pub bid_qty: f64,
59    pub ask_qty: f64,
60    pub high_24h: f64,
61    pub low_24h: f64,
62    pub open_price: f64,
63    pub prev_close_price: f64,
64    pub vol_24h: f64,
65    pub quote_vol_24h: f64,
66    pub change_pct_24h: f64,
67    pub weighted_avg_price: f64,
68    pub last_qty: f64,
69    pub mark_price: f64,
70    pub index_price: f64,
71    pub open_interest: f64,
72    pub funding_rate: f64,
73    /// `None` stored as `i64::MIN`.
74    pub count: Option<i64>,
75}
76
77const INDICATORS_SIZE: usize = 160;
78
79impl TickerIndicatorsPoint {
80    pub fn from_ticker(t: &Ticker) -> Self {
81        Self {
82            ts_ms: t.timestamp,
83            last: t.last_price,
84            bid: opt_f64(t.bid_price),
85            ask: opt_f64(t.ask_price),
86            bid_qty: opt_f64(t.bid_qty),
87            ask_qty: opt_f64(t.ask_qty),
88            high_24h: opt_f64(t.high_24h),
89            low_24h: opt_f64(t.low_24h),
90            open_price: opt_f64(t.open_price),
91            prev_close_price: opt_f64(t.prev_close_price),
92            vol_24h: opt_f64(t.volume_24h),
93            quote_vol_24h: opt_f64(t.quote_volume_24h),
94            change_pct_24h: opt_f64(t.price_change_percent_24h),
95            weighted_avg_price: opt_f64(t.weighted_avg_price),
96            last_qty: opt_f64(t.last_qty),
97            mark_price: opt_f64(t.mark_price),
98            index_price: opt_f64(t.index_price),
99            open_interest: opt_f64(t.open_interest),
100            funding_rate: opt_f64(t.funding_rate),
101            count: t.count,
102        }
103    }
104}
105
106impl DataPoint for TickerIndicatorsPoint {
107    const RECORD_SIZE: usize = INDICATORS_SIZE;
108
109    fn encode(&self, out: &mut [u8]) {
110        out[0..8].copy_from_slice(&(self.ts_ms as u64).to_le_bytes());
111        out[8..16].copy_from_slice(&self.last.to_le_bytes());
112        out[16..24].copy_from_slice(&self.bid.to_le_bytes());
113        out[24..32].copy_from_slice(&self.ask.to_le_bytes());
114        out[32..40].copy_from_slice(&self.bid_qty.to_le_bytes());
115        out[40..48].copy_from_slice(&self.ask_qty.to_le_bytes());
116        out[48..56].copy_from_slice(&self.high_24h.to_le_bytes());
117        out[56..64].copy_from_slice(&self.low_24h.to_le_bytes());
118        out[64..72].copy_from_slice(&self.open_price.to_le_bytes());
119        out[72..80].copy_from_slice(&self.prev_close_price.to_le_bytes());
120        out[80..88].copy_from_slice(&self.vol_24h.to_le_bytes());
121        out[88..96].copy_from_slice(&self.quote_vol_24h.to_le_bytes());
122        out[96..104].copy_from_slice(&self.change_pct_24h.to_le_bytes());
123        out[104..112].copy_from_slice(&self.weighted_avg_price.to_le_bytes());
124        out[112..120].copy_from_slice(&self.last_qty.to_le_bytes());
125        out[120..128].copy_from_slice(&self.mark_price.to_le_bytes());
126        out[128..136].copy_from_slice(&self.index_price.to_le_bytes());
127        out[136..144].copy_from_slice(&self.open_interest.to_le_bytes());
128        out[144..152].copy_from_slice(&self.funding_rate.to_le_bytes());
129        out[152..160].copy_from_slice(&opt_i64_enc(self.count).to_le_bytes());
130    }
131
132    fn decode(bytes: &[u8]) -> Option<Self> {
133        if bytes.len() != INDICATORS_SIZE {
134            return None;
135        }
136        Some(Self {
137            ts_ms: u64::from_le_bytes(bytes[0..8].try_into().ok()?) as i64,
138            last: f64::from_le_bytes(bytes[8..16].try_into().ok()?),
139            bid: f64::from_le_bytes(bytes[16..24].try_into().ok()?),
140            ask: f64::from_le_bytes(bytes[24..32].try_into().ok()?),
141            bid_qty: f64::from_le_bytes(bytes[32..40].try_into().ok()?),
142            ask_qty: f64::from_le_bytes(bytes[40..48].try_into().ok()?),
143            high_24h: f64::from_le_bytes(bytes[48..56].try_into().ok()?),
144            low_24h: f64::from_le_bytes(bytes[56..64].try_into().ok()?),
145            open_price: f64::from_le_bytes(bytes[64..72].try_into().ok()?),
146            prev_close_price: f64::from_le_bytes(bytes[72..80].try_into().ok()?),
147            vol_24h: f64::from_le_bytes(bytes[80..88].try_into().ok()?),
148            quote_vol_24h: f64::from_le_bytes(bytes[88..96].try_into().ok()?),
149            change_pct_24h: f64::from_le_bytes(bytes[96..104].try_into().ok()?),
150            weighted_avg_price: f64::from_le_bytes(bytes[104..112].try_into().ok()?),
151            last_qty: f64::from_le_bytes(bytes[112..120].try_into().ok()?),
152            mark_price: f64::from_le_bytes(bytes[120..128].try_into().ok()?),
153            index_price: f64::from_le_bytes(bytes[128..136].try_into().ok()?),
154            open_interest: f64::from_le_bytes(bytes[136..144].try_into().ok()?),
155            funding_rate: f64::from_le_bytes(bytes[144..152].try_into().ok()?),
156            count: opt_i64_dec(u64::from_le_bytes(bytes[152..160].try_into().ok()?)),
157        })
158    }
159
160    fn timestamp_ms(&self) -> i64 { self.ts_ms }
161
162    fn from_stream_event(ev: &StreamEvent) -> Option<Self> {
163        if let StreamEvent::Ticker { ticker, .. } = ev {
164            Some(Self::from_ticker(ticker))
165        } else {
166            None
167        }
168    }
169}
170
171// ─── TickerFullPoint ─────────────────────────────────────────────────────────
172
173/// Full Ticker record — every numeric wire field.
174///
175/// Layout (all LE):
176///   u64 ts_ms                                          (8)
177///   f64 last                                           (8)
178///   f64 bid, ask, bid_qty, ask_qty                    (4 × 8 = 32)
179///   f64 high_24h, low_24h, vol_24h, quote_vol_24h     (4 × 8 = 32)
180///   f64 price_change_24h, price_change_pct_24h        (2 × 8 = 16)
181///   f64 open_price, prev_close_price                   (2 × 8 = 16)
182///   f64 prev_price_24h, prev_price_1h                 (2 × 8 = 16)
183///   f64 weighted_avg_price, open_utc, turnover_24h    (3 × 8 = 24)
184///   u64 first_id, last_id, count (sentinels)          (3 × 8 = 24)
185///   f64 mark_price, index_price                        (2 × 8 = 16)
186///   f64 open_interest, open_interest_value            (2 × 8 = 16)
187///   f64 single_open_interest                          (8)
188///   f64 funding_rate                                   (8)
189///   u64 next_funding_time (sentinel)                  (8)
190///   f64 funding_interval_hour, funding_cap            (2 × 8 = 16)
191///   f64 basis, basis_rate                             (2 × 8 = 16)
192///   f64 predicted_delivery_price                       (8)
193///   u64 delivery_time (sentinel)                      (8)
194///   f64 settlement_price, funding_8h                  (2 × 8 = 16)
195///   f64 min_price, max_price, volume_notional         (3 × 8 = 24)
196///   f64 last_qty, interest_value                      (2 × 8 = 16)
197///   u64 last_trade_time, open_time, update_id (senti) (3 × 8 = 24)
198///   u64 blob_offset (8), u32 blob_len (4)             (12)
199///
200/// Fixed total: 8+8+32+32+16+16+16+24+24+16+16+8+8+8+16+16+8+8+16+24+16+24+12
201///            = 376 B
202///
203/// Blob (variable): u16-len-prefixed UTF-8 `state` string (empty if None).
204/// `update_id` is stored as u64 with sentinel.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TickerFullPoint {
207    pub ts_ms: i64,
208    pub last: f64,
209    pub bid: f64,
210    pub ask: f64,
211    pub bid_qty: f64,
212    pub ask_qty: f64,
213    pub high_24h: f64,
214    pub low_24h: f64,
215    pub vol_24h: f64,
216    pub quote_vol_24h: f64,
217    pub price_change_24h: f64,
218    pub price_change_pct_24h: f64,
219    pub open_price: f64,
220    pub prev_close_price: f64,
221    pub prev_price_24h: f64,
222    pub prev_price_1h: f64,
223    pub weighted_avg_price: f64,
224    pub open_utc: f64,
225    pub turnover_24h: f64,
226    pub first_id: Option<i64>,
227    pub last_id: Option<i64>,
228    pub count: Option<i64>,
229    pub mark_price: f64,
230    pub index_price: f64,
231    pub open_interest: f64,
232    pub open_interest_value: f64,
233    pub single_open_interest: f64,
234    pub funding_rate: f64,
235    pub next_funding_time: Option<i64>,
236    pub funding_interval_hour: f64,
237    pub funding_cap: f64,
238    pub basis: f64,
239    pub basis_rate: f64,
240    pub predicted_delivery_price: f64,
241    pub delivery_time: Option<i64>,
242    pub settlement_price: f64,
243    pub funding_8h: f64,
244    pub min_price: f64,
245    pub max_price: f64,
246    pub volume_notional: f64,
247    pub last_qty: f64,
248    pub interest_value: f64,
249    pub last_trade_time: Option<i64>,
250    pub open_time: Option<i64>,
251    pub update_id: Option<i64>,
252    /// Instrument state string ("open"/"closed" etc.) — stored in blob.
253    pub state: Option<String>,
254}
255
256// Fixed part: 376 - 12 (blob pointer tail) = 364 numeric bytes.
257// blob pointer at offset 364: u64 blob_off + u32 blob_len = 12 B.
258const FULL_BLOB_OFFSET: usize = 364;
259const FULL_SIZE: usize = 376;
260
261fn encode_ticker_full_numeric(p: &TickerFullPoint, out: &mut [u8]) {
262    let mut o = 0usize;
263    macro_rules! w8 {
264        ($v:expr) => { out[o..o+8].copy_from_slice(&$v.to_le_bytes()); o += 8; };
265    }
266    w8!((p.ts_ms as u64));
267    w8!(p.last);
268    w8!(p.bid);
269    w8!(p.ask);
270    w8!(p.bid_qty);
271    w8!(p.ask_qty);
272    w8!(p.high_24h);
273    w8!(p.low_24h);
274    w8!(p.vol_24h);
275    w8!(p.quote_vol_24h);
276    w8!(p.price_change_24h);
277    w8!(p.price_change_pct_24h);
278    w8!(p.open_price);
279    w8!(p.prev_close_price);
280    w8!(p.prev_price_24h);
281    w8!(p.prev_price_1h);
282    w8!(p.weighted_avg_price);
283    w8!(p.open_utc);
284    w8!(p.turnover_24h);
285    w8!(opt_i64_enc(p.first_id));
286    w8!(opt_i64_enc(p.last_id));
287    w8!(opt_i64_enc(p.count));
288    w8!(p.mark_price);
289    w8!(p.index_price);
290    w8!(p.open_interest);
291    w8!(p.open_interest_value);
292    w8!(p.single_open_interest);
293    w8!(p.funding_rate);
294    w8!(opt_i64_enc(p.next_funding_time));
295    w8!(p.funding_interval_hour);
296    w8!(p.funding_cap);
297    w8!(p.basis);
298    w8!(p.basis_rate);
299    w8!(p.predicted_delivery_price);
300    w8!(opt_i64_enc(p.delivery_time));
301    w8!(p.settlement_price);
302    w8!(p.funding_8h);
303    w8!(p.min_price);
304    w8!(p.max_price);
305    w8!(p.volume_notional);
306    w8!(p.last_qty);
307    w8!(p.interest_value);
308    w8!(opt_i64_enc(p.last_trade_time));
309    w8!(opt_i64_enc(p.open_time));
310    w8!(opt_i64_enc(p.update_id));
311    debug_assert_eq!(o, FULL_BLOB_OFFSET);
312}
313
314fn decode_ticker_full_numeric(bytes: &[u8]) -> Option<TickerFullPoint> {
315    if bytes.len() < FULL_BLOB_OFFSET {
316        return None;
317    }
318    let mut o = 0usize;
319    macro_rules! rf64 {
320        () => {{ let v = f64::from_le_bytes(bytes[o..o+8].try_into().ok()?); o += 8; v }};
321    }
322    macro_rules! ru64 {
323        () => {{ let v = u64::from_le_bytes(bytes[o..o+8].try_into().ok()?); o += 8; v }};
324    }
325    let result = Some(TickerFullPoint {
326        ts_ms: ru64!() as i64,
327        last: rf64!(),
328        bid: rf64!(),
329        ask: rf64!(),
330        bid_qty: rf64!(),
331        ask_qty: rf64!(),
332        high_24h: rf64!(),
333        low_24h: rf64!(),
334        vol_24h: rf64!(),
335        quote_vol_24h: rf64!(),
336        price_change_24h: rf64!(),
337        price_change_pct_24h: rf64!(),
338        open_price: rf64!(),
339        prev_close_price: rf64!(),
340        prev_price_24h: rf64!(),
341        prev_price_1h: rf64!(),
342        weighted_avg_price: rf64!(),
343        open_utc: rf64!(),
344        turnover_24h: rf64!(),
345        first_id: opt_i64_dec(ru64!()),
346        last_id: opt_i64_dec(ru64!()),
347        count: opt_i64_dec(ru64!()),
348        mark_price: rf64!(),
349        index_price: rf64!(),
350        open_interest: rf64!(),
351        open_interest_value: rf64!(),
352        single_open_interest: rf64!(),
353        funding_rate: rf64!(),
354        next_funding_time: opt_i64_dec(ru64!()),
355        funding_interval_hour: rf64!(),
356        funding_cap: rf64!(),
357        basis: rf64!(),
358        basis_rate: rf64!(),
359        predicted_delivery_price: rf64!(),
360        delivery_time: opt_i64_dec(ru64!()),
361        settlement_price: rf64!(),
362        funding_8h: rf64!(),
363        min_price: rf64!(),
364        max_price: rf64!(),
365        volume_notional: rf64!(),
366        last_qty: rf64!(),
367        interest_value: rf64!(),
368        last_trade_time: opt_i64_dec(ru64!()),
369        open_time: opt_i64_dec(ru64!()),
370        update_id: opt_i64_dec(ru64!()),
371        state: None, // filled by decode_blob
372    });
373    debug_assert_eq!(o, FULL_BLOB_OFFSET, "ticker full decode offset mismatch");
374    result
375}
376
377impl TickerFullPoint {
378    pub fn from_ticker(t: &Ticker) -> Self {
379        Self {
380            ts_ms: t.timestamp,
381            last: t.last_price,
382            bid: opt_f64(t.bid_price),
383            ask: opt_f64(t.ask_price),
384            bid_qty: opt_f64(t.bid_qty),
385            ask_qty: opt_f64(t.ask_qty),
386            high_24h: opt_f64(t.high_24h),
387            low_24h: opt_f64(t.low_24h),
388            vol_24h: opt_f64(t.volume_24h),
389            quote_vol_24h: opt_f64(t.quote_volume_24h),
390            price_change_24h: opt_f64(t.price_change_24h),
391            price_change_pct_24h: opt_f64(t.price_change_percent_24h),
392            open_price: opt_f64(t.open_price),
393            prev_close_price: opt_f64(t.prev_close_price),
394            prev_price_24h: opt_f64(t.prev_price_24h),
395            prev_price_1h: opt_f64(t.prev_price_1h),
396            weighted_avg_price: opt_f64(t.weighted_avg_price),
397            open_utc: opt_f64(t.open_utc),
398            turnover_24h: opt_f64(t.turnover_24h),
399            first_id: t.first_id,
400            last_id: t.last_id,
401            count: t.count,
402            mark_price: opt_f64(t.mark_price),
403            index_price: opt_f64(t.index_price),
404            open_interest: opt_f64(t.open_interest),
405            open_interest_value: opt_f64(t.open_interest_value),
406            single_open_interest: opt_f64(t.single_open_interest),
407            funding_rate: opt_f64(t.funding_rate),
408            next_funding_time: t.next_funding_time,
409            funding_interval_hour: opt_f64(t.funding_interval_hour),
410            funding_cap: opt_f64(t.funding_cap),
411            basis: opt_f64(t.basis),
412            basis_rate: opt_f64(t.basis_rate),
413            predicted_delivery_price: opt_f64(t.predicted_delivery_price),
414            delivery_time: t.delivery_time,
415            settlement_price: opt_f64(t.settlement_price),
416            funding_8h: opt_f64(t.funding_8h),
417            min_price: opt_f64(t.min_price),
418            max_price: opt_f64(t.max_price),
419            volume_notional: opt_f64(t.volume_notional),
420            last_qty: opt_f64(t.last_qty),
421            interest_value: opt_f64(t.interest_value),
422            last_trade_time: t.last_trade_time,
423            open_time: t.open_time,
424            update_id: t.update_id,
425            state: t.state.clone(),
426        }
427    }
428}
429
430impl DataPoint for TickerFullPoint {
431    const RECORD_SIZE: usize = FULL_SIZE;
432
433    fn encode(&self, out: &mut [u8]) {
434        encode_ticker_full_numeric(self, out);
435        // blob pointer tail (offset + len) patched by DiskStore after encode.
436    }
437
438    fn decode(bytes: &[u8]) -> Option<Self> {
439        if bytes.len() != FULL_SIZE {
440            return None;
441        }
442        decode_ticker_full_numeric(bytes)
443    }
444
445    fn timestamp_ms(&self) -> i64 { self.ts_ms }
446
447    fn from_stream_event(ev: &StreamEvent) -> Option<Self> {
448        if let StreamEvent::Ticker { ticker, .. } = ev {
449            Some(Self::from_ticker(ticker))
450        } else {
451            None
452        }
453    }
454
455    fn encode_blob(&self) -> Option<Vec<u8>> {
456        let s = self.state.as_deref().unwrap_or("");
457        let bytes = s.as_bytes();
458        let len = bytes.len().min(u16::MAX as usize);
459        let mut blob = Vec::with_capacity(2 + len);
460        blob.extend_from_slice(&(len as u16).to_le_bytes());
461        blob.extend_from_slice(&bytes[..len]);
462        Some(blob)
463    }
464
465    fn decode_blob(header: &[u8], blob: &[u8]) -> Option<Self> {
466        let mut p = decode_ticker_full_numeric(header)?;
467        if blob.len() >= 2 {
468            let slen = u16::from_le_bytes(blob[0..2].try_into().ok()?) as usize;
469            if blob.len() >= 2 + slen {
470                let s = std::str::from_utf8(&blob[2..2 + slen]).ok()?;
471                if !s.is_empty() {
472                    p.state = Some(s.to_owned());
473                }
474            }
475        }
476        Some(p)
477    }
478
479    fn blob_pointer_offset() -> Option<usize> { Some(FULL_BLOB_OFFSET) }
480}