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