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}