Skip to main content

nautilus_data/
aggregation.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bar aggregation machinery.
17//!
18//! Defines the `BarAggregator` trait and core aggregation types (tick, volume, value, time),
19//! along with the `BarBuilder` and `BarAggregatorCore` components for constructing bars.
20
21use std::{
22    any::Any,
23    cell::RefCell,
24    fmt::Debug,
25    ops::Add,
26    rc::{Rc, Weak},
27};
28
29use ahash::AHashMap;
30use jiff::SignedDuration;
31use nautilus_common::{
32    clock::{Clock, TestClock},
33    timer::{TimeEvent, TimeEventCallback},
34};
35use nautilus_core::{
36    DurationNanos, UnixNanos,
37    correctness::{self, FAILED},
38    datetime::{
39        add_n_months, add_n_months_nanos, add_n_years, add_n_years_nanos, subtract_n_months_nanos,
40        subtract_n_years_nanos,
41    },
42};
43use nautilus_model::{
44    data::{
45        QuoteTick, TradeTick,
46        bar::{Bar, BarType, get_bar_interval_ns, get_time_bar_start},
47    },
48    enums::{
49        AggregationSource, AggressorSide, BarAggregation, BarIntervalType,
50        ContinuousFutureAdjustmentType,
51    },
52    identifiers::InstrumentId,
53    instruments::{FixedTickScheme, TickSchemeRule},
54    types::{
55        Price, Quantity,
56        fixed::{FIXED_PRECISION, FIXED_SCALAR, mantissa_exponent_to_fixed_i128},
57        price::PriceRaw,
58        quantity::QuantityRaw,
59    },
60};
61use rust_decimal::{Decimal, prelude::ToPrimitive};
62
63/// Type alias for bar handler to reduce type complexity.
64type BarHandler = Box<dyn FnMut(Bar)>;
65
66/// Trait for aggregating incoming price and trade events into time-, tick-, volume-, or value-based bars.
67///
68/// Implementors receive updates and produce completed bars via handlers.
69pub trait BarAggregator: Any + Debug {
70    /// The [`BarType`] to be aggregated.
71    fn bar_type(&self) -> BarType;
72    /// If the aggregator is running and will receive data from the message bus.
73    fn is_running(&self) -> bool;
74    /// Sets the running state of the aggregator (receiving updates when `true`).
75    fn set_is_running(&mut self, value: bool);
76    /// Updates the aggregator  with the given price and size.
77    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos);
78    /// Updates the aggregator with the given quote.
79    fn handle_quote(&mut self, quote: QuoteTick) {
80        let spec = self.bar_type().spec();
81        // Quote-fed aggregators use Bid/Ask/Mid (Last uses trades), so this cannot fail; guard
82        // rather than unwrap to stay panic-free
83        let (Ok(price), Ok(size)) = (
84            quote.extract_price(spec.price_type),
85            quote.extract_size(spec.price_type),
86        ) else {
87            log::error!(
88                "Cannot aggregate quote for {}: price type {} unsupported for quotes",
89                self.bar_type(),
90                spec.price_type,
91            );
92            return;
93        };
94
95        self.update(price, size, quote.ts_init);
96    }
97    /// Updates the aggregator with the given trade.
98    fn handle_trade(&mut self, trade: TradeTick) {
99        self.update(trade.price, trade.size, trade.ts_init);
100    }
101    /// Updates the aggregator with the given bar.
102    fn handle_bar(&mut self, bar: Bar) {
103        self.update_bar(bar, bar.volume, bar.ts_init);
104    }
105    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos);
106    /// Stop the aggregator, e.g., cancel timers. Default is no-op.
107    fn stop(&mut self) {}
108    /// Sets historical mode and the handler used for completed bars.
109    fn set_historical_mode(&mut self, _historical_mode: bool, _handler: Box<dyn FnMut(Bar)>) {}
110    /// Sets historical events (default implementation does nothing, `TimeBarAggregator` overrides)
111    fn set_historical_events(&mut self, _events: Vec<TimeEvent>) {}
112    /// Sets clock for time bar aggregators (default implementation does nothing, `TimeBarAggregator` overrides)
113    fn set_clock(&mut self, _clock: Rc<RefCell<dyn Clock>>) {}
114    /// Builds a bar from a time event (default implementation does nothing, `TimeBarAggregator` overrides)
115    fn build_bar(&mut self, _event: &TimeEvent) {}
116    /// Starts the timer for time bar aggregators.
117    /// Default implementation does nothing, `TimeBarAggregator` overrides.
118    /// Takes an optional Rc to create weak reference internally.
119    fn start_timer(&mut self, _aggregator_rc: Option<Rc<RefCell<Box<dyn BarAggregator>>>>) {}
120    /// Sets the weak reference to the aggregator wrapper (for historical mode).
121    /// Default implementation does nothing, `TimeBarAggregator` overrides.
122    fn set_aggregator_weak(&mut self, _weak: Weak<RefCell<Box<dyn BarAggregator>>>) {}
123    /// Configures the continuous-future price adjustment for the underlying builder.
124    fn set_adjustment(&mut self, _adjustment: Decimal, _mode: ContinuousFutureAdjustmentType) {}
125    /// Sets whether empty intervals emit bars at the last close.
126    /// Default implementation does nothing, `TimeBarAggregator` overrides.
127    fn set_build_with_no_updates(&mut self, _value: bool) {}
128    /// If the aggregator is processing historical data on a private clock.
129    /// Default implementation returns `false`, `TimeBarAggregator` overrides.
130    fn is_historical(&self) -> bool {
131        false
132    }
133}
134
135impl dyn BarAggregator {
136    /// Returns a reference to this aggregator as `Any` for downcasting.
137    pub fn as_any(&self) -> &dyn Any {
138        self
139    }
140    /// Returns a mutable reference to this aggregator as `Any` for downcasting.
141    pub fn as_any_mut(&mut self) -> &mut dyn Any {
142        self
143    }
144}
145
146/// Provides a generic bar builder for aggregation.
147#[derive(Debug)]
148pub struct BarBuilder {
149    bar_type: BarType,
150    price_precision: u8,
151    size_precision: u8,
152    initialized: bool,
153    ts_last: UnixNanos,
154    count: usize,
155    last_close: Option<Price>,
156    open: Option<Price>,
157    high: Option<Price>,
158    low: Option<Price>,
159    close: Option<Price>,
160    volume: Quantity,
161    adjustment_spread: Price,
162    adjustment_ratio: f64,
163    adjustment_active: bool,
164    adjustment_is_ratio: bool,
165}
166
167impl BarBuilder {
168    /// Creates a new [`BarBuilder`] instance.
169    ///
170    /// # Panics
171    ///
172    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
173    #[must_use]
174    pub fn new(bar_type: BarType, price_precision: u8, size_precision: u8) -> Self {
175        correctness::check_equal(
176            &bar_type.aggregation_source(),
177            &AggregationSource::Internal,
178            "bar_type.aggregation_source",
179            "AggregationSource::Internal",
180        )
181        .expect(FAILED);
182
183        Self {
184            bar_type,
185            price_precision,
186            size_precision,
187            initialized: false,
188            ts_last: UnixNanos::default(),
189            count: 0,
190            last_close: None,
191            open: None,
192            high: None,
193            low: None,
194            close: None,
195            volume: Quantity::zero(size_precision),
196            adjustment_spread: Price::zero(0),
197            adjustment_ratio: 1.0,
198            adjustment_active: false,
199            adjustment_is_ratio: false,
200        }
201    }
202
203    /// Configures the per-tick continuous-future price adjustment.
204    ///
205    /// Adjustment applies on ingress in [`Self::update`] and [`Self::update_bar`], so the running
206    /// OHLC state is always in the adjusted (common) frame. The adjustment configuration is
207    /// retained across [`Self::reset`] so it spans subsequent bars within the same continuous-
208    /// future segment.
209    ///
210    /// # Panics
211    ///
212    /// Panics if scaling the spread `adjustment` to the fixed-point representation overflows.
213    pub fn set_adjustment(&mut self, adjustment: Decimal, mode: ContinuousFutureAdjustmentType) {
214        if mode.is_ratio() {
215            self.adjustment_is_ratio = true;
216            self.adjustment_ratio = adjustment.to_f64().unwrap_or(1.0);
217            self.adjustment_active = adjustment != Decimal::ONE;
218            return;
219        }
220
221        // Retain sub-display-precision offsets when adding the spread to each price.
222        self.adjustment_is_ratio = false;
223        let exponent = -(adjustment.scale() as i8);
224        let raw_i128 =
225            mantissa_exponent_to_fixed_i128(adjustment.mantissa(), exponent, FIXED_PRECISION)
226                .expect("Failed to scale continuous-future adjustment to fixed precision");
227
228        #[allow(
229            clippy::useless_conversion,
230            reason = "i128 to PriceRaw is real when not high-precision"
231        )]
232        let raw: PriceRaw = raw_i128
233            .try_into()
234            .expect("Continuous-future adjustment exceeds PriceRaw range");
235
236        self.adjustment_spread = Price::from_raw(raw, FIXED_PRECISION);
237        self.adjustment_active = !self.adjustment_spread.is_zero();
238    }
239
240    fn apply_adjustment_to_price(&self, price: Price) -> Price {
241        if !self.adjustment_active {
242            return price;
243        }
244
245        if self.adjustment_is_ratio {
246            // Multiply in double; `Price::new` rounds to the target precision.
247            // Float can shift 1 ULP for high-precision raws (spread mode is exact).
248            return Price::new(price.as_f64() * self.adjustment_ratio, price.precision);
249        }
250
251        let mut spread = self.adjustment_spread;
252        spread.precision = price.precision;
253        let mut adjusted = price
254            .checked_add(spread)
255            .expect("Continuous-future adjustment exceeds Price bounds");
256        adjusted.precision = price.precision;
257        adjusted
258    }
259
260    /// Updates the builder state with the given price, size, and init timestamp.
261    ///
262    /// # Panics
263    ///
264    /// Panics if `high` or `low` values are unexpectedly `None` when updating.
265    pub fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
266        if ts_init < self.ts_last {
267            return; // Not applicable
268        }
269
270        let price = self.apply_adjustment_to_price(price);
271
272        if self.open.is_none() {
273            self.open = Some(price);
274            self.high = Some(price);
275            self.low = Some(price);
276            self.initialized = true;
277        } else {
278            if price > self.high.unwrap() {
279                self.high = Some(price);
280            }
281
282            if price < self.low.unwrap() {
283                self.low = Some(price);
284            }
285        }
286
287        self.close = Some(price);
288        self.volume = self.volume.add(size);
289        self.count += 1;
290        self.ts_last = ts_init;
291
292        debug_assert!(self.high >= self.low, "OHLC invariant violated: high < low");
293    }
294
295    /// Updates the builder state with a completed bar, its volume, and the bar init timestamp.
296    ///
297    /// # Panics
298    ///
299    /// Panics if `high` or `low` values are unexpectedly `None` when updating.
300    pub fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
301        if ts_init < self.ts_last {
302            return; // Not applicable
303        }
304
305        let bar_open = self.apply_adjustment_to_price(bar.open);
306        let bar_high = self.apply_adjustment_to_price(bar.high);
307        let bar_low = self.apply_adjustment_to_price(bar.low);
308        let bar_close = self.apply_adjustment_to_price(bar.close);
309
310        if self.open.is_none() {
311            self.open = Some(bar_open);
312            self.high = Some(bar_high);
313            self.low = Some(bar_low);
314            self.initialized = true;
315        } else {
316            if bar_high > self.high.unwrap() {
317                self.high = Some(bar_high);
318            }
319
320            if bar_low < self.low.unwrap() {
321                self.low = Some(bar_low);
322            }
323        }
324
325        self.close = Some(bar_close);
326        self.volume = self.volume.add(volume);
327        self.count += 1;
328        self.ts_last = ts_init;
329
330        debug_assert!(self.high >= self.low, "OHLC invariant violated: high < low");
331    }
332
333    /// Resets per-bar OHLCV state.
334    ///
335    /// Adjustment configuration set via [`Self::set_adjustment`] is retained across resets so it
336    /// spans subsequent bars within the same continuous-future segment.
337    pub fn reset(&mut self) {
338        self.open = None;
339        self.high = None;
340        self.low = None;
341        self.close = None;
342        self.volume = Quantity::zero(self.size_precision);
343        self.count = 0;
344    }
345
346    /// Return the aggregated bar and reset.
347    pub fn build_now(&mut self) -> Bar {
348        self.build(self.ts_last, self.ts_last)
349    }
350
351    /// Returns the aggregated bar for the given timestamps, then resets the builder.
352    ///
353    /// # Panics
354    ///
355    /// Panics if `open`, `high`, `low`, or `close` values are `None` when building the bar.
356    pub fn build(&mut self, ts_event: UnixNanos, ts_init: UnixNanos) -> Bar {
357        if self.open.is_none() {
358            self.open = self.last_close;
359            self.high = self.last_close;
360            self.low = self.last_close;
361            self.close = self.last_close;
362        }
363
364        if let (Some(close), Some(low)) = (self.close, self.low)
365            && close < low
366        {
367            self.low = Some(close);
368        }
369
370        if let (Some(close), Some(high)) = (self.close, self.high)
371            && close > high
372        {
373            self.high = Some(close);
374        }
375
376        // The open was checked, so we can assume all prices are Some
377        let bar = Bar::new(
378            self.bar_type,
379            self.open.unwrap(),
380            self.high.unwrap(),
381            self.low.unwrap(),
382            self.close.unwrap(),
383            self.volume,
384            ts_event,
385            ts_init,
386        );
387
388        self.last_close = self.close;
389        self.reset();
390        bar
391    }
392}
393
394/// Provides a means of aggregating specified bar types and sending to a registered handler.
395pub struct BarAggregatorCore {
396    builder: BarBuilder,
397    handler: BarHandler,
398    is_running: bool,
399}
400
401impl Debug for BarAggregatorCore {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        f.debug_struct(stringify!(BarAggregatorCore))
404            .field("bar_type", &self.builder.bar_type)
405            .field("builder", &self.builder)
406            .field("is_running", &self.is_running)
407            .finish()
408    }
409}
410
411impl BarAggregatorCore {
412    /// Creates a new [`BarAggregatorCore`] instance.
413    ///
414    /// The `bar_type` is standardized so aggregators always emit bars carrying the
415    /// standard form: the composite suffix is a local aggregation detail and must not
416    /// leak into emitted bars, publish topics, or cache keys.
417    ///
418    /// # Panics
419    ///
420    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
421    pub fn new<H: FnMut(Bar) + 'static>(
422        bar_type: BarType,
423        price_precision: u8,
424        size_precision: u8,
425        handler: H,
426    ) -> Self {
427        let bar_type = bar_type.standard();
428        Self {
429            builder: BarBuilder::new(bar_type, price_precision, size_precision),
430            handler: Box::new(handler),
431            is_running: false,
432        }
433    }
434
435    /// Sets the running state of the aggregator (receives updates when `true`).
436    pub const fn set_is_running(&mut self, value: bool) {
437        self.is_running = value;
438    }
439
440    fn set_handler(&mut self, handler: BarHandler) {
441        self.handler = handler;
442    }
443
444    fn is_stale(&self, ts_init: UnixNanos) -> bool {
445        ts_init < self.builder.ts_last
446    }
447
448    fn build_now_and_send(&mut self) {
449        let bar = self.builder.build_now();
450        (self.handler)(bar);
451    }
452
453    fn build_and_send(&mut self, ts_event: UnixNanos, ts_init: UnixNanos) {
454        let bar = self.builder.build(ts_event, ts_init);
455        (self.handler)(bar);
456    }
457
458    fn set_adjustment(&mut self, adjustment: Decimal, mode: ContinuousFutureAdjustmentType) {
459        self.builder.set_adjustment(adjustment, mode);
460    }
461}
462
463macro_rules! impl_core_bar_aggregator {
464    () => {
465        fn bar_type(&self) -> BarType {
466            self.core.builder.bar_type
467        }
468
469        fn is_running(&self) -> bool {
470            self.core.is_running
471        }
472
473        fn set_is_running(&mut self, value: bool) {
474            self.core.set_is_running(value);
475        }
476
477        fn set_historical_mode(&mut self, _historical_mode: bool, handler: Box<dyn FnMut(Bar)>) {
478            self.core.set_handler(handler);
479        }
480
481        fn set_adjustment(&mut self, adjustment: Decimal, mode: ContinuousFutureAdjustmentType) {
482            self.core.set_adjustment(adjustment, mode);
483        }
484    };
485}
486
487/// Provides a means of building tick bars aggregated from quote and trades.
488///
489/// When received tick count reaches the step threshold of the bar
490/// specification, then a bar is created and sent to the handler.
491#[derive(Debug)]
492pub struct TickBarAggregator {
493    core: BarAggregatorCore,
494}
495
496impl TickBarAggregator {
497    /// Creates a new [`TickBarAggregator`] instance.
498    ///
499    /// # Panics
500    ///
501    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
502    pub fn new<H: FnMut(Bar) + 'static>(
503        bar_type: BarType,
504        price_precision: u8,
505        size_precision: u8,
506        handler: H,
507    ) -> Self {
508        Self {
509            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
510        }
511    }
512}
513
514impl BarAggregator for TickBarAggregator {
515    impl_core_bar_aggregator!();
516
517    /// Apply the given update to the aggregator.
518    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
519        self.core.builder.update(price, size, ts_init);
520        let spec = self.core.builder.bar_type.spec();
521
522        if self.core.builder.count >= spec.step.get() {
523            self.core.build_now_and_send();
524        }
525    }
526
527    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
528        self.core.builder.update_bar(bar, volume, ts_init);
529        let spec = self.core.builder.bar_type.spec();
530
531        if self.core.builder.count >= spec.step.get() {
532            self.core.build_now_and_send();
533        }
534    }
535}
536
537/// Aggregates bars based on tick buy/sell imbalance.
538///
539/// Increments imbalance by +1 for buyer-aggressed trades and -1 for seller-aggressed trades.
540/// Emits a bar when the absolute imbalance reaches the step threshold.
541#[derive(Debug)]
542pub struct TickImbalanceBarAggregator {
543    core: BarAggregatorCore,
544    imbalance: isize,
545}
546
547impl TickImbalanceBarAggregator {
548    /// Creates a new [`TickImbalanceBarAggregator`] instance.
549    ///
550    /// # Panics
551    ///
552    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
553    pub fn new<H: FnMut(Bar) + 'static>(
554        bar_type: BarType,
555        price_precision: u8,
556        size_precision: u8,
557        handler: H,
558    ) -> Self {
559        Self {
560            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
561            imbalance: 0,
562        }
563    }
564}
565
566impl BarAggregator for TickImbalanceBarAggregator {
567    impl_core_bar_aggregator!();
568
569    /// Apply the given update to the aggregator.
570    ///
571    /// Note: side-aware logic lives in `handle_trade`. This method is used for
572    /// quote/bar updates where no aggressor side is available.
573    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
574        self.core.builder.update(price, size, ts_init);
575    }
576
577    fn handle_trade(&mut self, trade: TradeTick) {
578        if self.core.is_stale(trade.ts_init) {
579            return;
580        }
581
582        self.core
583            .builder
584            .update(trade.price, trade.size, trade.ts_init);
585
586        let delta = match trade.aggressor_side {
587            AggressorSide::Buy => 1,
588            AggressorSide::Sell => -1,
589            AggressorSide::NoAggressor => return,
590        };
591
592        self.imbalance += delta;
593        let threshold = self.core.builder.bar_type.spec().step.get();
594        if self.imbalance.unsigned_abs() >= threshold {
595            self.core.build_now_and_send();
596            self.imbalance = 0;
597        }
598    }
599
600    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
601        self.core.builder.update_bar(bar, volume, ts_init);
602    }
603}
604
605/// Aggregates bars based on consecutive buy/sell tick runs.
606#[derive(Debug)]
607pub struct TickRunsBarAggregator {
608    core: BarAggregatorCore,
609    current_run_side: Option<AggressorSide>,
610    run_count: usize,
611}
612
613impl TickRunsBarAggregator {
614    /// Creates a new [`TickRunsBarAggregator`] instance.
615    ///
616    /// # Panics
617    ///
618    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
619    pub fn new<H: FnMut(Bar) + 'static>(
620        bar_type: BarType,
621        price_precision: u8,
622        size_precision: u8,
623        handler: H,
624    ) -> Self {
625        Self {
626            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
627            current_run_side: None,
628            run_count: 0,
629        }
630    }
631}
632
633impl BarAggregator for TickRunsBarAggregator {
634    impl_core_bar_aggregator!();
635
636    /// Apply the given update to the aggregator.
637    ///
638    /// Note: side-aware logic lives in `handle_trade`. This method is used for
639    /// quote/bar updates where no aggressor side is available.
640    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
641        self.core.builder.update(price, size, ts_init);
642    }
643
644    fn handle_trade(&mut self, trade: TradeTick) {
645        if self.core.is_stale(trade.ts_init) {
646            return;
647        }
648
649        let side = match trade.aggressor_side {
650            AggressorSide::Buy => AggressorSide::Buy,
651            AggressorSide::Sell => AggressorSide::Sell,
652            AggressorSide::NoAggressor => {
653                self.core
654                    .builder
655                    .update(trade.price, trade.size, trade.ts_init);
656                return;
657            }
658        };
659
660        if self.current_run_side != Some(side) {
661            self.current_run_side = Some(side);
662            self.run_count = 0;
663            self.core.builder.reset();
664        }
665
666        self.core
667            .builder
668            .update(trade.price, trade.size, trade.ts_init);
669        self.run_count += 1;
670
671        let threshold = self.core.builder.bar_type.spec().step.get();
672        if self.run_count >= threshold {
673            self.core.build_now_and_send();
674            self.run_count = 0;
675            self.current_run_side = None;
676        }
677    }
678
679    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
680        self.core.builder.update_bar(bar, volume, ts_init);
681    }
682}
683
684/// Provides a means of building volume bars aggregated from quote and trades.
685#[derive(Debug)]
686pub struct VolumeBarAggregator {
687    core: BarAggregatorCore,
688    step: Quantity,
689}
690
691impl VolumeBarAggregator {
692    /// Creates a new [`VolumeBarAggregator`] instance.
693    ///
694    /// # Panics
695    ///
696    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
697    pub fn new<H: FnMut(Bar) + 'static>(
698        bar_type: BarType,
699        price_precision: u8,
700        size_precision: u8,
701        handler: H,
702    ) -> Self {
703        Self {
704            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
705            step: step_as_quantity(bar_type.spec().step.get(), size_precision),
706        }
707    }
708}
709
710impl BarAggregator for VolumeBarAggregator {
711    impl_core_bar_aggregator!();
712
713    /// Apply the given update to the aggregator.
714    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
715        if self.core.is_stale(ts_init) {
716            return;
717        }
718
719        let mut size_update = size;
720        let step = self.step;
721
722        while size_update.non_zero() {
723            debug_assert!(
724                self.core.builder.volume < step,
725                "builder volume must stay below the step threshold between emissions"
726            );
727
728            let mut size_diff = step - self.core.builder.volume;
729            size_diff.precision = size.precision;
730
731            if size_update < size_diff {
732                self.core.builder.update(price, size_update, ts_init);
733                break;
734            }
735
736            self.core.builder.update(price, size_diff, ts_init);
737
738            self.core.build_now_and_send();
739            size_update = size_update - size_diff;
740        }
741    }
742
743    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
744        if self.core.is_stale(ts_init) {
745            return;
746        }
747
748        let mut volume_update = volume;
749        let step = self.step;
750
751        while volume_update.non_zero() {
752            debug_assert!(
753                self.core.builder.volume < step,
754                "builder volume must stay below the step threshold between emissions"
755            );
756
757            let mut volume_diff = step - self.core.builder.volume;
758            volume_diff.precision = volume.precision;
759
760            if volume_update < volume_diff {
761                self.core.builder.update_bar(bar, volume_update, ts_init);
762                break;
763            }
764
765            self.core.builder.update_bar(bar, volume_diff, ts_init);
766
767            self.core.build_now_and_send();
768            volume_update = volume_update - volume_diff;
769        }
770    }
771}
772
773/// Aggregates bars based on buy/sell volume imbalance.
774#[derive(Debug)]
775pub struct VolumeImbalanceBarAggregator {
776    core: BarAggregatorCore,
777    imbalance: Quantity,
778    imbalance_side: AggressorSide,
779    step: Quantity,
780}
781
782impl VolumeImbalanceBarAggregator {
783    /// Creates a new [`VolumeImbalanceBarAggregator`] instance.
784    ///
785    /// # Panics
786    ///
787    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
788    pub fn new<H: FnMut(Bar) + 'static>(
789        bar_type: BarType,
790        price_precision: u8,
791        size_precision: u8,
792        handler: H,
793    ) -> Self {
794        let step = step_as_quantity(bar_type.spec().step.get(), size_precision);
795
796        Self {
797            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
798            imbalance: Quantity::zero(size_precision),
799            imbalance_side: AggressorSide::NoAggressor,
800            step,
801        }
802    }
803}
804
805impl BarAggregator for VolumeImbalanceBarAggregator {
806    impl_core_bar_aggregator!();
807
808    /// Apply the given update to the aggregator.
809    ///
810    /// Note: side-aware logic lives in `handle_trade`. This method is used for
811    /// quote/bar updates where no aggressor side is available.
812    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
813        self.core.builder.update(price, size, ts_init);
814    }
815
816    fn handle_trade(&mut self, trade: TradeTick) {
817        if self.core.is_stale(trade.ts_init) {
818            return;
819        }
820
821        let side = match trade.aggressor_side {
822            AggressorSide::Buy => AggressorSide::Buy,
823            AggressorSide::Sell => AggressorSide::Sell,
824            AggressorSide::NoAggressor => {
825                self.core
826                    .builder
827                    .update(trade.price, trade.size, trade.ts_init);
828                return;
829            }
830        };
831
832        let mut remaining = trade.size;
833        while remaining.non_zero() {
834            let mut needed = self.step - self.imbalance;
835            needed.precision = trade.size.precision;
836            let qty_chunk = remaining.min(needed);
837
838            self.core
839                .builder
840                .update(trade.price, qty_chunk, trade.ts_init);
841
842            if self.imbalance_side == side {
843                self.imbalance = self.imbalance + qty_chunk;
844            } else if qty_chunk >= self.imbalance {
845                self.imbalance = qty_chunk - self.imbalance;
846                self.imbalance_side = side;
847            } else {
848                self.imbalance = self.imbalance - qty_chunk;
849            }
850
851            remaining = remaining - qty_chunk;
852
853            if self.imbalance >= self.step {
854                self.core.build_now_and_send();
855                self.imbalance = Quantity::zero(trade.size.precision);
856                self.imbalance_side = AggressorSide::NoAggressor;
857            }
858        }
859    }
860
861    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
862        self.core.builder.update_bar(bar, volume, ts_init);
863    }
864}
865
866/// Aggregates bars based on consecutive buy/sell volume runs.
867#[derive(Debug)]
868pub struct VolumeRunsBarAggregator {
869    core: BarAggregatorCore,
870    current_run_side: Option<AggressorSide>,
871    run_volume: Quantity,
872    step: Quantity,
873}
874
875impl VolumeRunsBarAggregator {
876    /// Creates a new [`VolumeRunsBarAggregator`] instance.
877    ///
878    /// # Panics
879    ///
880    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
881    pub fn new<H: FnMut(Bar) + 'static>(
882        bar_type: BarType,
883        price_precision: u8,
884        size_precision: u8,
885        handler: H,
886    ) -> Self {
887        let step = step_as_quantity(bar_type.spec().step.get(), size_precision);
888
889        Self {
890            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
891            current_run_side: None,
892            run_volume: Quantity::zero(size_precision),
893            step,
894        }
895    }
896}
897
898impl BarAggregator for VolumeRunsBarAggregator {
899    impl_core_bar_aggregator!();
900
901    /// Apply the given update to the aggregator.
902    ///
903    /// Note: side-aware logic lives in `handle_trade`. This method is used for
904    /// quote/bar updates where no aggressor side is available.
905    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
906        self.core.builder.update(price, size, ts_init);
907    }
908
909    fn handle_trade(&mut self, trade: TradeTick) {
910        if self.core.is_stale(trade.ts_init) {
911            return;
912        }
913
914        let side = match trade.aggressor_side {
915            AggressorSide::Buy => AggressorSide::Buy,
916            AggressorSide::Sell => AggressorSide::Sell,
917            AggressorSide::NoAggressor => {
918                self.core
919                    .builder
920                    .update(trade.price, trade.size, trade.ts_init);
921                return;
922            }
923        };
924
925        if self.current_run_side != Some(side) {
926            self.current_run_side = Some(side);
927            self.run_volume = Quantity::zero(trade.size.precision);
928            self.core.builder.reset();
929        }
930
931        let mut remaining = trade.size;
932        while remaining.non_zero() {
933            let mut needed = self.step - self.run_volume;
934            needed.precision = trade.size.precision;
935            let chunk = remaining.min(needed);
936
937            self.core.builder.update(trade.price, chunk, trade.ts_init);
938
939            self.run_volume = self.run_volume + chunk;
940            remaining = remaining - chunk;
941
942            if self.run_volume >= self.step {
943                self.core.build_now_and_send();
944                self.run_volume = Quantity::zero(trade.size.precision);
945                self.current_run_side = None;
946            }
947        }
948
949        // Leftover volume past the last emitted bar starts a new run on the same
950        // side; without this the next same-side trade reads as a side change and
951        // resets the builder, silently dropping the pending volume.
952        if self.run_volume.non_zero() {
953            self.current_run_side = Some(side);
954        }
955    }
956
957    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
958        self.core.builder.update_bar(bar, volume, ts_init);
959    }
960}
961
962/// Provides a means of building value bars aggregated from quote and trades.
963///
964/// When received value reaches the step threshold of the bar
965/// specification, then a bar is created and sent to the handler.
966#[derive(Debug)]
967pub struct ValueBarAggregator {
968    core: BarAggregatorCore,
969    cum_value: Decimal,
970}
971
972impl ValueBarAggregator {
973    /// Creates a new [`ValueBarAggregator`] instance.
974    ///
975    /// # Panics
976    ///
977    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
978    pub fn new<H: FnMut(Bar) + 'static>(
979        bar_type: BarType,
980        price_precision: u8,
981        size_precision: u8,
982        handler: H,
983    ) -> Self {
984        Self {
985            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
986            cum_value: Decimal::ZERO,
987        }
988    }
989
990    #[must_use]
991    /// Returns the cumulative value for the aggregator.
992    pub const fn get_cumulative_value(&self) -> Decimal {
993        self.cum_value
994    }
995}
996
997impl BarAggregator for ValueBarAggregator {
998    impl_core_bar_aggregator!();
999
1000    /// Apply the given update to the aggregator.
1001    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
1002        if self.core.is_stale(ts_init) {
1003            return;
1004        }
1005
1006        let step_value = Decimal::from(self.core.builder.bar_type.spec().step.get());
1007        let price_value = price.as_decimal();
1008        let mut size_update = size.as_decimal();
1009
1010        while size_update > Decimal::ZERO {
1011            // cum_value < step_value holds between emissions, so a zero value_update
1012            // (zero price) always falls into the accumulate branch below and the
1013            // division cannot see a zero divisor.
1014            debug_assert!(self.cum_value < step_value);
1015            let value_update = price_value * size_update;
1016
1017            if self.cum_value + value_update < step_value {
1018                self.cum_value += value_update;
1019                self.core.builder.update(
1020                    price,
1021                    quantity_from_decimal(size_update, size.precision),
1022                    ts_init,
1023                );
1024                break;
1025            }
1026
1027            let value_diff = step_value - self.cum_value;
1028            let mut size_diff = size_update * (value_diff / value_update);
1029
1030            // Clamp to minimum representable size to avoid zero-volume bars
1031            if is_below_min_size_decimal(size_diff, size.precision) {
1032                if is_below_min_size_decimal(size_update, size.precision) {
1033                    break;
1034                }
1035                size_diff = min_size_decimal(size.precision);
1036            }
1037
1038            // Subtract the representable quantity actually applied, not the ideal
1039            // fraction, so rounding does not leak volume from the accounting
1040            let applied = quantity_from_decimal(size_diff, size.precision);
1041            self.core.builder.update(price, applied, ts_init);
1042
1043            self.core.build_now_and_send();
1044            self.cum_value = Decimal::ZERO;
1045            size_update -= applied.as_decimal();
1046        }
1047    }
1048
1049    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
1050        if self.core.is_stale(ts_init) {
1051            return;
1052        }
1053
1054        let step_value = Decimal::from(self.core.builder.bar_type.spec().step.get());
1055        let average_price =
1056            ((bar.high.as_decimal() + bar.low.as_decimal() + bar.close.as_decimal())
1057                / Decimal::from(3))
1058            .round_dp(u32::from(self.core.builder.price_precision));
1059        let mut volume_update = volume.as_decimal();
1060
1061        while volume_update > Decimal::ZERO {
1062            // See `update` for why a zero divisor cannot occur here.
1063            debug_assert!(self.cum_value < step_value);
1064            let value_update = average_price * volume_update;
1065
1066            if self.cum_value + value_update < step_value {
1067                self.cum_value += value_update;
1068                self.core.builder.update_bar(
1069                    bar,
1070                    quantity_from_decimal(volume_update, volume.precision),
1071                    ts_init,
1072                );
1073                break;
1074            }
1075
1076            let value_diff = step_value - self.cum_value;
1077            let mut volume_diff = volume_update * (value_diff / value_update);
1078
1079            // Clamp to minimum representable size to avoid zero-volume bars
1080            if is_below_min_size_decimal(volume_diff, volume.precision) {
1081                if is_below_min_size_decimal(volume_update, volume.precision) {
1082                    break;
1083                }
1084                volume_diff = min_size_decimal(volume.precision);
1085            }
1086
1087            // Subtract the representable quantity actually applied, not the ideal
1088            // fraction, so rounding does not leak volume from the accounting
1089            let applied = quantity_from_decimal(volume_diff, volume.precision);
1090            self.core.builder.update_bar(bar, applied, ts_init);
1091
1092            self.core.build_now_and_send();
1093            self.cum_value = Decimal::ZERO;
1094            volume_update -= applied.as_decimal();
1095        }
1096    }
1097}
1098
1099/// Aggregates bars based on buy/sell notional imbalance.
1100#[derive(Debug)]
1101pub struct ValueImbalanceBarAggregator {
1102    core: BarAggregatorCore,
1103    imbalance_value: Decimal,
1104    step_value: Decimal,
1105}
1106
1107impl ValueImbalanceBarAggregator {
1108    /// Creates a new [`ValueImbalanceBarAggregator`] instance.
1109    ///
1110    /// # Panics
1111    ///
1112    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
1113    pub fn new<H: FnMut(Bar) + 'static>(
1114        bar_type: BarType,
1115        price_precision: u8,
1116        size_precision: u8,
1117        handler: H,
1118    ) -> Self {
1119        Self {
1120            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
1121            imbalance_value: Decimal::ZERO,
1122            step_value: Decimal::from(bar_type.spec().step.get()),
1123        }
1124    }
1125}
1126
1127impl BarAggregator for ValueImbalanceBarAggregator {
1128    impl_core_bar_aggregator!();
1129
1130    /// Apply the given update to the aggregator.
1131    ///
1132    /// Note: side-aware logic lives in `handle_trade`. This method is used for
1133    /// quote/bar updates where no aggressor side is available.
1134    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
1135        self.core.builder.update(price, size, ts_init);
1136    }
1137
1138    fn handle_trade(&mut self, trade: TradeTick) {
1139        if self.core.is_stale(trade.ts_init) {
1140            return;
1141        }
1142
1143        let price_value = trade.price.as_decimal();
1144        if price_value.is_zero() {
1145            self.core
1146                .builder
1147                .update(trade.price, trade.size, trade.ts_init);
1148            return;
1149        }
1150
1151        let (side_sign, side_is_buy) = match trade.aggressor_side {
1152            AggressorSide::Buy => (Decimal::ONE, true),
1153            AggressorSide::Sell => (Decimal::NEGATIVE_ONE, false),
1154            AggressorSide::NoAggressor => {
1155                self.core
1156                    .builder
1157                    .update(trade.price, trade.size, trade.ts_init);
1158                return;
1159            }
1160        };
1161
1162        let precision = trade.size.precision;
1163        let mut size_remaining = trade.size.as_decimal();
1164        while size_remaining > Decimal::ZERO {
1165            let value_remaining = price_value * size_remaining;
1166
1167            if self.imbalance_value.is_zero()
1168                || self.imbalance_value.is_sign_positive() == side_is_buy
1169            {
1170                let needed = self.step_value - self.imbalance_value.abs();
1171                if value_remaining <= needed {
1172                    self.imbalance_value += side_sign * value_remaining;
1173                    self.core.builder.update(
1174                        trade.price,
1175                        quantity_from_decimal(size_remaining, precision),
1176                        trade.ts_init,
1177                    );
1178
1179                    if self.imbalance_value.abs() >= self.step_value {
1180                        self.core.build_now_and_send();
1181                        self.imbalance_value = Decimal::ZERO;
1182                    }
1183                    break;
1184                }
1185
1186                let mut value_chunk = needed;
1187                let mut size_chunk = value_chunk / price_value;
1188
1189                // Clamp to minimum representable size to avoid zero-volume bars
1190                if is_below_min_size_decimal(size_chunk, precision) {
1191                    if is_below_min_size_decimal(size_remaining, precision) {
1192                        break;
1193                    }
1194                    size_chunk = min_size_decimal(precision);
1195                    value_chunk = price_value * size_chunk;
1196                }
1197
1198                // Subtract the representable quantity actually applied, not the ideal
1199                // fraction, so rounding does not leak volume from the accounting
1200                let applied = quantity_from_decimal(size_chunk, precision);
1201                self.core
1202                    .builder
1203                    .update(trade.price, applied, trade.ts_init);
1204                self.imbalance_value += side_sign * value_chunk;
1205                size_remaining -= applied.as_decimal();
1206
1207                if self.imbalance_value.abs() >= self.step_value {
1208                    self.core.build_now_and_send();
1209                    self.imbalance_value = Decimal::ZERO;
1210                }
1211            } else {
1212                // Opposing side: first neutralize existing imbalance
1213                let mut value_to_flatten = self.imbalance_value.abs().min(value_remaining);
1214                let mut size_chunk = value_to_flatten / price_value;
1215
1216                // Clamp to minimum representable size to avoid zero-volume bars
1217                if is_below_min_size_decimal(size_chunk, precision) {
1218                    if is_below_min_size_decimal(size_remaining, precision) {
1219                        break;
1220                    }
1221                    size_chunk = min_size_decimal(precision);
1222                    value_to_flatten = price_value * size_chunk;
1223                }
1224
1225                // Subtract the representable quantity actually applied, not the ideal
1226                // fraction, so rounding does not leak volume from the accounting
1227                let applied = quantity_from_decimal(size_chunk, precision);
1228                self.core
1229                    .builder
1230                    .update(trade.price, applied, trade.ts_init);
1231                self.imbalance_value += side_sign * value_to_flatten;
1232
1233                // Min-size clamp can overshoot past threshold
1234                if self.imbalance_value.abs() >= self.step_value {
1235                    self.core.build_now_and_send();
1236                    self.imbalance_value = Decimal::ZERO;
1237                }
1238                size_remaining -= applied.as_decimal();
1239            }
1240        }
1241    }
1242
1243    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
1244        self.core.builder.update_bar(bar, volume, ts_init);
1245    }
1246}
1247
1248/// Aggregates bars based on consecutive buy/sell notional runs.
1249#[derive(Debug)]
1250pub struct ValueRunsBarAggregator {
1251    core: BarAggregatorCore,
1252    current_run_side: Option<AggressorSide>,
1253    run_value: Decimal,
1254    step_value: Decimal,
1255}
1256
1257impl ValueRunsBarAggregator {
1258    /// Creates a new [`ValueRunsBarAggregator`] instance.
1259    ///
1260    /// # Panics
1261    ///
1262    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
1263    pub fn new<H: FnMut(Bar) + 'static>(
1264        bar_type: BarType,
1265        price_precision: u8,
1266        size_precision: u8,
1267        handler: H,
1268    ) -> Self {
1269        Self {
1270            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
1271            current_run_side: None,
1272            run_value: Decimal::ZERO,
1273            step_value: Decimal::from(bar_type.spec().step.get()),
1274        }
1275    }
1276}
1277
1278impl BarAggregator for ValueRunsBarAggregator {
1279    impl_core_bar_aggregator!();
1280
1281    /// Apply the given update to the aggregator.
1282    ///
1283    /// Note: side-aware logic lives in `handle_trade`. This method is used for
1284    /// quote/bar updates where no aggressor side is available.
1285    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
1286        self.core.builder.update(price, size, ts_init);
1287    }
1288
1289    fn handle_trade(&mut self, trade: TradeTick) {
1290        if self.core.is_stale(trade.ts_init) {
1291            return;
1292        }
1293
1294        let price_value = trade.price.as_decimal();
1295        if price_value.is_zero() {
1296            self.core
1297                .builder
1298                .update(trade.price, trade.size, trade.ts_init);
1299            return;
1300        }
1301
1302        let side = match trade.aggressor_side {
1303            AggressorSide::Buy => AggressorSide::Buy,
1304            AggressorSide::Sell => AggressorSide::Sell,
1305            AggressorSide::NoAggressor => {
1306                self.core
1307                    .builder
1308                    .update(trade.price, trade.size, trade.ts_init);
1309                return;
1310            }
1311        };
1312
1313        if self.current_run_side != Some(side) {
1314            self.current_run_side = Some(side);
1315            self.run_value = Decimal::ZERO;
1316            self.core.builder.reset();
1317        }
1318
1319        let precision = trade.size.precision;
1320        let mut size_remaining = trade.size.as_decimal();
1321        while size_remaining > Decimal::ZERO {
1322            let value_update = price_value * size_remaining;
1323            if self.run_value + value_update < self.step_value {
1324                self.run_value += value_update;
1325                self.core.builder.update(
1326                    trade.price,
1327                    quantity_from_decimal(size_remaining, precision),
1328                    trade.ts_init,
1329                );
1330                break;
1331            }
1332
1333            let value_needed = self.step_value - self.run_value;
1334            let mut size_chunk = value_needed / price_value;
1335
1336            // Clamp to minimum representable size to avoid zero-volume bars
1337            if is_below_min_size_decimal(size_chunk, precision) {
1338                if is_below_min_size_decimal(size_remaining, precision) {
1339                    break;
1340                }
1341                size_chunk = min_size_decimal(precision);
1342            }
1343
1344            // Subtract the representable quantity actually applied, not the ideal
1345            // fraction, so rounding does not leak volume from the accounting
1346            let applied = quantity_from_decimal(size_chunk, precision);
1347            self.core
1348                .builder
1349                .update(trade.price, applied, trade.ts_init);
1350
1351            self.core.build_now_and_send();
1352            self.run_value = Decimal::ZERO;
1353            self.current_run_side = None;
1354            size_remaining -= applied.as_decimal();
1355        }
1356
1357        // Leftover value past the last emitted bar starts a new run on the same
1358        // side; without this the next same-side trade reads as a side change and
1359        // resets the builder, silently dropping the pending volume.
1360        if self.run_value > Decimal::ZERO {
1361            self.current_run_side = Some(side);
1362        }
1363    }
1364
1365    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
1366        self.core.builder.update_bar(bar, volume, ts_init);
1367    }
1368}
1369
1370/// Provides a means of building Renko bars aggregated from quote and trades.
1371///
1372/// Renko bars are created when the price moves by a fixed amount (brick size)
1373/// regardless of time or volume. Each bar represents a price movement equal
1374/// to the step size in the bar specification.
1375#[derive(Debug)]
1376pub struct RenkoBarAggregator {
1377    core: BarAggregatorCore,
1378    pub brick_size: Price,
1379    last_close: Option<Price>,
1380}
1381
1382impl RenkoBarAggregator {
1383    /// Creates a new [`RenkoBarAggregator`] instance.
1384    ///
1385    /// # Panics
1386    ///
1387    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
1388    pub fn new<H: FnMut(Bar) + 'static>(
1389        bar_type: BarType,
1390        price_precision: u8,
1391        size_precision: u8,
1392        price_increment: Price,
1393        handler: H,
1394    ) -> Self {
1395        let brick_size = Price::from_raw(
1396            price_increment.raw() * bar_type.spec().step.get() as PriceRaw,
1397            price_increment.precision,
1398        );
1399
1400        Self {
1401            core: BarAggregatorCore::new(bar_type, price_precision, size_precision, handler),
1402            brick_size,
1403            last_close: None,
1404        }
1405    }
1406}
1407
1408impl BarAggregator for RenkoBarAggregator {
1409    impl_core_bar_aggregator!();
1410
1411    /// Apply the given update to the aggregator.
1412    ///
1413    /// For Renko bars, we check if the price movement from the last close
1414    /// is greater than or equal to the brick size. If so, we create new bars.
1415    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
1416        if self.core.is_stale(ts_init) {
1417            return;
1418        }
1419
1420        // Always update the builder with the current tick
1421        self.core.builder.update(price, size, ts_init);
1422        self.build_bricks(price, ts_init);
1423    }
1424
1425    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
1426        if self.core.is_stale(ts_init) {
1427            return;
1428        }
1429
1430        // Always update the builder with the current bar
1431        self.core.builder.update_bar(bar, volume, ts_init);
1432        self.build_bricks(bar.close, ts_init);
1433    }
1434}
1435
1436impl RenkoBarAggregator {
1437    fn build_bricks(&mut self, price: Price, ts_init: UnixNanos) {
1438        let Some(last_close) = self.last_close else {
1439            self.last_close = Some(price);
1440            return;
1441        };
1442
1443        let rising = price > last_close;
1444
1445        let mut remaining_move = if rising {
1446            price - last_close
1447        } else {
1448            last_close - price
1449        };
1450
1451        if remaining_move < self.brick_size {
1452            return;
1453        }
1454
1455        assert!(
1456            self.brick_size.is_positive(),
1457            "Renko brick size must be positive"
1458        );
1459        let mut current_close = last_close;
1460        let total_volume = self.core.builder.volume;
1461
1462        while remaining_move >= self.brick_size {
1463            let mut brick_close = if rising {
1464                current_close + self.brick_size
1465            } else {
1466                current_close - self.brick_size
1467            };
1468
1469            brick_close.precision = price.precision;
1470
1471            let (brick_high, brick_low) = if rising {
1472                (brick_close, current_close)
1473            } else {
1474                (current_close, brick_close)
1475            };
1476
1477            self.core.builder.reset();
1478            self.core.builder.open = Some(current_close);
1479            self.core.builder.high = Some(brick_high);
1480            self.core.builder.low = Some(brick_low);
1481            self.core.builder.close = Some(brick_close);
1482            self.core.builder.volume = total_volume;
1483            self.core.builder.count = 1;
1484            self.core.builder.ts_last = ts_init;
1485            self.core.builder.initialized = true;
1486            self.core.build_and_send(ts_init, ts_init);
1487
1488            current_close = brick_close;
1489            self.last_close = Some(brick_close);
1490            remaining_move = remaining_move - self.brick_size;
1491        }
1492    }
1493}
1494
1495/// Provides a means of building time bars aggregated from quote and trades.
1496///
1497/// At each aggregation time interval, a bar is created and sent to the handler.
1498pub struct TimeBarAggregator {
1499    core: BarAggregatorCore,
1500    clock: Rc<RefCell<dyn Clock>>,
1501    build_with_no_updates: bool,
1502    timestamp_on_close: bool,
1503    is_left_open: bool,
1504    stored_open_ns: UnixNanos,
1505    timer_name: String,
1506    interval_ns: DurationNanos,
1507    next_close_ns: UnixNanos,
1508    first_close_ns: UnixNanos,
1509    bar_build_delay: u64,
1510    time_bars_origin_offset: Option<SignedDuration>,
1511    skip_first_non_full_bar: bool,
1512    pub historical_mode: bool,
1513    historical_events: Vec<TimeEvent>,
1514    historical_event_at_ts_init: Option<TimeEvent>,
1515    aggregator_weak: Option<Weak<RefCell<Box<dyn BarAggregator>>>>,
1516}
1517
1518impl Debug for TimeBarAggregator {
1519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1520        f.debug_struct(stringify!(TimeBarAggregator))
1521            .field("core", &self.core)
1522            .field("build_with_no_updates", &self.build_with_no_updates)
1523            .field("timestamp_on_close", &self.timestamp_on_close)
1524            .field("is_left_open", &self.is_left_open)
1525            .field("timer_name", &self.timer_name)
1526            .field("interval_ns", &self.interval_ns)
1527            .field("bar_build_delay", &self.bar_build_delay)
1528            .field("skip_first_non_full_bar", &self.skip_first_non_full_bar)
1529            .finish()
1530    }
1531}
1532
1533impl TimeBarAggregator {
1534    /// Creates a new [`TimeBarAggregator`] instance.
1535    ///
1536    /// # Panics
1537    ///
1538    /// Panics if `bar_type.aggregation_source` is not `AggregationSource::Internal`.
1539    #[expect(clippy::too_many_arguments)]
1540    pub fn new<H: FnMut(Bar) + 'static>(
1541        bar_type: BarType,
1542        price_precision: u8,
1543        size_precision: u8,
1544        clock: Rc<RefCell<dyn Clock>>,
1545        handler: H,
1546        build_with_no_updates: bool,
1547        timestamp_on_close: bool,
1548        interval_type: BarIntervalType,
1549        time_bars_origin_offset: Option<SignedDuration>,
1550        bar_build_delay: u64,
1551        skip_first_non_full_bar: bool,
1552    ) -> Self {
1553        let is_left_open = match interval_type {
1554            BarIntervalType::LeftOpen => true,
1555            BarIntervalType::RightOpen => false,
1556        };
1557
1558        let core = BarAggregatorCore::new(bar_type, price_precision, size_precision, handler);
1559
1560        Self {
1561            clock,
1562            build_with_no_updates,
1563            timestamp_on_close,
1564            is_left_open,
1565            stored_open_ns: UnixNanos::default(),
1566            timer_name: format!("TIME_BAR_{}", core.builder.bar_type),
1567            interval_ns: get_bar_interval_ns(&bar_type),
1568            core,
1569            next_close_ns: UnixNanos::default(),
1570            first_close_ns: UnixNanos::default(),
1571            bar_build_delay,
1572            time_bars_origin_offset,
1573            skip_first_non_full_bar,
1574            historical_mode: false,
1575            historical_events: Vec::new(),
1576            historical_event_at_ts_init: None,
1577            aggregator_weak: None,
1578        }
1579    }
1580
1581    /// Sets the clock for the aggregator (internal method).
1582    pub fn set_clock_internal(&mut self, clock: Rc<RefCell<dyn Clock>>) {
1583        self.clock = clock;
1584    }
1585
1586    /// Starts the time bar aggregator, scheduling periodic bar builds on the clock.
1587    ///
1588    /// Creates a callback to `build_bar` using a weak reference to the aggregator.
1589    ///
1590    /// # Panics
1591    ///
1592    /// Panics if `aggregator_rc` is None and `aggregator_weak` hasn't been set, or if timer registration fails.
1593    pub fn start_timer_internal(
1594        &mut self,
1595        aggregator_rc: Option<Rc<RefCell<Box<dyn BarAggregator>>>>,
1596    ) {
1597        // Create callback that calls build_bar through the weak reference
1598        let aggregator_weak = if let Some(rc) = aggregator_rc {
1599            // Store weak reference for future use (e.g., in build_bar for month/year)
1600            let weak = Rc::downgrade(&rc);
1601            self.aggregator_weak = Some(weak.clone());
1602            weak
1603        } else {
1604            // Use existing weak reference (for historical mode where it was set earlier)
1605            self.aggregator_weak
1606                .as_ref()
1607                .expect("Aggregator weak reference must be set before calling start_timer()")
1608                .clone()
1609        };
1610
1611        let callback = TimeEventCallback::RustLocal(Rc::new(move |event: TimeEvent| {
1612            if let Some(agg) = aggregator_weak.upgrade() {
1613                agg.borrow_mut().build_bar(&event);
1614            }
1615        }));
1616
1617        // Computing start_time
1618        let now = self.clock.borrow().utc_now();
1619        let mut start_time =
1620            get_time_bar_start(now, &self.bar_type(), self.time_bars_origin_offset);
1621        start_time += SignedDuration::from_micros(self.bar_build_delay as i64);
1622
1623        // Closing a partial bar at the transition from historical to backtest data
1624        let fire_immediately = start_time == now;
1625
1626        let spec = &self.bar_type().spec();
1627        let start_time_ns = UnixNanos::from(start_time);
1628        let step = spec.step.get() as u32;
1629
1630        if spec.aggregation != BarAggregation::Month && spec.aggregation != BarAggregation::Year {
1631            self.clock
1632                .borrow_mut()
1633                .set_timer_ns(
1634                    &self.timer_name,
1635                    self.interval_ns,
1636                    Some(start_time_ns),
1637                    None,
1638                    Some(callback),
1639                    Some(true), // allow_past
1640                    Some(fire_immediately),
1641                )
1642                .expect(FAILED);
1643
1644            if fire_immediately {
1645                self.next_close_ns = start_time_ns;
1646            } else {
1647                let interval_duration = SignedDuration::from(self.interval_ns);
1648                self.next_close_ns = UnixNanos::from(start_time + interval_duration);
1649            }
1650
1651            self.stored_open_ns = self.next_close_ns.saturating_sub(self.interval_ns);
1652        } else {
1653            // The monthly/yearly alert time is defined iteratively at each alert time as there is no regular interval
1654            let alert_time = if fire_immediately {
1655                start_time
1656            } else if spec.aggregation == BarAggregation::Month {
1657                add_n_months(start_time, step).expect(FAILED)
1658            } else {
1659                add_n_years(start_time, step).expect(FAILED)
1660            };
1661
1662            self.clock
1663                .borrow_mut()
1664                .set_time_alert_ns(
1665                    &self.timer_name,
1666                    UnixNanos::from(alert_time),
1667                    Some(callback),
1668                    Some(true), // allow_past
1669                )
1670                .expect(FAILED);
1671
1672            self.next_close_ns = UnixNanos::from(alert_time);
1673            // With fire_immediately the current (partial) bar started `step` periods before
1674            // start_time, so stored_open resolves to close_time - step.
1675            self.stored_open_ns = if fire_immediately {
1676                if spec.aggregation == BarAggregation::Month {
1677                    subtract_n_months_nanos(start_time_ns, step).expect(FAILED)
1678                } else {
1679                    subtract_n_years_nanos(start_time_ns, step).expect(FAILED)
1680                }
1681            } else {
1682                start_time_ns
1683            };
1684        }
1685
1686        if self.skip_first_non_full_bar {
1687            self.first_close_ns = self.next_close_ns;
1688        }
1689
1690        log::debug!(
1691            "Started timer {}, start_time={:?}, historical_mode={}, fire_immediately={}, now={:?}, bar_build_delay={}",
1692            self.timer_name,
1693            start_time,
1694            self.historical_mode,
1695            fire_immediately,
1696            now,
1697            self.bar_build_delay
1698        );
1699    }
1700
1701    /// Stops the time bar aggregator.
1702    pub fn stop(&mut self) {
1703        self.clock.borrow_mut().cancel_timer(&self.timer_name);
1704    }
1705
1706    fn build_and_send(&mut self, ts_event: UnixNanos, ts_init: UnixNanos) {
1707        if self.skip_first_non_full_bar && ts_init <= self.first_close_ns {
1708            self.core.builder.reset();
1709        } else {
1710            // Clear for the transition from historical to live data; subsequent
1711            // bars always emit regardless of timestamp.
1712            self.skip_first_non_full_bar = false;
1713            self.core.build_and_send(ts_event, ts_init);
1714        }
1715    }
1716
1717    fn build_bar(&mut self, event: &TimeEvent) {
1718        if !self.core.builder.initialized {
1719            return;
1720        }
1721
1722        if !self.build_with_no_updates && self.core.builder.count == 0 {
1723            return; // Do not build bar when no update
1724        }
1725
1726        let ts_init = event.ts_event;
1727        let ts_event = if self.is_left_open {
1728            if self.timestamp_on_close {
1729                event.ts_event
1730            } else {
1731                self.stored_open_ns
1732            }
1733        } else {
1734            self.stored_open_ns
1735        };
1736
1737        self.build_and_send(ts_event, ts_init);
1738
1739        // Close time becomes the next open time
1740        self.stored_open_ns = event.ts_event;
1741
1742        if self.bar_type().spec().aggregation == BarAggregation::Month {
1743            let step = self.bar_type().spec().step.get() as u32;
1744            let alert_time_ns = add_n_months_nanos(event.ts_event, step).expect(FAILED);
1745
1746            self.clock
1747                .borrow_mut()
1748                .set_time_alert_ns(&self.timer_name, alert_time_ns, None, None)
1749                .expect(FAILED);
1750
1751            self.next_close_ns = alert_time_ns;
1752        } else if self.bar_type().spec().aggregation == BarAggregation::Year {
1753            let step = self.bar_type().spec().step.get() as u32;
1754            let alert_time_ns = add_n_years_nanos(event.ts_event, step).expect(FAILED);
1755
1756            self.clock
1757                .borrow_mut()
1758                .set_time_alert_ns(&self.timer_name, alert_time_ns, None, None)
1759                .expect(FAILED);
1760
1761            self.next_close_ns = alert_time_ns;
1762        } else {
1763            // On receiving this event, timer should now have a new `next_time_ns`
1764            self.next_close_ns = self
1765                .clock
1766                .borrow()
1767                .next_time_ns(&self.timer_name)
1768                .unwrap_or_default();
1769        }
1770    }
1771
1772    fn preprocess_historical_events(&mut self, ts_init: UnixNanos) {
1773        if self.clock.borrow().timestamp_ns() == UnixNanos::default() {
1774            // In historical mode, clock is always a TestClock (set by data engine)
1775            {
1776                let mut clock_borrow = self.clock.borrow_mut();
1777                let test_clock = clock_borrow
1778                    .as_any_mut()
1779                    .downcast_mut::<TestClock>()
1780                    .expect("Expected TestClock in historical mode");
1781                test_clock.set_time(ts_init);
1782            }
1783            // In historical mode, weak reference should already be set
1784            self.start_timer_internal(None);
1785        }
1786
1787        // Advance this aggregator's independent clock and collect timer events.
1788        let events = {
1789            let mut clock_borrow = self.clock.borrow_mut();
1790            let test_clock = clock_borrow
1791                .as_any_mut()
1792                .downcast_mut::<TestClock>()
1793                .expect("Expected TestClock in historical mode");
1794            test_clock.advance_time(ts_init, true)
1795        };
1796
1797        for event in events {
1798            if event.ts_event == ts_init {
1799                self.historical_event_at_ts_init = Some(event);
1800            } else {
1801                self.build_bar(&event);
1802            }
1803        }
1804    }
1805
1806    fn postprocess_historical_events(&mut self, _ts_init: UnixNanos) {
1807        if let Some(ref event) = self.historical_event_at_ts_init.take() {
1808            self.build_bar(event);
1809        }
1810    }
1811
1812    /// Sets historical events (called by data engine after advancing clock)
1813    pub fn set_historical_events_internal(&mut self, events: Vec<TimeEvent>) {
1814        self.historical_events = events;
1815    }
1816}
1817
1818impl BarAggregator for TimeBarAggregator {
1819    fn bar_type(&self) -> BarType {
1820        self.core.builder.bar_type
1821    }
1822
1823    fn is_running(&self) -> bool {
1824        self.core.is_running
1825    }
1826
1827    fn set_is_running(&mut self, value: bool) {
1828        self.core.set_is_running(value);
1829    }
1830
1831    /// Stop time-based aggregator by canceling its timer.
1832    fn stop(&mut self) {
1833        Self::stop(self);
1834    }
1835
1836    fn update(&mut self, price: Price, size: Quantity, ts_init: UnixNanos) {
1837        if self.historical_mode {
1838            self.preprocess_historical_events(ts_init);
1839        }
1840
1841        self.core.builder.update(price, size, ts_init);
1842
1843        if self.historical_mode {
1844            self.postprocess_historical_events(ts_init);
1845        }
1846    }
1847
1848    fn update_bar(&mut self, bar: Bar, volume: Quantity, ts_init: UnixNanos) {
1849        if self.historical_mode {
1850            self.preprocess_historical_events(ts_init);
1851        }
1852
1853        self.core.builder.update_bar(bar, volume, ts_init);
1854
1855        if self.historical_mode {
1856            self.postprocess_historical_events(ts_init);
1857        }
1858    }
1859
1860    fn set_historical_mode(&mut self, historical_mode: bool, handler: Box<dyn FnMut(Bar)>) {
1861        self.historical_mode = historical_mode;
1862        self.core.handler = handler;
1863    }
1864
1865    fn set_historical_events(&mut self, events: Vec<TimeEvent>) {
1866        self.set_historical_events_internal(events);
1867    }
1868
1869    fn set_clock(&mut self, clock: Rc<RefCell<dyn Clock>>) {
1870        self.set_clock_internal(clock);
1871    }
1872
1873    fn build_bar(&mut self, event: &TimeEvent) {
1874        // Delegate to the implementation method
1875        // We use the struct name here to disambiguate from the trait method
1876        {
1877            #[expect(clippy::use_self)]
1878            TimeBarAggregator::build_bar(self, event);
1879        }
1880    }
1881
1882    fn set_aggregator_weak(&mut self, weak: Weak<RefCell<Box<dyn BarAggregator>>>) {
1883        self.aggregator_weak = Some(weak);
1884    }
1885
1886    fn start_timer(&mut self, aggregator_rc: Option<Rc<RefCell<Box<dyn BarAggregator>>>>) {
1887        self.start_timer_internal(aggregator_rc);
1888    }
1889
1890    fn set_adjustment(&mut self, adjustment: Decimal, mode: ContinuousFutureAdjustmentType) {
1891        self.core.set_adjustment(adjustment, mode);
1892    }
1893
1894    fn set_build_with_no_updates(&mut self, value: bool) {
1895        self.build_with_no_updates = value;
1896    }
1897
1898    fn is_historical(&self) -> bool {
1899        self.historical_mode
1900    }
1901}
1902
1903fn is_below_min_size_decimal(size: Decimal, precision: u8) -> bool {
1904    quantity_from_decimal(size, precision).is_zero()
1905}
1906
1907fn min_size_decimal(precision: u8) -> Decimal {
1908    Decimal::new(1, u32::from(precision))
1909}
1910
1911fn quantity_from_decimal(size: Decimal, precision: u8) -> Quantity {
1912    Quantity::from_decimal_dp(size, precision).expect(FAILED)
1913}
1914
1915fn step_as_quantity(step: usize, precision: u8) -> Quantity {
1916    let raw = (FIXED_SCALAR as QuantityRaw)
1917        .checked_mul(step as QuantityRaw)
1918        .expect("`step` overflows raw quantity units for volume aggregation");
1919    Quantity::from_raw(raw, precision)
1920}
1921
1922/// Provider for vega per leg (option spreads). Returns `None` when greeks are unavailable.
1923pub trait VegaProvider {
1924    /// Returns vega for the given leg instrument, or `None` if not available.
1925    fn vega_for_leg(&self, instrument_id: InstrumentId) -> Option<f64>;
1926}
1927
1928/// Rounder for spread bid/ask (e.g. tick scheme). When absent, raw prices are used with instrument precision.
1929pub trait SpreadPriceRounder {
1930    /// Rounds raw bid/ask to valid prices (handles negative prices with mirroring when using tick scheme).
1931    fn round_prices(&self, raw_bid: f64, raw_ask: f64, precision: u8) -> (Price, Price);
1932}
1933
1934/// Vega provider that returns leg vegas from a map (e.g. populated from greeks cache).
1935#[derive(Debug, Default)]
1936pub struct MapVegaProvider {
1937    vegas: AHashMap<InstrumentId, f64>,
1938}
1939
1940impl MapVegaProvider {
1941    pub fn new() -> Self {
1942        Self::default()
1943    }
1944
1945    pub fn insert(&mut self, instrument_id: InstrumentId, vega: f64) {
1946        self.vegas.insert(instrument_id, vega);
1947    }
1948
1949    pub fn get(&self, instrument_id: &InstrumentId) -> Option<f64> {
1950        self.vegas.get(instrument_id).copied()
1951    }
1952}
1953
1954impl VegaProvider for MapVegaProvider {
1955    fn vega_for_leg(&self, instrument_id: InstrumentId) -> Option<f64> {
1956        self.vegas.get(&instrument_id).copied()
1957    }
1958}
1959
1960/// Rounder that uses a fixed tick size; mirrors negative prices for tick alignment.
1961#[derive(Debug)]
1962pub struct FixedTickSchemeRounder {
1963    scheme: FixedTickScheme,
1964}
1965
1966impl FixedTickSchemeRounder {
1967    /// Creates a rounder with the given tick size.
1968    ///
1969    /// # Errors
1970    ///
1971    /// Returns an error if `tick` is not positive.
1972    pub fn new(tick: f64) -> anyhow::Result<Self> {
1973        Ok(Self {
1974            scheme: FixedTickScheme::new(tick)?,
1975        })
1976    }
1977
1978    fn round_one(&self, raw: f64, precision: u8, use_bid_rounding: bool) -> Price {
1979        if raw >= 0.0 {
1980            let p = if use_bid_rounding {
1981                self.scheme.next_bid_price(raw, 0, precision)
1982            } else {
1983                self.scheme.next_ask_price(raw, 0, precision)
1984            };
1985            p.unwrap_or_else(|| Price::new(raw, precision))
1986        } else {
1987            let p = if use_bid_rounding {
1988                self.scheme.next_ask_price(-raw, 0, precision)
1989            } else {
1990                self.scheme.next_bid_price(-raw, 0, precision)
1991            };
1992            p.map_or_else(
1993                || Price::new(raw, precision),
1994                |q| Price::new(-q.as_f64(), precision),
1995            )
1996        }
1997    }
1998}
1999
2000impl SpreadPriceRounder for FixedTickSchemeRounder {
2001    fn round_prices(&self, raw_bid: f64, raw_ask: f64, precision: u8) -> (Price, Price) {
2002        (
2003            self.round_one(raw_bid, precision, true),
2004            self.round_one(raw_ask, precision, false),
2005        )
2006    }
2007}
2008
2009/// Spread quote aggregator: builds synthetic quotes from leg quotes.
2010///
2011/// Quote-driven mode (`update_interval_seconds == None`): emits when all legs have quotes.
2012/// Timer-driven mode: emits on timer fire when `_has_update` is true.
2013/// Historical mode: defers timer event at `ts_init` until after the update.
2014pub struct SpreadQuoteAggregator {
2015    spread_instrument_id: InstrumentId,
2016    leg_ids: Vec<InstrumentId>,
2017    ratios: Vec<i64>,
2018    is_futures_spread: bool,
2019    price_precision: u8,
2020    size_precision: u8,
2021    last_quotes: AHashMap<InstrumentId, QuoteTick>,
2022    mid_prices: Vec<f64>,
2023    bid_prices: Vec<f64>,
2024    ask_prices: Vec<f64>,
2025    vegas: Vec<f64>,
2026    bid_ask_spreads: Vec<f64>,
2027    bid_sizes: Vec<f64>,
2028    ask_sizes: Vec<f64>,
2029    handler: Box<dyn FnMut(QuoteTick)>,
2030    clock: Rc<RefCell<dyn Clock>>,
2031    historical_mode: bool,
2032    update_interval_seconds: Option<u64>,
2033    quote_build_delay: u64,
2034    has_update: bool,
2035    timer_name: String,
2036    vega_pricing_timeout_timer_name: String,
2037    historical_event_at_ts_init: Option<TimeEvent>,
2038    vega_provider: Option<Box<dyn VegaProvider>>,
2039    disable_vega_pricing: bool,
2040    vega_pricing_temporarily_disabled: bool,
2041    vega_pricing_timeout_seconds: u64,
2042    price_rounder: Option<Box<dyn SpreadPriceRounder>>,
2043    is_running: bool,
2044    aggregator_weak: Option<Weak<RefCell<Self>>>,
2045}
2046
2047impl Debug for SpreadQuoteAggregator {
2048    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2049        f.debug_struct(stringify!(SpreadQuoteAggregator))
2050            .field("spread_instrument_id", &self.spread_instrument_id)
2051            .field("n_legs", &self.leg_ids.len())
2052            .field("is_futures_spread", &self.is_futures_spread)
2053            .field("update_interval_seconds", &self.update_interval_seconds)
2054            .finish()
2055    }
2056}
2057
2058impl SpreadQuoteAggregator {
2059    /// Creates a new [`SpreadQuoteAggregator`].
2060    ///
2061    /// # Panics
2062    ///
2063    /// Panics if `legs` has fewer than 2 entries or any ratio is zero.
2064    #[expect(clippy::too_many_arguments)]
2065    pub fn new(
2066        spread_instrument_id: InstrumentId,
2067        legs: &[(InstrumentId, i64)],
2068        is_futures_spread: bool,
2069        price_precision: u8,
2070        size_precision: u8,
2071        handler: Box<dyn FnMut(QuoteTick)>,
2072        clock: Rc<RefCell<dyn Clock>>,
2073        historical_mode: bool,
2074        update_interval_seconds: Option<u64>,
2075        quote_build_delay: u64,
2076        disable_vega_pricing: bool,
2077        vega_pricing_timeout_seconds: u64,
2078        vega_provider: Option<Box<dyn VegaProvider>>,
2079        price_rounder: Option<Box<dyn SpreadPriceRounder>>,
2080    ) -> Self {
2081        assert!(legs.len() >= 2, "Spread must have more than one leg");
2082        let n_legs = legs.len();
2083        let leg_ids: Vec<InstrumentId> = legs.iter().map(|(id, _)| *id).collect();
2084        let ratios: Vec<i64> = legs.iter().map(|(_, r)| *r).collect();
2085        for &r in &ratios {
2086            assert!(r != 0, "Ratio cannot be zero");
2087        }
2088        let timer_name = format!("SPREAD_QUOTE_{spread_instrument_id}");
2089        let vega_pricing_timeout_timer_name =
2090            format!("VEGA_PRICING_TIMEOUT_{spread_instrument_id}");
2091        Self {
2092            spread_instrument_id,
2093            leg_ids,
2094            ratios,
2095            is_futures_spread,
2096            price_precision,
2097            size_precision,
2098            last_quotes: AHashMap::new(),
2099            mid_prices: vec![0.0; n_legs],
2100            bid_prices: vec![0.0; n_legs],
2101            ask_prices: vec![0.0; n_legs],
2102            vegas: vec![0.0; n_legs],
2103            bid_ask_spreads: vec![0.0; n_legs],
2104            bid_sizes: vec![0.0; n_legs],
2105            ask_sizes: vec![0.0; n_legs],
2106            handler,
2107            clock,
2108            historical_mode,
2109            update_interval_seconds,
2110            quote_build_delay,
2111            has_update: false,
2112            timer_name,
2113            vega_pricing_timeout_timer_name,
2114            historical_event_at_ts_init: None,
2115            vega_provider,
2116            disable_vega_pricing,
2117            vega_pricing_temporarily_disabled: false,
2118            vega_pricing_timeout_seconds,
2119            price_rounder,
2120            is_running: false,
2121            aggregator_weak: None,
2122        }
2123    }
2124
2125    /// Sets the weak reference to this aggregator (used when starting the timer so the callback can call back).
2126    /// Prefer [`Self::prepare_for_timer_mode`] so the owner passes the owning `Rc` in one step.
2127    pub fn set_aggregator_weak(&mut self, weak: Weak<RefCell<Self>>) {
2128        self.aggregator_weak = Some(weak);
2129    }
2130
2131    /// One-step setup for timer-driven mode (live or historical). Call this with the `Rc` that owns
2132    /// this aggregator before feeding any quotes when `update_interval_seconds` is set. The timer
2133    /// callback will use the stored weak reference to call back into this aggregator; without this,
2134    /// [`Self::start_timer`] will panic in historical mode or when called with `None`.
2135    pub fn prepare_for_timer_mode(&mut self, self_rc: &Rc<RefCell<Self>>) {
2136        self.aggregator_weak = Some(Rc::downgrade(self_rc));
2137    }
2138
2139    /// Sets historical mode and handler (and optionally greeks provider when switching).
2140    pub fn set_historical_mode(
2141        &mut self,
2142        historical_mode: bool,
2143        handler: Box<dyn FnMut(QuoteTick)>,
2144        vega_provider: Option<Box<dyn VegaProvider>>,
2145    ) {
2146        self.historical_mode = historical_mode;
2147        self.handler = handler;
2148
2149        if let Some(vp) = vega_provider {
2150            self.vega_provider = Some(vp);
2151        }
2152    }
2153
2154    pub fn set_running(&mut self, is_running: bool) {
2155        self.is_running = is_running;
2156    }
2157
2158    pub fn set_clock(&mut self, clock: Rc<RefCell<dyn Clock>>) {
2159        self.clock = clock;
2160    }
2161
2162    /// Starts the timer when `update_interval_seconds` is set (timer-driven mode).
2163    /// In live mode pass `Some(rc)` so the weak is set and the timer can call back.
2164    /// In historical mode the owner must have called [`Self::prepare_for_timer_mode`] with the
2165    /// owning `Rc` before any quote is processed, then call with `None` here.
2166    ///
2167    /// # Panics
2168    ///
2169    /// Panics if called with `None` in timer mode without a prior [`Self::prepare_for_timer_mode`] call.
2170    pub fn start_timer(&mut self, aggregator_rc: Option<Rc<RefCell<Self>>>) {
2171        if let Some(rc) = aggregator_rc {
2172            self.aggregator_weak = Some(Rc::downgrade(&rc));
2173        }
2174
2175        let Some(interval_secs) = self.update_interval_seconds else {
2176            return;
2177        };
2178        let aggregator_weak = self.aggregator_weak.clone().expect(
2179            "SpreadQuoteAggregator: timer mode requires prepare_for_timer_mode(rc) to be \
2180                 called first with the Rc that wraps this aggregator (before feeding quotes in \
2181                 historical mode or before start_timer(None)).",
2182        );
2183
2184        let callback = TimeEventCallback::RustLocal(Rc::new(move |event: TimeEvent| {
2185            if let Some(agg) = aggregator_weak.upgrade() {
2186                agg.borrow_mut().on_timer_fire(event.ts_event);
2187            }
2188        }));
2189
2190        let now_ns = self.clock.borrow().timestamp_ns();
2191        let interval_ns = DurationNanos::from_secs(interval_secs);
2192        let start_time =
2193            now_ns.floor(interval_ns) + DurationNanos::from_micros(self.quote_build_delay);
2194        let fire_immediately = now_ns == start_time;
2195        self.clock
2196            .borrow_mut()
2197            .set_timer_ns(
2198                &self.timer_name,
2199                interval_ns,
2200                Some(start_time),
2201                None,
2202                Some(callback),
2203                Some(true),
2204                Some(fire_immediately),
2205            )
2206            .expect("Failed to set spread quote timer");
2207    }
2208
2209    /// Called when the timer fires (live mode). Builds and sends a spread quote using the timer event timestamp.
2210    pub fn on_timer_fire(&mut self, ts_event: UnixNanos) {
2211        if self.last_quotes.len() == self.leg_ids.len() {
2212            self.build_and_send_quote(ts_event);
2213        }
2214    }
2215
2216    /// Stops the timer when in timer-driven mode.
2217    pub fn stop_timer(&mut self) {
2218        if self.update_interval_seconds.is_some()
2219            && self
2220                .clock
2221                .borrow()
2222                .timer_names()
2223                .contains(&self.timer_name.as_str())
2224        {
2225            self.clock.borrow_mut().cancel_timer(&self.timer_name);
2226        }
2227
2228        if self
2229            .clock
2230            .borrow()
2231            .timer_names()
2232            .contains(&self.vega_pricing_timeout_timer_name.as_str())
2233        {
2234            self.clock
2235                .borrow_mut()
2236                .cancel_timer(&self.vega_pricing_timeout_timer_name);
2237        }
2238    }
2239
2240    /// Handles an incoming leg quote.
2241    pub fn handle_quote_tick(&mut self, tick: QuoteTick) {
2242        let ts_init = tick.ts_init;
2243
2244        if self.update_interval_seconds.is_some() && self.historical_mode {
2245            self.process_historical_events(ts_init);
2246        }
2247        self.last_quotes.insert(tick.instrument_id, tick);
2248        self.has_update = true;
2249
2250        if self.update_interval_seconds.is_none() && self.last_quotes.len() == self.leg_ids.len() {
2251            self.build_and_send_quote(ts_init);
2252        }
2253    }
2254
2255    /// Flushes the deferred historical timer event, if any.
2256    ///
2257    /// This is intended for historical request finalization, where we know no more historical
2258    /// quotes will arrive for the requested range and should not require a later live tick just
2259    /// to release the final same-timestamp spread quote.
2260    pub fn flush_pending_historical_quote(&mut self) {
2261        if self.update_interval_seconds.is_none() || !self.historical_mode {
2262            return;
2263        }
2264
2265        let Some(event) = self.historical_event_at_ts_init.take() else {
2266            return;
2267        };
2268
2269        if self.last_quotes.len() == self.leg_ids.len() {
2270            self.build_and_send_quote(event.ts_event);
2271        }
2272    }
2273
2274    /// Advances the historical clock and collects timer events. Events at `ts_init` are
2275    /// deferred until the next call when time advances. The deferred event is only flushed
2276    /// when all legs have quotes and time has moved past the deferred timestamp. This
2277    /// prevents building a spread quote with stale leg data when multiple legs update at
2278    /// the same timestamp.
2279    fn process_historical_events(&mut self, ts_init: UnixNanos) {
2280        if self.clock.borrow().timestamp_ns() == UnixNanos::default() {
2281            let mut clock_borrow = self.clock.borrow_mut();
2282            let test_clock = clock_borrow
2283                .as_any_mut()
2284                .downcast_mut::<TestClock>()
2285                .expect("Expected TestClock in historical mode");
2286            test_clock.set_time(ts_init);
2287            drop(clock_borrow);
2288            self.start_timer(None);
2289        }
2290
2291        if self.last_quotes.len() == self.leg_ids.len()
2292            && let Some(ref event) = self.historical_event_at_ts_init
2293            && event.ts_event < ts_init
2294        {
2295            // Guarded by `let Some(ref event)` above
2296            let event = self.historical_event_at_ts_init.take().unwrap();
2297            self.build_and_send_quote(event.ts_event);
2298        }
2299
2300        let events = {
2301            let mut clock_borrow = self.clock.borrow_mut();
2302            let test_clock = clock_borrow
2303                .as_any_mut()
2304                .downcast_mut::<TestClock>()
2305                .expect("Expected TestClock in historical mode");
2306            test_clock.advance_time(ts_init, true)
2307        };
2308
2309        for event in events {
2310            if event.ts_event == ts_init {
2311                self.historical_event_at_ts_init = Some(event);
2312            } else if self.last_quotes.len() == self.leg_ids.len() {
2313                self.build_and_send_quote(event.ts_event);
2314            }
2315        }
2316    }
2317
2318    /// Builds and sends one spread quote.
2319    fn build_and_send_quote(&mut self, ts_event: UnixNanos) {
2320        if !self.has_update {
2321            return;
2322        }
2323
2324        let use_vega_pricing =
2325            !(self.disable_vega_pricing || self.vega_pricing_temporarily_disabled);
2326
2327        for (idx, &leg_id) in self.leg_ids.iter().enumerate() {
2328            let Some(tick) = self.last_quotes.get(&leg_id) else {
2329                log::error!(
2330                    "SpreadQuoteAggregator[{}]: Missing quote for leg {}",
2331                    self.spread_instrument_id,
2332                    leg_id
2333                );
2334                return;
2335            };
2336            let ask_price = tick.ask_price.as_f64();
2337            let bid_price = tick.bid_price.as_f64();
2338            self.bid_prices[idx] = bid_price;
2339            self.ask_prices[idx] = ask_price;
2340            self.bid_sizes[idx] = tick.bid_size.as_f64();
2341            self.ask_sizes[idx] = tick.ask_size.as_f64();
2342
2343            if !self.is_futures_spread {
2344                self.mid_prices[idx] = f64::midpoint(ask_price, bid_price);
2345                self.bid_ask_spreads[idx] = ask_price - bid_price;
2346
2347                if use_vega_pricing
2348                    && let Some(ref vp) = self.vega_provider
2349                    && let Some(vega) = vp.vega_for_leg(leg_id)
2350                {
2351                    self.vegas[idx] = vega;
2352                }
2353            }
2354        }
2355        let (raw_bid, raw_ask) = if self.is_futures_spread {
2356            self.create_futures_spread_prices()
2357        } else {
2358            self.create_option_spread_prices()
2359        };
2360        let spread_quote = self.create_quote_tick_from_raw_prices(raw_bid, raw_ask, ts_event);
2361        self.has_update = false;
2362        (self.handler)(spread_quote);
2363    }
2364
2365    fn create_option_spread_prices(&mut self) -> (f64, f64) {
2366        if self.disable_vega_pricing || self.vega_pricing_temporarily_disabled {
2367            return self.create_futures_spread_prices();
2368        }
2369
2370        let (vega_multiplier_sum, vega_multiplier_count) = (0..self.leg_ids.len())
2371            .filter_map(|i| {
2372                let multiplier = if self.vegas[i] == 0.0 {
2373                    0.0
2374                } else {
2375                    self.bid_ask_spreads[i] / self.vegas[i]
2376                };
2377                (multiplier != 0.0).then_some(multiplier.abs())
2378            })
2379            .fold((0.0, 0_usize), |(sum, count), multiplier| {
2380                (sum + multiplier, count + 1)
2381            });
2382
2383        if vega_multiplier_count == 0 {
2384            log::warn!(
2385                "No vega information available for the components of {}; will generate spread quote using component quotes only, vega pricing is disabled for {} seconds, subscribe to some underlying price information for more precise quotes",
2386                self.spread_instrument_id,
2387                self.vega_pricing_timeout_seconds
2388            );
2389            self.start_vega_pricing_timeout();
2390            return self.create_futures_spread_prices();
2391        }
2392        let vega_multiplier = vega_multiplier_sum / vega_multiplier_count as f64;
2393        let spread_vega = self
2394            .vegas
2395            .iter()
2396            .zip(self.ratios.iter())
2397            .map(|(v, r)| v * (*r as f64))
2398            .sum::<f64>()
2399            .abs();
2400        let bid_ask_spread = spread_vega * vega_multiplier;
2401        let spread_mid_price: f64 = self
2402            .mid_prices
2403            .iter()
2404            .zip(self.ratios.iter())
2405            .map(|(m, r)| m * (*r as f64))
2406            .sum();
2407        let raw_bid = spread_mid_price - bid_ask_spread * 0.5;
2408        let raw_ask = spread_mid_price + bid_ask_spread * 0.5;
2409        (raw_bid, raw_ask)
2410    }
2411
2412    fn clear_vega_pricing_timeout(&mut self) {
2413        self.vega_pricing_temporarily_disabled = false;
2414    }
2415
2416    fn start_vega_pricing_timeout(&mut self) {
2417        self.vega_pricing_temporarily_disabled = true;
2418
2419        if self
2420            .clock
2421            .borrow()
2422            .timer_names()
2423            .contains(&self.vega_pricing_timeout_timer_name.as_str())
2424        {
2425            return;
2426        }
2427
2428        let Some(aggregator_weak) = self.aggregator_weak.clone() else {
2429            return;
2430        };
2431        let callback = TimeEventCallback::RustLocal(Rc::new(move |_event: TimeEvent| {
2432            if let Some(agg) = aggregator_weak.upgrade() {
2433                agg.borrow_mut().clear_vega_pricing_timeout();
2434            }
2435        }));
2436        let timeout = DurationNanos::try_from_secs(self.vega_pricing_timeout_seconds)
2437            .expect("vega pricing timeout exceeds the nanosecond range");
2438        let alert_time = self.clock.borrow().timestamp_ns() + timeout;
2439
2440        self.clock
2441            .borrow_mut()
2442            .set_time_alert_ns(
2443                &self.vega_pricing_timeout_timer_name,
2444                alert_time,
2445                Some(callback),
2446                Some(true),
2447            )
2448            .expect("Failed to set spread quote vega pricing timeout");
2449    }
2450
2451    fn create_futures_spread_prices(&self) -> (f64, f64) {
2452        let mut raw_ask = 0.0_f64;
2453        let mut raw_bid = 0.0_f64;
2454
2455        for i in 0..self.leg_ids.len() {
2456            let r = self.ratios[i] as f64;
2457            if self.ratios[i] >= 0 {
2458                raw_ask += r * self.ask_prices[i];
2459                raw_bid += r * self.bid_prices[i];
2460            } else {
2461                raw_ask += r * self.bid_prices[i];
2462                raw_bid += r * self.ask_prices[i];
2463            }
2464        }
2465        (raw_bid, raw_ask)
2466    }
2467
2468    fn create_quote_tick_from_raw_prices(
2469        &self,
2470        raw_bid_price: f64,
2471        raw_ask_price: f64,
2472        ts_event: UnixNanos,
2473    ) -> QuoteTick {
2474        let (bid_price, ask_price) = if let Some(ref rounder) = self.price_rounder {
2475            rounder.round_prices(raw_bid_price, raw_ask_price, self.price_precision)
2476        } else {
2477            (
2478                Price::new(raw_bid_price, self.price_precision),
2479                Price::new(raw_ask_price, self.price_precision),
2480            )
2481        };
2482        let mut min_bid_size = f64::INFINITY;
2483        let mut min_ask_size = f64::INFINITY;
2484        for i in 0..self.leg_ids.len() {
2485            let abs_ratio = self.ratios[i].unsigned_abs() as f64;
2486            let (bid_size, ask_size) = if self.ratios[i] >= 0 {
2487                (self.bid_sizes[i], self.ask_sizes[i])
2488            } else {
2489                (self.ask_sizes[i], self.bid_sizes[i])
2490            };
2491            let bid_size = bid_size / abs_ratio;
2492            if bid_size < min_bid_size {
2493                min_bid_size = bid_size;
2494            }
2495            let ask_size = ask_size / abs_ratio;
2496            if ask_size < min_ask_size {
2497                min_ask_size = ask_size;
2498            }
2499        }
2500        let bid_size = Quantity::new(min_bid_size, self.size_precision);
2501        let ask_size = Quantity::new(min_ask_size, self.size_precision);
2502        QuoteTick::new(
2503            self.spread_instrument_id,
2504            bid_price,
2505            ask_price,
2506            bid_size,
2507            ask_size,
2508            ts_event,
2509            ts_event,
2510        )
2511    }
2512}
2513
2514#[cfg(test)]
2515mod tests {
2516    use std::sync::Arc;
2517
2518    use nautilus_common::{clock::TestClock, timer::TimeEvent};
2519    use nautilus_core::{UUID4, UnixNanos};
2520    use nautilus_model::{
2521        data::{BarSpecification, BarType, QuoteTick},
2522        enums::{AggregationSource, AggressorSide, BarAggregation, PriceType},
2523        identifiers::InstrumentId,
2524        instruments::{CurrencyPair, Equity, Instrument, InstrumentAny, stubs::*},
2525        types::{Price, Quantity, price::PRICE_RAW_MAX},
2526    };
2527    use parking_lot::Mutex;
2528    use rstest::rstest;
2529    use ustr::Ustr;
2530
2531    use super::*;
2532
2533    #[rstest]
2534    fn test_bar_builder_initialization(equity_aapl: Equity) {
2535        let instrument = InstrumentAny::Equity(equity_aapl);
2536        let bar_type = BarType::new(
2537            instrument.id(),
2538            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2539            AggregationSource::Internal,
2540        );
2541        let builder = BarBuilder::new(
2542            bar_type,
2543            instrument.price_precision(),
2544            instrument.size_precision(),
2545        );
2546
2547        assert!(!builder.initialized);
2548        assert_eq!(builder.ts_last, 0);
2549        assert_eq!(builder.count, 0);
2550    }
2551
2552    #[rstest]
2553    fn test_bar_builder_maintains_ohlc_order(equity_aapl: Equity) {
2554        let instrument = InstrumentAny::Equity(equity_aapl);
2555        let bar_type = BarType::new(
2556            instrument.id(),
2557            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2558            AggregationSource::Internal,
2559        );
2560        let mut builder = BarBuilder::new(
2561            bar_type,
2562            instrument.price_precision(),
2563            instrument.size_precision(),
2564        );
2565
2566        builder.update(
2567            Price::from("100.00"),
2568            Quantity::from(1),
2569            UnixNanos::from(1000),
2570        );
2571        builder.update(
2572            Price::from("95.00"),
2573            Quantity::from(1),
2574            UnixNanos::from(2000),
2575        );
2576        builder.update(
2577            Price::from("105.00"),
2578            Quantity::from(1),
2579            UnixNanos::from(3000),
2580        );
2581
2582        let bar = builder.build_now();
2583        assert!(bar.high > bar.low);
2584        assert_eq!(bar.open, Price::from("100.00"));
2585        assert_eq!(bar.high, Price::from("105.00"));
2586        assert_eq!(bar.low, Price::from("95.00"));
2587        assert_eq!(bar.close, Price::from("105.00"));
2588    }
2589
2590    #[rstest]
2591    fn test_update_ignores_earlier_timestamps(equity_aapl: Equity) {
2592        let instrument = InstrumentAny::Equity(equity_aapl);
2593        let bar_type = BarType::new(
2594            instrument.id(),
2595            BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
2596            AggregationSource::Internal,
2597        );
2598        let mut builder = BarBuilder::new(
2599            bar_type,
2600            instrument.price_precision(),
2601            instrument.size_precision(),
2602        );
2603
2604        builder.update(Price::from("1.00000"), Quantity::from(1), 1_000.into());
2605        builder.update(Price::from("1.00001"), Quantity::from(1), 500.into());
2606
2607        assert_eq!(builder.ts_last, 1_000);
2608        assert_eq!(builder.count, 1);
2609    }
2610
2611    #[rstest]
2612    fn test_bar_builder_single_update_results_in_expected_properties(equity_aapl: Equity) {
2613        let instrument = InstrumentAny::Equity(equity_aapl);
2614        let bar_type = BarType::new(
2615            instrument.id(),
2616            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2617            AggregationSource::Internal,
2618        );
2619        let mut builder = BarBuilder::new(
2620            bar_type,
2621            instrument.price_precision(),
2622            instrument.size_precision(),
2623        );
2624
2625        builder.update(
2626            Price::from("1.00000"),
2627            Quantity::from(1),
2628            UnixNanos::default(),
2629        );
2630
2631        assert!(builder.initialized);
2632        assert_eq!(builder.ts_last, 0);
2633        assert_eq!(builder.count, 1);
2634    }
2635
2636    #[rstest]
2637    fn test_bar_builder_single_update_when_timestamp_less_than_last_update_ignores(
2638        equity_aapl: Equity,
2639    ) {
2640        let instrument = InstrumentAny::Equity(equity_aapl);
2641        let bar_type = BarType::new(
2642            instrument.id(),
2643            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2644            AggregationSource::Internal,
2645        );
2646        let mut builder = BarBuilder::new(bar_type, 2, 0);
2647
2648        builder.update(
2649            Price::from("1.00000"),
2650            Quantity::from(1),
2651            UnixNanos::from(1_000),
2652        );
2653        builder.update(
2654            Price::from("1.00001"),
2655            Quantity::from(1),
2656            UnixNanos::from(500),
2657        );
2658
2659        assert!(builder.initialized);
2660        assert_eq!(builder.ts_last, 1_000);
2661        assert_eq!(builder.count, 1);
2662    }
2663
2664    #[rstest]
2665    fn test_bar_builder_multiple_updates_correctly_increments_count(equity_aapl: Equity) {
2666        let instrument = InstrumentAny::Equity(equity_aapl);
2667        let bar_type = BarType::new(
2668            instrument.id(),
2669            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2670            AggregationSource::Internal,
2671        );
2672        let mut builder = BarBuilder::new(
2673            bar_type,
2674            instrument.price_precision(),
2675            instrument.size_precision(),
2676        );
2677
2678        for _ in 0..5 {
2679            builder.update(
2680                Price::from("1.00000"),
2681                Quantity::from(1),
2682                UnixNanos::from(1_000),
2683            );
2684        }
2685
2686        assert_eq!(builder.count, 5);
2687    }
2688
2689    #[rstest]
2690    #[should_panic]
2691    fn test_bar_builder_build_when_no_updates_panics(equity_aapl: Equity) {
2692        let instrument = InstrumentAny::Equity(equity_aapl);
2693        let bar_type = BarType::new(
2694            instrument.id(),
2695            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2696            AggregationSource::Internal,
2697        );
2698        let mut builder = BarBuilder::new(
2699            bar_type,
2700            instrument.price_precision(),
2701            instrument.size_precision(),
2702        );
2703        let _ = builder.build_now();
2704    }
2705
2706    #[rstest]
2707    fn test_bar_builder_build_when_received_updates_returns_expected_bar(equity_aapl: Equity) {
2708        let instrument = InstrumentAny::Equity(equity_aapl);
2709        let bar_type = BarType::new(
2710            instrument.id(),
2711            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2712            AggregationSource::Internal,
2713        );
2714        let mut builder = BarBuilder::new(
2715            bar_type,
2716            instrument.price_precision(),
2717            instrument.size_precision(),
2718        );
2719
2720        builder.update(
2721            Price::from("1.00001"),
2722            Quantity::from(2),
2723            UnixNanos::default(),
2724        );
2725        builder.update(
2726            Price::from("1.00002"),
2727            Quantity::from(2),
2728            UnixNanos::default(),
2729        );
2730        builder.update(
2731            Price::from("1.00000"),
2732            Quantity::from(1),
2733            UnixNanos::from(1_000_000_000),
2734        );
2735
2736        let bar = builder.build_now();
2737
2738        assert_eq!(bar.open, Price::from("1.00001"));
2739        assert_eq!(bar.high, Price::from("1.00002"));
2740        assert_eq!(bar.low, Price::from("1.00000"));
2741        assert_eq!(bar.close, Price::from("1.00000"));
2742        assert_eq!(bar.volume, Quantity::from(5));
2743        assert_eq!(bar.ts_init, 1_000_000_000);
2744        assert_eq!(builder.ts_last, 1_000_000_000);
2745        assert_eq!(builder.count, 0);
2746    }
2747
2748    #[rstest]
2749    fn test_bar_builder_build_with_previous_close(equity_aapl: Equity) {
2750        let instrument = InstrumentAny::Equity(equity_aapl);
2751        let bar_type = BarType::new(
2752            instrument.id(),
2753            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2754            AggregationSource::Internal,
2755        );
2756        let mut builder = BarBuilder::new(bar_type, 2, 0);
2757
2758        builder.update(
2759            Price::from("1.00001"),
2760            Quantity::from(1),
2761            UnixNanos::default(),
2762        );
2763        builder.build_now();
2764
2765        builder.update(
2766            Price::from("1.00000"),
2767            Quantity::from(1),
2768            UnixNanos::default(),
2769        );
2770        builder.update(
2771            Price::from("1.00003"),
2772            Quantity::from(1),
2773            UnixNanos::default(),
2774        );
2775        builder.update(
2776            Price::from("1.00002"),
2777            Quantity::from(1),
2778            UnixNanos::default(),
2779        );
2780
2781        let bar = builder.build_now();
2782
2783        assert_eq!(bar.open, Price::from("1.00000"));
2784        assert_eq!(bar.high, Price::from("1.00003"));
2785        assert_eq!(bar.low, Price::from("1.00000"));
2786        assert_eq!(bar.close, Price::from("1.00002"));
2787        assert_eq!(bar.volume, Quantity::from(3));
2788    }
2789
2790    #[rstest]
2791    fn test_bar_builder_update_bar_initializes_then_accumulates(equity_aapl: Equity) {
2792        let instrument = InstrumentAny::Equity(equity_aapl);
2793        let bar_type = BarType::new(
2794            instrument.id(),
2795            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2796            AggregationSource::Internal,
2797        );
2798        let mut builder = BarBuilder::new(
2799            bar_type,
2800            instrument.price_precision(),
2801            instrument.size_precision(),
2802        );
2803
2804        let bar_one = Bar::new(
2805            bar_type,
2806            Price::from("100.00"),
2807            Price::from("102.00"),
2808            Price::from("99.00"),
2809            Price::from("101.00"),
2810            Quantity::from(10),
2811            UnixNanos::from(1_000),
2812            UnixNanos::from(1_000),
2813        );
2814        let bar_two = Bar::new(
2815            bar_type,
2816            Price::from("101.00"),
2817            Price::from("103.00"),
2818            Price::from("98.00"),
2819            Price::from("102.00"),
2820            Quantity::from(5),
2821            UnixNanos::from(2_000),
2822            UnixNanos::from(2_000),
2823        );
2824
2825        builder.update_bar(bar_one, bar_one.volume, bar_one.ts_init);
2826        builder.update_bar(bar_two, bar_two.volume, bar_two.ts_init);
2827        let bar = builder.build_now();
2828
2829        assert_eq!(bar.open, Price::from("100.00"));
2830        assert_eq!(bar.high, Price::from("103.00"));
2831        assert_eq!(bar.low, Price::from("98.00"));
2832        assert_eq!(bar.close, Price::from("102.00"));
2833        assert_eq!(bar.volume, Quantity::from(15));
2834        assert_eq!(builder.count, 0);
2835    }
2836
2837    #[rstest]
2838    fn test_bar_builder_update_bar_ignores_earlier_timestamp(equity_aapl: Equity) {
2839        let instrument = InstrumentAny::Equity(equity_aapl);
2840        let bar_type = BarType::new(
2841            instrument.id(),
2842            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2843            AggregationSource::Internal,
2844        );
2845        let mut builder = BarBuilder::new(
2846            bar_type,
2847            instrument.price_precision(),
2848            instrument.size_precision(),
2849        );
2850
2851        let bar_later = Bar::new(
2852            bar_type,
2853            Price::from("100.00"),
2854            Price::from("101.00"),
2855            Price::from("99.00"),
2856            Price::from("100.50"),
2857            Quantity::from(10),
2858            UnixNanos::from(2_000),
2859            UnixNanos::from(2_000),
2860        );
2861        let bar_earlier = Bar::new(
2862            bar_type,
2863            Price::from("200.00"),
2864            Price::from("210.00"),
2865            Price::from("190.00"),
2866            Price::from("205.00"),
2867            Quantity::from(50),
2868            UnixNanos::from(1_000),
2869            UnixNanos::from(1_000),
2870        );
2871
2872        builder.update_bar(bar_later, bar_later.volume, bar_later.ts_init);
2873        builder.update_bar(bar_earlier, bar_earlier.volume, bar_earlier.ts_init);
2874
2875        assert_eq!(builder.ts_last, 2_000);
2876        assert_eq!(builder.count, 1);
2877        assert_eq!(builder.volume, Quantity::from(10));
2878    }
2879
2880    #[rstest]
2881    #[case::spread_zero_inactive(
2882        Decimal::ZERO,
2883        ContinuousFutureAdjustmentType::BackwardSpread,
2884        false
2885    )]
2886    #[case::spread_positive_active(
2887        Decimal::new(150, 2), // 1.50
2888        ContinuousFutureAdjustmentType::BackwardSpread,
2889        true,
2890    )]
2891    #[case::spread_negative_active(
2892        Decimal::new(-250, 2), // -2.50
2893        ContinuousFutureAdjustmentType::ForwardSpread,
2894        true,
2895    )]
2896    #[case::spread_sub_precision_inactive(
2897        // 1e-28 scales to 0 raw under banker's rounding, so should be inactive.
2898        Decimal::new(1, 28),
2899        ContinuousFutureAdjustmentType::BackwardSpread,
2900        false,
2901    )]
2902    #[case::ratio_one_inactive(Decimal::ONE, ContinuousFutureAdjustmentType::BackwardRatio, false)]
2903    #[case::ratio_non_one_active(
2904        Decimal::new(105, 2), // 1.05
2905        ContinuousFutureAdjustmentType::ForwardRatio,
2906        true,
2907    )]
2908    fn test_bar_builder_set_adjustment_active_flag(
2909        equity_aapl: Equity,
2910        #[case] adjustment: Decimal,
2911        #[case] mode: ContinuousFutureAdjustmentType,
2912        #[case] expected_active: bool,
2913    ) {
2914        let instrument = InstrumentAny::Equity(equity_aapl);
2915        let bar_type = BarType::new(
2916            instrument.id(),
2917            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2918            AggregationSource::Internal,
2919        );
2920        let mut builder = BarBuilder::new(bar_type, 2, 0);
2921
2922        builder.set_adjustment(adjustment, mode);
2923
2924        assert_eq!(builder.adjustment_active, expected_active);
2925        assert_eq!(builder.adjustment_is_ratio, mode.is_ratio());
2926    }
2927
2928    #[rstest]
2929    fn test_bar_builder_set_adjustment_mode_switch_resets_flags(equity_aapl: Equity) {
2930        let instrument = InstrumentAny::Equity(equity_aapl);
2931        let bar_type = BarType::new(
2932            instrument.id(),
2933            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2934            AggregationSource::Internal,
2935        );
2936        let mut builder = BarBuilder::new(bar_type, 2, 0);
2937
2938        // ratio -> spread: subsequent update must shift, not scale.
2939        builder.set_adjustment(
2940            Decimal::new(150, 2), // 1.50
2941            ContinuousFutureAdjustmentType::BackwardRatio,
2942        );
2943        builder.set_adjustment(
2944            Decimal::new(50, 2), // +0.50
2945            ContinuousFutureAdjustmentType::BackwardSpread,
2946        );
2947        assert!(!builder.adjustment_is_ratio);
2948        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
2949        assert_eq!(builder.build_now().close, Price::from("100.50"));
2950
2951        // spread -> ratio: subsequent update must scale, not shift.
2952        builder.set_adjustment(
2953            Decimal::new(11, 1), // 1.1
2954            ContinuousFutureAdjustmentType::ForwardRatio,
2955        );
2956        assert!(builder.adjustment_is_ratio);
2957        builder.update(Price::from("100.00"), Quantity::from(1), 2_000.into());
2958        assert_eq!(builder.build_now().close, Price::from("110.00"));
2959    }
2960
2961    #[cfg(feature = "defi")]
2962    #[rstest]
2963    fn test_bar_builder_spread_preserves_legacy_native_raw_units(equity_aapl: Equity) {
2964        let bar_type = BarType::new(
2965            equity_aapl.id(),
2966            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2967            AggregationSource::Internal,
2968        );
2969
2970        let mut builder = BarBuilder::new(bar_type, 18, 0);
2971        builder.set_adjustment(Decimal::ONE, ContinuousFutureAdjustmentType::BackwardSpread);
2972        let adjusted =
2973            builder.apply_adjustment_to_price(Price::from_raw(1_000_000_000_000_000_000, 18));
2974        // Preserve the legacy fixed-scale offset, not a native-scale one-unit adjustment
2975        assert_eq!(adjusted.raw(), 1_010_000_000_000_000_000);
2976        assert_eq!(adjusted.precision, 18);
2977    }
2978
2979    #[rstest]
2980    #[should_panic(expected = "Continuous-future adjustment exceeds Price bounds")]
2981    fn test_bar_builder_spread_rejects_out_of_domain_price(equity_aapl: Equity) {
2982        let bar_type = BarType::new(
2983            equity_aapl.id(),
2984            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2985            AggregationSource::Internal,
2986        );
2987
2988        let mut builder = BarBuilder::new(bar_type, 2, 0);
2989        builder.set_adjustment(Decimal::ONE, ContinuousFutureAdjustmentType::BackwardSpread);
2990        builder.apply_adjustment_to_price(Price::from_raw(PRICE_RAW_MAX, 2));
2991    }
2992
2993    #[cfg(feature = "defi")]
2994    #[rstest]
2995    #[case(BarAggregation::Volume)]
2996    #[case(BarAggregation::VolumeImbalance)]
2997    #[case(BarAggregation::VolumeRuns)]
2998    fn test_volume_aggregators_preserve_legacy_native_raw_threshold(
2999        #[case] aggregation: BarAggregation,
3000        equity_aapl: Equity,
3001    ) {
3002        let bar_type = BarType::new(
3003            equity_aapl.id(),
3004            BarSpecification::new(1, aggregation, PriceType::Last),
3005            AggregationSource::Internal,
3006        );
3007        let (handler, record) = recording_handler();
3008
3009        let mut aggregator: Box<dyn BarAggregator> = match aggregation {
3010            BarAggregation::Volume => Box::new(VolumeBarAggregator::new(bar_type, 2, 18, record)),
3011            BarAggregation::VolumeImbalance => {
3012                Box::new(VolumeImbalanceBarAggregator::new(bar_type, 2, 18, record))
3013            }
3014            BarAggregation::VolumeRuns => {
3015                Box::new(VolumeRunsBarAggregator::new(bar_type, 2, 18, record))
3016            }
3017            _ => unreachable!(),
3018        };
3019
3020        // Preserve the legacy threshold: 0.01 native units for a step of one
3021        let size = Quantity::from_raw(FIXED_SCALAR as QuantityRaw, 18);
3022        aggregator.handle_trade(TradeTick {
3023            price: Price::from("1.00"),
3024            size,
3025            aggressor_side: AggressorSide::Buy,
3026            ..TradeTick::default()
3027        });
3028
3029        let bars = handler.lock();
3030        assert_eq!(bars.len(), 1);
3031        assert_eq!(bars[0].volume, size);
3032        assert_eq!(bars[0].volume.precision, 18);
3033    }
3034
3035    #[rstest]
3036    fn test_bar_builder_update_applies_backward_spread_adjustment(equity_aapl: Equity) {
3037        let instrument = InstrumentAny::Equity(equity_aapl);
3038        let bar_type = BarType::new(
3039            instrument.id(),
3040            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3041            AggregationSource::Internal,
3042        );
3043        let mut builder = BarBuilder::new(bar_type, 2, 0);
3044
3045        builder.set_adjustment(
3046            Decimal::new(250, 2), // +2.50
3047            ContinuousFutureAdjustmentType::BackwardSpread,
3048        );
3049
3050        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3051        builder.update(Price::from("99.00"), Quantity::from(1), 2_000.into());
3052        builder.update(Price::from("101.00"), Quantity::from(1), 3_000.into());
3053
3054        let bar = builder.build_now();
3055        assert_eq!(bar.open, Price::from("102.50"));
3056        assert_eq!(bar.high, Price::from("103.50"));
3057        assert_eq!(bar.low, Price::from("101.50"));
3058        assert_eq!(bar.close, Price::from("103.50"));
3059    }
3060
3061    #[rstest]
3062    fn test_bar_builder_update_applies_forward_ratio_adjustment(equity_aapl: Equity) {
3063        let instrument = InstrumentAny::Equity(equity_aapl);
3064        let bar_type = BarType::new(
3065            instrument.id(),
3066            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3067            AggregationSource::Internal,
3068        );
3069        let mut builder = BarBuilder::new(bar_type, 2, 0);
3070
3071        builder.set_adjustment(
3072            Decimal::new(11, 1), // 1.1
3073            ContinuousFutureAdjustmentType::ForwardRatio,
3074        );
3075
3076        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3077        builder.update(Price::from("90.00"), Quantity::from(1), 2_000.into());
3078        builder.update(Price::from("110.00"), Quantity::from(1), 3_000.into());
3079
3080        let bar = builder.build_now();
3081        assert_eq!(bar.open, Price::from("110.00"));
3082        assert_eq!(bar.high, Price::from("121.00"));
3083        assert_eq!(bar.low, Price::from("99.00"));
3084        assert_eq!(bar.close, Price::from("121.00"));
3085    }
3086
3087    #[rstest]
3088    fn test_bar_builder_update_bar_applies_adjustment_to_ohlc(equity_aapl: Equity) {
3089        let instrument = InstrumentAny::Equity(equity_aapl);
3090        let bar_type = BarType::new(
3091            instrument.id(),
3092            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3093            AggregationSource::Internal,
3094        );
3095        let mut builder = BarBuilder::new(bar_type, 2, 0);
3096
3097        builder.set_adjustment(
3098            Decimal::new(-100, 2), // -1.00
3099            ContinuousFutureAdjustmentType::BackwardSpread,
3100        );
3101
3102        let input = Bar::new(
3103            bar_type,
3104            Price::from("100.00"),
3105            Price::from("105.00"),
3106            Price::from("99.00"),
3107            Price::from("102.00"),
3108            Quantity::from(10),
3109            UnixNanos::from(1_000),
3110            UnixNanos::from(1_000),
3111        );
3112        builder.update_bar(input, input.volume, input.ts_init);
3113
3114        let bar = builder.build_now();
3115        assert_eq!(bar.open, Price::from("99.00"));
3116        assert_eq!(bar.high, Price::from("104.00"));
3117        assert_eq!(bar.low, Price::from("98.00"));
3118        assert_eq!(bar.close, Price::from("101.00"));
3119    }
3120
3121    #[rstest]
3122    fn test_bar_builder_reset_retains_adjustment(equity_aapl: Equity) {
3123        let instrument = InstrumentAny::Equity(equity_aapl);
3124        let bar_type = BarType::new(
3125            instrument.id(),
3126            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3127            AggregationSource::Internal,
3128        );
3129        let mut builder = BarBuilder::new(bar_type, 2, 0);
3130
3131        builder.set_adjustment(
3132            Decimal::new(500, 2), // +5.00
3133            ContinuousFutureAdjustmentType::BackwardSpread,
3134        );
3135        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3136        let bar_one = builder.build_now();
3137        assert_eq!(bar_one.close, Price::from("105.00"));
3138
3139        // Adjustment must persist across the reset triggered by build_now.
3140        assert!(builder.adjustment_active);
3141
3142        builder.update(Price::from("110.00"), Quantity::from(1), 2_000.into());
3143        let bar_two = builder.build_now();
3144        assert_eq!(bar_two.close, Price::from("115.00"));
3145    }
3146
3147    #[rstest]
3148    fn test_bar_builder_update_bar_applies_ratio_adjustment(equity_aapl: Equity) {
3149        let instrument = InstrumentAny::Equity(equity_aapl);
3150        let bar_type = BarType::new(
3151            instrument.id(),
3152            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3153            AggregationSource::Internal,
3154        );
3155        let mut builder = BarBuilder::new(bar_type, 2, 0);
3156
3157        builder.set_adjustment(
3158            Decimal::new(11, 1), // 1.1
3159            ContinuousFutureAdjustmentType::ForwardRatio,
3160        );
3161
3162        let input = Bar::new(
3163            bar_type,
3164            Price::from("100.00"),
3165            Price::from("110.00"),
3166            Price::from("90.00"),
3167            Price::from("105.00"),
3168            Quantity::from(10),
3169            UnixNanos::from(1_000),
3170            UnixNanos::from(1_000),
3171        );
3172        builder.update_bar(input, input.volume, input.ts_init);
3173
3174        let bar = builder.build_now();
3175        assert_eq!(bar.open, Price::from("110.00"));
3176        assert_eq!(bar.high, Price::from("121.00"));
3177        assert_eq!(bar.low, Price::from("99.00"));
3178        assert_eq!(bar.close, Price::from("115.50"));
3179    }
3180
3181    #[rstest]
3182    fn test_bar_builder_spread_below_zero_representable(equity_aapl: Equity) {
3183        // Backward-spread offsets that push prices below zero must stay representable in PriceRaw
3184        let instrument = InstrumentAny::Equity(equity_aapl);
3185        let bar_type = BarType::new(
3186            instrument.id(),
3187            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3188            AggregationSource::Internal,
3189        );
3190        let mut builder = BarBuilder::new(bar_type, 2, 0);
3191
3192        builder.set_adjustment(
3193            Decimal::new(-15000, 2), // -150.00
3194            ContinuousFutureAdjustmentType::BackwardSpread,
3195        );
3196
3197        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3198        let bar = builder.build_now();
3199        assert_eq!(bar.close, Price::from("-50.00"));
3200        assert!(bar.close.is_negative());
3201        assert_eq!(bar.close.precision, 2);
3202    }
3203
3204    #[rstest]
3205    fn test_bar_builder_build_promotes_close_above_high_from_previous_close(equity_aapl: Equity) {
3206        let instrument = InstrumentAny::Equity(equity_aapl);
3207        let bar_type = BarType::new(
3208            instrument.id(),
3209            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3210            AggregationSource::Internal,
3211        );
3212        let mut builder = BarBuilder::new(bar_type, 2, 0);
3213
3214        builder.update(
3215            Price::from("110.00"),
3216            Quantity::from(1),
3217            UnixNanos::from(100),
3218        );
3219        builder.build_now();
3220
3221        builder.update(
3222            Price::from("100.00"),
3223            Quantity::from(1),
3224            UnixNanos::from(200),
3225        );
3226        builder.update(
3227            Price::from("101.00"),
3228            Quantity::from(1),
3229            UnixNanos::from(300),
3230        );
3231        builder.update(
3232            Price::from("200.00"),
3233            Quantity::from(1),
3234            UnixNanos::from(400),
3235        );
3236
3237        let bar = builder.build_now();
3238        assert_eq!(bar.open, Price::from("100.00"));
3239        assert_eq!(bar.high, Price::from("200.00"));
3240        assert_eq!(bar.low, Price::from("100.00"));
3241        assert_eq!(bar.close, Price::from("200.00"));
3242    }
3243
3244    #[rstest]
3245    fn test_bar_builder_build_clamps_low_to_close(equity_aapl: Equity) {
3246        // On `build`, if `close < low` the low is pulled down to close.
3247        // Reaching this branch requires bypassing `update`'s low tracking (e.g. via bar updates where
3248        // a later bar's close is below the accumulated low). We simulate by direct field assignment.
3249        let instrument = InstrumentAny::Equity(equity_aapl);
3250        let bar_type = BarType::new(
3251            instrument.id(),
3252            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3253            AggregationSource::Internal,
3254        );
3255        let mut builder = BarBuilder::new(bar_type, 2, 0);
3256
3257        builder.update(
3258            Price::from("100.00"),
3259            Quantity::from(1),
3260            UnixNanos::from(100),
3261        );
3262        builder.close = Some(Price::from("50.00"));
3263
3264        let bar = builder.build_now();
3265        assert_eq!(bar.low, Price::from("50.00"));
3266        assert_eq!(bar.close, Price::from("50.00"));
3267        assert!(bar.low <= bar.open);
3268    }
3269
3270    #[rstest]
3271    fn test_tick_bar_aggregator_handle_trade_when_step_count_below_threshold(equity_aapl: Equity) {
3272        let instrument = InstrumentAny::Equity(equity_aapl);
3273        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3274        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3275        let (handler, record) = recording_handler();
3276
3277        let mut aggregator = TickBarAggregator::new(
3278            bar_type,
3279            instrument.price_precision(),
3280            instrument.size_precision(),
3281            record,
3282        );
3283
3284        let trade = TradeTick::default();
3285        aggregator.handle_trade(trade);
3286
3287        let handler_guard = handler.lock();
3288        assert_eq!(handler_guard.len(), 0);
3289    }
3290
3291    #[rstest]
3292    fn test_tick_bar_aggregator_handle_trade_when_step_count_reached(equity_aapl: Equity) {
3293        let instrument = InstrumentAny::Equity(equity_aapl);
3294        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3295        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3296        let (handler, record) = recording_handler();
3297
3298        let mut aggregator = TickBarAggregator::new(
3299            bar_type,
3300            instrument.price_precision(),
3301            instrument.size_precision(),
3302            record,
3303        );
3304
3305        let trade = TradeTick::default();
3306        aggregator.handle_trade(trade);
3307        aggregator.handle_trade(trade);
3308        aggregator.handle_trade(trade);
3309
3310        let handler_guard = handler.lock();
3311        let bar = handler_guard.first().unwrap();
3312        assert_eq!(handler_guard.len(), 1);
3313        assert_eq!(bar.open, trade.price);
3314        assert_eq!(bar.high, trade.price);
3315        assert_eq!(bar.low, trade.price);
3316        assert_eq!(bar.close, trade.price);
3317        assert_eq!(bar.volume, Quantity::from(300000));
3318        assert_eq!(bar.ts_event, trade.ts_event);
3319        assert_eq!(bar.ts_init, trade.ts_init);
3320    }
3321
3322    #[rstest]
3323    fn test_tick_bar_aggregator_aggregates_to_step_size(equity_aapl: Equity) {
3324        let instrument = InstrumentAny::Equity(equity_aapl);
3325        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3326        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3327        let (handler, record) = recording_handler();
3328
3329        let mut aggregator = TickBarAggregator::new(
3330            bar_type,
3331            instrument.price_precision(),
3332            instrument.size_precision(),
3333            record,
3334        );
3335
3336        aggregator.update(
3337            Price::from("1.00001"),
3338            Quantity::from(1),
3339            UnixNanos::default(),
3340        );
3341        aggregator.update(
3342            Price::from("1.00002"),
3343            Quantity::from(1),
3344            UnixNanos::from(1000),
3345        );
3346        aggregator.update(
3347            Price::from("1.00003"),
3348            Quantity::from(1),
3349            UnixNanos::from(2000),
3350        );
3351
3352        let handler_guard = handler.lock();
3353        assert_eq!(handler_guard.len(), 1);
3354
3355        let bar = handler_guard.first().unwrap();
3356        assert_eq!(bar.open, Price::from("1.00001"));
3357        assert_eq!(bar.high, Price::from("1.00003"));
3358        assert_eq!(bar.low, Price::from("1.00001"));
3359        assert_eq!(bar.close, Price::from("1.00003"));
3360        assert_eq!(bar.volume, Quantity::from(3));
3361    }
3362
3363    #[rstest]
3364    fn test_tick_bar_aggregator_resets_after_bar_created(equity_aapl: Equity) {
3365        let instrument = InstrumentAny::Equity(equity_aapl);
3366        let bar_spec = BarSpecification::new(2, BarAggregation::Tick, PriceType::Last);
3367        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3368        let (handler, record) = recording_handler();
3369
3370        let mut aggregator = TickBarAggregator::new(
3371            bar_type,
3372            instrument.price_precision(),
3373            instrument.size_precision(),
3374            record,
3375        );
3376
3377        aggregator.update(
3378            Price::from("1.00001"),
3379            Quantity::from(1),
3380            UnixNanos::default(),
3381        );
3382        aggregator.update(
3383            Price::from("1.00002"),
3384            Quantity::from(1),
3385            UnixNanos::from(1000),
3386        );
3387        aggregator.update(
3388            Price::from("1.00003"),
3389            Quantity::from(1),
3390            UnixNanos::from(2000),
3391        );
3392        aggregator.update(
3393            Price::from("1.00004"),
3394            Quantity::from(1),
3395            UnixNanos::from(3000),
3396        );
3397
3398        let handler_guard = handler.lock();
3399        assert_eq!(handler_guard.len(), 2);
3400
3401        let bar1 = &handler_guard[0];
3402        assert_eq!(bar1.open, Price::from("1.00001"));
3403        assert_eq!(bar1.close, Price::from("1.00002"));
3404        assert_eq!(bar1.volume, Quantity::from(2));
3405
3406        let bar2 = &handler_guard[1];
3407        assert_eq!(bar2.open, Price::from("1.00003"));
3408        assert_eq!(bar2.close, Price::from("1.00004"));
3409        assert_eq!(bar2.volume, Quantity::from(2));
3410    }
3411
3412    #[rstest]
3413    #[case(PriceType::Bid, Price::from("100.00"), Quantity::from(10))]
3414    #[case(PriceType::Ask, Price::from("102.00"), Quantity::from(14))]
3415    #[case(PriceType::Mid, Price::from("101.000"), Quantity::from("12.0"))]
3416    fn test_bar_aggregator_handle_quote_selects_price_and_size(
3417        equity_aapl: Equity,
3418        #[case] price_type: PriceType,
3419        #[case] expected_price: Price,
3420        #[case] expected_size: Quantity,
3421    ) {
3422        let instrument = InstrumentAny::Equity(equity_aapl);
3423        let bar_type = BarType::new(
3424            instrument.id(),
3425            BarSpecification::new(1, BarAggregation::Tick, price_type),
3426            AggregationSource::Internal,
3427        );
3428        let (handler, record) = recording_handler();
3429        let mut aggregator = TickBarAggregator::new(
3430            bar_type,
3431            instrument.price_precision(),
3432            instrument.size_precision(),
3433            record,
3434        );
3435        let ts_init = UnixNanos::from(2_000);
3436        let quote = QuoteTick::new(
3437            instrument.id(),
3438            Price::from("100.00"),
3439            Price::from("102.00"),
3440            Quantity::from(10),
3441            Quantity::from(14),
3442            UnixNanos::from(1_000),
3443            ts_init,
3444        );
3445
3446        aggregator.handle_quote(quote);
3447
3448        let bars = handler.lock();
3449        assert_eq!(bars.len(), 1);
3450        assert_eq!(bars[0].open, expected_price);
3451        assert_eq!(bars[0].high, expected_price);
3452        assert_eq!(bars[0].low, expected_price);
3453        assert_eq!(bars[0].close, expected_price);
3454        assert_eq!(bars[0].volume, expected_size);
3455        assert_eq!(bars[0].ts_event, ts_init);
3456        assert_eq!(bars[0].ts_init, ts_init);
3457    }
3458
3459    #[rstest]
3460    fn test_bar_aggregator_handle_quote_rejects_last_price(equity_aapl: Equity) {
3461        let instrument = InstrumentAny::Equity(equity_aapl);
3462        let bar_type = BarType::new(
3463            instrument.id(),
3464            BarSpecification::new(1, BarAggregation::Tick, PriceType::Last),
3465            AggregationSource::Internal,
3466        );
3467        let (handler, record) = recording_handler();
3468        let mut aggregator = TickBarAggregator::new(
3469            bar_type,
3470            instrument.price_precision(),
3471            instrument.size_precision(),
3472            record,
3473        );
3474
3475        aggregator.handle_quote(QuoteTick::new(
3476            instrument.id(),
3477            Price::from("100.00"),
3478            Price::from("102.00"),
3479            Quantity::from(10),
3480            Quantity::from(14),
3481            UnixNanos::from(1_000),
3482            UnixNanos::from(2_000),
3483        ));
3484
3485        assert!(handler.lock().is_empty());
3486        assert!(!aggregator.core.builder.initialized);
3487        assert_eq!(aggregator.core.builder.count, 0);
3488        assert_eq!(aggregator.core.builder.volume, Quantity::zero(0));
3489    }
3490
3491    #[rstest]
3492    fn test_non_time_bar_aggregators_use_historical_handler(
3493        equity_aapl: Equity,
3494        audusd_sim: CurrencyPair,
3495    ) {
3496        let instrument = InstrumentAny::Equity(equity_aapl);
3497        let instrument_id = instrument.id();
3498        let price_precision = instrument.price_precision();
3499        let size_precision = instrument.size_precision();
3500        let make_sink = |bars: Arc<Mutex<Vec<Bar>>>| {
3501            move |bar: Bar| {
3502                bars.lock().push(bar);
3503            }
3504        };
3505        let make_trade = |price: &str, size: i64, ts: u64| TradeTick {
3506            instrument_id,
3507            price: Price::from(price),
3508            size: Quantity::from(size),
3509            aggressor_side: AggressorSide::Buy,
3510            ts_event: UnixNanos::from(ts),
3511            ts_init: UnixNanos::from(ts),
3512            ..TradeTick::default()
3513        };
3514
3515        macro_rules! assert_historical_sink_receives {
3516            ($name:expr, $aggregator:expr, $update:expr) => {{
3517                let initial_bars = Arc::new(Mutex::new(Vec::new()));
3518                let historical_bars = Arc::new(Mutex::new(Vec::new()));
3519                let mut aggregator = $aggregator(Arc::clone(&initial_bars));
3520                aggregator
3521                    .set_historical_mode(true, Box::new(make_sink(Arc::clone(&historical_bars))));
3522                {
3523                    let aggregator: &mut dyn BarAggregator = &mut aggregator;
3524                    $update(aggregator);
3525                }
3526
3527                assert_eq!(initial_bars.lock().len(), 0, "{}", $name,);
3528                assert_eq!(historical_bars.lock().len(), 1, "{}", $name,);
3529            }};
3530        }
3531
3532        let tick_type = BarType::new(
3533            instrument_id,
3534            BarSpecification::new(1, BarAggregation::Tick, PriceType::Last),
3535            AggregationSource::Internal,
3536        );
3537        assert_historical_sink_receives!(
3538            "TickBarAggregator",
3539            |bars| TickBarAggregator::new(
3540                tick_type,
3541                price_precision,
3542                size_precision,
3543                make_sink(bars)
3544            ),
3545            |aggregator: &mut dyn BarAggregator| {
3546                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3547            }
3548        );
3549
3550        let tick_imbalance_type = BarType::new(
3551            instrument_id,
3552            BarSpecification::new(1, BarAggregation::TickImbalance, PriceType::Last),
3553            AggregationSource::Internal,
3554        );
3555        assert_historical_sink_receives!(
3556            "TickImbalanceBarAggregator",
3557            |bars| TickImbalanceBarAggregator::new(
3558                tick_imbalance_type,
3559                price_precision,
3560                size_precision,
3561                make_sink(bars),
3562            ),
3563            |aggregator: &mut dyn BarAggregator| {
3564                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3565            }
3566        );
3567
3568        let tick_runs_type = BarType::new(
3569            instrument_id,
3570            BarSpecification::new(1, BarAggregation::TickRuns, PriceType::Last),
3571            AggregationSource::Internal,
3572        );
3573        assert_historical_sink_receives!(
3574            "TickRunsBarAggregator",
3575            |bars| TickRunsBarAggregator::new(
3576                tick_runs_type,
3577                price_precision,
3578                size_precision,
3579                make_sink(bars),
3580            ),
3581            |aggregator: &mut dyn BarAggregator| {
3582                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3583            }
3584        );
3585
3586        let volume_type = BarType::new(
3587            instrument_id,
3588            BarSpecification::new(1, BarAggregation::Volume, PriceType::Last),
3589            AggregationSource::Internal,
3590        );
3591        assert_historical_sink_receives!(
3592            "VolumeBarAggregator",
3593            |bars| VolumeBarAggregator::new(
3594                volume_type,
3595                price_precision,
3596                size_precision,
3597                make_sink(bars),
3598            ),
3599            |aggregator: &mut dyn BarAggregator| {
3600                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3601            }
3602        );
3603
3604        let volume_imbalance_type = BarType::new(
3605            instrument_id,
3606            BarSpecification::new(1, BarAggregation::VolumeImbalance, PriceType::Last),
3607            AggregationSource::Internal,
3608        );
3609        assert_historical_sink_receives!(
3610            "VolumeImbalanceBarAggregator",
3611            |bars| VolumeImbalanceBarAggregator::new(
3612                volume_imbalance_type,
3613                price_precision,
3614                size_precision,
3615                make_sink(bars),
3616            ),
3617            |aggregator: &mut dyn BarAggregator| {
3618                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3619            }
3620        );
3621
3622        let volume_runs_type = BarType::new(
3623            instrument_id,
3624            BarSpecification::new(1, BarAggregation::VolumeRuns, PriceType::Last),
3625            AggregationSource::Internal,
3626        );
3627        assert_historical_sink_receives!(
3628            "VolumeRunsBarAggregator",
3629            |bars| VolumeRunsBarAggregator::new(
3630                volume_runs_type,
3631                price_precision,
3632                size_precision,
3633                make_sink(bars),
3634            ),
3635            |aggregator: &mut dyn BarAggregator| {
3636                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3637            }
3638        );
3639
3640        let value_type = BarType::new(
3641            instrument_id,
3642            BarSpecification::new(100, BarAggregation::Value, PriceType::Last),
3643            AggregationSource::Internal,
3644        );
3645        assert_historical_sink_receives!(
3646            "ValueBarAggregator",
3647            |bars| ValueBarAggregator::new(
3648                value_type,
3649                price_precision,
3650                size_precision,
3651                make_sink(bars)
3652            ),
3653            |aggregator: &mut dyn BarAggregator| {
3654                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3655            }
3656        );
3657
3658        let value_imbalance_type = BarType::new(
3659            instrument_id,
3660            BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last),
3661            AggregationSource::Internal,
3662        );
3663        assert_historical_sink_receives!(
3664            "ValueImbalanceBarAggregator",
3665            |bars| ValueImbalanceBarAggregator::new(
3666                value_imbalance_type,
3667                price_precision,
3668                size_precision,
3669                make_sink(bars),
3670            ),
3671            |aggregator: &mut dyn BarAggregator| {
3672                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3673            }
3674        );
3675
3676        let value_runs_type = BarType::new(
3677            instrument_id,
3678            BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last),
3679            AggregationSource::Internal,
3680        );
3681        assert_historical_sink_receives!(
3682            "ValueRunsBarAggregator",
3683            |bars| ValueRunsBarAggregator::new(
3684                value_runs_type,
3685                price_precision,
3686                size_precision,
3687                make_sink(bars),
3688            ),
3689            |aggregator: &mut dyn BarAggregator| {
3690                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3691            }
3692        );
3693
3694        let fx = InstrumentAny::CurrencyPair(audusd_sim);
3695        let renko_type = BarType::new(
3696            fx.id(),
3697            BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid),
3698            AggregationSource::Internal,
3699        );
3700        let fx_price_precision = fx.price_precision();
3701        let fx_size_precision = fx.size_precision();
3702        let fx_price_increment = fx.price_increment();
3703        assert_historical_sink_receives!(
3704            "RenkoBarAggregator",
3705            |bars| RenkoBarAggregator::new(
3706                renko_type,
3707                fx_price_precision,
3708                fx_size_precision,
3709                fx_price_increment,
3710                make_sink(bars),
3711            ),
3712            |aggregator: &mut dyn BarAggregator| {
3713                aggregator.update(
3714                    Price::from("1.00000"),
3715                    Quantity::from(1),
3716                    UnixNanos::from(1_000),
3717                );
3718                aggregator.update(
3719                    Price::from("1.00010"),
3720                    Quantity::from(1),
3721                    UnixNanos::from(2_000),
3722                );
3723            }
3724        );
3725    }
3726
3727    #[rstest]
3728    fn test_tick_imbalance_bar_aggregator_emits_at_threshold(equity_aapl: Equity) {
3729        let instrument = InstrumentAny::Equity(equity_aapl);
3730        let bar_spec = BarSpecification::new(2, BarAggregation::TickImbalance, PriceType::Last);
3731        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3732        let (handler, record) = recording_handler();
3733
3734        let mut aggregator = TickImbalanceBarAggregator::new(
3735            bar_type,
3736            instrument.price_precision(),
3737            instrument.size_precision(),
3738            record,
3739        );
3740
3741        let trade = TradeTick::default();
3742        aggregator.handle_trade(trade);
3743        aggregator.handle_trade(trade);
3744
3745        let handler_guard = handler.lock();
3746        assert_eq!(handler_guard.len(), 1);
3747        let bar = handler_guard.first().unwrap();
3748        assert_eq!(bar.volume, Quantity::from(200000));
3749    }
3750
3751    #[rstest]
3752    fn test_tick_imbalance_bar_aggregator_handles_seller_direction(equity_aapl: Equity) {
3753        let instrument = InstrumentAny::Equity(equity_aapl);
3754        let bar_spec = BarSpecification::new(1, BarAggregation::TickImbalance, PriceType::Last);
3755        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3756        let (handler, record) = recording_handler();
3757
3758        let mut aggregator = TickImbalanceBarAggregator::new(
3759            bar_type,
3760            instrument.price_precision(),
3761            instrument.size_precision(),
3762            record,
3763        );
3764
3765        let sell = TradeTick {
3766            aggressor_side: AggressorSide::Sell,
3767            ..TradeTick::default()
3768        };
3769
3770        aggregator.handle_trade(sell);
3771
3772        let handler_guard = handler.lock();
3773        assert_eq!(handler_guard.len(), 1);
3774    }
3775
3776    #[rstest]
3777    fn test_tick_runs_bar_aggregator_resets_on_side_change(equity_aapl: Equity) {
3778        let instrument = InstrumentAny::Equity(equity_aapl);
3779        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
3780        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3781        let (handler, record) = recording_handler();
3782
3783        let mut aggregator = TickRunsBarAggregator::new(
3784            bar_type,
3785            instrument.price_precision(),
3786            instrument.size_precision(),
3787            record,
3788        );
3789
3790        let buy = TradeTick {
3791            instrument_id: instrument.id(),
3792            price: Price::from("100.00"),
3793            size: Quantity::from(1),
3794            ts_event: UnixNanos::from(1_000),
3795            ts_init: UnixNanos::from(1_000),
3796            ..TradeTick::default()
3797        };
3798        let sell_one = TradeTick {
3799            price: Price::from("200.00"),
3800            size: Quantity::from(2),
3801            aggressor_side: AggressorSide::Sell,
3802            ts_event: UnixNanos::from(2_000),
3803            ts_init: UnixNanos::from(2_000),
3804            ..buy
3805        };
3806        let sell_two = TradeTick {
3807            price: Price::from("201.00"),
3808            size: Quantity::from(3),
3809            ts_event: UnixNanos::from(3_000),
3810            ts_init: UnixNanos::from(3_000),
3811            ..sell_one
3812        };
3813
3814        aggregator.handle_trade(buy);
3815        aggregator.handle_trade(sell_one);
3816        aggregator.handle_trade(sell_two);
3817
3818        let handler_guard = handler.lock();
3819        assert_eq!(handler_guard.len(), 1);
3820        assert_eq!(handler_guard[0].open, Price::from("200.00"));
3821        assert_eq!(handler_guard[0].high, Price::from("201.00"));
3822        assert_eq!(handler_guard[0].low, Price::from("200.00"));
3823        assert_eq!(handler_guard[0].close, Price::from("201.00"));
3824        assert_eq!(handler_guard[0].volume, Quantity::from(5));
3825        assert_eq!(handler_guard[0].ts_event, UnixNanos::from(3_000));
3826        assert_eq!(handler_guard[0].ts_init, UnixNanos::from(3_000));
3827    }
3828
3829    #[rstest]
3830    fn test_tick_runs_bar_aggregator_volume_conservation(equity_aapl: Equity) {
3831        let instrument = InstrumentAny::Equity(equity_aapl);
3832        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
3833        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3834        let (handler, record) = recording_handler();
3835
3836        let mut aggregator = TickRunsBarAggregator::new(
3837            bar_type,
3838            instrument.price_precision(),
3839            instrument.size_precision(),
3840            record,
3841        );
3842
3843        let buy = TradeTick {
3844            size: Quantity::from(1),
3845            ..TradeTick::default()
3846        };
3847        let sell = TradeTick {
3848            aggressor_side: AggressorSide::Sell,
3849            size: Quantity::from(1),
3850            ..buy
3851        };
3852
3853        aggregator.handle_trade(buy);
3854        aggregator.handle_trade(buy);
3855        aggregator.handle_trade(sell);
3856        aggregator.handle_trade(sell);
3857
3858        let handler_guard = handler.lock();
3859        assert_eq!(handler_guard.len(), 2);
3860        assert_eq!(handler_guard[0].volume, Quantity::from(2));
3861        assert_eq!(handler_guard[1].volume, Quantity::from(2));
3862    }
3863
3864    #[rstest]
3865    fn test_volume_bar_aggregator_builds_multiple_bars_from_large_update(equity_aapl: Equity) {
3866        let instrument = InstrumentAny::Equity(equity_aapl);
3867        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
3868        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3869        let (handler, record) = recording_handler();
3870
3871        let mut aggregator = VolumeBarAggregator::new(
3872            bar_type,
3873            instrument.price_precision(),
3874            instrument.size_precision(),
3875            record,
3876        );
3877
3878        aggregator.update(
3879            Price::from("1.00001"),
3880            Quantity::from(25),
3881            UnixNanos::default(),
3882        );
3883
3884        let handler_guard = handler.lock();
3885        assert_eq!(handler_guard.len(), 2);
3886        let bar1 = &handler_guard[0];
3887        assert_eq!(bar1.volume, Quantity::from(10));
3888        let bar2 = &handler_guard[1];
3889        assert_eq!(bar2.volume, Quantity::from(10));
3890    }
3891
3892    #[rstest]
3893    fn test_volume_bar_aggregator_zero_size_update_is_noop(equity_aapl: Equity) {
3894        let instrument = InstrumentAny::Equity(equity_aapl);
3895        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
3896        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3897        let (handler, record) = recording_handler();
3898
3899        let mut aggregator = VolumeBarAggregator::new(
3900            bar_type,
3901            instrument.price_precision(),
3902            instrument.size_precision(),
3903            record,
3904        );
3905
3906        aggregator.update(
3907            Price::from("100.00"),
3908            Quantity::from(0),
3909            UnixNanos::default(),
3910        );
3911
3912        let handler_guard = handler.lock();
3913        assert_eq!(handler_guard.len(), 0);
3914    }
3915
3916    #[rstest]
3917    fn test_volume_bar_aggregator_ignores_out_of_order_update(equity_aapl: Equity) {
3918        let instrument = InstrumentAny::Equity(equity_aapl);
3919        let bar_spec = BarSpecification::new(2, BarAggregation::Volume, PriceType::Last);
3920        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3921        let (handler, record) = recording_handler();
3922
3923        let mut aggregator = VolumeBarAggregator::new(
3924            bar_type,
3925            instrument.price_precision(),
3926            instrument.size_precision(),
3927            record,
3928        );
3929
3930        aggregator.update(
3931            Price::from("100.00"),
3932            Quantity::from(1),
3933            UnixNanos::from(1_000),
3934        );
3935        aggregator.update(
3936            Price::from("200.00"),
3937            Quantity::from(3),
3938            UnixNanos::from(500),
3939        );
3940
3941        let handler_guard = handler.lock();
3942        assert!(handler_guard.is_empty());
3943        assert_eq!(aggregator.core.builder.count, 1);
3944        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
3945        assert_eq!(aggregator.core.builder.close, Some(Price::from("100.00")));
3946        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
3947    }
3948
3949    #[rstest]
3950    fn test_volume_bar_aggregator_ignores_out_of_order_bar(equity_aapl: Equity) {
3951        let instrument = InstrumentAny::Equity(equity_aapl);
3952        let bar_spec = BarSpecification::new(2, BarAggregation::Volume, PriceType::Last);
3953        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3954        let (handler, record) = recording_handler();
3955
3956        let mut aggregator = VolumeBarAggregator::new(
3957            bar_type,
3958            instrument.price_precision(),
3959            instrument.size_precision(),
3960            record,
3961        );
3962
3963        aggregator.update(
3964            Price::from("100.00"),
3965            Quantity::from(1),
3966            UnixNanos::from(1_000),
3967        );
3968        let stale_bar = Bar::new(
3969            bar_type,
3970            Price::from("200.00"),
3971            Price::from("201.00"),
3972            Price::from("199.00"),
3973            Price::from("200.50"),
3974            Quantity::from(3),
3975            UnixNanos::from(500),
3976            UnixNanos::from(500),
3977        );
3978        aggregator.update_bar(stale_bar, stale_bar.volume, stale_bar.ts_init);
3979
3980        let handler_guard = handler.lock();
3981        assert!(handler_guard.is_empty());
3982        assert_eq!(aggregator.core.builder.count, 1);
3983        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
3984        assert_eq!(aggregator.core.builder.close, Some(Price::from("100.00")));
3985        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
3986    }
3987
3988    #[rstest]
3989    fn test_volume_imbalance_bar_aggregator_ignores_out_of_order_trade(equity_aapl: Equity) {
3990        let instrument = InstrumentAny::Equity(equity_aapl);
3991        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeImbalance, PriceType::Last);
3992        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3993        let (handler, record) = recording_handler();
3994        let mut aggregator = VolumeImbalanceBarAggregator::new(
3995            bar_type,
3996            instrument.price_precision(),
3997            instrument.size_precision(),
3998            record,
3999        );
4000        let first = TradeTick {
4001            price: Price::from("100.00"),
4002            size: Quantity::from(1),
4003            aggressor_side: AggressorSide::Buy,
4004            ts_init: UnixNanos::from(1_000),
4005            ..TradeTick::default()
4006        };
4007        let stale = TradeTick {
4008            price: Price::from("200.00"),
4009            size: Quantity::from(2),
4010            aggressor_side: AggressorSide::Buy,
4011            ts_init: UnixNanos::from(500),
4012            ..TradeTick::default()
4013        };
4014
4015        aggregator.handle_trade(first);
4016        aggregator.handle_trade(stale);
4017
4018        assert!(handler.lock().is_empty());
4019        assert_eq!(aggregator.imbalance, Quantity::from(1));
4020        assert_eq!(aggregator.imbalance_side, AggressorSide::Buy);
4021        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4022        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
4023    }
4024
4025    #[rstest]
4026    #[case(BarAggregation::TickImbalance)]
4027    #[case(BarAggregation::TickRuns)]
4028    #[case(BarAggregation::VolumeRuns)]
4029    #[case(BarAggregation::ValueImbalance)]
4030    #[case(BarAggregation::ValueRuns)]
4031    fn test_stateful_trade_aggregators_ignore_out_of_order_trade(
4032        equity_aapl: Equity,
4033        #[case] aggregation: BarAggregation,
4034    ) {
4035        let instrument = InstrumentAny::Equity(equity_aapl);
4036        let (step, price) = match aggregation {
4037            BarAggregation::ValueImbalance | BarAggregation::ValueRuns => {
4038                (100, Price::from("50.00"))
4039            }
4040            _ => (2, Price::from("100.00")),
4041        };
4042        let bar_type = BarType::new(
4043            instrument.id(),
4044            BarSpecification::new(step, aggregation, PriceType::Last),
4045            AggregationSource::Internal,
4046        );
4047        let (handler, record) = recording_handler();
4048        let make_handler = record;
4049        let mut aggregator: Box<dyn BarAggregator> = match aggregation {
4050            BarAggregation::TickImbalance => Box::new(TickImbalanceBarAggregator::new(
4051                bar_type,
4052                instrument.price_precision(),
4053                instrument.size_precision(),
4054                make_handler,
4055            )),
4056            BarAggregation::TickRuns => Box::new(TickRunsBarAggregator::new(
4057                bar_type,
4058                instrument.price_precision(),
4059                instrument.size_precision(),
4060                make_handler,
4061            )),
4062            BarAggregation::VolumeRuns => Box::new(VolumeRunsBarAggregator::new(
4063                bar_type,
4064                instrument.price_precision(),
4065                instrument.size_precision(),
4066                make_handler,
4067            )),
4068            BarAggregation::ValueImbalance => Box::new(ValueImbalanceBarAggregator::new(
4069                bar_type,
4070                instrument.price_precision(),
4071                instrument.size_precision(),
4072                make_handler,
4073            )),
4074            BarAggregation::ValueRuns => Box::new(ValueRunsBarAggregator::new(
4075                bar_type,
4076                instrument.price_precision(),
4077                instrument.size_precision(),
4078                make_handler,
4079            )),
4080            _ => unreachable!(),
4081        };
4082        let first = TradeTick {
4083            instrument_id: instrument.id(),
4084            price,
4085            size: Quantity::from(1),
4086            aggressor_side: AggressorSide::Buy,
4087            ts_event: UnixNanos::from(1_000),
4088            ts_init: UnixNanos::from(1_000),
4089            ..TradeTick::default()
4090        };
4091        let stale = TradeTick {
4092            price: Price::from("999.00"),
4093            ts_event: UnixNanos::from(500),
4094            ts_init: UnixNanos::from(500),
4095            ..first
4096        };
4097        let second = TradeTick {
4098            ts_event: UnixNanos::from(2_000),
4099            ts_init: UnixNanos::from(2_000),
4100            ..first
4101        };
4102
4103        aggregator.handle_trade(first);
4104        aggregator.handle_trade(stale);
4105        aggregator.handle_trade(second);
4106
4107        let bars = handler.lock();
4108        assert_eq!(bars.len(), 1);
4109        assert_eq!(bars[0].open, price);
4110        assert_eq!(bars[0].high, price);
4111        assert_eq!(bars[0].low, price);
4112        assert_eq!(bars[0].close, price);
4113        assert_eq!(bars[0].volume, Quantity::from(2));
4114        assert_eq!(bars[0].ts_event, UnixNanos::from(2_000));
4115        assert_eq!(bars[0].ts_init, UnixNanos::from(2_000));
4116    }
4117
4118    #[rstest]
4119    fn test_volume_bar_aggregator_exact_threshold_emits_single_bar(equity_aapl: Equity) {
4120        let instrument = InstrumentAny::Equity(equity_aapl);
4121        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
4122        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4123        let (handler, record) = recording_handler();
4124
4125        let mut aggregator = VolumeBarAggregator::new(
4126            bar_type,
4127            instrument.price_precision(),
4128            instrument.size_precision(),
4129            record,
4130        );
4131
4132        aggregator.update(
4133            Price::from("100.00"),
4134            Quantity::from(7),
4135            UnixNanos::from(1_000),
4136        );
4137        aggregator.update(
4138            Price::from("101.00"),
4139            Quantity::from(3),
4140            UnixNanos::from(2_000),
4141        );
4142
4143        let handler_guard = handler.lock();
4144        assert_eq!(handler_guard.len(), 1);
4145        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4146        assert_eq!(handler_guard[0].close, Price::from("101.00"));
4147    }
4148
4149    #[rstest]
4150    fn test_volume_bar_aggregator_step_of_one_emits_per_unit(equity_aapl: Equity) {
4151        let instrument = InstrumentAny::Equity(equity_aapl);
4152        let bar_spec = BarSpecification::new(1, BarAggregation::Volume, PriceType::Last);
4153        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4154        let (handler, record) = recording_handler();
4155
4156        let mut aggregator = VolumeBarAggregator::new(
4157            bar_type,
4158            instrument.price_precision(),
4159            instrument.size_precision(),
4160            record,
4161        );
4162
4163        aggregator.update(
4164            Price::from("100.00"),
4165            Quantity::from(1),
4166            UnixNanos::default(),
4167        );
4168
4169        let handler_guard = handler.lock();
4170        assert_eq!(handler_guard.len(), 1);
4171        assert_eq!(handler_guard[0].volume, Quantity::from(1));
4172    }
4173
4174    #[rstest]
4175    fn test_volume_runs_bar_aggregator_side_change_resets(equity_aapl: Equity) {
4176        let instrument = InstrumentAny::Equity(equity_aapl);
4177        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeRuns, PriceType::Last);
4178        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4179        let (handler, record) = recording_handler();
4180
4181        let mut aggregator = VolumeRunsBarAggregator::new(
4182            bar_type,
4183            instrument.price_precision(),
4184            instrument.size_precision(),
4185            record,
4186        );
4187
4188        let buy = TradeTick {
4189            instrument_id: instrument.id(),
4190            price: Price::from("100.00"),
4191            size: Quantity::from(4),
4192            ts_event: UnixNanos::from(1_000),
4193            ts_init: UnixNanos::from(1_000),
4194            ..TradeTick::default()
4195        };
4196        let sell_one = TradeTick {
4197            price: Price::from("200.00"),
4198            size: Quantity::from(6),
4199            aggressor_side: AggressorSide::Sell,
4200            ts_event: UnixNanos::from(2_000),
4201            ts_init: UnixNanos::from(2_000),
4202            ..buy
4203        };
4204        let sell_two = TradeTick {
4205            price: Price::from("201.00"),
4206            size: Quantity::from(4),
4207            ts_event: UnixNanos::from(3_000),
4208            ts_init: UnixNanos::from(3_000),
4209            ..sell_one
4210        };
4211
4212        aggregator.handle_trade(buy);
4213        aggregator.handle_trade(sell_one);
4214        aggregator.handle_trade(sell_two);
4215
4216        let handler_guard = handler.lock();
4217        assert_eq!(handler_guard.len(), 1);
4218        assert_eq!(handler_guard[0].open, Price::from("200.00"));
4219        assert_eq!(handler_guard[0].high, Price::from("201.00"));
4220        assert_eq!(handler_guard[0].low, Price::from("200.00"));
4221        assert_eq!(handler_guard[0].close, Price::from("201.00"));
4222        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4223        assert_eq!(handler_guard[0].ts_event, UnixNanos::from(3_000));
4224        assert_eq!(handler_guard[0].ts_init, UnixNanos::from(3_000));
4225    }
4226
4227    #[rstest]
4228    fn test_volume_runs_bar_aggregator_handles_large_single_trade(equity_aapl: Equity) {
4229        let instrument = InstrumentAny::Equity(equity_aapl);
4230        let bar_spec = BarSpecification::new(3, BarAggregation::VolumeRuns, PriceType::Last);
4231        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4232        let (handler, record) = recording_handler();
4233
4234        let mut aggregator = VolumeRunsBarAggregator::new(
4235            bar_type,
4236            instrument.price_precision(),
4237            instrument.size_precision(),
4238            record,
4239        );
4240
4241        let trade = TradeTick {
4242            instrument_id: instrument.id(),
4243            price: Price::from("1.0"),
4244            size: Quantity::from(5),
4245            ..TradeTick::default()
4246        };
4247
4248        aggregator.handle_trade(trade);
4249
4250        let handler_guard = handler.lock();
4251        assert!(!handler_guard.is_empty());
4252        assert!(handler_guard[0].volume.as_f64() > 0.0);
4253        assert!(handler_guard[0].volume.as_f64() < trade.size.as_f64());
4254    }
4255
4256    #[rstest]
4257    fn test_volume_imbalance_bar_aggregator_splits_large_trade(equity_aapl: Equity) {
4258        let instrument = InstrumentAny::Equity(equity_aapl);
4259        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeImbalance, PriceType::Last);
4260        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4261        let (handler, record) = recording_handler();
4262
4263        let mut aggregator = VolumeImbalanceBarAggregator::new(
4264            bar_type,
4265            instrument.price_precision(),
4266            instrument.size_precision(),
4267            record,
4268        );
4269
4270        let trade_small = TradeTick {
4271            instrument_id: instrument.id(),
4272            price: Price::from("1.0"),
4273            size: Quantity::from(1),
4274            ..TradeTick::default()
4275        };
4276        let trade_large = TradeTick {
4277            size: Quantity::from(3),
4278            ..trade_small
4279        };
4280
4281        aggregator.handle_trade(trade_small);
4282        aggregator.handle_trade(trade_large);
4283
4284        let handler_guard = handler.lock();
4285        assert_eq!(handler_guard.len(), 2);
4286        let total_output = handler_guard
4287            .iter()
4288            .map(|bar| bar.volume.as_f64())
4289            .sum::<f64>();
4290        let total_input = trade_small.size.as_f64() + trade_large.size.as_f64();
4291        assert!((total_output - total_input).abs() < f64::EPSILON);
4292    }
4293
4294    #[rstest]
4295    fn test_value_bar_aggregator_builds_at_value_threshold(equity_aapl: Equity) {
4296        let instrument = InstrumentAny::Equity(equity_aapl);
4297        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last); // $1000 value step
4298        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4299        let (handler, record) = recording_handler();
4300
4301        let mut aggregator = ValueBarAggregator::new(
4302            bar_type,
4303            instrument.price_precision(),
4304            instrument.size_precision(),
4305            record,
4306        );
4307
4308        // Updates to reach value threshold: 100 * 5 + 100 * 5 = $1000
4309        aggregator.update(
4310            Price::from("100.00"),
4311            Quantity::from(5),
4312            UnixNanos::default(),
4313        );
4314        aggregator.update(
4315            Price::from("100.00"),
4316            Quantity::from(5),
4317            UnixNanos::from(1000),
4318        );
4319
4320        let handler_guard = handler.lock();
4321        assert_eq!(handler_guard.len(), 1);
4322        let bar = handler_guard.first().unwrap();
4323        assert_eq!(bar.volume, Quantity::from(10));
4324    }
4325
4326    #[rstest]
4327    fn test_value_bar_aggregator_handles_large_update(equity_aapl: Equity) {
4328        let instrument = InstrumentAny::Equity(equity_aapl);
4329        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4330        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4331        let (handler, record) = recording_handler();
4332
4333        let mut aggregator = ValueBarAggregator::new(
4334            bar_type,
4335            instrument.price_precision(),
4336            instrument.size_precision(),
4337            record,
4338        );
4339
4340        // Single large update: $100 * 25 = $2500 (should create 2 bars)
4341        aggregator.update(
4342            Price::from("100.00"),
4343            Quantity::from(25),
4344            UnixNanos::default(),
4345        );
4346
4347        let handler_guard = handler.lock();
4348        assert_eq!(handler_guard.len(), 2);
4349        let remaining_value = aggregator.get_cumulative_value();
4350        assert!(remaining_value < Decimal::from(1_000)); // Should be less than threshold
4351    }
4352
4353    #[rstest]
4354    fn test_value_bar_aggregator_handles_zero_price(equity_aapl: Equity) {
4355        let instrument = InstrumentAny::Equity(equity_aapl);
4356        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4357        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4358        let (handler, record) = recording_handler();
4359
4360        let mut aggregator = ValueBarAggregator::new(
4361            bar_type,
4362            instrument.price_precision(),
4363            instrument.size_precision(),
4364            record,
4365        );
4366
4367        // Update with zero price should not cause division by zero
4368        aggregator.update(
4369            Price::from("0.00"),
4370            Quantity::from(100),
4371            UnixNanos::default(),
4372        );
4373
4374        // No bars should be emitted since value is zero
4375        let handler_guard = handler.lock();
4376        assert_eq!(handler_guard.len(), 0);
4377
4378        // Cumulative value should remain zero
4379        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4380    }
4381
4382    #[rstest]
4383    fn test_value_bar_aggregator_handles_zero_size(equity_aapl: Equity) {
4384        let instrument = InstrumentAny::Equity(equity_aapl);
4385        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4386        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4387        let (handler, record) = recording_handler();
4388
4389        let mut aggregator = ValueBarAggregator::new(
4390            bar_type,
4391            instrument.price_precision(),
4392            instrument.size_precision(),
4393            record,
4394        );
4395
4396        // Update with zero size should not cause issues
4397        aggregator.update(
4398            Price::from("100.00"),
4399            Quantity::from(0),
4400            UnixNanos::default(),
4401        );
4402
4403        // No bars should be emitted
4404        let handler_guard = handler.lock();
4405        assert_eq!(handler_guard.len(), 0);
4406
4407        // Cumulative value should remain zero
4408        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4409    }
4410
4411    #[rstest]
4412    fn test_value_bar_aggregator_conserves_volume_across_rounded_chunks(equity_aapl: Equity) {
4413        let instrument = InstrumentAny::Equity(equity_aapl);
4414        let bar_spec = BarSpecification::new(10, BarAggregation::Value, PriceType::Last);
4415        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4416        let (handler, record) = recording_handler();
4417
4418        let mut aggregator = ValueBarAggregator::new(
4419            bar_type,
4420            instrument.price_precision(),
4421            instrument.size_precision(),
4422            record,
4423        );
4424
4425        // Step 10 at price 3.00 needs fractional 3.33... chunks; the rounded
4426        // 3-unit chunks must still conserve the 10 input units (3 + 3 + 3 + 1)
4427        aggregator.update(
4428            Price::from("3.00"),
4429            Quantity::from(10),
4430            UnixNanos::from(1_000),
4431        );
4432
4433        let handler_guard = handler.lock();
4434        assert_eq!(handler_guard.len(), 3);
4435        for bar in handler_guard.iter() {
4436            assert_eq!(bar.volume, Quantity::from(3));
4437        }
4438        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4439    }
4440
4441    #[rstest]
4442    fn test_value_bar_aggregator_update_bar_conserves_volume_across_rounded_chunks(
4443        equity_aapl: Equity,
4444    ) {
4445        let instrument = InstrumentAny::Equity(equity_aapl);
4446        let bar_spec = BarSpecification::new(10, BarAggregation::Value, PriceType::Last);
4447        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4448        let (handler, record) = recording_handler();
4449
4450        let mut aggregator = ValueBarAggregator::new(
4451            bar_type,
4452            instrument.price_precision(),
4453            instrument.size_precision(),
4454            record,
4455        );
4456
4457        // Average price 3.00 with volume 10 mirrors the tick-path conservation case
4458        let input_bar = Bar::new(
4459            bar_type,
4460            Price::from("3.00"),
4461            Price::from("3.00"),
4462            Price::from("3.00"),
4463            Price::from("3.00"),
4464            Quantity::from(10),
4465            UnixNanos::from(1_000),
4466            UnixNanos::from(1_000),
4467        );
4468        aggregator.handle_bar(input_bar);
4469
4470        let handler_guard = handler.lock();
4471        assert_eq!(handler_guard.len(), 3);
4472        for bar in handler_guard.iter() {
4473            assert_eq!(bar.volume, Quantity::from(3));
4474        }
4475        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4476    }
4477
4478    #[rstest]
4479    fn test_value_bar_aggregator_exact_threshold_emits_one_bar(equity_aapl: Equity) {
4480        let instrument = InstrumentAny::Equity(equity_aapl);
4481        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4482        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4483        let (handler, record) = recording_handler();
4484
4485        let mut aggregator = ValueBarAggregator::new(
4486            bar_type,
4487            instrument.price_precision(),
4488            instrument.size_precision(),
4489            record,
4490        );
4491
4492        aggregator.update(
4493            Price::from("100.00"),
4494            Quantity::from(5),
4495            UnixNanos::from(1_000),
4496        );
4497        aggregator.update(
4498            Price::from("100.00"),
4499            Quantity::from(5),
4500            UnixNanos::from(2_000),
4501        );
4502
4503        let handler_guard = handler.lock();
4504        assert_eq!(handler_guard.len(), 1);
4505        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4506        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4507    }
4508
4509    #[rstest]
4510    fn test_value_bar_aggregator_precision_boundary_min_size_clamp(equity_aapl: Equity) {
4511        // step=100, price=100 per-unit value=100 with size_precision=0 lands the divided
4512        // size_chunk at the precision floor. Verifies the min-size clamp branch in update()
4513        // emits one bar per unit rather than looping on zero-volume chunks.
4514        let instrument = InstrumentAny::Equity(equity_aapl);
4515        let bar_spec = BarSpecification::new(100, BarAggregation::Value, PriceType::Last);
4516        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4517        let (handler, record) = recording_handler();
4518
4519        let mut aggregator = ValueBarAggregator::new(
4520            bar_type,
4521            instrument.price_precision(),
4522            instrument.size_precision(),
4523            record,
4524        );
4525
4526        // 4 units at $100 = $400 value, with step $100 gives 4 bars exactly.
4527        aggregator.update(
4528            Price::from("100.00"),
4529            Quantity::from(4),
4530            UnixNanos::default(),
4531        );
4532
4533        let handler_guard = handler.lock();
4534        assert_eq!(handler_guard.len(), 4);
4535        for bar in handler_guard.iter() {
4536            assert_eq!(bar.volume, Quantity::from(1));
4537        }
4538    }
4539
4540    #[rstest]
4541    fn test_value_imbalance_bar_aggregator_emits_on_opposing_overflow(equity_aapl: Equity) {
4542        let instrument = InstrumentAny::Equity(equity_aapl);
4543        let bar_spec = BarSpecification::new(10, BarAggregation::ValueImbalance, PriceType::Last);
4544        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4545        let (handler, record) = recording_handler();
4546
4547        let mut aggregator = ValueImbalanceBarAggregator::new(
4548            bar_type,
4549            instrument.price_precision(),
4550            instrument.size_precision(),
4551            record,
4552        );
4553
4554        let buy = TradeTick {
4555            price: Price::from("5.0"),
4556            size: Quantity::from(2), // value 10, should emit one bar
4557            instrument_id: instrument.id(),
4558            ..TradeTick::default()
4559        };
4560        let sell = TradeTick {
4561            price: Price::from("5.0"),
4562            size: Quantity::from(2), // value 10, should emit another bar
4563            aggressor_side: AggressorSide::Sell,
4564            instrument_id: instrument.id(),
4565            ..buy
4566        };
4567
4568        aggregator.handle_trade(buy);
4569        aggregator.handle_trade(sell);
4570
4571        let handler_guard = handler.lock();
4572        assert_eq!(handler_guard.len(), 2);
4573    }
4574
4575    #[rstest]
4576    fn test_value_runs_bar_aggregator_emits_on_consecutive_side(equity_aapl: Equity) {
4577        let instrument = InstrumentAny::Equity(equity_aapl);
4578        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4579        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4580        let (handler, record) = recording_handler();
4581
4582        let mut aggregator = ValueRunsBarAggregator::new(
4583            bar_type,
4584            instrument.price_precision(),
4585            instrument.size_precision(),
4586            record,
4587        );
4588
4589        let trade = TradeTick {
4590            price: Price::from("10.0"),
4591            size: Quantity::from(5),
4592            instrument_id: instrument.id(),
4593            ..TradeTick::default()
4594        };
4595
4596        aggregator.handle_trade(trade);
4597        aggregator.handle_trade(trade);
4598
4599        let handler_guard = handler.lock();
4600        assert_eq!(handler_guard.len(), 1);
4601        let bar = handler_guard.first().unwrap();
4602        assert_eq!(bar.volume, Quantity::from(10));
4603    }
4604
4605    #[rstest]
4606    fn test_value_runs_bar_aggregator_resets_on_side_change(equity_aapl: Equity) {
4607        let instrument = InstrumentAny::Equity(equity_aapl);
4608        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4609        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4610        let (handler, record) = recording_handler();
4611
4612        let mut aggregator = ValueRunsBarAggregator::new(
4613            bar_type,
4614            instrument.price_precision(),
4615            instrument.size_precision(),
4616            record,
4617        );
4618
4619        let buy = TradeTick {
4620            price: Price::from("10.0"),
4621            size: Quantity::from(5),
4622            instrument_id: instrument.id(),
4623            ..TradeTick::default()
4624        }; // value 50
4625        let sell = TradeTick {
4626            price: Price::from("10.0"),
4627            size: Quantity::from(10),
4628            aggressor_side: AggressorSide::Sell,
4629            ..buy
4630        }; // value 100
4631
4632        aggregator.handle_trade(buy);
4633        aggregator.handle_trade(sell);
4634
4635        let handler_guard = handler.lock();
4636        assert_eq!(handler_guard.len(), 1);
4637        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4638    }
4639
4640    #[rstest]
4641    fn test_tick_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4642        let instrument = InstrumentAny::Equity(equity_aapl);
4643        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
4644        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4645        let (handler, record) = recording_handler();
4646
4647        let mut aggregator = TickRunsBarAggregator::new(
4648            bar_type,
4649            instrument.price_precision(),
4650            instrument.size_precision(),
4651            record,
4652        );
4653
4654        let buy = TradeTick::default();
4655
4656        aggregator.handle_trade(buy);
4657        aggregator.handle_trade(buy); // Emit bar 1 (run complete)
4658        aggregator.handle_trade(buy); // Start new run
4659        aggregator.handle_trade(buy); // Emit bar 2 (new run complete)
4660
4661        let handler_guard = handler.lock();
4662        assert_eq!(handler_guard.len(), 2);
4663    }
4664
4665    #[rstest]
4666    fn test_tick_runs_bar_aggregator_handles_no_aggressor_trades(equity_aapl: Equity) {
4667        let instrument = InstrumentAny::Equity(equity_aapl);
4668        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
4669        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4670        let (handler, record) = recording_handler();
4671
4672        let mut aggregator = TickRunsBarAggregator::new(
4673            bar_type,
4674            instrument.price_precision(),
4675            instrument.size_precision(),
4676            record,
4677        );
4678
4679        let buy = TradeTick::default();
4680        let no_aggressor = TradeTick {
4681            aggressor_side: AggressorSide::NoAggressor,
4682            ..buy
4683        };
4684
4685        aggregator.handle_trade(buy);
4686        aggregator.handle_trade(no_aggressor); // Should not affect run count
4687        aggregator.handle_trade(no_aggressor); // Should not affect run count
4688        aggregator.handle_trade(buy); // Continue run to threshold
4689
4690        let handler_guard = handler.lock();
4691        assert_eq!(handler_guard.len(), 1);
4692    }
4693
4694    #[rstest]
4695    fn test_volume_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4696        let instrument = InstrumentAny::Equity(equity_aapl);
4697        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeRuns, PriceType::Last);
4698        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4699        let (handler, record) = recording_handler();
4700
4701        let mut aggregator = VolumeRunsBarAggregator::new(
4702            bar_type,
4703            instrument.price_precision(),
4704            instrument.size_precision(),
4705            record,
4706        );
4707
4708        let buy = TradeTick {
4709            instrument_id: instrument.id(),
4710            price: Price::from("1.0"),
4711            size: Quantity::from(1),
4712            ..TradeTick::default()
4713        };
4714
4715        aggregator.handle_trade(buy);
4716        aggregator.handle_trade(buy); // Emit bar 1 (2.0 volume reached)
4717        aggregator.handle_trade(buy); // Start new run
4718        aggregator.handle_trade(buy); // Emit bar 2 (new 2.0 volume reached)
4719
4720        let handler_guard = handler.lock();
4721        assert_eq!(handler_guard.len(), 2);
4722        assert_eq!(handler_guard[0].volume, Quantity::from(2));
4723        assert_eq!(handler_guard[1].volume, Quantity::from(2));
4724    }
4725
4726    #[rstest]
4727    fn test_value_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4728        let instrument = InstrumentAny::Equity(equity_aapl);
4729        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4730        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4731        let (handler, record) = recording_handler();
4732
4733        let mut aggregator = ValueRunsBarAggregator::new(
4734            bar_type,
4735            instrument.price_precision(),
4736            instrument.size_precision(),
4737            record,
4738        );
4739
4740        let buy = TradeTick {
4741            instrument_id: instrument.id(),
4742            price: Price::from("10.0"),
4743            size: Quantity::from(5),
4744            ..TradeTick::default()
4745        }; // value 50 per trade
4746
4747        aggregator.handle_trade(buy);
4748        aggregator.handle_trade(buy); // Emit bar 1 (100 value reached)
4749        aggregator.handle_trade(buy); // Start new run
4750        aggregator.handle_trade(buy); // Emit bar 2 (new 100 value reached)
4751
4752        let handler_guard = handler.lock();
4753        assert_eq!(handler_guard.len(), 2);
4754        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4755        assert_eq!(handler_guard[1].volume, Quantity::from(10));
4756    }
4757
4758    #[rstest]
4759    fn test_time_bar_aggregator_builds_at_interval(equity_aapl: Equity) {
4760        let instrument = InstrumentAny::Equity(equity_aapl);
4761        // One second bars
4762        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4763        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4764        let (handler, record) = recording_handler();
4765        let clock = Rc::new(RefCell::new(TestClock::new()));
4766
4767        let mut aggregator = TimeBarAggregator::new(
4768            bar_type,
4769            instrument.price_precision(),
4770            instrument.size_precision(),
4771            clock.clone(),
4772            record,
4773            true,  // build_with_no_updates
4774            false, // timestamp_on_close
4775            BarIntervalType::LeftOpen,
4776            None,  // time_bars_origin_offset
4777            15,    // bar_build_delay
4778            false, // skip_first_non_full_bar
4779        );
4780
4781        aggregator.update(
4782            Price::from("100.00"),
4783            Quantity::from(1),
4784            UnixNanos::default(),
4785        );
4786
4787        let next_sec = UnixNanos::from(1_000_000_000);
4788        clock.borrow_mut().set_time(next_sec);
4789
4790        let event = TimeEvent::new(
4791            Ustr::from("1-SECOND-LAST"),
4792            UUID4::new(),
4793            next_sec,
4794            next_sec,
4795        );
4796        aggregator.build_bar(&event);
4797
4798        let handler_guard = handler.lock();
4799        assert_eq!(handler_guard.len(), 1);
4800        let bar = handler_guard.first().unwrap();
4801        assert_eq!(bar.ts_event, UnixNanos::default());
4802        assert_eq!(bar.ts_init, next_sec);
4803    }
4804
4805    #[rstest]
4806    fn test_time_bar_aggregator_stop_clears_timer_and_allows_restart(equity_aapl: Equity) {
4807        let instrument = InstrumentAny::Equity(equity_aapl);
4808        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4809        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4810        let timer_name = format!("TIME_BAR_{bar_type}");
4811        let clock = Rc::new(RefCell::new(TestClock::new()));
4812
4813        let aggregator = TimeBarAggregator::new(
4814            bar_type,
4815            instrument.price_precision(),
4816            instrument.size_precision(),
4817            clock.clone(),
4818            |_bar: Bar| {},
4819            true,
4820            false,
4821            BarIntervalType::LeftOpen,
4822            None,
4823            15,
4824            false,
4825        );
4826
4827        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
4828        let rc = Rc::new(RefCell::new(boxed));
4829
4830        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
4831        assert_eq!(clock.borrow().timer_names(), vec![timer_name.as_str()]);
4832
4833        rc.borrow_mut().stop();
4834        assert!(clock.borrow().timer_names().is_empty());
4835
4836        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
4837        assert_eq!(clock.borrow().timer_names(), vec![timer_name.as_str()]);
4838    }
4839
4840    #[rstest]
4841    fn test_time_bar_aggregator_accepts_interval_above_i64_nanos(equity_aapl: Equity) {
4842        let instrument = InstrumentAny::Equity(equity_aapl);
4843        let bar_spec = BarSpecification::new(106_752, BarAggregation::Day, PriceType::Last);
4844        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4845        let interval_ns = get_bar_interval_ns(&bar_type);
4846        let timer_name = format!("TIME_BAR_{bar_type}");
4847        let clock = Rc::new(RefCell::new(TestClock::new()));
4848        clock.borrow_mut().set_time(UnixNanos::from(1));
4849        let aggregator = TimeBarAggregator::new(
4850            bar_type,
4851            instrument.price_precision(),
4852            instrument.size_precision(),
4853            clock.clone(),
4854            |_bar: Bar| {},
4855            true,
4856            false,
4857            BarIntervalType::LeftOpen,
4858            None,
4859            0,
4860            false,
4861        );
4862        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
4863        let rc = Rc::new(RefCell::new(boxed));
4864
4865        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
4866
4867        assert_eq!(
4868            clock.borrow().next_time_ns(&timer_name),
4869            UnixNanos::from(1).checked_add(interval_ns)
4870        );
4871    }
4872
4873    #[rstest]
4874    fn test_time_bar_aggregator_left_open_interval(equity_aapl: Equity) {
4875        let instrument = InstrumentAny::Equity(equity_aapl);
4876        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4877        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4878        let (handler, record) = recording_handler();
4879        let clock = Rc::new(RefCell::new(TestClock::new()));
4880
4881        let mut aggregator = TimeBarAggregator::new(
4882            bar_type,
4883            instrument.price_precision(),
4884            instrument.size_precision(),
4885            clock.clone(),
4886            record,
4887            true, // build_with_no_updates
4888            true, // timestamp_on_close - changed to true to verify left-open behavior
4889            BarIntervalType::LeftOpen,
4890            None,
4891            15,
4892            false, // skip_first_non_full_bar
4893        );
4894
4895        // Update in first interval
4896        aggregator.update(
4897            Price::from("100.00"),
4898            Quantity::from(1),
4899            UnixNanos::default(),
4900        );
4901
4902        // First interval close
4903        let ts1 = UnixNanos::from(1_000_000_000);
4904        clock.borrow_mut().set_time(ts1);
4905        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
4906        aggregator.build_bar(&event);
4907
4908        // Update in second interval
4909        aggregator.update(Price::from("101.00"), Quantity::from(1), ts1);
4910
4911        // Second interval close
4912        let ts2 = UnixNanos::from(2_000_000_000);
4913        clock.borrow_mut().set_time(ts2);
4914        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
4915        aggregator.build_bar(&event);
4916
4917        let handler_guard = handler.lock();
4918        assert_eq!(handler_guard.len(), 2);
4919
4920        let bar1 = &handler_guard[0];
4921        assert_eq!(bar1.ts_event, ts1); // For left-open with timestamp_on_close=true
4922        assert_eq!(bar1.ts_init, ts1);
4923        assert_eq!(bar1.close, Price::from("100.00"));
4924        let bar2 = &handler_guard[1];
4925        assert_eq!(bar2.ts_event, ts2);
4926        assert_eq!(bar2.ts_init, ts2);
4927        assert_eq!(bar2.close, Price::from("101.00"));
4928    }
4929
4930    #[rstest]
4931    fn test_time_bar_aggregator_right_open_interval(equity_aapl: Equity) {
4932        let instrument = InstrumentAny::Equity(equity_aapl);
4933        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4934        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4935        let (handler, record) = recording_handler();
4936        let clock = Rc::new(RefCell::new(TestClock::new()));
4937        let mut aggregator = TimeBarAggregator::new(
4938            bar_type,
4939            instrument.price_precision(),
4940            instrument.size_precision(),
4941            clock.clone(),
4942            record,
4943            true, // build_with_no_updates
4944            true, // timestamp_on_close
4945            BarIntervalType::RightOpen,
4946            None,
4947            15,
4948            false, // skip_first_non_full_bar
4949        );
4950
4951        // Update in first interval
4952        aggregator.update(
4953            Price::from("100.00"),
4954            Quantity::from(1),
4955            UnixNanos::default(),
4956        );
4957
4958        // First interval close
4959        let ts1 = UnixNanos::from(1_000_000_000);
4960        clock.borrow_mut().set_time(ts1);
4961        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
4962        aggregator.build_bar(&event);
4963
4964        // Update in second interval
4965        aggregator.update(Price::from("101.00"), Quantity::from(1), ts1);
4966
4967        // Second interval close
4968        let ts2 = UnixNanos::from(2_000_000_000);
4969        clock.borrow_mut().set_time(ts2);
4970        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
4971        aggregator.build_bar(&event);
4972
4973        let handler_guard = handler.lock();
4974        assert_eq!(handler_guard.len(), 2);
4975
4976        let bar1 = &handler_guard[0];
4977        assert_eq!(bar1.ts_event, UnixNanos::default()); // Right-open interval starts inclusive
4978        assert_eq!(bar1.ts_init, ts1);
4979        assert_eq!(bar1.close, Price::from("100.00"));
4980
4981        let bar2 = &handler_guard[1];
4982        assert_eq!(bar2.ts_event, ts1);
4983        assert_eq!(bar2.ts_init, ts2);
4984        assert_eq!(bar2.close, Price::from("101.00"));
4985    }
4986
4987    #[rstest]
4988    fn test_time_bar_aggregator_no_updates_behavior(equity_aapl: Equity) {
4989        let instrument = InstrumentAny::Equity(equity_aapl);
4990        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4991        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4992        let (handler, record) = recording_handler();
4993        let clock = Rc::new(RefCell::new(TestClock::new()));
4994
4995        // First test with build_with_no_updates = false
4996        let mut aggregator = TimeBarAggregator::new(
4997            bar_type,
4998            instrument.price_precision(),
4999            instrument.size_precision(),
5000            clock.clone(),
5001            record,
5002            false, // build_with_no_updates disabled
5003            true,  // timestamp_on_close
5004            BarIntervalType::LeftOpen,
5005            None,
5006            15,
5007            false, // skip_first_non_full_bar
5008        );
5009
5010        // No updates, just interval close
5011        let ts1 = UnixNanos::from(1_000_000_000);
5012        clock.borrow_mut().set_time(ts1);
5013        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5014        aggregator.build_bar(&event);
5015
5016        let handler_guard = handler.lock();
5017        assert_eq!(handler_guard.len(), 0); // No bar should be built without updates
5018        drop(handler_guard);
5019
5020        // Now test with build_with_no_updates = true
5021        let (handler, record) = recording_handler();
5022        let mut aggregator = TimeBarAggregator::new(
5023            bar_type,
5024            instrument.price_precision(),
5025            instrument.size_precision(),
5026            clock.clone(),
5027            record,
5028            true, // build_with_no_updates enabled
5029            true, // timestamp_on_close
5030            BarIntervalType::LeftOpen,
5031            None,
5032            15,
5033            false, // skip_first_non_full_bar
5034        );
5035
5036        aggregator.update(
5037            Price::from("100.00"),
5038            Quantity::from(1),
5039            UnixNanos::default(),
5040        );
5041
5042        // First interval with update
5043        let ts1 = UnixNanos::from(1_000_000_000);
5044        clock.borrow_mut().set_time(ts1);
5045        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5046        aggregator.build_bar(&event);
5047
5048        // Second interval without updates
5049        let ts2 = UnixNanos::from(2_000_000_000);
5050        clock.borrow_mut().set_time(ts2);
5051        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5052        aggregator.build_bar(&event);
5053
5054        let handler_guard = handler.lock();
5055        assert_eq!(handler_guard.len(), 2); // Both bars should be built
5056        let bar1 = &handler_guard[0];
5057        assert_eq!(bar1.close, Price::from("100.00"));
5058        let bar2 = &handler_guard[1];
5059        assert_eq!(bar2.close, Price::from("100.00")); // Should use last close
5060    }
5061
5062    #[rstest]
5063    fn test_time_bar_aggregator_respects_timestamp_on_close(equity_aapl: Equity) {
5064        let instrument = InstrumentAny::Equity(equity_aapl);
5065        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
5066        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5067        let clock = Rc::new(RefCell::new(TestClock::new()));
5068        let (handler, record) = recording_handler();
5069
5070        let mut aggregator = TimeBarAggregator::new(
5071            bar_type,
5072            instrument.price_precision(),
5073            instrument.size_precision(),
5074            clock.clone(),
5075            record,
5076            true, // build_with_no_updates
5077            true, // timestamp_on_close
5078            BarIntervalType::RightOpen,
5079            None,
5080            15,
5081            false, // skip_first_non_full_bar
5082        );
5083
5084        let ts1 = UnixNanos::from(1_000_000_000);
5085        aggregator.update(Price::from("100.00"), Quantity::from(1), ts1);
5086
5087        let ts2 = UnixNanos::from(2_000_000_000);
5088        clock.borrow_mut().set_time(ts2);
5089
5090        // Simulate timestamp on close
5091        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5092        aggregator.build_bar(&event);
5093
5094        let handler_guard = handler.lock();
5095        let bar = handler_guard.first().unwrap();
5096        assert_eq!(bar.ts_event, UnixNanos::default());
5097        assert_eq!(bar.ts_init, ts2);
5098    }
5099
5100    #[rstest]
5101    fn test_renko_brick_preserves_subprecision_increment(audusd_sim: CurrencyPair) {
5102        let bar_type = BarType::new(
5103            audusd_sim.id(),
5104            BarSpecification::new(2, BarAggregation::Renko, PriceType::Last),
5105            AggregationSource::Internal,
5106        );
5107        let mut increment = Price::from("0.015");
5108        increment.precision = 2;
5109
5110        let aggregator = RenkoBarAggregator::new(bar_type, 2, 0, increment, |_| {});
5111        assert_eq!(aggregator.brick_size, Price::from("0.03"));
5112    }
5113
5114    #[rstest]
5115    fn test_renko_bar_aggregator_initialization(audusd_sim: CurrencyPair) {
5116        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5117        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5118        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5119        let (_handler, record) = recording_handler();
5120
5121        let aggregator = RenkoBarAggregator::new(
5122            bar_type,
5123            instrument.price_precision(),
5124            instrument.size_precision(),
5125            instrument.price_increment(),
5126            record,
5127        );
5128
5129        assert_eq!(aggregator.bar_type(), bar_type);
5130        assert!(!aggregator.is_running());
5131        // 10 pips * price_increment.raw (depends on precision mode)
5132        let expected_brick_size = Price::from_decimal_dp(
5133            instrument.price_increment() * Decimal::from(10),
5134            instrument.price_precision(),
5135        )
5136        .unwrap();
5137        assert_eq!(aggregator.brick_size, expected_brick_size);
5138    }
5139
5140    #[rstest]
5141    fn test_renko_bar_aggregator_update_below_brick_size_no_bar(audusd_sim: CurrencyPair) {
5142        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5143        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5144        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5145        let (handler, record) = recording_handler();
5146
5147        let mut aggregator = RenkoBarAggregator::new(
5148            bar_type,
5149            instrument.price_precision(),
5150            instrument.size_precision(),
5151            instrument.price_increment(),
5152            record,
5153        );
5154
5155        // Small price movement (5 pips, less than 10 pip brick size)
5156        aggregator.update(
5157            Price::from("1.00000"),
5158            Quantity::from(1),
5159            UnixNanos::default(),
5160        );
5161        aggregator.update(
5162            Price::from("1.00005"),
5163            Quantity::from(1),
5164            UnixNanos::from(1000),
5165        );
5166
5167        let handler_guard = handler.lock();
5168        assert_eq!(handler_guard.len(), 0); // No bar created yet
5169    }
5170
5171    #[rstest]
5172    fn test_renko_bar_aggregator_ignores_out_of_order_bar(audusd_sim: CurrencyPair) {
5173        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5174        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid);
5175        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5176        let (handler, record) = recording_handler();
5177        let mut aggregator = RenkoBarAggregator::new(
5178            bar_type,
5179            instrument.price_precision(),
5180            instrument.size_precision(),
5181            instrument.price_increment(),
5182            record,
5183        );
5184        let first = Bar::new(
5185            bar_type,
5186            Price::from("1.00000"),
5187            Price::from("1.00000"),
5188            Price::from("1.00000"),
5189            Price::from("1.00000"),
5190            Quantity::from(1),
5191            UnixNanos::from(1_000),
5192            UnixNanos::from(1_000),
5193        );
5194        let stale = Bar::new(
5195            bar_type,
5196            Price::from("1.00020"),
5197            Price::from("1.00020"),
5198            Price::from("1.00020"),
5199            Price::from("1.00020"),
5200            Quantity::from(1),
5201            UnixNanos::from(500),
5202            UnixNanos::from(500),
5203        );
5204
5205        aggregator.update_bar(first, first.volume, first.ts_init);
5206        aggregator.update_bar(stale, stale.volume, stale.ts_init);
5207
5208        assert!(handler.lock().is_empty());
5209        assert_eq!(aggregator.last_close, Some(Price::from("1.00000")));
5210        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
5211    }
5212
5213    #[rstest]
5214    fn test_renko_bar_aggregator_update_exceeds_brick_size_creates_bar(audusd_sim: CurrencyPair) {
5215        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5216        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5217        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5218        let (handler, record) = recording_handler();
5219
5220        let mut aggregator = RenkoBarAggregator::new(
5221            bar_type,
5222            instrument.price_precision(),
5223            instrument.size_precision(),
5224            instrument.price_increment(),
5225            record,
5226        );
5227
5228        // Price movement exceeding brick size (15 pips)
5229        aggregator.update(
5230            Price::from("1.00000"),
5231            Quantity::from(1),
5232            UnixNanos::default(),
5233        );
5234        aggregator.update(
5235            Price::from("1.00015"),
5236            Quantity::from(1),
5237            UnixNanos::from(1000),
5238        );
5239
5240        let handler_guard = handler.lock();
5241        assert_eq!(handler_guard.len(), 1);
5242
5243        let bar = handler_guard.first().unwrap();
5244        assert_eq!(bar.open, Price::from("1.00000"));
5245        assert_eq!(bar.high, Price::from("1.00010"));
5246        assert_eq!(bar.low, Price::from("1.00000"));
5247        assert_eq!(bar.close, Price::from("1.00010"));
5248        assert_eq!(bar.volume, Quantity::from(2));
5249        assert_eq!(bar.ts_event, UnixNanos::from(1000));
5250        assert_eq!(bar.ts_init, UnixNanos::from(1000));
5251    }
5252
5253    #[rstest]
5254    fn test_renko_bar_aggregator_multiple_bricks_in_one_update(audusd_sim: CurrencyPair) {
5255        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5256        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5257        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5258        let (handler, record) = recording_handler();
5259
5260        let mut aggregator = RenkoBarAggregator::new(
5261            bar_type,
5262            instrument.price_precision(),
5263            instrument.size_precision(),
5264            instrument.price_increment(),
5265            record,
5266        );
5267
5268        // Large price movement creating multiple bricks (25 pips = 2 bricks)
5269        aggregator.update(
5270            Price::from("1.00000"),
5271            Quantity::from(1),
5272            UnixNanos::default(),
5273        );
5274        aggregator.update(
5275            Price::from("1.00025"),
5276            Quantity::from(1),
5277            UnixNanos::from(1000),
5278        );
5279
5280        let handler_guard = handler.lock();
5281        assert_eq!(handler_guard.len(), 2);
5282
5283        let bar1 = &handler_guard[0];
5284        assert_eq!(bar1.open, Price::from("1.00000"));
5285        assert_eq!(bar1.high, Price::from("1.00010"));
5286        assert_eq!(bar1.low, Price::from("1.00000"));
5287        assert_eq!(bar1.close, Price::from("1.00010"));
5288
5289        let bar2 = &handler_guard[1];
5290        assert_eq!(bar2.open, Price::from("1.00010"));
5291        assert_eq!(bar2.high, Price::from("1.00020"));
5292        assert_eq!(bar2.low, Price::from("1.00010"));
5293        assert_eq!(bar2.close, Price::from("1.00020"));
5294    }
5295
5296    #[rstest]
5297    fn test_renko_bar_aggregator_downward_movement(audusd_sim: CurrencyPair) {
5298        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5299        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5300        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5301        let (handler, record) = recording_handler();
5302
5303        let mut aggregator = RenkoBarAggregator::new(
5304            bar_type,
5305            instrument.price_precision(),
5306            instrument.size_precision(),
5307            instrument.price_increment(),
5308            record,
5309        );
5310
5311        // Start at higher price and move down
5312        aggregator.update(
5313            Price::from("1.00020"),
5314            Quantity::from(1),
5315            UnixNanos::default(),
5316        );
5317        aggregator.update(
5318            Price::from("1.00005"),
5319            Quantity::from(1),
5320            UnixNanos::from(1000),
5321        );
5322
5323        let handler_guard = handler.lock();
5324        assert_eq!(handler_guard.len(), 1);
5325
5326        let bar = handler_guard.first().unwrap();
5327        assert_eq!(bar.open, Price::from("1.00020"));
5328        assert_eq!(bar.high, Price::from("1.00020"));
5329        assert_eq!(bar.low, Price::from("1.00010"));
5330        assert_eq!(bar.close, Price::from("1.00010"));
5331        assert_eq!(bar.volume, Quantity::from(2));
5332    }
5333
5334    #[rstest]
5335    fn test_renko_bar_aggregator_handle_bar_below_brick_size(audusd_sim: CurrencyPair) {
5336        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5337        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5338        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5339        let (handler, record) = recording_handler();
5340
5341        let mut aggregator = RenkoBarAggregator::new(
5342            bar_type,
5343            instrument.price_precision(),
5344            instrument.size_precision(),
5345            instrument.price_increment(),
5346            record,
5347        );
5348
5349        // Create a bar with small price movement (5 pips)
5350        let input_bar = Bar::new(
5351            BarType::new(
5352                instrument.id(),
5353                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5354                AggregationSource::Internal,
5355            ),
5356            Price::from("1.00000"),
5357            Price::from("1.00005"),
5358            Price::from("0.99995"),
5359            Price::from("1.00005"), // 5 pip move up (less than 10 pip brick)
5360            Quantity::from(100),
5361            UnixNanos::default(),
5362            UnixNanos::from(1000),
5363        );
5364
5365        aggregator.handle_bar(input_bar);
5366
5367        let handler_guard = handler.lock();
5368        assert_eq!(handler_guard.len(), 0); // No bar created yet
5369    }
5370
5371    #[rstest]
5372    fn test_renko_bar_aggregator_handle_bar_exceeds_brick_size(audusd_sim: CurrencyPair) {
5373        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5374        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5375        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5376        let (handler, record) = recording_handler();
5377
5378        let mut aggregator = RenkoBarAggregator::new(
5379            bar_type,
5380            instrument.price_precision(),
5381            instrument.size_precision(),
5382            instrument.price_increment(),
5383            record,
5384        );
5385
5386        // First bar to establish baseline
5387        let bar1 = Bar::new(
5388            BarType::new(
5389                instrument.id(),
5390                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5391                AggregationSource::Internal,
5392            ),
5393            Price::from("1.00000"),
5394            Price::from("1.00005"),
5395            Price::from("0.99995"),
5396            Price::from("1.00000"),
5397            Quantity::from(100),
5398            UnixNanos::default(),
5399            UnixNanos::default(),
5400        );
5401
5402        // Second bar with price movement exceeding brick size (10 pips)
5403        let bar2 = Bar::new(
5404            BarType::new(
5405                instrument.id(),
5406                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5407                AggregationSource::Internal,
5408            ),
5409            Price::from("1.00000"),
5410            Price::from("1.00015"),
5411            Price::from("0.99995"),
5412            Price::from("1.00010"), // 10 pip move up (exactly 1 brick)
5413            Quantity::from(50),
5414            UnixNanos::from(60_000_000_000),
5415            UnixNanos::from(60_000_000_000),
5416        );
5417
5418        aggregator.handle_bar(bar1);
5419        aggregator.handle_bar(bar2);
5420
5421        let handler_guard = handler.lock();
5422        assert_eq!(handler_guard.len(), 1);
5423
5424        let bar = handler_guard.first().unwrap();
5425        assert_eq!(bar.open, Price::from("1.00000"));
5426        assert_eq!(bar.high, Price::from("1.00010"));
5427        assert_eq!(bar.low, Price::from("1.00000"));
5428        assert_eq!(bar.close, Price::from("1.00010"));
5429        assert_eq!(bar.volume, Quantity::from(150));
5430    }
5431
5432    #[rstest]
5433    fn test_renko_bar_aggregator_handle_bar_multiple_bricks(audusd_sim: CurrencyPair) {
5434        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5435        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5436        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5437        let (handler, record) = recording_handler();
5438
5439        let mut aggregator = RenkoBarAggregator::new(
5440            bar_type,
5441            instrument.price_precision(),
5442            instrument.size_precision(),
5443            instrument.price_increment(),
5444            record,
5445        );
5446
5447        // First bar to establish baseline
5448        let bar1 = Bar::new(
5449            BarType::new(
5450                instrument.id(),
5451                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5452                AggregationSource::Internal,
5453            ),
5454            Price::from("1.00000"),
5455            Price::from("1.00005"),
5456            Price::from("0.99995"),
5457            Price::from("1.00000"),
5458            Quantity::from(100),
5459            UnixNanos::default(),
5460            UnixNanos::default(),
5461        );
5462
5463        // Second bar with large price movement (30 pips = 3 bricks)
5464        let bar2 = Bar::new(
5465            BarType::new(
5466                instrument.id(),
5467                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5468                AggregationSource::Internal,
5469            ),
5470            Price::from("1.00000"),
5471            Price::from("1.00035"),
5472            Price::from("0.99995"),
5473            Price::from("1.00030"), // 30 pip move up (exactly 3 bricks)
5474            Quantity::from(50),
5475            UnixNanos::from(60_000_000_000),
5476            UnixNanos::from(60_000_000_000),
5477        );
5478
5479        aggregator.handle_bar(bar1);
5480        aggregator.handle_bar(bar2);
5481
5482        let handler_guard = handler.lock();
5483        assert_eq!(handler_guard.len(), 3);
5484
5485        let bar1 = &handler_guard[0];
5486        assert_eq!(bar1.open, Price::from("1.00000"));
5487        assert_eq!(bar1.close, Price::from("1.00010"));
5488
5489        let bar2 = &handler_guard[1];
5490        assert_eq!(bar2.open, Price::from("1.00010"));
5491        assert_eq!(bar2.close, Price::from("1.00020"));
5492
5493        let bar3 = &handler_guard[2];
5494        assert_eq!(bar3.open, Price::from("1.00020"));
5495        assert_eq!(bar3.close, Price::from("1.00030"));
5496    }
5497
5498    #[rstest]
5499    fn test_renko_bar_aggregator_handle_bar_downward_movement(audusd_sim: CurrencyPair) {
5500        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5501        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5502        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5503        let (handler, record) = recording_handler();
5504
5505        let mut aggregator = RenkoBarAggregator::new(
5506            bar_type,
5507            instrument.price_precision(),
5508            instrument.size_precision(),
5509            instrument.price_increment(),
5510            record,
5511        );
5512
5513        // First bar to establish baseline
5514        let bar1 = Bar::new(
5515            BarType::new(
5516                instrument.id(),
5517                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5518                AggregationSource::Internal,
5519            ),
5520            Price::from("1.00020"),
5521            Price::from("1.00025"),
5522            Price::from("1.00015"),
5523            Price::from("1.00020"),
5524            Quantity::from(100),
5525            UnixNanos::default(),
5526            UnixNanos::default(),
5527        );
5528
5529        // Second bar with downward price movement (10 pips down)
5530        let bar2 = Bar::new(
5531            BarType::new(
5532                instrument.id(),
5533                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5534                AggregationSource::Internal,
5535            ),
5536            Price::from("1.00020"),
5537            Price::from("1.00025"),
5538            Price::from("1.00005"),
5539            Price::from("1.00010"), // 10 pip move down (exactly 1 brick)
5540            Quantity::from(50),
5541            UnixNanos::from(60_000_000_000),
5542            UnixNanos::from(60_000_000_000),
5543        );
5544
5545        aggregator.handle_bar(bar1);
5546        aggregator.handle_bar(bar2);
5547
5548        let handler_guard = handler.lock();
5549        assert_eq!(handler_guard.len(), 1);
5550
5551        let bar = handler_guard.first().unwrap();
5552        assert_eq!(bar.open, Price::from("1.00020"));
5553        assert_eq!(bar.high, Price::from("1.00020"));
5554        assert_eq!(bar.low, Price::from("1.00010"));
5555        assert_eq!(bar.close, Price::from("1.00010"));
5556        assert_eq!(bar.volume, Quantity::from(150));
5557    }
5558
5559    #[rstest]
5560    fn test_renko_bar_aggregator_brick_size_calculation(audusd_sim: CurrencyPair) {
5561        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5562
5563        // Test different brick sizes
5564        let bar_spec_5 = BarSpecification::new(5, BarAggregation::Renko, PriceType::Mid); // 5 pip brick size
5565        let bar_type_5 = BarType::new(instrument.id(), bar_spec_5, AggregationSource::Internal);
5566        let (_handler_5, record) = recording_handler();
5567
5568        let aggregator_5 = RenkoBarAggregator::new(
5569            bar_type_5,
5570            instrument.price_precision(),
5571            instrument.size_precision(),
5572            instrument.price_increment(),
5573            record,
5574        );
5575
5576        // 5 pips * price_increment.raw (depends on precision mode)
5577        let expected_brick_size_5 = Price::from_decimal_dp(
5578            instrument.price_increment() * Decimal::from(5),
5579            instrument.price_precision(),
5580        )
5581        .unwrap();
5582        assert_eq!(aggregator_5.brick_size, expected_brick_size_5);
5583
5584        let bar_spec_20 = BarSpecification::new(20, BarAggregation::Renko, PriceType::Mid); // 20 pip brick size
5585        let bar_type_20 = BarType::new(instrument.id(), bar_spec_20, AggregationSource::Internal);
5586        let (_handler_20, record) = recording_handler();
5587
5588        let aggregator_20 = RenkoBarAggregator::new(
5589            bar_type_20,
5590            instrument.price_precision(),
5591            instrument.size_precision(),
5592            instrument.price_increment(),
5593            record,
5594        );
5595
5596        // 20 pips * price_increment.raw (depends on precision mode)
5597        let expected_brick_size_20 = Price::from_decimal_dp(
5598            instrument.price_increment() * Decimal::from(20),
5599            instrument.price_precision(),
5600        )
5601        .unwrap();
5602        assert_eq!(aggregator_20.brick_size, expected_brick_size_20);
5603    }
5604
5605    #[rstest]
5606    fn test_renko_bar_aggregator_sequential_updates(audusd_sim: CurrencyPair) {
5607        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5608        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5609        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5610        let (handler, record) = recording_handler();
5611
5612        let mut aggregator = RenkoBarAggregator::new(
5613            bar_type,
5614            instrument.price_precision(),
5615            instrument.size_precision(),
5616            instrument.price_increment(),
5617            record,
5618        );
5619
5620        // Sequential updates creating multiple bars
5621        aggregator.update(
5622            Price::from("1.00000"),
5623            Quantity::from(1),
5624            UnixNanos::from(1000),
5625        );
5626        aggregator.update(
5627            Price::from("1.00010"),
5628            Quantity::from(1),
5629            UnixNanos::from(2000),
5630        ); // First brick
5631        aggregator.update(
5632            Price::from("1.00020"),
5633            Quantity::from(1),
5634            UnixNanos::from(3000),
5635        ); // Second brick
5636        aggregator.update(
5637            Price::from("1.00025"),
5638            Quantity::from(1),
5639            UnixNanos::from(4000),
5640        ); // Partial third brick
5641        aggregator.update(
5642            Price::from("1.00030"),
5643            Quantity::from(1),
5644            UnixNanos::from(5000),
5645        ); // Complete third brick
5646
5647        let handler_guard = handler.lock();
5648        assert_eq!(handler_guard.len(), 3);
5649
5650        let bar1 = &handler_guard[0];
5651        assert_eq!(bar1.open, Price::from("1.00000"));
5652        assert_eq!(bar1.close, Price::from("1.00010"));
5653
5654        let bar2 = &handler_guard[1];
5655        assert_eq!(bar2.open, Price::from("1.00010"));
5656        assert_eq!(bar2.close, Price::from("1.00020"));
5657
5658        let bar3 = &handler_guard[2];
5659        assert_eq!(bar3.open, Price::from("1.00020"));
5660        assert_eq!(bar3.close, Price::from("1.00030"));
5661    }
5662
5663    #[rstest]
5664    fn test_renko_bar_aggregator_mixed_direction_movement(audusd_sim: CurrencyPair) {
5665        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5666        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5667        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5668        let (handler, record) = recording_handler();
5669
5670        let mut aggregator = RenkoBarAggregator::new(
5671            bar_type,
5672            instrument.price_precision(),
5673            instrument.size_precision(),
5674            instrument.price_increment(),
5675            record,
5676        );
5677
5678        // Mixed direction movement: up then down
5679        aggregator.update(
5680            Price::from("1.00000"),
5681            Quantity::from(1),
5682            UnixNanos::from(1000),
5683        );
5684        aggregator.update(
5685            Price::from("1.00010"),
5686            Quantity::from(1),
5687            UnixNanos::from(2000),
5688        ); // Up brick
5689        aggregator.update(
5690            Price::from("0.99990"),
5691            Quantity::from(1),
5692            UnixNanos::from(3000),
5693        ); // Down 2 bricks (20 pips)
5694
5695        let handler_guard = handler.lock();
5696        assert_eq!(handler_guard.len(), 3);
5697
5698        let bar1 = &handler_guard[0]; // Up brick
5699        assert_eq!(bar1.open, Price::from("1.00000"));
5700        assert_eq!(bar1.high, Price::from("1.00010"));
5701        assert_eq!(bar1.low, Price::from("1.00000"));
5702        assert_eq!(bar1.close, Price::from("1.00010"));
5703
5704        let bar2 = &handler_guard[1]; // First down brick
5705        assert_eq!(bar2.open, Price::from("1.00010"));
5706        assert_eq!(bar2.high, Price::from("1.00010"));
5707        assert_eq!(bar2.low, Price::from("1.00000"));
5708        assert_eq!(bar2.close, Price::from("1.00000"));
5709
5710        let bar3 = &handler_guard[2]; // Second down brick
5711        assert_eq!(bar3.open, Price::from("1.00000"));
5712        assert_eq!(bar3.high, Price::from("1.00000"));
5713        assert_eq!(bar3.low, Price::from("0.99990"));
5714        assert_eq!(bar3.close, Price::from("0.99990"));
5715    }
5716
5717    #[rstest]
5718    fn test_tick_imbalance_bar_aggregator_mixed_trades_cancel_out(equity_aapl: Equity) {
5719        let instrument = InstrumentAny::Equity(equity_aapl);
5720        let bar_spec = BarSpecification::new(3, BarAggregation::TickImbalance, PriceType::Last);
5721        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5722        let (handler, record) = recording_handler();
5723
5724        let mut aggregator = TickImbalanceBarAggregator::new(
5725            bar_type,
5726            instrument.price_precision(),
5727            instrument.size_precision(),
5728            record,
5729        );
5730
5731        let buy = TradeTick {
5732            aggressor_side: AggressorSide::Buy,
5733            ..TradeTick::default()
5734        };
5735        let sell = TradeTick {
5736            aggressor_side: AggressorSide::Sell,
5737            ..TradeTick::default()
5738        };
5739
5740        aggregator.handle_trade(buy);
5741        aggregator.handle_trade(sell);
5742        aggregator.handle_trade(buy);
5743
5744        let handler_guard = handler.lock();
5745        assert_eq!(handler_guard.len(), 0);
5746    }
5747
5748    #[rstest]
5749    fn test_tick_imbalance_bar_aggregator_no_aggressor_ignored(equity_aapl: Equity) {
5750        let instrument = InstrumentAny::Equity(equity_aapl);
5751        let bar_spec = BarSpecification::new(2, BarAggregation::TickImbalance, PriceType::Last);
5752        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5753        let (handler, record) = recording_handler();
5754
5755        let mut aggregator = TickImbalanceBarAggregator::new(
5756            bar_type,
5757            instrument.price_precision(),
5758            instrument.size_precision(),
5759            record,
5760        );
5761
5762        let buy = TradeTick {
5763            aggressor_side: AggressorSide::Buy,
5764            ..TradeTick::default()
5765        };
5766        let no_aggressor = TradeTick {
5767            aggressor_side: AggressorSide::NoAggressor,
5768            ..TradeTick::default()
5769        };
5770
5771        aggregator.handle_trade(buy);
5772        aggregator.handle_trade(no_aggressor);
5773        aggregator.handle_trade(buy);
5774
5775        let handler_guard = handler.lock();
5776        assert_eq!(handler_guard.len(), 1);
5777    }
5778
5779    #[rstest]
5780    fn test_tick_runs_bar_aggregator_multiple_consecutive_runs(equity_aapl: Equity) {
5781        let instrument = InstrumentAny::Equity(equity_aapl);
5782        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
5783        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5784        let (handler, record) = recording_handler();
5785
5786        let mut aggregator = TickRunsBarAggregator::new(
5787            bar_type,
5788            instrument.price_precision(),
5789            instrument.size_precision(),
5790            record,
5791        );
5792
5793        let buy = TradeTick {
5794            aggressor_side: AggressorSide::Buy,
5795            ..TradeTick::default()
5796        };
5797        let sell = TradeTick {
5798            aggressor_side: AggressorSide::Sell,
5799            ..TradeTick::default()
5800        };
5801
5802        aggregator.handle_trade(buy);
5803        aggregator.handle_trade(buy);
5804        aggregator.handle_trade(sell);
5805        aggregator.handle_trade(sell);
5806
5807        let handler_guard = handler.lock();
5808        assert_eq!(handler_guard.len(), 2);
5809    }
5810
5811    #[rstest]
5812    fn test_volume_imbalance_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
5813        let instrument = InstrumentAny::Equity(equity_aapl);
5814        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeImbalance, PriceType::Last);
5815        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5816        let (handler, record) = recording_handler();
5817
5818        let mut aggregator = VolumeImbalanceBarAggregator::new(
5819            bar_type,
5820            instrument.price_precision(),
5821            instrument.size_precision(),
5822            record,
5823        );
5824
5825        let large_trade = TradeTick {
5826            size: Quantity::from(25),
5827            aggressor_side: AggressorSide::Buy,
5828            ..TradeTick::default()
5829        };
5830
5831        aggregator.handle_trade(large_trade);
5832
5833        let handler_guard = handler.lock();
5834        assert_eq!(handler_guard.len(), 2);
5835    }
5836
5837    #[rstest]
5838    fn test_volume_imbalance_bar_aggregator_no_aggressor_does_not_affect_imbalance(
5839        equity_aapl: Equity,
5840    ) {
5841        let instrument = InstrumentAny::Equity(equity_aapl);
5842        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeImbalance, PriceType::Last);
5843        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5844        let (handler, record) = recording_handler();
5845
5846        let mut aggregator = VolumeImbalanceBarAggregator::new(
5847            bar_type,
5848            instrument.price_precision(),
5849            instrument.size_precision(),
5850            record,
5851        );
5852
5853        let buy = TradeTick {
5854            size: Quantity::from(5),
5855            aggressor_side: AggressorSide::Buy,
5856            ..TradeTick::default()
5857        };
5858        let no_aggressor = TradeTick {
5859            size: Quantity::from(3),
5860            aggressor_side: AggressorSide::NoAggressor,
5861            ..TradeTick::default()
5862        };
5863
5864        aggregator.handle_trade(buy);
5865        aggregator.handle_trade(no_aggressor);
5866        aggregator.handle_trade(buy);
5867
5868        let handler_guard = handler.lock();
5869        assert_eq!(handler_guard.len(), 1);
5870    }
5871
5872    #[rstest]
5873    fn test_volume_runs_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
5874        let instrument = InstrumentAny::Equity(equity_aapl);
5875        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeRuns, PriceType::Last);
5876        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5877        let (handler, record) = recording_handler();
5878
5879        let mut aggregator = VolumeRunsBarAggregator::new(
5880            bar_type,
5881            instrument.price_precision(),
5882            instrument.size_precision(),
5883            record,
5884        );
5885
5886        let large_trade = TradeTick {
5887            size: Quantity::from(25),
5888            aggressor_side: AggressorSide::Buy,
5889            ..TradeTick::default()
5890        };
5891
5892        aggregator.handle_trade(large_trade);
5893
5894        let handler_guard = handler.lock();
5895        assert_eq!(handler_guard.len(), 2);
5896    }
5897
5898    #[rstest]
5899    fn test_value_runs_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
5900        let instrument = InstrumentAny::Equity(equity_aapl);
5901        let bar_spec = BarSpecification::new(50, BarAggregation::ValueRuns, PriceType::Last);
5902        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5903        let (handler, record) = recording_handler();
5904
5905        let mut aggregator = ValueRunsBarAggregator::new(
5906            bar_type,
5907            instrument.price_precision(),
5908            instrument.size_precision(),
5909            record,
5910        );
5911
5912        let large_trade = TradeTick {
5913            price: Price::from("5.00"),
5914            size: Quantity::from(25),
5915            aggressor_side: AggressorSide::Buy,
5916            ..TradeTick::default()
5917        };
5918
5919        aggregator.handle_trade(large_trade);
5920
5921        let handler_guard = handler.lock();
5922        assert_eq!(handler_guard.len(), 2);
5923    }
5924
5925    #[rstest]
5926    fn test_value_runs_bar_aggregator_keeps_leftover_volume_for_same_side_run(equity_aapl: Equity) {
5927        let instrument = InstrumentAny::Equity(equity_aapl);
5928        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
5929        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5930        let (handler, record) = recording_handler();
5931
5932        let mut aggregator = ValueRunsBarAggregator::new(
5933            bar_type,
5934            instrument.price_precision(),
5935            instrument.size_precision(),
5936            record,
5937        );
5938
5939        // First trade spans one bar (value 150 = step 100 + 50 leftover), the
5940        // leftover 5 units must survive as the start of a new same-side run.
5941        let first = TradeTick {
5942            price: Price::from("10.00"),
5943            size: Quantity::from(15),
5944            aggressor_side: AggressorSide::Sell,
5945            ts_event: UnixNanos::from(1_000),
5946            ts_init: UnixNanos::from(1_000),
5947            ..TradeTick::default()
5948        };
5949        aggregator.handle_trade(first);
5950
5951        // Second same-side trade completes the run (50 + 50 >= 100).
5952        let second = TradeTick {
5953            price: Price::from("10.00"),
5954            size: Quantity::from(5),
5955            aggressor_side: AggressorSide::Sell,
5956            ts_event: UnixNanos::from(2_000),
5957            ts_init: UnixNanos::from(2_000),
5958            ..TradeTick::default()
5959        };
5960        aggregator.handle_trade(second);
5961
5962        let handler_guard = handler.lock();
5963        assert_eq!(handler_guard.len(), 2);
5964        assert_eq!(handler_guard[0].volume, Quantity::from(10));
5965        assert_eq!(handler_guard[1].volume, Quantity::from(10));
5966    }
5967
5968    #[rstest]
5969    fn test_value_bar_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
5970        let instrument = InstrumentAny::Equity(equity_aapl);
5971        let bar_spec = BarSpecification::new(100, BarAggregation::Value, PriceType::Last);
5972        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5973        let (handler, record) = recording_handler();
5974
5975        let mut aggregator = ValueBarAggregator::new(
5976            bar_type,
5977            instrument.price_precision(),
5978            instrument.size_precision(),
5979            record,
5980        );
5981
5982        // price=1000, size=3, value=3000, step=100 → size_chunk=0.1 rounds to 0 at precision 0
5983        aggregator.update(
5984            Price::from("1000.00"),
5985            Quantity::from(3),
5986            UnixNanos::default(),
5987        );
5988
5989        // 3 bars (one per min-size unit), not 30 zero-volume bars
5990        let handler_guard = handler.lock();
5991        assert_eq!(handler_guard.len(), 3);
5992        for bar in handler_guard.iter() {
5993            assert_eq!(bar.volume, Quantity::from(1));
5994        }
5995    }
5996
5997    #[rstest]
5998    fn test_value_imbalance_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
5999        let instrument = InstrumentAny::Equity(equity_aapl);
6000        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6001        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6002        let (handler, record) = recording_handler();
6003
6004        let mut aggregator = ValueImbalanceBarAggregator::new(
6005            bar_type,
6006            instrument.price_precision(),
6007            instrument.size_precision(),
6008            record,
6009        );
6010
6011        let trade = TradeTick {
6012            price: Price::from("1000.00"),
6013            size: Quantity::from(3),
6014            aggressor_side: AggressorSide::Buy,
6015            instrument_id: instrument.id(),
6016            ..TradeTick::default()
6017        };
6018
6019        aggregator.handle_trade(trade);
6020
6021        let handler_guard = handler.lock();
6022        assert_eq!(handler_guard.len(), 3);
6023        for bar in handler_guard.iter() {
6024            assert_eq!(bar.volume, Quantity::from(1));
6025        }
6026    }
6027
6028    #[rstest]
6029    fn test_value_imbalance_opposite_side_overshoot_emits_bar(equity_aapl: Equity) {
6030        let instrument = InstrumentAny::Equity(equity_aapl);
6031        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6032        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6033        let (handler, record) = recording_handler();
6034
6035        let mut aggregator = ValueImbalanceBarAggregator::new(
6036            bar_type,
6037            instrument.price_precision(),
6038            instrument.size_precision(),
6039            record,
6040        );
6041
6042        // Build seller imbalance of -50 (below step=100, no bar yet)
6043        let sell_tick = TradeTick {
6044            price: Price::from("10.00"),
6045            size: Quantity::from(5),
6046            aggressor_side: AggressorSide::Sell,
6047            instrument_id: instrument.id(),
6048            ..TradeTick::default()
6049        };
6050
6051        // Opposite-side buyer: flatten amount 50/1000=0.05 < min_size (1),
6052        // clamp overshoots imbalance from -50 to +950, crossing threshold
6053        let buy_tick = TradeTick {
6054            price: Price::from("1000.00"),
6055            size: Quantity::from(1),
6056            aggressor_side: AggressorSide::Buy,
6057            instrument_id: instrument.id(),
6058            ts_init: UnixNanos::from(1),
6059            ts_event: UnixNanos::from(1),
6060            ..TradeTick::default()
6061        };
6062
6063        aggregator.handle_trade(sell_tick);
6064        aggregator.handle_trade(buy_tick);
6065
6066        let handler_guard = handler.lock();
6067        assert_eq!(handler_guard.len(), 1);
6068        assert_eq!(handler_guard[0].volume, Quantity::from(6));
6069    }
6070
6071    #[rstest]
6072    fn test_value_runs_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
6073        let instrument = InstrumentAny::Equity(equity_aapl);
6074        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
6075        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6076        let (handler, record) = recording_handler();
6077
6078        let mut aggregator = ValueRunsBarAggregator::new(
6079            bar_type,
6080            instrument.price_precision(),
6081            instrument.size_precision(),
6082            record,
6083        );
6084
6085        let trade = TradeTick {
6086            price: Price::from("1000.00"),
6087            size: Quantity::from(3),
6088            aggressor_side: AggressorSide::Buy,
6089            instrument_id: instrument.id(),
6090            ..TradeTick::default()
6091        };
6092
6093        aggregator.handle_trade(trade);
6094
6095        let handler_guard = handler.lock();
6096        assert_eq!(handler_guard.len(), 3);
6097        for bar in handler_guard.iter() {
6098            assert_eq!(bar.volume, Quantity::from(1));
6099        }
6100    }
6101
6102    #[rstest]
6103    fn test_value_imbalance_bar_aggregator_exact_below_step_retains_pending() {
6104        // step=9_007_199_254; a single buy of 9007199253.999999999 @ price 1 has a notional
6105        // exactly one raw unit below the step. Exact Decimal arithmetic must NOT emit a bar; the
6106        // prior f64 path rounded the size up to 9007199254.0 and emitted early.
6107        let instrument_id = InstrumentId::from("AAPL.XNAS");
6108        let bar_spec = BarSpecification::new(
6109            9_007_199_254,
6110            BarAggregation::ValueImbalance,
6111            PriceType::Last,
6112        );
6113        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6114        let (handler, record) = recording_handler();
6115
6116        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 0, 9, record);
6117
6118        let below_step = TradeTick {
6119            instrument_id,
6120            price: Price::from("1"),
6121            size: Quantity::from("9007199253.999999999"),
6122            aggressor_side: AggressorSide::Buy,
6123            ..TradeTick::default()
6124        };
6125        aggregator.handle_trade(below_step);
6126
6127        assert!(handler.lock().is_empty());
6128        assert_eq!(
6129            aggregator.core.builder.volume,
6130            Quantity::from("9007199253.999999999"),
6131        );
6132
6133        // One additional raw unit lifts the notional to exactly the step, emitting one bar whose
6134        // volume is the exact total raw input.
6135        let one_raw_unit = TradeTick {
6136            instrument_id,
6137            price: Price::from("1"),
6138            size: Quantity::from("0.000000001"),
6139            aggressor_side: AggressorSide::Buy,
6140            ts_event: UnixNanos::from(1),
6141            ts_init: UnixNanos::from(1),
6142            ..TradeTick::default()
6143        };
6144        aggregator.handle_trade(one_raw_unit);
6145
6146        let handler_guard = handler.lock();
6147        assert_eq!(handler_guard.len(), 1);
6148        assert_eq!(
6149            handler_guard[0].volume,
6150            Quantity::from("9007199254.000000000")
6151        );
6152        assert_eq!(aggregator.core.builder.volume, Quantity::zero(9));
6153    }
6154
6155    #[rstest]
6156    fn test_value_imbalance_bar_aggregator_conserves_volume_across_split_bars() {
6157        // step=4, price=1: a same-side buy of 10.000000003 splits into two full bars of value 4
6158        // and leaves a fractional 2.000000003 pending. Emitted plus pending volume must equal the
6159        // exact input across the several split bars.
6160        let instrument_id = InstrumentId::from("AAPL.XNAS");
6161        let bar_spec = BarSpecification::new(4, BarAggregation::ValueImbalance, PriceType::Last);
6162        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6163        let (handler, record) = recording_handler();
6164
6165        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 0, 9, record);
6166
6167        let input = Quantity::from("10.000000003");
6168        let trade = TradeTick {
6169            instrument_id,
6170            price: Price::from("1"),
6171            size: input,
6172            aggressor_side: AggressorSide::Buy,
6173            ..TradeTick::default()
6174        };
6175        aggregator.handle_trade(trade);
6176
6177        let handler_guard = handler.lock();
6178        assert_eq!(handler_guard.len(), 2);
6179        for bar in handler_guard.iter() {
6180            assert_eq!(bar.volume, Quantity::from("4.000000000"));
6181        }
6182        assert_eq!(
6183            aggregator.core.builder.volume,
6184            Quantity::from("2.000000003"),
6185        );
6186        let emitted_plus_pending = handler_guard
6187            .iter()
6188            .map(|bar| bar.volume.as_decimal())
6189            .sum::<Decimal>()
6190            + aggregator.core.builder.volume.as_decimal();
6191        assert_eq!(emitted_plus_pending, input.as_decimal());
6192    }
6193
6194    #[rstest]
6195    fn test_value_runs_bar_aggregator_exact_below_step_retains_pending() {
6196        // step=9_007_199_254; a single buy of 9007199253.999999999 @ price 1 sits one raw unit
6197        // below the step. Exact Decimal arithmetic must NOT emit a bar; the prior f64 path rounded
6198        // the size up and emitted early.
6199        let instrument_id = InstrumentId::from("AAPL.XNAS");
6200        let bar_spec =
6201            BarSpecification::new(9_007_199_254, BarAggregation::ValueRuns, PriceType::Last);
6202        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6203        let (handler, record) = recording_handler();
6204
6205        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 0, 9, record);
6206
6207        let below_step = TradeTick {
6208            instrument_id,
6209            price: Price::from("1"),
6210            size: Quantity::from("9007199253.999999999"),
6211            aggressor_side: AggressorSide::Buy,
6212            ..TradeTick::default()
6213        };
6214        aggregator.handle_trade(below_step);
6215
6216        assert!(handler.lock().is_empty());
6217        assert_eq!(
6218            aggregator.core.builder.volume,
6219            Quantity::from("9007199253.999999999"),
6220        );
6221
6222        // One additional same-side raw unit completes the run at exactly the step, emitting one bar
6223        // whose volume is the exact total raw input.
6224        let one_raw_unit = TradeTick {
6225            instrument_id,
6226            price: Price::from("1"),
6227            size: Quantity::from("0.000000001"),
6228            aggressor_side: AggressorSide::Buy,
6229            ts_event: UnixNanos::from(1),
6230            ts_init: UnixNanos::from(1),
6231            ..TradeTick::default()
6232        };
6233        aggregator.handle_trade(one_raw_unit);
6234
6235        let handler_guard = handler.lock();
6236        assert_eq!(handler_guard.len(), 1);
6237        assert_eq!(
6238            handler_guard[0].volume,
6239            Quantity::from("9007199254.000000000")
6240        );
6241        assert_eq!(aggregator.core.builder.volume, Quantity::zero(9));
6242    }
6243
6244    #[rstest]
6245    fn test_value_runs_bar_aggregator_conserves_volume_across_split_bars() {
6246        // step=4, price=1: a same-side buy of 10.000000003 splits into two full bars of value 4 and
6247        // keeps a fractional 2.000000003 as the leftover of the same-side run. Emitted plus pending
6248        // volume must equal the exact input across the several split bars.
6249        let instrument_id = InstrumentId::from("AAPL.XNAS");
6250        let bar_spec = BarSpecification::new(4, BarAggregation::ValueRuns, PriceType::Last);
6251        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6252        let (handler, record) = recording_handler();
6253
6254        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 0, 9, record);
6255
6256        let input = Quantity::from("10.000000003");
6257        let trade = TradeTick {
6258            instrument_id,
6259            price: Price::from("1"),
6260            size: input,
6261            aggressor_side: AggressorSide::Buy,
6262            ..TradeTick::default()
6263        };
6264        aggregator.handle_trade(trade);
6265
6266        let handler_guard = handler.lock();
6267        assert_eq!(handler_guard.len(), 2);
6268        for bar in handler_guard.iter() {
6269            assert_eq!(bar.volume, Quantity::from("4.000000000"));
6270        }
6271        assert_eq!(
6272            aggregator.core.builder.volume,
6273            Quantity::from("2.000000003"),
6274        );
6275        let emitted_plus_pending = handler_guard
6276            .iter()
6277            .map(|bar| bar.volume.as_decimal())
6278            .sum::<Decimal>()
6279            + aggregator.core.builder.volume.as_decimal();
6280        assert_eq!(emitted_plus_pending, input.as_decimal());
6281    }
6282
6283    #[rstest]
6284    fn test_value_imbalance_bar_aggregator_no_aggressor_and_zero_price_fall_back_to_plain_volume() {
6285        // NoAggressor and zero-price trades carry no usable side signal, so they bypass imbalance
6286        // splitting and accumulate as plain builder volume without emitting a bar.
6287        let instrument_id = InstrumentId::from("AAPL.XNAS");
6288        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6289        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6290        let (handler, record) = recording_handler();
6291
6292        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 2, 0, record);
6293
6294        let no_aggressor = TradeTick {
6295            instrument_id,
6296            price: Price::from("10.00"),
6297            size: Quantity::from(3),
6298            aggressor_side: AggressorSide::NoAggressor,
6299            ..TradeTick::default()
6300        };
6301        let zero_price = TradeTick {
6302            instrument_id,
6303            price: Price::from("0.00"),
6304            size: Quantity::from(4),
6305            aggressor_side: AggressorSide::Buy,
6306            ts_event: UnixNanos::from(1),
6307            ts_init: UnixNanos::from(1),
6308            ..TradeTick::default()
6309        };
6310        aggregator.handle_trade(no_aggressor);
6311        aggregator.handle_trade(zero_price);
6312
6313        assert!(handler.lock().is_empty());
6314        assert_eq!(aggregator.core.builder.volume, Quantity::from(7));
6315    }
6316
6317    #[rstest]
6318    fn test_value_runs_bar_aggregator_no_aggressor_and_zero_price_fall_back_to_plain_volume() {
6319        // NoAggressor and zero-price trades carry no usable side signal, so they bypass the run
6320        // splitting and accumulate as plain builder volume without emitting a bar or resetting.
6321        let instrument_id = InstrumentId::from("AAPL.XNAS");
6322        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
6323        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6324        let (handler, record) = recording_handler();
6325
6326        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 2, 0, record);
6327
6328        let no_aggressor = TradeTick {
6329            instrument_id,
6330            price: Price::from("10.00"),
6331            size: Quantity::from(3),
6332            aggressor_side: AggressorSide::NoAggressor,
6333            ..TradeTick::default()
6334        };
6335        let zero_price = TradeTick {
6336            instrument_id,
6337            price: Price::from("0.00"),
6338            size: Quantity::from(4),
6339            aggressor_side: AggressorSide::Buy,
6340            ts_event: UnixNanos::from(1),
6341            ts_init: UnixNanos::from(1),
6342            ..TradeTick::default()
6343        };
6344        aggregator.handle_trade(no_aggressor);
6345        aggregator.handle_trade(zero_price);
6346
6347        assert!(handler.lock().is_empty());
6348        assert_eq!(aggregator.core.builder.volume, Quantity::from(7));
6349    }
6350
6351    #[rstest]
6352    fn test_value_imbalance_bar_aggregator_conserves_volume_with_indivisible_price() {
6353        // step=1, price=3, size precision 1: the ideal split 1/3 rounds to 0.3, so each emitted bar
6354        // carries a notional of 0.9 (below the step) exactly as the reference ValueBarAggregator
6355        // does with a non-dividing price. Per-bar notional is approximate by design, but total
6356        // volume (emitted plus pending) must still equal the exact input.
6357        let instrument_id = InstrumentId::from("AAPL.XNAS");
6358        let bar_spec = BarSpecification::new(1, BarAggregation::ValueImbalance, PriceType::Last);
6359        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6360        let (handler, record) = recording_handler();
6361
6362        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 2, 1, record);
6363
6364        let input = Quantity::from("1.0");
6365        let trade = TradeTick {
6366            instrument_id,
6367            price: Price::from("3.00"),
6368            size: input,
6369            aggressor_side: AggressorSide::Buy,
6370            ..TradeTick::default()
6371        };
6372        aggregator.handle_trade(trade);
6373
6374        let handler_guard = handler.lock();
6375        assert_eq!(handler_guard.len(), 3);
6376        for bar in handler_guard.iter() {
6377            assert_eq!(bar.volume, Quantity::from("0.3"));
6378        }
6379        assert_eq!(aggregator.core.builder.volume, Quantity::from("0.1"));
6380        let emitted_plus_pending = handler_guard
6381            .iter()
6382            .map(|bar| bar.volume.as_decimal())
6383            .sum::<Decimal>()
6384            + aggregator.core.builder.volume.as_decimal();
6385        assert_eq!(emitted_plus_pending, input.as_decimal());
6386    }
6387
6388    #[rstest]
6389    fn test_value_runs_bar_aggregator_conserves_volume_with_indivisible_price() {
6390        // step=1, price=3, size precision 1: the ideal split 1/3 rounds to 0.3, so each emitted bar
6391        // carries a notional of 0.9 (below the step) exactly as the reference ValueBarAggregator
6392        // does with a non-dividing price. Per-bar notional is approximate by design, but total
6393        // volume (emitted plus pending) must still equal the exact input.
6394        let instrument_id = InstrumentId::from("AAPL.XNAS");
6395        let bar_spec = BarSpecification::new(1, BarAggregation::ValueRuns, PriceType::Last);
6396        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6397        let (handler, record) = recording_handler();
6398
6399        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 2, 1, record);
6400
6401        let input = Quantity::from("1.0");
6402        let trade = TradeTick {
6403            instrument_id,
6404            price: Price::from("3.00"),
6405            size: input,
6406            aggressor_side: AggressorSide::Buy,
6407            ..TradeTick::default()
6408        };
6409        aggregator.handle_trade(trade);
6410
6411        let handler_guard = handler.lock();
6412        assert_eq!(handler_guard.len(), 3);
6413        for bar in handler_guard.iter() {
6414            assert_eq!(bar.volume, Quantity::from("0.3"));
6415        }
6416        assert_eq!(aggregator.core.builder.volume, Quantity::from("0.1"));
6417        let emitted_plus_pending = handler_guard
6418            .iter()
6419            .map(|bar| bar.volume.as_decimal())
6420            .sum::<Decimal>()
6421            + aggregator.core.builder.volume.as_decimal();
6422        assert_eq!(emitted_plus_pending, input.as_decimal());
6423    }
6424
6425    #[rstest]
6426    #[case(1000_u64)]
6427    #[case(1500_u64)]
6428    fn test_volume_imbalance_bar_aggregator_large_step_no_overflow(
6429        equity_aapl: Equity,
6430        #[case] step: u64,
6431    ) {
6432        let instrument = InstrumentAny::Equity(equity_aapl);
6433        let bar_spec = BarSpecification::new(
6434            step as usize,
6435            BarAggregation::VolumeImbalance,
6436            PriceType::Last,
6437        );
6438        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6439        let (handler, record) = recording_handler();
6440
6441        let mut aggregator = VolumeImbalanceBarAggregator::new(
6442            bar_type,
6443            instrument.price_precision(),
6444            instrument.size_precision(),
6445            record,
6446        );
6447
6448        let trade = TradeTick {
6449            size: Quantity::from(step * 2),
6450            aggressor_side: AggressorSide::Buy,
6451            ..TradeTick::default()
6452        };
6453
6454        aggregator.handle_trade(trade);
6455
6456        let handler_guard = handler.lock();
6457        assert_eq!(handler_guard.len(), 2);
6458        for bar in handler_guard.iter() {
6459            assert_eq!(bar.volume.as_f64(), step as f64);
6460        }
6461    }
6462
6463    #[rstest]
6464    fn test_volume_imbalance_bar_aggregator_different_large_steps_produce_different_bar_counts(
6465        equity_aapl: Equity,
6466    ) {
6467        let instrument = InstrumentAny::Equity(equity_aapl);
6468        let total_volume = 3000_u64;
6469        let mut results = Vec::new();
6470
6471        for step in [1000_usize, 1500] {
6472            let bar_spec =
6473                BarSpecification::new(step, BarAggregation::VolumeImbalance, PriceType::Last);
6474            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6475            let (handler, record) = recording_handler();
6476
6477            let mut aggregator = VolumeImbalanceBarAggregator::new(
6478                bar_type,
6479                instrument.price_precision(),
6480                instrument.size_precision(),
6481                record,
6482            );
6483
6484            let trade = TradeTick {
6485                size: Quantity::from(total_volume),
6486                aggressor_side: AggressorSide::Buy,
6487                ..TradeTick::default()
6488            };
6489
6490            aggregator.handle_trade(trade);
6491
6492            let handler_guard = handler.lock();
6493            results.push(handler_guard.len());
6494        }
6495
6496        assert_eq!(results[0], 3); // 3000 / 1000
6497        assert_eq!(results[1], 2); // 3000 / 1500
6498        assert_ne!(results[0], results[1]);
6499    }
6500
6501    #[rstest]
6502    #[case(1000_u64)]
6503    #[case(1500_u64)]
6504    fn test_volume_runs_bar_aggregator_large_step_no_overflow(
6505        equity_aapl: Equity,
6506        #[case] step: u64,
6507    ) {
6508        let instrument = InstrumentAny::Equity(equity_aapl);
6509        let bar_spec =
6510            BarSpecification::new(step as usize, BarAggregation::VolumeRuns, PriceType::Last);
6511        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6512        let (handler, record) = recording_handler();
6513
6514        let mut aggregator = VolumeRunsBarAggregator::new(
6515            bar_type,
6516            instrument.price_precision(),
6517            instrument.size_precision(),
6518            record,
6519        );
6520
6521        let trade = TradeTick {
6522            size: Quantity::from(step * 2),
6523            aggressor_side: AggressorSide::Buy,
6524            ..TradeTick::default()
6525        };
6526
6527        aggregator.handle_trade(trade);
6528
6529        let handler_guard = handler.lock();
6530        assert_eq!(handler_guard.len(), 2);
6531        for bar in handler_guard.iter() {
6532            assert_eq!(bar.volume.as_f64(), step as f64);
6533        }
6534    }
6535
6536    #[rstest]
6537    fn test_volume_runs_bar_aggregator_different_large_steps_produce_different_bar_counts(
6538        equity_aapl: Equity,
6539    ) {
6540        let instrument = InstrumentAny::Equity(equity_aapl);
6541        let total_volume = 3000_u64;
6542        let mut results = Vec::new();
6543
6544        for step in [1000_usize, 1500] {
6545            let bar_spec = BarSpecification::new(step, BarAggregation::VolumeRuns, PriceType::Last);
6546            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6547            let (handler, record) = recording_handler();
6548
6549            let mut aggregator = VolumeRunsBarAggregator::new(
6550                bar_type,
6551                instrument.price_precision(),
6552                instrument.size_precision(),
6553                record,
6554            );
6555
6556            let trade = TradeTick {
6557                size: Quantity::from(total_volume),
6558                aggressor_side: AggressorSide::Buy,
6559                ..TradeTick::default()
6560            };
6561
6562            aggregator.handle_trade(trade);
6563
6564            let handler_guard = handler.lock();
6565            results.push(handler_guard.len());
6566        }
6567
6568        assert_eq!(results[0], 3); // 3000 / 1000
6569        assert_eq!(results[1], 2); // 3000 / 1500
6570        assert_ne!(results[0], results[1]);
6571    }
6572
6573    /// Historical time-bar: event at `ts_init` is deferred until after the update.
6574    #[rstest]
6575    fn test_time_bar_historical_defers_event_at_ts_init_until_after_update(equity_aapl: Equity) {
6576        let instrument = InstrumentAny::Equity(equity_aapl);
6577        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
6578        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6579        let (handler, record) = recording_handler();
6580        let clock = Rc::new(RefCell::new(TestClock::new()));
6581
6582        let mut agg = TimeBarAggregator::new(
6583            bar_type,
6584            instrument.price_precision(),
6585            instrument.size_precision(),
6586            clock.clone(),
6587            record,
6588            true,
6589            true,
6590            BarIntervalType::LeftOpen,
6591            None,
6592            0,
6593            false,
6594        );
6595        agg.historical_mode = true;
6596        agg.set_clock_internal(clock);
6597        let boxed: Box<dyn BarAggregator> = Box::new(agg);
6598        let rc = Rc::new(RefCell::new(boxed));
6599        rc.borrow_mut().set_aggregator_weak(Rc::downgrade(&rc));
6600
6601        rc.borrow_mut().update(
6602            Price::from("100.00"),
6603            Quantity::from(1),
6604            UnixNanos::default(),
6605        );
6606        rc.borrow_mut().update(
6607            Price::from("100.00"),
6608            Quantity::from(1),
6609            UnixNanos::from(1_000_000_000),
6610        );
6611
6612        let bars = handler.lock();
6613        assert!(
6614            !bars.is_empty(),
6615            "deferred event at ts_init should produce a bar that includes the update"
6616        );
6617        let last_bar = bars.last().unwrap();
6618        assert_eq!(last_bar.close, Price::from("100.00"));
6619        assert!(
6620            last_bar.volume.as_f64() >= 1.0,
6621            "bar built after deferred event should include the update at ts_init"
6622        );
6623    }
6624
6625    #[rstest]
6626    #[case(10.03, 10.07, Price::from("10.00"), Price::from("10.10"))]
6627    #[case(-10.07, -10.03, Price::from("-10.10"), Price::from("-10.00"))]
6628    fn test_fixed_tick_scheme_rounder_rounds_bid_and_ask_outward(
6629        #[case] raw_bid: f64,
6630        #[case] raw_ask: f64,
6631        #[case] expected_bid: Price,
6632        #[case] expected_ask: Price,
6633    ) {
6634        let rounder = FixedTickSchemeRounder::new(0.05).unwrap();
6635
6636        let (bid, ask) = rounder.round_prices(raw_bid, raw_ask, 2);
6637
6638        assert_eq!(bid, expected_bid);
6639        assert_eq!(ask, expected_ask);
6640    }
6641
6642    #[rstest]
6643    fn test_spread_quote_quote_driven_emits_when_all_legs_received(equity_aapl: Equity) {
6644        let instrument = InstrumentAny::Equity(equity_aapl);
6645        let leg1 = instrument.id();
6646        let leg2 = InstrumentId::from("MSFT.XNAS");
6647        let spread_id = InstrumentId::from("SPREAD.XNAS");
6648        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6649        let (handler, record) = recording_handler();
6650        let clock = Rc::new(RefCell::new(TestClock::new()));
6651
6652        let mut agg = SpreadQuoteAggregator::new(
6653            spread_id,
6654            &legs,
6655            true,
6656            instrument.price_precision(),
6657            0,
6658            Box::new(record),
6659            clock,
6660            false,
6661            None,
6662            0,
6663            false,
6664            60,
6665            None,
6666            None,
6667        );
6668
6669        let ts = UnixNanos::from(1_000_000_000);
6670        agg.handle_quote_tick(QuoteTick::new(
6671            leg1,
6672            Price::from("100.00"),
6673            Price::from("100.10"),
6674            Quantity::from(10),
6675            Quantity::from(10),
6676            ts,
6677            ts,
6678        ));
6679        assert_eq!(handler.lock().len(), 0);
6680
6681        agg.handle_quote_tick(QuoteTick::new(
6682            leg2,
6683            Price::from("99.00"),
6684            Price::from("99.10"),
6685            Quantity::from(10),
6686            Quantity::from(10),
6687            ts,
6688            ts,
6689        ));
6690        let quotes = handler.lock();
6691        assert_eq!(quotes.len(), 1);
6692        assert_eq!(quotes[0].instrument_id, spread_id);
6693        assert!(quotes[0].bid_price < quotes[0].ask_price);
6694    }
6695
6696    #[rstest]
6697    fn test_spread_quote_futures_pricing_signed_ratios(equity_aapl: Equity) {
6698        let instrument = InstrumentAny::Equity(equity_aapl);
6699        let leg1 = instrument.id();
6700        let leg2 = InstrumentId::from("MSFT.XNAS");
6701        let spread_id = InstrumentId::from("SPREAD.XNAS");
6702        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6703        let (handler, record) = recording_handler();
6704        let clock = Rc::new(RefCell::new(TestClock::new()));
6705
6706        let mut agg = SpreadQuoteAggregator::new(
6707            spread_id,
6708            &legs,
6709            true,
6710            instrument.price_precision(),
6711            0,
6712            Box::new(record),
6713            clock,
6714            false,
6715            None,
6716            0,
6717            false,
6718            60,
6719            None,
6720            None,
6721        );
6722
6723        let ts = UnixNanos::from(1_000_000_000);
6724        agg.handle_quote_tick(QuoteTick::new(
6725            leg1,
6726            Price::from("10.00"),
6727            Price::from("10.10"),
6728            Quantity::from(100),
6729            Quantity::from(100),
6730            ts,
6731            ts,
6732        ));
6733        agg.handle_quote_tick(QuoteTick::new(
6734            leg2,
6735            Price::from("20.00"),
6736            Price::from("20.10"),
6737            Quantity::from(100),
6738            Quantity::from(100),
6739            ts,
6740            ts,
6741        ));
6742        let quotes = handler.lock();
6743        assert_eq!(quotes.len(), 1);
6744        let q = &quotes[0];
6745        assert_eq!(q.instrument_id, spread_id);
6746        assert_eq!(q.bid_price, Price::from("-10.10"));
6747        assert_eq!(q.ask_price, Price::from("-9.90"));
6748    }
6749
6750    #[rstest]
6751    fn test_spread_quote_size_calculation_non_unit_ratios(equity_aapl: Equity) {
6752        let instrument = InstrumentAny::Equity(equity_aapl);
6753        let leg1 = instrument.id();
6754        let leg2 = InstrumentId::from("MSFT.XNAS");
6755        let spread_id = InstrumentId::from("SPREAD.XNAS");
6756        let legs = vec![(leg1, 2_i64), (leg2, -1_i64)];
6757        let (handler, record) = recording_handler();
6758        let clock = Rc::new(RefCell::new(TestClock::new()));
6759
6760        let mut agg = SpreadQuoteAggregator::new(
6761            spread_id,
6762            &legs,
6763            true,
6764            instrument.price_precision(),
6765            0,
6766            Box::new(record),
6767            clock,
6768            false,
6769            None,
6770            0,
6771            false,
6772            60,
6773            None,
6774            None,
6775        );
6776
6777        let ts = UnixNanos::from(1_000_000_000);
6778        agg.handle_quote_tick(QuoteTick::new(
6779            leg1,
6780            Price::from("10.00"),
6781            Price::from("10.10"),
6782            Quantity::from(100),
6783            Quantity::from(40),
6784            ts,
6785            ts,
6786        ));
6787        agg.handle_quote_tick(QuoteTick::new(
6788            leg2,
6789            Price::from("10.00"),
6790            Price::from("10.10"),
6791            Quantity::from(50),
6792            Quantity::from(30),
6793            ts,
6794            ts,
6795        ));
6796        let quotes = handler.lock();
6797        assert_eq!(quotes.len(), 1);
6798        let q = &quotes[0];
6799        assert_eq!(q.bid_size.as_f64(), 30.0);
6800        assert_eq!(q.ask_size.as_f64(), 20.0);
6801    }
6802
6803    #[rstest]
6804    fn test_spread_quote_timer_driven_emission_cadence(equity_aapl: Equity) {
6805        let instrument = InstrumentAny::Equity(equity_aapl);
6806        let leg1 = instrument.id();
6807        let leg2 = InstrumentId::from("MSFT.XNAS");
6808        let spread_id = InstrumentId::from("SPREAD.XNAS");
6809        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6810        let (handler, record) = recording_handler();
6811        let clock = Rc::new(RefCell::new(TestClock::new()));
6812        clock.borrow_mut().set_time(UnixNanos::from(0));
6813
6814        let agg = SpreadQuoteAggregator::new(
6815            spread_id,
6816            &legs,
6817            true,
6818            instrument.price_precision(),
6819            0,
6820            Box::new(record),
6821            clock.clone(),
6822            false,
6823            Some(1),
6824            0,
6825            false,
6826            60,
6827            None,
6828            None,
6829        );
6830        let rc = Rc::new(RefCell::new(agg));
6831        rc.borrow_mut().prepare_for_timer_mode(&rc);
6832        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
6833
6834        for event in clock.borrow_mut().advance_time(UnixNanos::from(0), true) {
6835            rc.borrow_mut().on_timer_fire(event.ts_event);
6836        }
6837        assert_eq!(handler.lock().len(), 0);
6838
6839        let ts1 = UnixNanos::from(1_000_000_000);
6840        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6841            leg1,
6842            Price::from("100.00"),
6843            Price::from("100.10"),
6844            Quantity::from(10),
6845            Quantity::from(10),
6846            ts1,
6847            ts1,
6848        ));
6849        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6850            leg2,
6851            Price::from("99.00"),
6852            Price::from("99.10"),
6853            Quantity::from(10),
6854            Quantity::from(10),
6855            ts1,
6856            ts1,
6857        ));
6858
6859        for event in clock.borrow_mut().advance_time(ts1, true) {
6860            rc.borrow_mut().on_timer_fire(event.ts_event);
6861        }
6862
6863        {
6864            let quotes = handler.lock();
6865            assert_eq!(quotes.len(), 1);
6866            assert_eq!(quotes[0].ts_event, ts1);
6867            assert_eq!(quotes[0].ts_init, ts1);
6868        }
6869
6870        let ts2 = UnixNanos::from(2_000_000_000);
6871        for event in clock.borrow_mut().advance_time(ts2, true) {
6872            rc.borrow_mut().on_timer_fire(event.ts_event);
6873        }
6874
6875        let quotes = handler.lock();
6876        assert_eq!(quotes.len(), 1);
6877    }
6878
6879    #[rstest]
6880    fn test_spread_quote_historical_timer_waits_for_all_legs(equity_aapl: Equity) {
6881        let instrument = InstrumentAny::Equity(equity_aapl);
6882        let leg1 = instrument.id();
6883        let leg2 = InstrumentId::from("MSFT.XNAS");
6884        let spread_id = InstrumentId::from("SPREAD.XNAS");
6885        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6886        let (handler, record) = recording_handler();
6887        let clock = Rc::new(RefCell::new(TestClock::new()));
6888
6889        let agg = SpreadQuoteAggregator::new(
6890            spread_id,
6891            &legs,
6892            true,
6893            instrument.price_precision(),
6894            0,
6895            Box::new(record),
6896            // need clock for set_clock after
6897            clock.clone(),
6898            true,
6899            Some(1),
6900            0,
6901            false,
6902            60,
6903            None,
6904            None,
6905        );
6906        let rc = Rc::new(RefCell::new(agg));
6907        rc.borrow_mut().prepare_for_timer_mode(&rc);
6908        rc.borrow_mut().set_clock(clock);
6909
6910        let ts1 = UnixNanos::from(1_000_000_000);
6911        let ts2 = UnixNanos::from(2_000_000_000);
6912        let ts3 = UnixNanos::from(3_000_000_000);
6913        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6914            leg1,
6915            Price::from("100.00"),
6916            Price::from("100.10"),
6917            Quantity::from(10),
6918            Quantity::from(10),
6919            ts1,
6920            ts1,
6921        ));
6922        assert_eq!(handler.lock().len(), 0);
6923
6924        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6925            leg2,
6926            Price::from("99.00"),
6927            Price::from("99.10"),
6928            Quantity::from(10),
6929            Quantity::from(10),
6930            ts2,
6931            ts2,
6932        ));
6933        assert_eq!(handler.lock().len(), 0);
6934
6935        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6936            leg1,
6937            Price::from("100.00"),
6938            Price::from("100.10"),
6939            Quantity::from(10),
6940            Quantity::from(10),
6941            ts3,
6942            ts3,
6943        ));
6944        let quotes = handler.lock();
6945        assert_eq!(
6946            quotes.len(),
6947            1,
6948            "deferred event at ts2 is processed when we have all legs and advance to ts3"
6949        );
6950    }
6951
6952    #[rstest]
6953    fn test_spread_quote_historical_flush_emits_pending_final_quote(equity_aapl: Equity) {
6954        let instrument = InstrumentAny::Equity(equity_aapl);
6955        let leg1 = instrument.id();
6956        let leg2 = InstrumentId::from("MSFT.XNAS");
6957        let spread_id = InstrumentId::from("SPREAD.XNAS");
6958        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6959        let (handler, record) = recording_handler();
6960        let clock = Rc::new(RefCell::new(TestClock::new()));
6961
6962        let agg = SpreadQuoteAggregator::new(
6963            spread_id,
6964            &legs,
6965            true,
6966            instrument.price_precision(),
6967            0,
6968            Box::new(record),
6969            // need clock for set_clock after
6970            clock.clone(),
6971            true,
6972            Some(1),
6973            0,
6974            false,
6975            60,
6976            None,
6977            None,
6978        );
6979        let rc = Rc::new(RefCell::new(agg));
6980        rc.borrow_mut().prepare_for_timer_mode(&rc);
6981        rc.borrow_mut().set_clock(clock);
6982
6983        let ts1 = UnixNanos::from(1_000_000_000);
6984        let ts2 = UnixNanos::from(2_000_000_000);
6985        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6986            leg1,
6987            Price::from("100.00"),
6988            Price::from("100.10"),
6989            Quantity::from(10),
6990            Quantity::from(10),
6991            ts1,
6992            ts1,
6993        ));
6994        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
6995            leg2,
6996            Price::from("99.00"),
6997            Price::from("99.10"),
6998            Quantity::from(10),
6999            Quantity::from(10),
7000            ts2,
7001            ts2,
7002        ));
7003
7004        assert_eq!(handler.lock().len(), 0);
7005
7006        rc.borrow_mut().flush_pending_historical_quote();
7007
7008        let quotes = handler.lock();
7009        assert_eq!(
7010            quotes.len(),
7011            1,
7012            "final historical quote should be emitted when the deferred event is flushed",
7013        );
7014        assert_eq!(quotes[0].ts_event, ts2);
7015    }
7016
7017    #[rstest]
7018    fn test_spread_quote_option_vega_weighting(equity_aapl: Equity) {
7019        let instrument = InstrumentAny::Equity(equity_aapl);
7020        let leg1 = instrument.id();
7021        let leg2 = InstrumentId::from("MSFT.XNAS");
7022        let spread_id = InstrumentId::from("SPREAD.XNAS");
7023        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7024        let (handler, record) = recording_handler();
7025        let clock = Rc::new(RefCell::new(TestClock::new()));
7026
7027        let mut vega_provider = MapVegaProvider::new();
7028        vega_provider.insert(leg1, 0.15);
7029        vega_provider.insert(leg2, 0.12);
7030
7031        let mut agg = SpreadQuoteAggregator::new(
7032            spread_id,
7033            &legs,
7034            false,
7035            instrument.price_precision(),
7036            0,
7037            Box::new(record),
7038            clock,
7039            false,
7040            None,
7041            0,
7042            false,
7043            60,
7044            Some(Box::new(vega_provider)),
7045            None,
7046        );
7047
7048        let ts = UnixNanos::from(1_000_000_000);
7049        agg.handle_quote_tick(QuoteTick::new(
7050            leg1,
7051            Price::from("10.00"),
7052            Price::from("10.20"),
7053            Quantity::from(100),
7054            Quantity::from(100),
7055            ts,
7056            ts,
7057        ));
7058        agg.handle_quote_tick(QuoteTick::new(
7059            leg2,
7060            Price::from("11.00"),
7061            Price::from("11.20"),
7062            Quantity::from(100),
7063            Quantity::from(100),
7064            ts,
7065            ts,
7066        ));
7067        let quotes = handler.lock();
7068        assert_eq!(quotes.len(), 1);
7069        let q = &quotes[0];
7070        assert_eq!(q.instrument_id, spread_id);
7071        assert_eq!(q.bid_price, Price::from("-1.02"));
7072        assert_eq!(q.ask_price, Price::from("-0.98"));
7073        assert_eq!(q.bid_size, Quantity::from(100));
7074        assert_eq!(q.ask_size, Quantity::from(100));
7075        assert_eq!(q.ts_event, ts);
7076        assert_eq!(q.ts_init, ts);
7077    }
7078
7079    #[rstest]
7080    fn test_spread_quote_all_zero_vega_fallback(equity_aapl: Equity) {
7081        let instrument = InstrumentAny::Equity(equity_aapl);
7082        let leg1 = instrument.id();
7083        let leg2 = InstrumentId::from("MSFT.XNAS");
7084        let spread_id = InstrumentId::from("SPREAD.XNAS");
7085        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7086        let (handler, record) = recording_handler();
7087        let clock = Rc::new(RefCell::new(TestClock::new()));
7088
7089        let mut vega_provider = MapVegaProvider::new();
7090        vega_provider.insert(leg1, 0.0);
7091        vega_provider.insert(leg2, 0.0);
7092
7093        let agg = SpreadQuoteAggregator::new(
7094            spread_id,
7095            &legs,
7096            false,
7097            instrument.price_precision(),
7098            0,
7099            Box::new(record),
7100            clock.clone(),
7101            false,
7102            None,
7103            0,
7104            false,
7105            1,
7106            Some(Box::new(vega_provider)),
7107            None,
7108        );
7109        let rc = Rc::new(RefCell::new(agg));
7110        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7111
7112        let ts = UnixNanos::from(1_000_000_000);
7113        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7114            leg1,
7115            Price::from("10.00"),
7116            Price::from("10.10"),
7117            Quantity::from(100),
7118            Quantity::from(100),
7119            ts,
7120            ts,
7121        ));
7122        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7123            leg2,
7124            Price::from("20.00"),
7125            Price::from("20.10"),
7126            Quantity::from(100),
7127            Quantity::from(100),
7128            ts,
7129            ts,
7130        ));
7131        {
7132            let quotes = handler.lock();
7133            assert_eq!(quotes.len(), 1);
7134            let q = &quotes[0];
7135            assert_eq!(q.bid_price, Price::from("-10.10"));
7136            assert_eq!(q.ask_price, Price::from("-9.90"));
7137        }
7138        assert!(rc.borrow().vega_pricing_temporarily_disabled);
7139
7140        let timeout_name = rc.borrow().vega_pricing_timeout_timer_name.clone();
7141        assert!(
7142            clock
7143                .borrow()
7144                .timer_names()
7145                .contains(&timeout_name.as_str())
7146        );
7147
7148        let events = clock
7149            .borrow_mut()
7150            .advance_time(UnixNanos::from(2_000_000_000), true);
7151
7152        for handler in clock.borrow().match_handlers(events) {
7153            handler.run();
7154        }
7155
7156        assert!(!rc.borrow().vega_pricing_temporarily_disabled);
7157
7158        let (_cancel_handler, record) = recording_handler();
7159        let mut cancel_vega_provider = MapVegaProvider::new();
7160        cancel_vega_provider.insert(leg1, 0.0);
7161        cancel_vega_provider.insert(leg2, 0.0);
7162        let cancel_agg = SpreadQuoteAggregator::new(
7163            spread_id,
7164            &legs,
7165            false,
7166            instrument.price_precision(),
7167            0,
7168            Box::new(record),
7169            clock.clone(),
7170            false,
7171            None,
7172            0,
7173            false,
7174            10,
7175            Some(Box::new(cancel_vega_provider)),
7176            None,
7177        );
7178        let cancel_rc = Rc::new(RefCell::new(cancel_agg));
7179        cancel_rc
7180            .borrow_mut()
7181            .start_timer(Some(Rc::clone(&cancel_rc)));
7182        cancel_rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7183            leg1,
7184            Price::from("10.00"),
7185            Price::from("10.10"),
7186            Quantity::from(100),
7187            Quantity::from(100),
7188            ts,
7189            ts,
7190        ));
7191        cancel_rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7192            leg2,
7193            Price::from("20.00"),
7194            Price::from("20.10"),
7195            Quantity::from(100),
7196            Quantity::from(100),
7197            ts,
7198            ts,
7199        ));
7200        let cancel_timeout_name = cancel_rc.borrow().vega_pricing_timeout_timer_name.clone();
7201        assert!(
7202            clock
7203                .borrow()
7204                .timer_names()
7205                .contains(&cancel_timeout_name.as_str())
7206        );
7207        cancel_rc.borrow_mut().stop_timer();
7208        assert!(
7209            !clock
7210                .borrow()
7211                .timer_names()
7212                .contains(&cancel_timeout_name.as_str())
7213        );
7214
7215        let (permanent_handler, record) = recording_handler();
7216        let mut permanent_vega_provider = MapVegaProvider::new();
7217        permanent_vega_provider.insert(leg1, 0.15);
7218        permanent_vega_provider.insert(leg2, 0.12);
7219        let mut permanent_agg = SpreadQuoteAggregator::new(
7220            spread_id,
7221            &legs,
7222            false,
7223            instrument.price_precision(),
7224            0,
7225            Box::new(record),
7226            Rc::new(RefCell::new(TestClock::new())),
7227            false,
7228            None,
7229            0,
7230            true,
7231            1,
7232            Some(Box::new(permanent_vega_provider)),
7233            None,
7234        );
7235
7236        permanent_agg.handle_quote_tick(QuoteTick::new(
7237            leg1,
7238            Price::from("10.00"),
7239            Price::from("10.10"),
7240            Quantity::from(100),
7241            Quantity::from(100),
7242            ts,
7243            ts,
7244        ));
7245        permanent_agg.handle_quote_tick(QuoteTick::new(
7246            leg2,
7247            Price::from("20.00"),
7248            Price::from("20.10"),
7249            Quantity::from(100),
7250            Quantity::from(100),
7251            ts,
7252            ts,
7253        ));
7254
7255        let permanent_quotes = permanent_handler.lock();
7256        assert_eq!(permanent_quotes.len(), 1);
7257        assert_eq!(permanent_quotes[0].bid_price, Price::from("-10.10"));
7258        assert_eq!(permanent_quotes[0].ask_price, Price::from("-9.90"));
7259        assert!(!permanent_agg.vega_pricing_temporarily_disabled);
7260    }
7261
7262    #[rstest]
7263    fn test_spread_quote_negative_prices_tick_scheme(equity_aapl: Equity) {
7264        let instrument = InstrumentAny::Equity(equity_aapl);
7265        let leg1 = instrument.id();
7266        let leg2 = InstrumentId::from("MSFT.XNAS");
7267        let spread_id = InstrumentId::from("SPREAD.XNAS");
7268        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7269        let (handler, record) = recording_handler();
7270        let clock = Rc::new(RefCell::new(TestClock::new()));
7271        let rounder = FixedTickSchemeRounder::new(0.01).unwrap();
7272
7273        let mut agg = SpreadQuoteAggregator::new(
7274            spread_id,
7275            &legs,
7276            true,
7277            2,
7278            0,
7279            Box::new(record),
7280            clock,
7281            false,
7282            None,
7283            0,
7284            false,
7285            60,
7286            None,
7287            Some(Box::new(rounder)),
7288        );
7289
7290        let ts = UnixNanos::from(1_000_000_000);
7291        agg.handle_quote_tick(QuoteTick::new(
7292            leg1,
7293            Price::from("10.00"),
7294            Price::from("10.10"),
7295            Quantity::from(100),
7296            Quantity::from(100),
7297            ts,
7298            ts,
7299        ));
7300        agg.handle_quote_tick(QuoteTick::new(
7301            leg2,
7302            Price::from("20.00"),
7303            Price::from("20.10"),
7304            Quantity::from(100),
7305            Quantity::from(100),
7306            ts,
7307            ts,
7308        ));
7309        let quotes = handler.lock();
7310        assert_eq!(quotes.len(), 1);
7311        let q = &quotes[0];
7312        assert!(q.bid_price.as_f64() < 0.0);
7313        assert!(q.ask_price.as_f64() < 0.0);
7314        assert!(q.bid_price < q.ask_price);
7315    }
7316
7317    #[rstest]
7318    #[case(BarIntervalType::LeftOpen)]
7319    #[case(BarIntervalType::RightOpen)]
7320    fn test_time_bar_skip_first_non_full_bar_noop_on_boundary(
7321        equity_aapl: Equity,
7322        #[case] interval_type: BarIntervalType,
7323    ) {
7324        // When the clock sits on a bar boundary, fire_immediately=true and
7325        // first_close_ns equals that boundary. Every subsequent bar closes
7326        // strictly after first_close_ns, so skip_first_non_full_bar never
7327        // triggers and both bars emit.
7328        let instrument = InstrumentAny::Equity(equity_aapl);
7329        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7330        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7331        let (handler, record) = recording_handler();
7332        let clock = Rc::new(RefCell::new(TestClock::new()));
7333        clock.borrow_mut().set_time(UnixNanos::from(1_000_000_000));
7334        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7335
7336        let aggregator = TimeBarAggregator::new(
7337            bar_type,
7338            instrument.price_precision(),
7339            instrument.size_precision(),
7340            clock,
7341            record,
7342            false,
7343            false,
7344            interval_type,
7345            None,
7346            0,
7347            true, // skip_first_non_full_bar
7348        );
7349
7350        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7351        let rc = Rc::new(RefCell::new(boxed));
7352        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7353
7354        rc.borrow_mut().update(
7355            Price::from("100.00"),
7356            Quantity::from(1),
7357            UnixNanos::from(1_000_000_000),
7358        );
7359        rc.borrow_mut().build_bar(&TimeEvent::new(
7360            event_name,
7361            UUID4::new(),
7362            UnixNanos::from(2_000_000_000),
7363            UnixNanos::from(2_000_000_000),
7364        ));
7365        rc.borrow_mut().update(
7366            Price::from("101.00"),
7367            Quantity::from(1),
7368            UnixNanos::from(2_500_000_000),
7369        );
7370        rc.borrow_mut().build_bar(&TimeEvent::new(
7371            event_name,
7372            UUID4::new(),
7373            UnixNanos::from(3_000_000_000),
7374            UnixNanos::from(3_000_000_000),
7375        ));
7376
7377        let bars = handler.lock();
7378        assert_eq!(bars.len(), 2);
7379        assert_eq!(bars[0].close, Price::from("100.00"));
7380        assert_eq!(bars[1].close, Price::from("101.00"));
7381    }
7382
7383    #[rstest]
7384    #[case(BarIntervalType::LeftOpen)]
7385    #[case(BarIntervalType::RightOpen)]
7386    fn test_time_bar_skip_first_non_full_bar_drops_partial_bar(
7387        equity_aapl: Equity,
7388        #[case] interval_type: BarIntervalType,
7389    ) {
7390        // When the clock starts past a boundary (mid-interval), first_close_ns
7391        // is the upcoming boundary. The bar closing at first_close_ns is partial,
7392        // so skip_first_non_full_bar drops it; subsequent full bars emit.
7393        let instrument = InstrumentAny::Equity(equity_aapl);
7394        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7395        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7396        let (handler, record) = recording_handler();
7397        let clock = Rc::new(RefCell::new(TestClock::new()));
7398        clock.borrow_mut().set_time(UnixNanos::from(1_500_000_000));
7399        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7400
7401        let aggregator = TimeBarAggregator::new(
7402            bar_type,
7403            instrument.price_precision(),
7404            instrument.size_precision(),
7405            clock,
7406            record,
7407            false,
7408            false,
7409            interval_type,
7410            None,
7411            0,
7412            true, // skip_first_non_full_bar
7413        );
7414
7415        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7416        let rc = Rc::new(RefCell::new(boxed));
7417        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7418
7419        rc.borrow_mut().update(
7420            Price::from("100.00"),
7421            Quantity::from(1),
7422            UnixNanos::from(1_500_000_000),
7423        );
7424        rc.borrow_mut().build_bar(&TimeEvent::new(
7425            event_name,
7426            UUID4::new(),
7427            UnixNanos::from(2_000_000_000),
7428            UnixNanos::from(2_000_000_000),
7429        ));
7430        rc.borrow_mut().update(
7431            Price::from("101.00"),
7432            Quantity::from(1),
7433            UnixNanos::from(2_500_000_000),
7434        );
7435        rc.borrow_mut().build_bar(&TimeEvent::new(
7436            event_name,
7437            UUID4::new(),
7438            UnixNanos::from(3_000_000_000),
7439            UnixNanos::from(3_000_000_000),
7440        ));
7441
7442        let bars = handler.lock();
7443        assert_eq!(bars.len(), 1);
7444        assert_eq!(bars[0].close, Price::from("101.00"));
7445    }
7446
7447    #[rstest]
7448    fn test_time_bar_skip_first_non_full_bar_skips_every_call_before_first_close(
7449        equity_aapl: Equity,
7450    ) {
7451        // The flag must remain set across every build_and_send call whose
7452        // ts_init <= first_close_ns, and only flip once a bar actually emits.
7453        // Catches a mutation that flips skip_first_non_full_bar early.
7454        let instrument = InstrumentAny::Equity(equity_aapl);
7455        let bar_spec = BarSpecification::new(10, BarAggregation::Second, PriceType::Last);
7456        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7457        let (handler, record) = recording_handler();
7458        let clock = Rc::new(RefCell::new(TestClock::new()));
7459        clock.borrow_mut().set_time(UnixNanos::from(5_000_000_000));
7460        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7461
7462        let aggregator = TimeBarAggregator::new(
7463            bar_type,
7464            instrument.price_precision(),
7465            instrument.size_precision(),
7466            clock,
7467            record,
7468            false,
7469            false,
7470            BarIntervalType::LeftOpen,
7471            None,
7472            0,
7473            true, // skip_first_non_full_bar
7474        );
7475
7476        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7477        let rc = Rc::new(RefCell::new(boxed));
7478        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7479
7480        // first_close_ns is 10_000_000_000 (first 10s boundary after start).
7481        // Drive three build_bar calls at ts <= first_close_ns, each preceded by a
7482        // distinct update. Every one of them must be skipped.
7483        for (price, update_ts, event_ts) in [
7484            ("100.00", 5_500_000_000_u64, 7_000_000_000_u64),
7485            ("101.00", 7_500_000_000_u64, 8_000_000_000_u64),
7486            ("102.00", 9_000_000_000_u64, 10_000_000_000_u64),
7487        ] {
7488            rc.borrow_mut().update(
7489                Price::from(price),
7490                Quantity::from(1),
7491                UnixNanos::from(update_ts),
7492            );
7493            rc.borrow_mut().build_bar(&TimeEvent::new(
7494                event_name,
7495                UUID4::new(),
7496                UnixNanos::from(event_ts),
7497                UnixNanos::from(event_ts),
7498            ));
7499        }
7500
7501        // Final update + build past first_close_ns emits for the first time.
7502        rc.borrow_mut().update(
7503            Price::from("103.00"),
7504            Quantity::from(1),
7505            UnixNanos::from(10_500_000_000),
7506        );
7507        rc.borrow_mut().build_bar(&TimeEvent::new(
7508            event_name,
7509            UUID4::new(),
7510            UnixNanos::from(11_000_000_000),
7511            UnixNanos::from(11_000_000_000),
7512        ));
7513
7514        let bars = handler.lock();
7515        assert_eq!(bars.len(), 1);
7516        assert_eq!(bars[0].close, Price::from("103.00"));
7517    }
7518
7519    #[rstest]
7520    fn test_time_bar_skip_first_non_full_bar_skips_when_build_delay_shifts_start(
7521        equity_aapl: Equity,
7522    ) {
7523        // When bar_build_delay > 0 pushes start_time past a boundary (even if `now` is on a
7524        // boundary), first_close_ns is set and the first bar is skipped. A `now > start_time`
7525        // guard would incorrectly keep this first bar.
7526        let instrument = InstrumentAny::Equity(equity_aapl);
7527        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7528        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7529        let (handler, record) = recording_handler();
7530        let clock = Rc::new(RefCell::new(TestClock::new()));
7531        clock.borrow_mut().set_time(UnixNanos::from(2_000_000_000));
7532        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7533
7534        let aggregator = TimeBarAggregator::new(
7535            bar_type,
7536            instrument.price_precision(),
7537            instrument.size_precision(),
7538            clock,
7539            record,
7540            false,
7541            false,
7542            BarIntervalType::LeftOpen,
7543            None,
7544            100,  // bar_build_delay (microseconds)
7545            true, // skip_first_non_full_bar
7546        );
7547
7548        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7549        let rc = Rc::new(RefCell::new(boxed));
7550        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7551
7552        // start_time = 2s + 100us = 2_000_100_000 ns; first_close_ns = 3_000_100_000 ns.
7553        rc.borrow_mut().update(
7554            Price::from("100.00"),
7555            Quantity::from(1),
7556            UnixNanos::from(2_500_000_000),
7557        );
7558        rc.borrow_mut().build_bar(&TimeEvent::new(
7559            event_name,
7560            UUID4::new(),
7561            UnixNanos::from(3_000_100_000),
7562            UnixNanos::from(3_000_100_000),
7563        ));
7564        rc.borrow_mut().update(
7565            Price::from("101.00"),
7566            Quantity::from(1),
7567            UnixNanos::from(3_500_000_000),
7568        );
7569        rc.borrow_mut().build_bar(&TimeEvent::new(
7570            event_name,
7571            UUID4::new(),
7572            UnixNanos::from(4_000_100_000),
7573            UnixNanos::from(4_000_100_000),
7574        ));
7575
7576        let bars = handler.lock();
7577        assert_eq!(bars.len(), 1);
7578        assert_eq!(bars[0].close, Price::from("101.00"));
7579    }
7580
7581    #[rstest]
7582    #[case(
7583        BarAggregation::Month,
7584        1_735_689_600_000_000_000_u64,
7585        1_733_011_200_000_000_000_u64
7586    )]
7587    #[case(
7588        BarAggregation::Year,
7589        1_735_689_600_000_000_000_u64,
7590        1_704_067_200_000_000_000_u64
7591    )]
7592    fn test_time_bar_fire_immediately_month_year_stored_open_points_to_previous_period(
7593        equity_aapl: Equity,
7594        #[case] aggregation: BarAggregation,
7595        #[case] start_ns: u64,
7596        #[case] expected_stored_open_ns: u64,
7597    ) {
7598        // When the clock is exactly on a month/year boundary, fire_immediately=true.
7599        // stored_open_ns must resolve to one step before start_time (close_time - step)
7600        // so the first bar's open timestamp marks the true start of the in-progress interval.
7601        let instrument = InstrumentAny::Equity(equity_aapl);
7602        let bar_spec = BarSpecification::new(1, aggregation, PriceType::Last);
7603        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7604        let (handler, record) = recording_handler();
7605        let clock = Rc::new(RefCell::new(TestClock::new()));
7606        clock.borrow_mut().set_time(UnixNanos::from(start_ns));
7607        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7608
7609        let aggregator = TimeBarAggregator::new(
7610            bar_type,
7611            instrument.price_precision(),
7612            instrument.size_precision(),
7613            clock,
7614            record,
7615            false,
7616            false,
7617            BarIntervalType::RightOpen, // ts_event = stored_open_ns
7618            None,
7619            0,
7620            false, // skip_first_non_full_bar
7621        );
7622
7623        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7624        let rc = Rc::new(RefCell::new(boxed));
7625        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7626
7627        rc.borrow_mut().update(
7628            Price::from("100.00"),
7629            Quantity::from(1),
7630            UnixNanos::from(start_ns),
7631        );
7632        rc.borrow_mut().build_bar(&TimeEvent::new(
7633            event_name,
7634            UUID4::new(),
7635            UnixNanos::from(start_ns),
7636            UnixNanos::from(start_ns),
7637        ));
7638
7639        let bars = handler.lock();
7640        assert_eq!(bars.len(), 1);
7641        assert_eq!(bars[0].ts_event, UnixNanos::from(expected_stored_open_ns));
7642        assert_eq!(bars[0].ts_init, UnixNanos::from(start_ns));
7643    }
7644
7645    #[rstest]
7646    fn test_time_bar_historical_prevents_bars_for_timer_before_last_data(equity_aapl: Equity) {
7647        let instrument = InstrumentAny::Equity(equity_aapl);
7648        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7649        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7650        let (handler, record) = recording_handler();
7651        let clock = Rc::new(RefCell::new(TestClock::new()));
7652
7653        let mut agg = TimeBarAggregator::new(
7654            bar_type,
7655            instrument.price_precision(),
7656            instrument.size_precision(),
7657            clock.clone(),
7658            record,
7659            true,
7660            true,
7661            BarIntervalType::LeftOpen,
7662            None,
7663            0,
7664            false,
7665        );
7666        agg.historical_mode = true;
7667        agg.set_clock_internal(clock);
7668        let boxed: Box<dyn BarAggregator> = Box::new(agg);
7669        let rc = Rc::new(RefCell::new(boxed));
7670        rc.borrow_mut().set_aggregator_weak(Rc::downgrade(&rc));
7671
7672        let ts1 = UnixNanos::from(2_000_000_000);
7673        rc.borrow_mut()
7674            .update(Price::from("100.00"), Quantity::from(1), ts1);
7675
7676        let ts2 = UnixNanos::from(3_000_000_000);
7677        rc.borrow_mut()
7678            .update(Price::from("101.00"), Quantity::from(1), ts2);
7679
7680        let bars = handler.lock();
7681        assert!(
7682            !bars.is_empty(),
7683            "advancing time from ts1 to ts2 should produce at least one bar"
7684        );
7685        assert_eq!(bars[0].close, Price::from("100.00"));
7686    }
7687
7688    #[rstest]
7689    #[case(BarAggregation::Tick)]
7690    #[case(BarAggregation::TickImbalance)]
7691    #[case(BarAggregation::TickRuns)]
7692    #[case(BarAggregation::Volume)]
7693    #[case(BarAggregation::VolumeImbalance)]
7694    #[case(BarAggregation::VolumeRuns)]
7695    #[case(BarAggregation::Value)]
7696    #[case(BarAggregation::ValueImbalance)]
7697    #[case(BarAggregation::ValueRuns)]
7698    #[case(BarAggregation::Renko)]
7699    fn test_aggregators_standardize_composite_bar_type(
7700        equity_aapl: Equity,
7701        #[case] aggregation: BarAggregation,
7702    ) {
7703        let instrument = InstrumentAny::Equity(equity_aapl);
7704        let bar_type = BarType::new_composite(
7705            instrument.id(),
7706            BarSpecification::new(10, aggregation, PriceType::Last),
7707            AggregationSource::Internal,
7708            1,
7709            BarAggregation::Minute,
7710            AggregationSource::External,
7711        );
7712        let handler = |_: Bar| {};
7713
7714        let aggregator: Box<dyn BarAggregator> = match aggregation {
7715            BarAggregation::Tick => Box::new(TickBarAggregator::new(
7716                bar_type,
7717                instrument.price_precision(),
7718                instrument.size_precision(),
7719                handler,
7720            )),
7721            BarAggregation::TickImbalance => Box::new(TickImbalanceBarAggregator::new(
7722                bar_type,
7723                instrument.price_precision(),
7724                instrument.size_precision(),
7725                handler,
7726            )),
7727            BarAggregation::TickRuns => Box::new(TickRunsBarAggregator::new(
7728                bar_type,
7729                instrument.price_precision(),
7730                instrument.size_precision(),
7731                handler,
7732            )),
7733            BarAggregation::Volume => Box::new(VolumeBarAggregator::new(
7734                bar_type,
7735                instrument.price_precision(),
7736                instrument.size_precision(),
7737                handler,
7738            )),
7739            BarAggregation::VolumeImbalance => Box::new(VolumeImbalanceBarAggregator::new(
7740                bar_type,
7741                instrument.price_precision(),
7742                instrument.size_precision(),
7743                handler,
7744            )),
7745            BarAggregation::VolumeRuns => Box::new(VolumeRunsBarAggregator::new(
7746                bar_type,
7747                instrument.price_precision(),
7748                instrument.size_precision(),
7749                handler,
7750            )),
7751            BarAggregation::Value => Box::new(ValueBarAggregator::new(
7752                bar_type,
7753                instrument.price_precision(),
7754                instrument.size_precision(),
7755                handler,
7756            )),
7757            BarAggregation::ValueImbalance => Box::new(ValueImbalanceBarAggregator::new(
7758                bar_type,
7759                instrument.price_precision(),
7760                instrument.size_precision(),
7761                handler,
7762            )),
7763            BarAggregation::ValueRuns => Box::new(ValueRunsBarAggregator::new(
7764                bar_type,
7765                instrument.price_precision(),
7766                instrument.size_precision(),
7767                handler,
7768            )),
7769            BarAggregation::Renko => Box::new(RenkoBarAggregator::new(
7770                bar_type,
7771                instrument.price_precision(),
7772                instrument.size_precision(),
7773                Price::from("0.01"),
7774                handler,
7775            )),
7776            _ => unreachable!(),
7777        };
7778
7779        assert!(aggregator.bar_type().is_standard());
7780        assert_eq!(aggregator.bar_type(), bar_type.standard());
7781    }
7782
7783    #[rstest]
7784    fn test_composite_tick_bar_aggregator_emits_standard_bar_type(equity_aapl: Equity) {
7785        let instrument = InstrumentAny::Equity(equity_aapl);
7786        let bar_type = BarType::new_composite(
7787            instrument.id(),
7788            BarSpecification::new(1, BarAggregation::Tick, PriceType::Last),
7789            AggregationSource::Internal,
7790            1,
7791            BarAggregation::Minute,
7792            AggregationSource::External,
7793        );
7794        let (handler, record) = recording_handler();
7795
7796        let mut aggregator = TickBarAggregator::new(
7797            bar_type,
7798            instrument.price_precision(),
7799            instrument.size_precision(),
7800            record,
7801        );
7802
7803        let input_bar = Bar::new(
7804            bar_type.composite(),
7805            Price::from("100.00"),
7806            Price::from("101.00"),
7807            Price::from("99.00"),
7808            Price::from("100.50"),
7809            Quantity::from(10),
7810            UnixNanos::from(1_000),
7811            UnixNanos::from(1_000),
7812        );
7813        aggregator.handle_bar(input_bar);
7814
7815        let handler_guard = handler.lock();
7816        assert_eq!(handler_guard.len(), 1);
7817        assert_eq!(handler_guard[0].bar_type, bar_type.standard());
7818    }
7819
7820    #[rstest]
7821    fn test_composite_time_bar_aggregator_uses_standard_timer_name(equity_aapl: Equity) {
7822        let instrument = InstrumentAny::Equity(equity_aapl);
7823        let bar_type = BarType::new_composite(
7824            instrument.id(),
7825            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
7826            AggregationSource::Internal,
7827            1,
7828            BarAggregation::Minute,
7829            AggregationSource::External,
7830        );
7831        let clock = Rc::new(RefCell::new(TestClock::new()));
7832
7833        let aggregator = TimeBarAggregator::new(
7834            bar_type,
7835            instrument.price_precision(),
7836            instrument.size_precision(),
7837            clock.clone(),
7838            |_: Bar| {},
7839            false,
7840            true,
7841            BarIntervalType::LeftOpen,
7842            None,
7843            0,
7844            false,
7845        );
7846
7847        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7848        let rc = Rc::new(RefCell::new(boxed));
7849        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7850
7851        let expected = format!("TIME_BAR_{}", bar_type.standard());
7852        assert!(
7853            clock.borrow().timer_names().contains(&expected.as_str()),
7854            "timer names {:?} should contain {expected}",
7855            clock.borrow().timer_names(),
7856        );
7857    }
7858
7859    pub(super) fn recording_handler<T: 'static>() -> (Arc<Mutex<Vec<T>>>, impl FnMut(T)) {
7860        let events = Arc::new(Mutex::new(Vec::new()));
7861        let recorded_events = Arc::clone(&events);
7862        (events, move |event| recorded_events.lock().push(event))
7863    }
7864}
7865
7866#[cfg(test)]
7867mod property_tests {
7868    use std::{cell::RefCell, rc::Rc};
7869
7870    use nautilus_common::{clock::TestClock, timer::TimeEvent};
7871    use nautilus_core::{UUID4, UnixNanos};
7872    use nautilus_model::{
7873        data::{Bar, BarSpecification, BarType, TradeTick, bar::get_bar_interval_ns},
7874        enums::{AggregationSource, AggressorSide, BarAggregation, BarIntervalType, PriceType},
7875        instruments::{Instrument, InstrumentAny, stubs::equity_aapl},
7876        types::{Price, Quantity},
7877    };
7878    use proptest::prelude::*;
7879    use rstest::rstest;
7880    use ustr::Ustr;
7881
7882    use super::{tests::recording_handler, *};
7883
7884    fn time_bar_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
7885        prop_oneof![
7886            (Just(BarAggregation::Second), 1usize..=5),
7887            (Just(BarAggregation::Minute), 1usize..=5),
7888            (Just(BarAggregation::Hour), 1usize..=4),
7889        ]
7890    }
7891
7892    fn interval_type_strategy() -> impl Strategy<Value = BarIntervalType> {
7893        prop_oneof![
7894            Just(BarIntervalType::LeftOpen),
7895            Just(BarIntervalType::RightOpen),
7896        ]
7897    }
7898
7899    proptest! {
7900        #[rstest]
7901        fn prop_skip_first_drops_partial_then_emits(
7902            (aggregation, step) in time_bar_spec_strategy(),
7903            interval_type in interval_type_strategy(),
7904            skip_first in any::<bool>(),
7905        ) {
7906            let instrument = InstrumentAny::Equity(equity_aapl());
7907            let bar_spec = BarSpecification::new(step, aggregation, PriceType::Last);
7908            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7909            let interval_ns = get_bar_interval_ns(&bar_type);
7910
7911            // Anchor the clock one full interval past epoch plus a half-interval offset
7912            // so start_time lands mid-interval and fire_immediately is false.
7913            let now_ns = UnixNanos::default() + interval_ns + interval_ns / 2;
7914
7915            let (handler, record) = recording_handler();
7916            let clock = Rc::new(RefCell::new(TestClock::new()));
7917            clock.borrow_mut().set_time(now_ns);
7918            let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7919
7920            let aggregator = TimeBarAggregator::new(
7921                bar_type,
7922                instrument.price_precision(),
7923                instrument.size_precision(),
7924                clock,
7925                record,
7926                false,
7927                false,
7928                interval_type,
7929                None,
7930                0,
7931                skip_first,
7932            );
7933
7934            let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7935            let rc = Rc::new(RefCell::new(boxed));
7936            rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7937
7938            // First tick + first close event. start_time = 1 * interval, first_close
7939            // = 2 * interval. ts_init == first_close_ns: partial bar.
7940            rc.borrow_mut().update(
7941                Price::from("100.00"),
7942                Quantity::from(1),
7943                now_ns,
7944            );
7945            let first_close = UnixNanos::default() + interval_ns * 2;
7946            rc.borrow_mut().build_bar(&TimeEvent::new(
7947                event_name,
7948                UUID4::new(),
7949                first_close,
7950                first_close,
7951            ));
7952
7953            // Second tick + later close; emits unconditionally.
7954            rc.borrow_mut().update(
7955                Price::from("101.00"),
7956                Quantity::from(1),
7957                first_close + interval_ns / 2,
7958            );
7959            let second_close = first_close + interval_ns;
7960            rc.borrow_mut().build_bar(&TimeEvent::new(
7961                event_name,
7962                UUID4::new(),
7963                second_close,
7964                second_close,
7965            ));
7966
7967            let bars = handler.lock();
7968            let expected = if skip_first { 1 } else { 2 };
7969            prop_assert_eq!(bars.len(), expected);
7970            prop_assert_eq!(bars.last().unwrap().close, Price::from("101.00"));
7971            for bar in bars.iter() {
7972                prop_assert!(bar.high >= bar.open);
7973                prop_assert!(bar.high >= bar.close);
7974                prop_assert!(bar.low <= bar.open);
7975                prop_assert!(bar.low <= bar.close);
7976            }
7977        }
7978
7979        #[rstest]
7980        fn prop_skip_first_noop_on_exact_boundary(
7981            (aggregation, step) in time_bar_spec_strategy(),
7982            interval_type in interval_type_strategy(),
7983        ) {
7984            let instrument = InstrumentAny::Equity(equity_aapl());
7985            let bar_spec = BarSpecification::new(step, aggregation, PriceType::Last);
7986            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7987            let interval_ns = get_bar_interval_ns(&bar_type);
7988
7989            // Clock exactly on a bar boundary: fire_immediately=true, so the first
7990            // bar that reaches build_and_send must emit regardless of skip_first.
7991            let now_ns = UnixNanos::default() + interval_ns;
7992            let (handler, record) = recording_handler();
7993            let clock = Rc::new(RefCell::new(TestClock::new()));
7994            clock.borrow_mut().set_time(now_ns);
7995            let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7996
7997            let aggregator = TimeBarAggregator::new(
7998                bar_type,
7999                instrument.price_precision(),
8000                instrument.size_precision(),
8001                clock,
8002                record,
8003                false,
8004                false,
8005                interval_type,
8006                None,
8007                0,
8008                true, // skip_first_non_full_bar
8009            );
8010
8011            let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
8012            let rc = Rc::new(RefCell::new(boxed));
8013            rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
8014
8015            rc.borrow_mut().update(
8016                Price::from("100.00"),
8017                Quantity::from(1),
8018                now_ns,
8019            );
8020            let next_close = now_ns + interval_ns;
8021            rc.borrow_mut().build_bar(&TimeEvent::new(
8022                event_name,
8023                UUID4::new(),
8024                next_close,
8025                next_close,
8026            ));
8027
8028            let bars = handler.lock();
8029            prop_assert_eq!(bars.len(), 1);
8030            prop_assert_eq!(bars[0].close, Price::from("100.00"));
8031        }
8032
8033        #[rstest]
8034        fn prop_bar_builder_ohlc_invariants(
8035            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=1_000u64), 1..=50),
8036        ) {
8037            let instrument = InstrumentAny::Equity(equity_aapl());
8038            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8039            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8040            let mut builder = BarBuilder::new(bar_type, 2, 0);
8041
8042            let mut total_volume: u64 = 0;
8043
8044            for (i, (price_cents, size)) in updates.iter().enumerate() {
8045                let price = Price::new((*price_cents as f64) / 100.0, 2);
8046                let qty = Quantity::new(*size as f64, 0);
8047                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8048                total_volume += *size;
8049                builder.update(price, qty, ts);
8050            }
8051
8052            let bar = builder.build_now();
8053            prop_assert!(bar.low <= bar.open);
8054            prop_assert!(bar.low <= bar.close);
8055            prop_assert!(bar.high >= bar.open);
8056            prop_assert!(bar.high >= bar.close);
8057            prop_assert!(bar.low <= bar.high);
8058            prop_assert_eq!(bar.volume.as_f64(), total_volume as f64);
8059        }
8060
8061        #[rstest]
8062        fn prop_tick_bar_aggregator_volume_conservation(
8063            ticks in prop::collection::vec((1i64..=1_000i64, 1u64..=100u64), 3..=60),
8064            step in 1usize..=5,
8065        ) {
8066            let instrument = InstrumentAny::Equity(equity_aapl());
8067            let bar_spec = BarSpecification::new(step, BarAggregation::Tick, PriceType::Last);
8068            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8069            let (handler, record) = recording_handler();
8070
8071            let mut aggregator = TickBarAggregator::new(
8072                bar_type,
8073                instrument.price_precision(),
8074                instrument.size_precision(),
8075                record,
8076            );
8077
8078            let mut total_input: u64 = 0;
8079
8080            for (i, (price_cents, size)) in ticks.iter().enumerate() {
8081                let price = Price::new((*price_cents as f64) / 100.0, 2);
8082                let qty = Quantity::new(*size as f64, 0);
8083                aggregator.update(price, qty, UnixNanos::from((i as u64 + 1) * 1_000));
8084                total_input += *size;
8085            }
8086
8087            let bars = handler.lock();
8088            let emitted_count = bars.len();
8089            prop_assert_eq!(emitted_count, ticks.len() / step);
8090
8091            let mut sum_emitted: f64 = 0.0;
8092
8093            for bar in bars.iter() {
8094                prop_assert!(bar.low <= bar.open);
8095                prop_assert!(bar.low <= bar.close);
8096                prop_assert!(bar.high >= bar.open);
8097                prop_assert!(bar.high >= bar.close);
8098                sum_emitted += bar.volume.as_f64();
8099            }
8100
8101            // Unemitted pending size remains in the builder for the remainder `ticks.len() % step` ticks.
8102            let pending_size: u64 = ticks.iter()
8103                .skip(emitted_count * step)
8104                .map(|(_, s)| *s)
8105                .sum();
8106            prop_assert!((sum_emitted + pending_size as f64 - total_input as f64).abs() < 1e-6);
8107        }
8108
8109        #[rstest]
8110        fn prop_volume_bar_aggregator_conservation(
8111            sizes in prop::collection::vec(1u64..=50u64, 3..=40),
8112            step in 2u64..=10u64,
8113        ) {
8114            let instrument = InstrumentAny::Equity(equity_aapl());
8115            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Volume, PriceType::Last);
8116            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8117            let (handler, record) = recording_handler();
8118
8119            let mut aggregator = VolumeBarAggregator::new(
8120                bar_type,
8121                instrument.price_precision(),
8122                instrument.size_precision(),
8123                record,
8124            );
8125
8126            let mut total_input: u64 = 0;
8127
8128            for (i, size) in sizes.iter().enumerate() {
8129                aggregator.update(
8130                    Price::from("100.00"),
8131                    Quantity::new(*size as f64, 0),
8132                    UnixNanos::from((i as u64 + 1) * 1_000),
8133                );
8134                total_input += *size;
8135            }
8136
8137            let bars = handler.lock();
8138
8139            // Every emitted bar has exactly `step` volume and OHLC ordering holds.
8140            for bar in bars.iter() {
8141                prop_assert_eq!(bar.volume, Quantity::from(step));
8142                prop_assert!(bar.low <= bar.open);
8143                prop_assert!(bar.low <= bar.close);
8144                prop_assert!(bar.high >= bar.open);
8145                prop_assert!(bar.high >= bar.close);
8146            }
8147
8148            // Conservation: total emitted + pending builder volume equals total input.
8149            let emitted_total: u64 = bars.len() as u64 * step;
8150            let pending = aggregator.core.builder.volume.as_f64();
8151            prop_assert!((emitted_total as f64 + pending - total_input as f64).abs() < 1e-6);
8152        }
8153
8154        #[rstest]
8155        fn prop_volume_bar_matches_unit_trade_reference(
8156            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=8u64, 0u64..=30u64), 1..=30),
8157            step in 1usize..=5,
8158        ) {
8159            let instrument = InstrumentAny::Equity(equity_aapl());
8160            let bar_spec = BarSpecification::new(step, BarAggregation::Volume, PriceType::Last);
8161            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8162            let (handler, record) = recording_handler();
8163            let mut aggregator = VolumeBarAggregator::new(
8164                bar_type,
8165                instrument.price_precision(),
8166                instrument.size_precision(),
8167                record,
8168            );
8169            let price = |cents| {
8170                Price::from_decimal_dp(Decimal::new(cents, 2), 2)
8171                    .expect("bounded cents must produce a valid price")
8172            };
8173            let mut last_timestamp = UnixNanos::default();
8174            let mut pending_units = Vec::new();
8175            let mut expected_bars = Vec::new();
8176
8177            for (price_cents, size, timestamp) in &updates {
8178                let timestamp = UnixNanos::from(*timestamp);
8179                aggregator.update(price(*price_cents), Quantity::from(*size), timestamp);
8180
8181                if timestamp < last_timestamp {
8182                    continue;
8183                }
8184
8185                last_timestamp = timestamp;
8186                for _ in 0..*size {
8187                    pending_units.push((*price_cents, timestamp));
8188                }
8189
8190                while pending_units.len() >= step {
8191                    let units: Vec<_> = pending_units.drain(..step).collect();
8192                    let first = units.first().unwrap();
8193                    let last = units.last().unwrap();
8194                    let low = units.iter().map(|(cents, _)| *cents).min().unwrap();
8195                    let high = units.iter().map(|(cents, _)| *cents).max().unwrap();
8196                    expected_bars.push((
8197                        price(first.0),
8198                        price(high),
8199                        price(low),
8200                        price(last.0),
8201                        Quantity::from(step as u64),
8202                        last.1,
8203                    ));
8204                }
8205            }
8206
8207            let bars = handler.lock();
8208            prop_assert_eq!(bars.len(), expected_bars.len());
8209            for (actual, (open, high, low, close, volume, timestamp))
8210                in bars.iter().zip(expected_bars)
8211            {
8212                prop_assert_eq!(actual.open, open);
8213                prop_assert_eq!(actual.high, high);
8214                prop_assert_eq!(actual.low, low);
8215                prop_assert_eq!(actual.close, close);
8216                prop_assert_eq!(actual.volume, volume);
8217                prop_assert_eq!(actual.ts_event, timestamp);
8218                prop_assert_eq!(actual.ts_init, timestamp);
8219            }
8220
8221            prop_assert_eq!(aggregator.core.builder.volume, Quantity::from(pending_units.len() as u64));
8222            prop_assert_eq!(aggregator.core.builder.ts_last, last_timestamp);
8223
8224            if let Some((first, rest)) = pending_units.split_first() {
8225                let last = rest.last().unwrap_or(first);
8226                let low = pending_units.iter().map(|(cents, _)| *cents).min().unwrap();
8227                let high = pending_units.iter().map(|(cents, _)| *cents).max().unwrap();
8228                prop_assert_eq!(aggregator.core.builder.open, Some(price(first.0)));
8229                prop_assert_eq!(aggregator.core.builder.high, Some(price(high)));
8230                prop_assert_eq!(aggregator.core.builder.low, Some(price(low)));
8231                prop_assert_eq!(aggregator.core.builder.close, Some(price(last.0)));
8232            } else {
8233                prop_assert_eq!(aggregator.core.builder.open, None);
8234                prop_assert_eq!(aggregator.core.builder.high, None);
8235                prop_assert_eq!(aggregator.core.builder.low, None);
8236                prop_assert_eq!(aggregator.core.builder.close, None);
8237            }
8238        }
8239
8240        #[rstest]
8241        fn prop_bar_builder_spread_adjustment_is_additive(
8242            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8243            spread_cents in -10_000i64..=10_000i64,
8244            backward in any::<bool>(),
8245        ) {
8246            let instrument = InstrumentAny::Equity(equity_aapl());
8247            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8248            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8249            let mut builder = BarBuilder::new(bar_type, 2, 0);
8250
8251            let spread = Decimal::new(spread_cents, 2);
8252            let mode = if backward {
8253                ContinuousFutureAdjustmentType::BackwardSpread
8254            } else {
8255                ContinuousFutureAdjustmentType::ForwardSpread
8256            };
8257            builder.set_adjustment(spread, mode);
8258
8259            let mut min_cents = i64::MAX;
8260            let mut max_cents = i64::MIN;
8261
8262            for (i, (price_cents, size)) in updates.iter().enumerate() {
8263                if *price_cents < min_cents {
8264                    min_cents = *price_cents;
8265                }
8266
8267                if *price_cents > max_cents {
8268                    max_cents = *price_cents;
8269                }
8270
8271                builder.update(
8272                    Price::new((*price_cents as f64) / 100.0, 2),
8273                    Quantity::new(*size as f64, 0),
8274                    UnixNanos::from((i as u64 + 1) * 1_000),
8275                );
8276            }
8277
8278            let bar = builder.build_now();
8279            let first_decimal = Decimal::new(updates.first().unwrap().0, 2);
8280            let last_decimal = Decimal::new(updates.last().unwrap().0, 2);
8281            let min_decimal = Decimal::new(min_cents, 2);
8282            let max_decimal = Decimal::new(max_cents, 2);
8283
8284            prop_assert_eq!(bar.open.as_decimal(), first_decimal + spread);
8285            prop_assert_eq!(bar.close.as_decimal(), last_decimal + spread);
8286            prop_assert_eq!(bar.low.as_decimal(), min_decimal + spread);
8287            prop_assert_eq!(bar.high.as_decimal(), max_decimal + spread);
8288        }
8289
8290        #[rstest]
8291        fn prop_bar_builder_inactive_adjustment_is_identity(
8292            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=1_000u64), 1..=20),
8293            use_ratio in any::<bool>(),
8294        ) {
8295            let instrument = InstrumentAny::Equity(equity_aapl());
8296            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8297            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8298
8299            let mut adjusted = BarBuilder::new(bar_type, 2, 0);
8300            let mut baseline = BarBuilder::new(bar_type, 2, 0);
8301
8302            // Inactive in either mode: ZERO spread or ONE ratio.
8303            let (input, mode) = if use_ratio {
8304                (Decimal::ONE, ContinuousFutureAdjustmentType::BackwardRatio)
8305            } else {
8306                (Decimal::ZERO, ContinuousFutureAdjustmentType::BackwardSpread)
8307            };
8308            adjusted.set_adjustment(input, mode);
8309
8310            for (i, (price_cents, size)) in updates.iter().enumerate() {
8311                let price = Price::new((*price_cents as f64) / 100.0, 2);
8312                let qty = Quantity::new(*size as f64, 0);
8313                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8314                adjusted.update(price, qty, ts);
8315                baseline.update(price, qty, ts);
8316            }
8317
8318            let bar_adjusted = adjusted.build_now();
8319            let bar_baseline = baseline.build_now();
8320            prop_assert_eq!(bar_adjusted.open, bar_baseline.open);
8321            prop_assert_eq!(bar_adjusted.high, bar_baseline.high);
8322            prop_assert_eq!(bar_adjusted.low, bar_baseline.low);
8323            prop_assert_eq!(bar_adjusted.close, bar_baseline.close);
8324            prop_assert_eq!(bar_adjusted.volume, bar_baseline.volume);
8325        }
8326
8327        #[rstest]
8328        fn prop_bar_builder_spread_preserves_raw_arithmetic(
8329            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8330            // Sub-precision spread: scale 4 versus price precision 2. Locks in that
8331            // spread mode performs raw addition without rounding to price precision.
8332            spread_micro in -10_000i64..=10_000i64,
8333        ) {
8334            let instrument = InstrumentAny::Equity(equity_aapl());
8335            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8336            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8337            let mut builder = BarBuilder::new(bar_type, 2, 0);
8338
8339            let spread = Decimal::new(spread_micro, 4);
8340            builder.set_adjustment(spread, ContinuousFutureAdjustmentType::BackwardSpread);
8341
8342            let adjustment_raw_i128 = mantissa_exponent_to_fixed_i128(
8343                spread.mantissa(),
8344                -(spread.scale() as i8),
8345                FIXED_PRECISION,
8346            )
8347            .expect("scale within range");
8348            #[allow(
8349                clippy::useless_conversion,
8350                reason = "i128 to PriceRaw is real when not high-precision"
8351            )]
8352            let expected_adjustment_raw: PriceRaw =
8353                adjustment_raw_i128.try_into().expect("within PriceRaw range");
8354
8355            let mut min_cents = i64::MAX;
8356            let mut max_cents = i64::MIN;
8357            let mut last_price = Price::new(0.0, 2);
8358            let mut first_price = Price::new(0.0, 2);
8359
8360            for (i, (price_cents, size)) in updates.iter().enumerate() {
8361                if *price_cents < min_cents {
8362                    min_cents = *price_cents;
8363                }
8364
8365                if *price_cents > max_cents {
8366                    max_cents = *price_cents;
8367                }
8368
8369                let price = Price::new((*price_cents as f64) / 100.0, 2);
8370
8371                if i == 0 {
8372                    first_price = price;
8373                }
8374
8375                last_price = price;
8376                builder.update(
8377                    price,
8378                    Quantity::new(*size as f64, 0),
8379                    UnixNanos::from((i as u64 + 1) * 1_000),
8380                );
8381            }
8382
8383            let bar = builder.build_now();
8384            let min_price = Price::new((min_cents as f64) / 100.0, 2);
8385            let max_price = Price::new((max_cents as f64) / 100.0, 2);
8386            prop_assert_eq!(bar.open.raw(), first_price.raw() + expected_adjustment_raw);
8387            prop_assert_eq!(bar.close.raw(), last_price.raw() + expected_adjustment_raw);
8388            prop_assert_eq!(bar.low.raw(), min_price.raw() + expected_adjustment_raw);
8389            prop_assert_eq!(bar.high.raw(), max_price.raw() + expected_adjustment_raw);
8390            prop_assert_eq!(bar.open.precision, 2);
8391            prop_assert_eq!(bar.high.precision, 2);
8392            prop_assert_eq!(bar.low.precision, 2);
8393            prop_assert_eq!(bar.close.precision, 2);
8394        }
8395
8396        #[rstest]
8397        fn prop_bar_builder_active_ratio_scales_each_ohlc(
8398            updates in prop::collection::vec((1_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8399            // Ratio in [0.50, 2.00] excluding exactly 1.00 to stay on the active path.
8400            ratio_centi in prop_oneof![50i64..=99i64, 101i64..=200i64],
8401            backward in any::<bool>(),
8402        ) {
8403            let instrument = InstrumentAny::Equity(equity_aapl());
8404            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8405            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8406            let mut builder = BarBuilder::new(bar_type, 2, 0);
8407
8408            let ratio_decimal = Decimal::new(ratio_centi, 2);
8409            let ratio_f64 = (ratio_centi as f64) / 100.0;
8410            let mode = if backward {
8411                ContinuousFutureAdjustmentType::BackwardRatio
8412            } else {
8413                ContinuousFutureAdjustmentType::ForwardRatio
8414            };
8415            builder.set_adjustment(ratio_decimal, mode);
8416
8417            let mut min_cents = i64::MAX;
8418            let mut max_cents = i64::MIN;
8419            let mut first_cents = 0i64;
8420            let mut last_cents = 0i64;
8421
8422            for (i, (price_cents, size)) in updates.iter().enumerate() {
8423                if *price_cents < min_cents {
8424                    min_cents = *price_cents;
8425                }
8426
8427                if *price_cents > max_cents {
8428                    max_cents = *price_cents;
8429                }
8430
8431                if i == 0 {
8432                    first_cents = *price_cents;
8433                }
8434
8435                last_cents = *price_cents;
8436                builder.update(
8437                    Price::new((*price_cents as f64) / 100.0, 2),
8438                    Quantity::new(*size as f64, 0),
8439                    UnixNanos::from((i as u64 + 1) * 1_000),
8440                );
8441            }
8442
8443            let bar = builder.build_now();
8444            // Recompute via the same float math as the hot path so equality is exact.
8445            let expect = |cents: i64| Price::new((cents as f64) / 100.0 * ratio_f64, 2);
8446            prop_assert_eq!(bar.open, expect(first_cents));
8447            prop_assert_eq!(bar.close, expect(last_cents));
8448            // Ratio with positive ratio_f64 preserves ordering, so min/max map directly.
8449            prop_assert_eq!(bar.low, expect(min_cents));
8450            prop_assert_eq!(bar.high, expect(max_cents));
8451        }
8452
8453        #[rstest]
8454        fn prop_bar_builder_spread_mode_direction_is_metadata_only(
8455            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8456            spread_cents in -10_000i64..=10_000i64,
8457        ) {
8458            let instrument = InstrumentAny::Equity(equity_aapl());
8459            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8460            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8461
8462            let spread = Decimal::new(spread_cents, 2);
8463            let mut backward = BarBuilder::new(bar_type, 2, 0);
8464            let mut forward = BarBuilder::new(bar_type, 2, 0);
8465            backward.set_adjustment(spread, ContinuousFutureAdjustmentType::BackwardSpread);
8466            forward.set_adjustment(spread, ContinuousFutureAdjustmentType::ForwardSpread);
8467
8468            for (i, (price_cents, size)) in updates.iter().enumerate() {
8469                let price = Price::new((*price_cents as f64) / 100.0, 2);
8470                let qty = Quantity::new(*size as f64, 0);
8471                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8472                backward.update(price, qty, ts);
8473                forward.update(price, qty, ts);
8474            }
8475
8476            let bar_backward = backward.build_now();
8477            let bar_forward = forward.build_now();
8478            prop_assert_eq!(bar_backward.open, bar_forward.open);
8479            prop_assert_eq!(bar_backward.high, bar_forward.high);
8480            prop_assert_eq!(bar_backward.low, bar_forward.low);
8481            prop_assert_eq!(bar_backward.close, bar_forward.close);
8482        }
8483
8484        #[rstest]
8485        fn prop_value_bar_aggregator_ohlc_invariants(
8486            ticks in prop::collection::vec((50i64..=500i64, 1u64..=20u64), 2..=30),
8487            step in 100u64..=2_000u64,
8488        ) {
8489            let instrument = InstrumentAny::Equity(equity_aapl());
8490            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Value, PriceType::Last);
8491            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8492            let (handler, record) = recording_handler();
8493
8494            let mut aggregator = ValueBarAggregator::new(
8495                bar_type,
8496                instrument.price_precision(),
8497                instrument.size_precision(),
8498                record,
8499            );
8500
8501            for (i, (price_cents, size)) in ticks.iter().enumerate() {
8502                aggregator.update(
8503                    Price::new((*price_cents as f64) / 100.0, 2),
8504                    Quantity::new(*size as f64, 0),
8505                    UnixNanos::from((i as u64 + 1) * 1_000),
8506                );
8507            }
8508
8509            let bars = handler.lock();
8510            for bar in bars.iter() {
8511                prop_assert!(bar.low <= bar.open);
8512                prop_assert!(bar.low <= bar.close);
8513                prop_assert!(bar.high >= bar.open);
8514                prop_assert!(bar.high >= bar.close);
8515                prop_assert!(bar.volume.as_f64() > 0.0);
8516            }
8517        }
8518
8519        #[rstest]
8520        fn prop_renko_brick_chain(
8521            moves in prop::collection::vec(-500i64..=500i64, 1..=60),
8522            step in 1usize..=10,
8523        ) {
8524            let instrument = InstrumentAny::Equity(equity_aapl());
8525            let bar_spec = BarSpecification::new(step, BarAggregation::Renko, PriceType::Last);
8526            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8527            let (handler, record) = recording_handler();
8528
8529            let price_increment = Price::from("0.01");
8530            let mut aggregator = RenkoBarAggregator::new(
8531                bar_type,
8532                2,
8533                0,
8534                price_increment,
8535                record,
8536            );
8537            let brick_size = aggregator.brick_size;
8538
8539            let base_raw = Price::from("1000.00").raw();
8540            let mut cum_increments: i64 = 0;
8541            let mut first_price: Option<Price> = None;
8542
8543            for (i, delta) in moves.iter().enumerate() {
8544                cum_increments += delta;
8545                let price = Price::from_raw(
8546                    base_raw + PriceRaw::from(cum_increments) * price_increment.raw(),
8547                    2,
8548                );
8549
8550                if first_price.is_none() {
8551                    first_price = Some(price);
8552                }
8553
8554                aggregator.update(price, Quantity::from(1), UnixNanos::from((i as u64 + 1) * 1_000));
8555            }
8556
8557            let bars = handler.lock();
8558            let mut expected_open = first_price.unwrap();
8559
8560            for bar in bars.iter() {
8561                // Bricks chain: each opens at the previous close.
8562                prop_assert_eq!(bar.open, expected_open);
8563                // Every brick spans exactly one brick size.
8564                let movement = if bar.close >= bar.open { bar.close - bar.open } else { bar.open - bar.close };
8565                prop_assert_eq!(movement, brick_size);
8566                // High/low are the brick endpoints.
8567                prop_assert_eq!(bar.high, bar.open.max(bar.close));
8568                prop_assert_eq!(bar.low, bar.open.min(bar.close));
8569                expected_open = bar.close;
8570            }
8571        }
8572
8573        #[rstest]
8574        fn prop_volume_imbalance_one_sided_conservation(
8575            sizes in prop::collection::vec(1u64..=50u64, 1..=40),
8576            step in 2u64..=10u64,
8577            buyer in any::<bool>(),
8578        ) {
8579            let instrument = InstrumentAny::Equity(equity_aapl());
8580            let bar_spec = BarSpecification::new(
8581                step as usize,
8582                BarAggregation::VolumeImbalance,
8583                PriceType::Last,
8584            );
8585            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8586            let (handler, record) = recording_handler();
8587
8588            let mut aggregator = VolumeImbalanceBarAggregator::new(
8589                bar_type,
8590                instrument.price_precision(),
8591                instrument.size_precision(),
8592                record,
8593            );
8594
8595            let side = if buyer { AggressorSide::Buy } else { AggressorSide::Sell };
8596            let mut total_input: u64 = 0;
8597
8598            for (i, size) in sizes.iter().enumerate() {
8599                let trade = TradeTick {
8600                    instrument_id: instrument.id(),
8601                    price: Price::from("100.00"),
8602                    size: Quantity::from(*size),
8603                    aggressor_side: side,
8604                    ts_event: UnixNanos::from((i as u64 + 1) * 1_000),
8605                    ts_init: UnixNanos::from((i as u64 + 1) * 1_000),
8606                    ..TradeTick::default()
8607                };
8608                aggregator.handle_trade(trade);
8609                total_input += *size;
8610            }
8611
8612            let bars = handler.lock();
8613
8614            // One-sided flow: every emitted bar carries exactly `step` volume.
8615            for bar in bars.iter() {
8616                prop_assert_eq!(bar.volume, Quantity::from(step));
8617            }
8618
8619            // Conservation: emitted volume plus pending builder volume equals input.
8620            let emitted: u64 = bars.len() as u64 * step;
8621            let pending = aggregator.core.builder.volume.as_f64();
8622            prop_assert!((emitted as f64 + pending - total_input as f64).abs() < 1e-9);
8623        }
8624
8625        #[rstest]
8626        fn prop_volume_runs_one_sided_conservation(
8627            sizes in prop::collection::vec(1u64..=50u64, 1..=40),
8628            step in 2u64..=10u64,
8629            buyer in any::<bool>(),
8630        ) {
8631            let instrument = InstrumentAny::Equity(equity_aapl());
8632            let bar_spec = BarSpecification::new(
8633                step as usize,
8634                BarAggregation::VolumeRuns,
8635                PriceType::Last,
8636            );
8637            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8638            let (handler, record) = recording_handler();
8639
8640            let mut aggregator = VolumeRunsBarAggregator::new(
8641                bar_type,
8642                instrument.price_precision(),
8643                instrument.size_precision(),
8644                record,
8645            );
8646
8647            let side = if buyer { AggressorSide::Buy } else { AggressorSide::Sell };
8648            let mut total_input: u64 = 0;
8649
8650            for (i, size) in sizes.iter().enumerate() {
8651                let trade = TradeTick {
8652                    instrument_id: instrument.id(),
8653                    price: Price::from("100.00"),
8654                    size: Quantity::from(*size),
8655                    aggressor_side: side,
8656                    ts_event: UnixNanos::from((i as u64 + 1) * 1_000),
8657                    ts_init: UnixNanos::from((i as u64 + 1) * 1_000),
8658                    ..TradeTick::default()
8659                };
8660                aggregator.handle_trade(trade);
8661                total_input += *size;
8662            }
8663
8664            let bars = handler.lock();
8665
8666            // A single-sided run never resets, so every bar carries exactly `step` volume.
8667            for bar in bars.iter() {
8668                prop_assert_eq!(bar.volume, Quantity::from(step));
8669            }
8670
8671            let emitted: u64 = bars.len() as u64 * step;
8672            let pending = aggregator.core.builder.volume.as_f64();
8673            prop_assert!((emitted as f64 + pending - total_input as f64).abs() < 1e-9);
8674        }
8675
8676        #[rstest]
8677        fn prop_value_bar_cum_value_stays_below_step(
8678            ticks in prop::collection::vec((50i64..=500i64, 1u64..=20u64), 1..=30),
8679            step in 100u64..=2_000u64,
8680        ) {
8681            let instrument = InstrumentAny::Equity(equity_aapl());
8682            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Value, PriceType::Last);
8683            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8684            let step_decimal = Decimal::from(step);
8685
8686            let mut aggregator = ValueBarAggregator::new(
8687                bar_type,
8688                instrument.price_precision(),
8689                instrument.size_precision(),
8690                |_: Bar| {},
8691            );
8692
8693            for (i, (price_cents, size)) in ticks.iter().enumerate() {
8694                aggregator.update(
8695                    Price::new((*price_cents as f64) / 100.0, 2),
8696                    Quantity::new(*size as f64, 0),
8697                    UnixNanos::from((i as u64 + 1) * 1_000),
8698                );
8699
8700                // Invariant: the accumulator is always strictly below the step threshold,
8701                // which also guarantees the loop division never sees a zero divisor.
8702                prop_assert!(aggregator.get_cumulative_value() < step_decimal);
8703            }
8704        }
8705    }
8706}