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