Skip to main content

rustrade_data/exchange/massive/
transformer.rs

1//! JSON to rustrade type transformations for Massive API responses.
2
3use super::error::MassiveError;
4use crate::{
5    books::Level,
6    subscription::{
7        book::OrderBookL1,
8        candle::{Candle, IntervalStep, close_time_from_open},
9        trade::PublicTrade,
10    },
11};
12use chrono::{DateTime, Duration, TimeZone, Utc};
13use rust_decimal::Decimal;
14use rustrade_instrument::Side;
15use serde::{Deserialize, Deserializer, Serialize};
16use smol_str::SmolStr;
17
18/// Deserialize only the first element of a conditions array without allocating a Vec.
19pub(super) fn deserialize_first_condition<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
20where
21    D: Deserializer<'de>,
22{
23    use serde::de::{SeqAccess, Visitor};
24
25    struct FirstElementVisitor;
26
27    impl<'de> Visitor<'de> for FirstElementVisitor {
28        type Value = Option<i32>;
29
30        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31            formatter.write_str("an array of integers or null")
32        }
33
34        fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
35            Ok(None)
36        }
37
38        fn visit_some<D: Deserializer<'de>>(
39            self,
40            deserializer: D,
41        ) -> Result<Self::Value, D::Error> {
42            deserializer.deserialize_seq(self)
43        }
44
45        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
46            let first = seq.next_element()?;
47            // Drain remaining elements without storing them
48            while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
49            Ok(first)
50        }
51    }
52
53    deserializer.deserialize_option(FirstElementVisitor)
54}
55
56/// Raw aggregates response from Massive REST API.
57#[derive(Debug, Deserialize)]
58pub struct AggregatesResponse {
59    /// Ticker symbol requested
60    #[allow(dead_code)] // Retained for API schema; internal pagination uses next_url
61    pub ticker: Option<String>,
62
63    /// Number of results in this response
64    #[serde(rename = "resultsCount", default)]
65    pub results_count: usize,
66
67    /// Status of the request
68    #[serde(default)]
69    #[allow(dead_code)] // HTTP status already checked in fetch_page_body
70    pub status: String,
71
72    /// Request ID for debugging
73    #[allow(dead_code)] // Retained for API schema completeness
74    pub request_id: Option<String>,
75
76    /// URL for next page of results (pagination)
77    pub next_url: Option<String>,
78
79    /// Aggregate bars
80    pub results: Option<Vec<AggregateBar>>,
81}
82
83/// Single OHLCV bar from Massive aggregates endpoint.
84#[derive(Debug, Deserialize)]
85pub struct AggregateBar {
86    /// Open price
87    #[serde(rename = "o", with = "rust_decimal::serde::float")]
88    pub open: Decimal,
89
90    /// High price
91    #[serde(rename = "h", with = "rust_decimal::serde::float")]
92    pub high: Decimal,
93
94    /// Low price
95    #[serde(rename = "l", with = "rust_decimal::serde::float")]
96    pub low: Decimal,
97
98    /// Close price
99    #[serde(rename = "c", with = "rust_decimal::serde::float")]
100    pub close: Decimal,
101
102    /// Volume
103    #[serde(rename = "v", with = "rust_decimal::serde::float")]
104    pub volume: Decimal,
105
106    /// Volume-weighted average price
107    #[serde(rename = "vw", with = "rust_decimal::serde::float_option", default)]
108    #[allow(dead_code)] // Retained for API schema completeness; not yet consumed
109    pub vwap: Option<Decimal>,
110
111    /// Unix timestamp in milliseconds (start of the bar)
112    #[serde(rename = "t")]
113    pub timestamp: i64,
114
115    /// Number of trades in this bar
116    #[serde(rename = "n")]
117    pub trade_count: Option<u64>,
118}
119
120/// What a Massive aggregate bar was **built from**, which is market-dependent and decides the
121/// meaning of every activity count on the bar.
122///
123/// Massive documents `v` identically across asset classes ("the trading volume of the symbol in
124/// the given time period"), but forex aggregates are not built from a trade tape: spot FX has no
125/// consolidated one, so Massive derives its forex bars from quoted bid/ask updates. `v` there
126/// counts *quote activity*, and a quote is not a trade — nobody transacted that "volume".
127///
128/// Carrying it into [`Candle::volume`] anyway would be exactly the silent lie
129/// [that field's contract](Candle::volume) exists to prevent: a volume-derived feature, a VWAP, or
130/// a liquidity filter would read a real number where the venue reports nothing about size at all.
131/// Spot FX is the motivating example named in `Candle::volume`'s own docs.
132///
133/// # Why this classifies the bar rather than one field
134/// `n` ("the number of transactions in the aggregate window") is documented just as generically as
135/// `v`, and the same fact overrides both: a bar generated from quotes contains **no transactions**,
136/// so `n` cannot be a transaction count on it either. It is a quote-update count — the same
137/// quantity as `v`, differing only in units. Gating `v` while passing `n` through would leave
138/// `candle.trade_count` reporting quote ticks in the hundreds per minute regardless of liquidity,
139/// so a `trade_count >= 50` liquidity filter would pass on every forex bar and look like it was
140/// working. That is bit-for-bit the defect gating `v` removes.
141///
142/// Refs: <https://massive.com/docs/rest/forex/aggregates/custom-bars> (forex aggregates are
143/// "generated from quoted bid/ask prices rather than executed trades").
144#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
145pub enum AggregateProvenance {
146    /// Built from a trade tape — stocks, options and crypto. `v` and `n` mean what they say.
147    TradeTape,
148    /// Built from quoted bid/ask updates, so neither `v` nor `n` describes any transaction and the
149    /// candle reports `volume: None` and `trade_count: None`.
150    QuoteTape,
151}
152
153impl AggregateProvenance {
154    /// Classify a Massive REST ticker by its asset-class prefix.
155    ///
156    /// `C:` is forex (`C:EURUSD`); everything else — `X:` crypto, `O:` options, bare stock
157    /// symbols — has a trade tape. A ticker whose class Massive adds later defaults to
158    /// [`TradeTape`](Self::TradeTape), matching the pre-existing behaviour for anything
159    /// unrecognised.
160    ///
161    /// The prefix match is **ASCII case-insensitive**. Massive's own tickers are uppercase, but
162    /// nothing upstream of here normalises one: `validate_ticker` rejects empty strings and
163    /// URL-breaking characters only. A case-sensitive match would classify `c:eurusd` as
164    /// [`TradeTape`](Self::TradeTape) and report quote-update counts as real trading activity —
165    /// precisely the silent lie this type exists to prevent.
166    #[must_use]
167    pub fn for_ticker(ticker: &str) -> Self {
168        // `get` rather than slicing: a leading character 3 or 4 bytes wide puts byte 2 *inside* it,
169        // and `&ticker[..2]` panics on a non-char boundary. `get` returns `None` there, falling
170        // through to `TradeTape` like any other ticker that is not `C:`-prefixed.
171        if ticker
172            .get(..2)
173            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("C:"))
174        {
175            Self::QuoteTape
176        } else {
177            Self::TradeTape
178        }
179    }
180
181    /// Apply these semantics to a wire `v`.
182    pub(super) fn volume(self, volume: Decimal) -> Option<Decimal> {
183        match self {
184            Self::TradeTape => Some(volume),
185            Self::QuoteTape => None,
186        }
187    }
188
189    /// Apply these semantics to a wire `n`.
190    ///
191    /// `None` in, `None` out: the provider already models an absent count, and that absence is
192    /// carried through as "unknown" rather than flattened to a fabricated `0`.
193    fn trade_count(self, trade_count: Option<u64>) -> Option<u64> {
194        match self {
195            Self::TradeTape => trade_count,
196            Self::QuoteTape => None,
197        }
198    }
199}
200
201impl AggregateBar {
202    /// Convert to rustrade [`Candle`], mapping `(multiplier, timespan)` to the
203    /// shared [`IntervalStep`].
204    ///
205    /// `close_time` is the exclusive end-of-period boundary `open + interval`,
206    /// computed per-bar via [`close_time_from_open`] — calendar intervals
207    /// (`month`/`quarter`/`year`) therefore use leap-year-correct month
208    /// arithmetic, not an approximate fixed `Duration`.
209    ///
210    /// `provenance` says what this market's `v` and `n` mean — see [`AggregateProvenance`], and
211    /// [`AggregateProvenance::for_ticker`] to derive it from the ticker you requested.
212    ///
213    /// Test-only. `mod transformer` is `pub(crate)` and this type is not re-exported, so a `pub fn`
214    /// here would not actually be reachable by a downstream user however it were annotated —
215    /// leaving it looking like public API while carrying `#[allow(dead_code)]` claimed a contract
216    /// that does not exist. Production code takes `into_candle_with_step`, which pre-computes the
217    /// step once per stream instead of per bar.
218    #[cfg(test)]
219    fn into_candle(
220        self,
221        multiplier: u32,
222        timespan: &str,
223        provenance: AggregateProvenance,
224    ) -> Result<Candle, MassiveError> {
225        self.into_candle_with_step(timespan_to_step(multiplier, timespan), provenance)
226    }
227
228    /// Convert to rustrade [`Candle`] with a pre-computed [`IntervalStep`].
229    ///
230    /// Use this variant when processing multiple bars with the same timespan to
231    /// avoid recomputing the step for each bar. The step is still applied
232    /// **per-bar** (`close_time_from_open(open, step)`) because calendar months
233    /// are variable-length — a single pre-computed `Duration` for the whole
234    /// stream would be wrong for `month`/`quarter`/`year`.
235    ///
236    /// `provenance` says what this market's `v` and `n` mean — see [`AggregateProvenance`].
237    ///
238    /// # Errors
239    ///
240    /// Returns [`MassiveError::InvalidInput`] if the computed `close_time`
241    /// overflows the representable [`DateTime<Utc>`] range. An un-parseable
242    /// *input* `timestamp` is, by contrast, tolerated with a warning and
243    /// `UNIX_EPOCH` (preserving prior behaviour) — only a computed *boundary*
244    /// overflow is a hard error.
245    pub fn into_candle_with_step(
246        self,
247        step: IntervalStep,
248        provenance: AggregateProvenance,
249    ) -> Result<Candle, MassiveError> {
250        let start_time = Utc
251            .timestamp_millis_opt(self.timestamp)
252            .single()
253            .unwrap_or_else(|| {
254                tracing::warn!(
255                    timestamp_ms = self.timestamp,
256                    "AggregateBar has out-of-range timestamp; using UNIX_EPOCH"
257                );
258                DateTime::<Utc>::UNIX_EPOCH
259            });
260        let close_time =
261            close_time_from_open(start_time, step).ok_or_else(|| MassiveError::InvalidInput {
262                message: format!("candle close_time overflow: open={start_time}, step={step:?}"),
263            })?;
264
265        Ok(Candle {
266            close_time,
267            open: self.open,
268            high: self.high,
269            low: self.low,
270            close: self.close,
271            // Neither field is unconditionally `Some`: a quote-derived bar contains no transactions,
272            // so `v` counts quote updates and `n` counts the same events in different units.
273            // Reporting either as trading activity would be indistinguishable downstream from the
274            // real thing. See `AggregateProvenance`.
275            //
276            // `n` is also absent on some trade-tape bars, and that `None` is carried through as
277            // "unknown" rather than the old lossy `unwrap_or(0)`. (The WebSocket aggregate has no
278            // count at all: its `z` is an average trade *size* — see `message::MassiveAggregate`.)
279            volume: provenance.volume(self.volume),
280            trade_count: provenance.trade_count(self.trade_count),
281        })
282    }
283}
284
285/// Parse aggregates JSON response.
286pub fn parse_aggregates_response(body: &str) -> Result<AggregatesResponse, MassiveError> {
287    serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
288        message: e.to_string(),
289        payload: body[..body.floor_char_boundary(512)].to_owned(),
290    })
291}
292
293/// Map a Massive `(multiplier, timespan)` to the shared [`IntervalStep`].
294///
295/// Fixed-length units (`second`…`week`) become [`IntervalStep::Fixed`], exact in
296/// UTC. Calendar units (`month`/`quarter`/`year`) become [`IntervalStep::Months`]
297/// (1 / 3 / 12 months per multiplier) so the boundary uses leap-year-correct
298/// calendar arithmetic instead of the previous approximate `+30/91/365 days`.
299pub fn timespan_to_step(multiplier: u32, timespan: &str) -> IntervalStep {
300    let mult = i64::from(multiplier);
301    match timespan {
302        "second" => IntervalStep::Fixed(Duration::seconds(mult)),
303        "minute" => IntervalStep::Fixed(Duration::minutes(mult)),
304        "hour" => IntervalStep::Fixed(Duration::hours(mult)),
305        "day" => IntervalStep::Fixed(Duration::days(mult)),
306        "week" => IntervalStep::Fixed(Duration::weeks(mult)),
307        "month" => IntervalStep::Months(multiplier),
308        "quarter" => IntervalStep::Months(multiplier.saturating_mul(3)),
309        "year" => IntervalStep::Months(multiplier.saturating_mul(12)),
310        _ => {
311            tracing::warn!(timespan = %timespan, "unknown timespan, defaulting to minutes");
312            IntervalStep::Fixed(Duration::minutes(mult))
313        }
314    }
315}
316
317// ============================================================================
318// Trades
319// ============================================================================
320
321/// Raw trades response from Massive REST API.
322#[derive(Debug, Deserialize)]
323pub struct TradesResponse {
324    /// Number of results in this response
325    #[serde(rename = "resultsCount", default)]
326    pub results_count: usize,
327
328    /// Status of the request
329    #[serde(default)]
330    #[allow(dead_code)] // HTTP status already checked in fetch_page_body
331    pub status: String,
332
333    /// URL for next page of results (pagination)
334    pub next_url: Option<String>,
335
336    /// Trade results
337    pub results: Option<Vec<TradeRecord>>,
338}
339
340/// Single trade from Massive trades endpoint.
341#[derive(Debug, Deserialize)]
342pub struct TradeRecord {
343    /// First trade condition code (crypto: 1=sell, 2=buy; equities: SIP codes differ).
344    ///
345    /// Only the first condition is extracted; remaining conditions are ignored.
346    #[serde(
347        rename = "conditions",
348        default,
349        deserialize_with = "deserialize_first_condition"
350    )]
351    pub first_condition: Option<i32>,
352
353    /// Exchange ID
354    #[serde(rename = "exchange")]
355    #[allow(dead_code)] // Retained for API schema completeness
356    pub exchange: Option<i32>,
357
358    /// Trade ID
359    #[serde(rename = "id", default)]
360    pub id: String,
361
362    /// Participant timestamp (nanoseconds)
363    #[serde(rename = "participant_timestamp", default)]
364    #[allow(dead_code)] // Accessed via timestamp() method
365    pub participant_timestamp: i64,
366
367    /// Trade price
368    #[serde(rename = "price", with = "rust_decimal::serde::float")]
369    pub price: Decimal,
370
371    /// Trade size
372    #[serde(rename = "size", with = "rust_decimal::serde::float")]
373    pub size: Decimal,
374
375    /// SIP timestamp (nanoseconds) - when SIP received the trade
376    #[serde(rename = "sip_timestamp", default)]
377    #[allow(dead_code)] // Retained for API schema completeness
378    pub sip_timestamp: i64,
379}
380
381impl TradeRecord {
382    /// Convert to rustrade PublicTrade type.
383    ///
384    /// # Side Detection (Crypto Only)
385    ///
386    /// The `side` field is only meaningful for **crypto tickers** (`X:` prefix).
387    /// Crypto condition codes: 1 = sell-initiated, 2 = buy-initiated.
388    ///
389    /// For equities and other asset classes, condition codes represent SIP/CTA
390    /// tape conditions (e.g., 1 = Regular Trade) which are unrelated to trade
391    /// direction. The `side` will be `None` or incorrect for non-crypto tickers.
392    ///
393    /// # Timestamps
394    ///
395    /// Trade timestamps are not preserved in [`PublicTrade`]. If timestamp
396    /// information is required, access the raw [`TradeRecord`] via
397    /// [`parse_trades_response`] directly.
398    pub fn into_public_trade(self) -> PublicTrade {
399        // Crypto condition codes: 1 = sell-initiated, 2 = buy-initiated
400        // Note: This mapping is ONLY valid for crypto (X: prefix) tickers
401        let side = self.first_condition.and_then(|c| match c {
402            1 => Some(Side::Sell),
403            2 => Some(Side::Buy),
404            _ => None,
405        });
406
407        PublicTrade {
408            id: SmolStr::from(self.id),
409            price: self.price,
410            amount: self.size,
411            side,
412        }
413    }
414
415    /// Get the exchange timestamp as `DateTime<Utc>`.
416    #[allow(dead_code)] // Public API for consumers accessing raw TradeRecord
417    pub fn timestamp(&self) -> DateTime<Utc> {
418        nanos_to_datetime(self.participant_timestamp)
419    }
420}
421
422/// Convert nanosecond timestamp to `DateTime<Utc>`.
423///
424/// Returns [`DateTime::<Utc>::UNIX_EPOCH`] for out-of-range timestamps.
425/// Negative values (pre-epoch) are handled correctly using Euclidean division.
426fn nanos_to_datetime(nanos: i64) -> DateTime<Utc> {
427    let secs = nanos.div_euclid(1_000_000_000);
428    // rem_euclid always returns non-negative value in [0, 999_999_999], fits u32
429    #[allow(clippy::cast_possible_truncation)]
430    let nsecs = nanos.rem_euclid(1_000_000_000) as u32;
431    Utc.timestamp_opt(secs, nsecs).single().unwrap_or_else(|| {
432        tracing::warn!(nanos, "out-of-range nanosecond timestamp; using UNIX_EPOCH");
433        DateTime::<Utc>::UNIX_EPOCH
434    })
435}
436
437/// Parse trades JSON response.
438pub fn parse_trades_response(body: &str) -> Result<TradesResponse, MassiveError> {
439    serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
440        message: e.to_string(),
441        payload: body[..body.floor_char_boundary(512)].to_owned(),
442    })
443}
444
445// ============================================================================
446// Quotes (BBO/NBBO)
447// ============================================================================
448
449/// Raw quotes response from Massive REST API.
450#[derive(Debug, Deserialize)]
451pub struct QuotesResponse {
452    /// Number of results in this response
453    #[serde(rename = "resultsCount", default)]
454    pub results_count: usize,
455
456    /// Status of the request
457    #[serde(default)]
458    #[allow(dead_code)] // HTTP status already checked in fetch_page_body
459    pub status: String,
460
461    /// URL for next page of results (pagination)
462    pub next_url: Option<String>,
463
464    /// Quote results
465    pub results: Option<Vec<QuoteRecord>>,
466}
467
468/// Single quote from Massive quotes endpoint.
469#[derive(Debug, Deserialize)]
470pub struct QuoteRecord {
471    /// Ask price
472    #[serde(rename = "ask_price", with = "rust_decimal::serde::float")]
473    pub ask_price: Decimal,
474
475    /// Ask size (optional - forex quotes don't include size)
476    #[serde(
477        rename = "ask_size",
478        default,
479        with = "rust_decimal::serde::float_option"
480    )]
481    pub ask_size: Option<Decimal>,
482
483    /// Bid price
484    #[serde(rename = "bid_price", with = "rust_decimal::serde::float")]
485    pub bid_price: Decimal,
486
487    /// Bid size (optional - forex quotes don't include size)
488    #[serde(
489        rename = "bid_size",
490        default,
491        with = "rust_decimal::serde::float_option"
492    )]
493    pub bid_size: Option<Decimal>,
494
495    /// Participant timestamp (nanoseconds)
496    #[serde(rename = "participant_timestamp", default)]
497    pub participant_timestamp: i64,
498
499    /// SIP timestamp (nanoseconds)
500    #[serde(rename = "sip_timestamp", default)]
501    #[allow(dead_code)] // Retained for API schema completeness
502    pub sip_timestamp: i64,
503}
504
505impl QuoteRecord {
506    /// Convert to rustrade OrderBookL1 type.
507    ///
508    /// Forex quotes from Massive omit `bid_size`/`ask_size`; absent sizes are
509    /// represented as `Decimal::ZERO` to satisfy the shared `Level` type.
510    /// Callers handling venues that may report zero-size quotes should
511    /// disambiguate via the source feed if required.
512    pub fn into_order_book_l1(self) -> OrderBookL1 {
513        let timestamp = self.timestamp();
514
515        OrderBookL1 {
516            last_update_time: timestamp,
517            best_bid: Some(Level {
518                price: self.bid_price,
519                amount: self.bid_size.unwrap_or(Decimal::ZERO),
520            }),
521            best_ask: Some(Level {
522                price: self.ask_price,
523                amount: self.ask_size.unwrap_or(Decimal::ZERO),
524            }),
525        }
526    }
527
528    /// Get the exchange timestamp as `DateTime<Utc>`.
529    pub fn timestamp(&self) -> DateTime<Utc> {
530        nanos_to_datetime(self.participant_timestamp)
531    }
532}
533
534/// Parse quotes JSON response.
535pub fn parse_quotes_response(body: &str) -> Result<QuotesResponse, MassiveError> {
536    serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
537        message: e.to_string(),
538        payload: body[..body.floor_char_boundary(512)].to_owned(),
539    })
540}
541
542// ============================================================================
543// Fair Market Value
544// ============================================================================
545
546/// Fair Market Value - a calculated mid-price from Massive.
547///
548/// This is a dedicated type (not mapped to PublicTrade) because FMV represents
549/// a calculated value, not an actual trade execution.
550#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
551pub struct FairMarketValue {
552    /// Timestamp of the FMV calculation
553    pub time: DateTime<Utc>,
554    /// Calculated fair market value price
555    pub price: Decimal,
556}
557
558impl FairMarketValue {
559    /// Create a new FairMarketValue.
560    pub fn new(time: DateTime<Utc>, price: Decimal) -> Self {
561        Self { time, price }
562    }
563}
564
565#[cfg(test)]
566#[allow(clippy::unwrap_used)] // Tests should panic on unexpected values
567mod tests {
568    use super::*;
569    use rust_decimal_macros::dec;
570
571    const SAMPLE_AGGREGATES: &str = r#"{
572        "ticker": "X:BTCUSD",
573        "queryCount": 2,
574        "resultsCount": 2,
575        "adjusted": true,
576        "results": [
577            {
578                "v": 234.5678,
579                "vw": 65432.1,
580                "o": 65000.0,
581                "c": 65100.0,
582                "h": 65200.0,
583                "l": 64900.0,
584                "t": 1704067200000,
585                "n": 150
586            },
587            {
588                "v": 345.6789,
589                "vw": 65150.0,
590                "o": 65100.0,
591                "c": 65250.0,
592                "h": 65300.0,
593                "l": 65050.0,
594                "t": 1704067260000,
595                "n": 175
596            }
597        ],
598        "status": "OK",
599        "request_id": "abc123"
600    }"#;
601
602    #[test]
603    fn test_parse_aggregates() {
604        let response = parse_aggregates_response(SAMPLE_AGGREGATES).unwrap();
605        assert_eq!(response.ticker, Some("X:BTCUSD".to_string()));
606        assert_eq!(response.results_count, 2);
607        assert_eq!(response.status, "OK");
608
609        let results = response.results.unwrap();
610        assert_eq!(results.len(), 2);
611
612        let bar = &results[0];
613        assert_eq!(bar.open, dec!(65000.0));
614        assert_eq!(bar.close, dec!(65100.0));
615        assert_eq!(bar.high, dec!(65200.0));
616        assert_eq!(bar.low, dec!(64900.0));
617        assert_eq!(bar.trade_count, Some(150));
618    }
619
620    #[test]
621    fn test_aggregate_bar_to_candle() {
622        let bar = AggregateBar {
623            open: dec!(65000.0),
624            high: dec!(65200.0),
625            low: dec!(64900.0),
626            close: dec!(65100.0),
627            volume: dec!(234.5678),
628            vwap: Some(dec!(65050.0)),
629            timestamp: 1704067200000,
630            trade_count: Some(150),
631        };
632
633        let candle = bar
634            .into_candle(1, "minute", AggregateProvenance::TradeTape)
635            .unwrap();
636
637        assert_eq!(candle.open, dec!(65000.0));
638        assert_eq!(candle.close, dec!(65100.0));
639        assert_eq!(candle.trade_count, Some(150));
640
641        // close_time should be 1 minute after start
642        let expected_close =
643            Utc.timestamp_millis_opt(1704067200000).single().unwrap() + Duration::minutes(1);
644        assert_eq!(candle.close_time, expected_close);
645    }
646
647    #[test]
648    fn test_aggregate_bar_monthly_calendar_boundary() {
649        // A January monthly bar must close at Feb 1 00:00 UTC via calendar
650        // arithmetic, NOT open + 30 days (= Jan 31, the previous approximate bug).
651        // `AggregateBar` is not `Clone`, so build a fresh bar per assertion.
652        let bar_at = || AggregateBar {
653            open: dec!(65000.0),
654            high: dec!(65200.0),
655            low: dec!(64900.0),
656            close: dec!(65100.0),
657            volume: dec!(234.5678),
658            vwap: None,
659            timestamp: 1_704_067_200_000, // 2024-01-01 00:00:00 UTC
660            trade_count: Some(150),
661        };
662
663        // Month: Jan 1 -> Feb 1 (not 1704067200000 + 30d = 2024-01-31).
664        assert_eq!(
665            bar_at()
666                .into_candle(1, "month", AggregateProvenance::TradeTape)
667                .unwrap()
668                .close_time
669                .timestamp_millis(),
670            1_706_745_600_000 // 2024-02-01 00:00:00 UTC
671        );
672        // Quarter: Jan 1 -> Apr 1.
673        assert_eq!(
674            bar_at()
675                .into_candle(1, "quarter", AggregateProvenance::TradeTape)
676                .unwrap()
677                .close_time
678                .timestamp_millis(),
679            1_711_929_600_000 // 2024-04-01 00:00:00 UTC
680        );
681        // Year: Jan 1 -> next Jan 1.
682        assert_eq!(
683            bar_at()
684                .into_candle(1, "year", AggregateProvenance::TradeTape)
685                .unwrap()
686                .close_time
687                .timestamp_millis(),
688            1_735_689_600_000 // 2025-01-01 00:00:00 UTC
689        );
690    }
691
692    #[test]
693    fn test_parse_with_next_url() {
694        let json = r#"{
695            "ticker": "X:BTCUSD",
696            "resultsCount": 50000,
697            "status": "OK",
698            "next_url": "https://api.massive.com/v2/aggs/ticker/X:BTCUSD/range/1/minute/1704067200000/1704153600000?cursor=abc123",
699            "results": []
700        }"#;
701
702        let response = parse_aggregates_response(json).unwrap();
703        assert!(response.next_url.is_some());
704        assert!(response.next_url.unwrap().contains("cursor="));
705    }
706
707    #[test]
708    fn test_parse_empty_results() {
709        let json = r#"{
710            "ticker": "X:BTCUSD",
711            "resultsCount": 0,
712            "status": "OK",
713            "results": []
714        }"#;
715
716        let response = parse_aggregates_response(json).unwrap();
717        assert_eq!(response.results_count, 0);
718        assert!(response.results.unwrap().is_empty());
719    }
720
721    #[test]
722    fn test_timespan_to_step() {
723        assert_eq!(
724            timespan_to_step(1, "second"),
725            IntervalStep::Fixed(Duration::seconds(1))
726        );
727        assert_eq!(
728            timespan_to_step(5, "minute"),
729            IntervalStep::Fixed(Duration::minutes(5))
730        );
731        assert_eq!(
732            timespan_to_step(1, "hour"),
733            IntervalStep::Fixed(Duration::hours(1))
734        );
735        assert_eq!(
736            timespan_to_step(1, "day"),
737            IntervalStep::Fixed(Duration::days(1))
738        );
739        assert_eq!(
740            timespan_to_step(1, "week"),
741            IntervalStep::Fixed(Duration::weeks(1))
742        );
743        // Calendar units map to month counts (multiplier-scaled), not Durations.
744        assert_eq!(timespan_to_step(1, "month"), IntervalStep::Months(1));
745        assert_eq!(timespan_to_step(2, "quarter"), IntervalStep::Months(6));
746        assert_eq!(timespan_to_step(1, "year"), IntervalStep::Months(12));
747    }
748
749    #[test]
750    fn test_parse_trades() {
751        let json = r#"{
752            "results": [
753                {
754                    "conditions": [2],
755                    "exchange": 1,
756                    "id": "12345",
757                    "participant_timestamp": 1704067200000000000,
758                    "price": 65100.50,
759                    "size": 0.5,
760                    "sip_timestamp": 1704067200001000000
761                }
762            ],
763            "status": "OK",
764            "resultsCount": 1
765        }"#;
766
767        let response = parse_trades_response(json).unwrap();
768        assert_eq!(response.results_count, 1);
769
770        let results = response.results.unwrap();
771        let trade = &results[0];
772        assert_eq!(trade.price, dec!(65100.50));
773        assert_eq!(trade.size, dec!(0.5));
774        assert_eq!(trade.first_condition, Some(2)); // buy-initiated (crypto)
775
776        let public_trade = results.into_iter().next().unwrap().into_public_trade();
777        assert_eq!(public_trade.side, Some(Side::Buy));
778    }
779
780    #[test]
781    fn test_parse_quotes() {
782        let json = r#"{
783            "results": [
784                {
785                    "ask_price": 65200.0,
786                    "ask_size": 1.5,
787                    "bid_price": 65100.0,
788                    "bid_size": 2.0,
789                    "participant_timestamp": 1704067200000000000,
790                    "sip_timestamp": 1704067200001000000
791                }
792            ],
793            "status": "OK",
794            "resultsCount": 1
795        }"#;
796
797        let response = parse_quotes_response(json).unwrap();
798        assert_eq!(response.results_count, 1);
799
800        let results = response.results.unwrap();
801        let quote = results.into_iter().next().unwrap();
802        let l1 = quote.into_order_book_l1();
803
804        assert_eq!(l1.best_bid.unwrap().price, dec!(65100.0));
805        assert_eq!(l1.best_ask.unwrap().price, dec!(65200.0));
806    }
807
808    #[test]
809    fn test_fair_market_value() {
810        let fmv = FairMarketValue::new(
811            Utc.timestamp_millis_opt(1704067200000).single().unwrap(),
812            dec!(65150.0),
813        );
814        assert_eq!(fmv.price, dec!(65150.0));
815    }
816
817    /// The REST path derives the same distinction from the ticker's asset-class prefix, and applies
818    /// it to **both** activity counts.
819    ///
820    /// `n` is documented as "the number of transactions in the aggregate window" — exactly as
821    /// generically as `v` is documented as trading volume, and overridden by exactly the same fact:
822    /// a bar Massive generated from quoted bid/ask updates contains no transactions to count. A
823    /// quote-tick count arriving in `candle.trade_count` reads as liquidity that is not there, so a
824    /// `trade_count >= 50` filter would pass on every forex bar and look like it was working.
825    #[test]
826    fn a_forex_rest_bar_reports_neither_volume_nor_trade_count() {
827        let bar = || AggregateBar {
828            open: dec!(1.1),
829            high: dec!(1.2),
830            low: dec!(1.0),
831            close: dec!(1.15),
832            volume: dec!(4321),
833            vwap: None,
834            timestamp: 1_704_067_200_000,
835            trade_count: Some(150),
836        };
837
838        let forex = bar()
839            .into_candle(1, "minute", AggregateProvenance::for_ticker("C:EURUSD"))
840            .unwrap();
841        assert_eq!(forex.volume, None);
842        assert_eq!(forex.trade_count, None);
843
844        // The identical wire shape on a market that does have a tape keeps both, so this is a
845        // per-market decision rather than a blanket drop.
846        let crypto = bar()
847            .into_candle(1, "minute", AggregateProvenance::for_ticker("X:BTCUSD"))
848            .unwrap();
849        assert_eq!(crypto.volume, Some(dec!(4321)));
850        assert_eq!(crypto.trade_count, Some(150));
851    }
852
853    #[test]
854    fn aggregate_provenance_classifies_only_the_forex_prefix_as_a_quote_tape() {
855        assert_eq!(
856            AggregateProvenance::for_ticker("C:EURUSD"),
857            AggregateProvenance::QuoteTape
858        );
859        for traded in ["X:BTCUSD", "AAPL", "O:SPY251219C00650000", "I:SPX"] {
860            assert_eq!(
861                AggregateProvenance::for_ticker(traded),
862                AggregateProvenance::TradeTape,
863                "{traded}"
864            );
865        }
866    }
867
868    /// Nothing upstream normalises a caller-supplied ticker, so a case-sensitive prefix match
869    /// would report a forex quote-update count as real traded volume.
870    #[test]
871    fn aggregate_provenance_classifies_the_forex_prefix_regardless_of_case() {
872        for forex in ["c:eurusd", "c:EURUSD", "C:eurusd"] {
873            assert_eq!(
874                AggregateProvenance::for_ticker(forex),
875                AggregateProvenance::QuoteTape,
876                "{forex}"
877            );
878        }
879    }
880
881    /// `..2` is a byte range, and `€:EURUSD` is the case that matters: a 3-byte leading character
882    /// puts byte 2 mid-character, which slicing would panic on. The rest do not straddle a
883    /// boundary — `é` is exactly 2 bytes, and the short strings stop before index 2 — and are here
884    /// to pin that every non-`C:` shape reaches `TradeTape`, not only the ones `get` rejects.
885    #[test]
886    fn aggregate_provenance_does_not_panic_on_a_short_or_non_ascii_ticker() {
887        for ticker in ["", "C", "€:EURUSD", "é"] {
888            assert_eq!(
889                AggregateProvenance::for_ticker(ticker),
890                AggregateProvenance::TradeTape,
891                "{ticker}"
892            );
893        }
894    }
895}