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, Mutex};
2791
2792    use nautilus_common::{clock::TestClock, timer::TimeEvent};
2793    use nautilus_core::{MUTEX_POISONED, 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 rstest::rstest;
2802    use ustr::Ustr;
2803
2804    use super::*;
2805
2806    #[rstest]
2807    fn test_bar_builder_initialization(equity_aapl: Equity) {
2808        let instrument = InstrumentAny::Equity(equity_aapl);
2809        let bar_type = BarType::new(
2810            instrument.id(),
2811            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2812            AggregationSource::Internal,
2813        );
2814        let builder = BarBuilder::new(
2815            bar_type,
2816            instrument.price_precision(),
2817            instrument.size_precision(),
2818        );
2819
2820        assert!(!builder.initialized);
2821        assert_eq!(builder.ts_last, 0);
2822        assert_eq!(builder.count, 0);
2823    }
2824
2825    #[rstest]
2826    fn test_bar_builder_maintains_ohlc_order(equity_aapl: Equity) {
2827        let instrument = InstrumentAny::Equity(equity_aapl);
2828        let bar_type = BarType::new(
2829            instrument.id(),
2830            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2831            AggregationSource::Internal,
2832        );
2833        let mut builder = BarBuilder::new(
2834            bar_type,
2835            instrument.price_precision(),
2836            instrument.size_precision(),
2837        );
2838
2839        builder.update(
2840            Price::from("100.00"),
2841            Quantity::from(1),
2842            UnixNanos::from(1000),
2843        );
2844        builder.update(
2845            Price::from("95.00"),
2846            Quantity::from(1),
2847            UnixNanos::from(2000),
2848        );
2849        builder.update(
2850            Price::from("105.00"),
2851            Quantity::from(1),
2852            UnixNanos::from(3000),
2853        );
2854
2855        let bar = builder.build_now();
2856        assert!(bar.high > bar.low);
2857        assert_eq!(bar.open, Price::from("100.00"));
2858        assert_eq!(bar.high, Price::from("105.00"));
2859        assert_eq!(bar.low, Price::from("95.00"));
2860        assert_eq!(bar.close, Price::from("105.00"));
2861    }
2862
2863    #[rstest]
2864    fn test_update_ignores_earlier_timestamps(equity_aapl: Equity) {
2865        let instrument = InstrumentAny::Equity(equity_aapl);
2866        let bar_type = BarType::new(
2867            instrument.id(),
2868            BarSpecification::new(100, BarAggregation::Tick, PriceType::Last),
2869            AggregationSource::Internal,
2870        );
2871        let mut builder = BarBuilder::new(
2872            bar_type,
2873            instrument.price_precision(),
2874            instrument.size_precision(),
2875        );
2876
2877        builder.update(Price::from("1.00000"), Quantity::from(1), 1_000.into());
2878        builder.update(Price::from("1.00001"), Quantity::from(1), 500.into());
2879
2880        assert_eq!(builder.ts_last, 1_000);
2881        assert_eq!(builder.count, 1);
2882    }
2883
2884    #[rstest]
2885    fn test_bar_builder_single_update_results_in_expected_properties(equity_aapl: Equity) {
2886        let instrument = InstrumentAny::Equity(equity_aapl);
2887        let bar_type = BarType::new(
2888            instrument.id(),
2889            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2890            AggregationSource::Internal,
2891        );
2892        let mut builder = BarBuilder::new(
2893            bar_type,
2894            instrument.price_precision(),
2895            instrument.size_precision(),
2896        );
2897
2898        builder.update(
2899            Price::from("1.00000"),
2900            Quantity::from(1),
2901            UnixNanos::default(),
2902        );
2903
2904        assert!(builder.initialized);
2905        assert_eq!(builder.ts_last, 0);
2906        assert_eq!(builder.count, 1);
2907    }
2908
2909    #[rstest]
2910    fn test_bar_builder_single_update_when_timestamp_less_than_last_update_ignores(
2911        equity_aapl: Equity,
2912    ) {
2913        let instrument = InstrumentAny::Equity(equity_aapl);
2914        let bar_type = BarType::new(
2915            instrument.id(),
2916            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2917            AggregationSource::Internal,
2918        );
2919        let mut builder = BarBuilder::new(bar_type, 2, 0);
2920
2921        builder.update(
2922            Price::from("1.00000"),
2923            Quantity::from(1),
2924            UnixNanos::from(1_000),
2925        );
2926        builder.update(
2927            Price::from("1.00001"),
2928            Quantity::from(1),
2929            UnixNanos::from(500),
2930        );
2931
2932        assert!(builder.initialized);
2933        assert_eq!(builder.ts_last, 1_000);
2934        assert_eq!(builder.count, 1);
2935    }
2936
2937    #[rstest]
2938    fn test_bar_builder_multiple_updates_correctly_increments_count(equity_aapl: Equity) {
2939        let instrument = InstrumentAny::Equity(equity_aapl);
2940        let bar_type = BarType::new(
2941            instrument.id(),
2942            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2943            AggregationSource::Internal,
2944        );
2945        let mut builder = BarBuilder::new(
2946            bar_type,
2947            instrument.price_precision(),
2948            instrument.size_precision(),
2949        );
2950
2951        for _ in 0..5 {
2952            builder.update(
2953                Price::from("1.00000"),
2954                Quantity::from(1),
2955                UnixNanos::from(1_000),
2956            );
2957        }
2958
2959        assert_eq!(builder.count, 5);
2960    }
2961
2962    #[rstest]
2963    #[should_panic]
2964    fn test_bar_builder_build_when_no_updates_panics(equity_aapl: Equity) {
2965        let instrument = InstrumentAny::Equity(equity_aapl);
2966        let bar_type = BarType::new(
2967            instrument.id(),
2968            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2969            AggregationSource::Internal,
2970        );
2971        let mut builder = BarBuilder::new(
2972            bar_type,
2973            instrument.price_precision(),
2974            instrument.size_precision(),
2975        );
2976        let _ = builder.build_now();
2977    }
2978
2979    #[rstest]
2980    fn test_bar_builder_build_when_received_updates_returns_expected_bar(equity_aapl: Equity) {
2981        let instrument = InstrumentAny::Equity(equity_aapl);
2982        let bar_type = BarType::new(
2983            instrument.id(),
2984            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
2985            AggregationSource::Internal,
2986        );
2987        let mut builder = BarBuilder::new(
2988            bar_type,
2989            instrument.price_precision(),
2990            instrument.size_precision(),
2991        );
2992
2993        builder.update(
2994            Price::from("1.00001"),
2995            Quantity::from(2),
2996            UnixNanos::default(),
2997        );
2998        builder.update(
2999            Price::from("1.00002"),
3000            Quantity::from(2),
3001            UnixNanos::default(),
3002        );
3003        builder.update(
3004            Price::from("1.00000"),
3005            Quantity::from(1),
3006            UnixNanos::from(1_000_000_000),
3007        );
3008
3009        let bar = builder.build_now();
3010
3011        assert_eq!(bar.open, Price::from("1.00001"));
3012        assert_eq!(bar.high, Price::from("1.00002"));
3013        assert_eq!(bar.low, Price::from("1.00000"));
3014        assert_eq!(bar.close, Price::from("1.00000"));
3015        assert_eq!(bar.volume, Quantity::from(5));
3016        assert_eq!(bar.ts_init, 1_000_000_000);
3017        assert_eq!(builder.ts_last, 1_000_000_000);
3018        assert_eq!(builder.count, 0);
3019    }
3020
3021    #[rstest]
3022    fn test_bar_builder_build_with_previous_close(equity_aapl: Equity) {
3023        let instrument = InstrumentAny::Equity(equity_aapl);
3024        let bar_type = BarType::new(
3025            instrument.id(),
3026            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3027            AggregationSource::Internal,
3028        );
3029        let mut builder = BarBuilder::new(bar_type, 2, 0);
3030
3031        builder.update(
3032            Price::from("1.00001"),
3033            Quantity::from(1),
3034            UnixNanos::default(),
3035        );
3036        builder.build_now();
3037
3038        builder.update(
3039            Price::from("1.00000"),
3040            Quantity::from(1),
3041            UnixNanos::default(),
3042        );
3043        builder.update(
3044            Price::from("1.00003"),
3045            Quantity::from(1),
3046            UnixNanos::default(),
3047        );
3048        builder.update(
3049            Price::from("1.00002"),
3050            Quantity::from(1),
3051            UnixNanos::default(),
3052        );
3053
3054        let bar = builder.build_now();
3055
3056        assert_eq!(bar.open, Price::from("1.00000"));
3057        assert_eq!(bar.high, Price::from("1.00003"));
3058        assert_eq!(bar.low, Price::from("1.00000"));
3059        assert_eq!(bar.close, Price::from("1.00002"));
3060        assert_eq!(bar.volume, Quantity::from(3));
3061    }
3062
3063    #[rstest]
3064    fn test_bar_builder_update_bar_initializes_then_accumulates(equity_aapl: Equity) {
3065        let instrument = InstrumentAny::Equity(equity_aapl);
3066        let bar_type = BarType::new(
3067            instrument.id(),
3068            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3069            AggregationSource::Internal,
3070        );
3071        let mut builder = BarBuilder::new(
3072            bar_type,
3073            instrument.price_precision(),
3074            instrument.size_precision(),
3075        );
3076
3077        let bar_one = Bar::new(
3078            bar_type,
3079            Price::from("100.00"),
3080            Price::from("102.00"),
3081            Price::from("99.00"),
3082            Price::from("101.00"),
3083            Quantity::from(10),
3084            UnixNanos::from(1_000),
3085            UnixNanos::from(1_000),
3086        );
3087        let bar_two = Bar::new(
3088            bar_type,
3089            Price::from("101.00"),
3090            Price::from("103.00"),
3091            Price::from("98.00"),
3092            Price::from("102.00"),
3093            Quantity::from(5),
3094            UnixNanos::from(2_000),
3095            UnixNanos::from(2_000),
3096        );
3097
3098        builder.update_bar(bar_one, bar_one.volume, bar_one.ts_init);
3099        builder.update_bar(bar_two, bar_two.volume, bar_two.ts_init);
3100        let bar = builder.build_now();
3101
3102        assert_eq!(bar.open, Price::from("100.00"));
3103        assert_eq!(bar.high, Price::from("103.00"));
3104        assert_eq!(bar.low, Price::from("98.00"));
3105        assert_eq!(bar.close, Price::from("102.00"));
3106        assert_eq!(bar.volume, Quantity::from(15));
3107        assert_eq!(builder.count, 0);
3108    }
3109
3110    #[rstest]
3111    fn test_bar_builder_update_bar_ignores_earlier_timestamp(equity_aapl: Equity) {
3112        let instrument = InstrumentAny::Equity(equity_aapl);
3113        let bar_type = BarType::new(
3114            instrument.id(),
3115            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3116            AggregationSource::Internal,
3117        );
3118        let mut builder = BarBuilder::new(
3119            bar_type,
3120            instrument.price_precision(),
3121            instrument.size_precision(),
3122        );
3123
3124        let bar_later = Bar::new(
3125            bar_type,
3126            Price::from("100.00"),
3127            Price::from("101.00"),
3128            Price::from("99.00"),
3129            Price::from("100.50"),
3130            Quantity::from(10),
3131            UnixNanos::from(2_000),
3132            UnixNanos::from(2_000),
3133        );
3134        let bar_earlier = Bar::new(
3135            bar_type,
3136            Price::from("200.00"),
3137            Price::from("210.00"),
3138            Price::from("190.00"),
3139            Price::from("205.00"),
3140            Quantity::from(50),
3141            UnixNanos::from(1_000),
3142            UnixNanos::from(1_000),
3143        );
3144
3145        builder.update_bar(bar_later, bar_later.volume, bar_later.ts_init);
3146        builder.update_bar(bar_earlier, bar_earlier.volume, bar_earlier.ts_init);
3147
3148        assert_eq!(builder.ts_last, 2_000);
3149        assert_eq!(builder.count, 1);
3150        assert_eq!(builder.volume, Quantity::from(10));
3151    }
3152
3153    #[rstest]
3154    #[case::spread_zero_inactive(
3155        Decimal::ZERO,
3156        ContinuousFutureAdjustmentType::BackwardSpread,
3157        false
3158    )]
3159    #[case::spread_positive_active(
3160        Decimal::new(150, 2), // 1.50
3161        ContinuousFutureAdjustmentType::BackwardSpread,
3162        true,
3163    )]
3164    #[case::spread_negative_active(
3165        Decimal::new(-250, 2), // -2.50
3166        ContinuousFutureAdjustmentType::ForwardSpread,
3167        true,
3168    )]
3169    #[case::spread_sub_precision_inactive(
3170        // 1e-28 scales to 0 raw under banker's rounding, so should be inactive.
3171        Decimal::new(1, 28),
3172        ContinuousFutureAdjustmentType::BackwardSpread,
3173        false,
3174    )]
3175    #[case::ratio_one_inactive(Decimal::ONE, ContinuousFutureAdjustmentType::BackwardRatio, false)]
3176    #[case::ratio_non_one_active(
3177        Decimal::new(105, 2), // 1.05
3178        ContinuousFutureAdjustmentType::ForwardRatio,
3179        true,
3180    )]
3181    fn test_bar_builder_set_adjustment_active_flag(
3182        equity_aapl: Equity,
3183        #[case] adjustment: Decimal,
3184        #[case] mode: ContinuousFutureAdjustmentType,
3185        #[case] expected_active: bool,
3186    ) {
3187        let instrument = InstrumentAny::Equity(equity_aapl);
3188        let bar_type = BarType::new(
3189            instrument.id(),
3190            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3191            AggregationSource::Internal,
3192        );
3193        let mut builder = BarBuilder::new(bar_type, 2, 0);
3194
3195        builder.set_adjustment(adjustment, mode);
3196
3197        assert_eq!(builder.adjustment_active, expected_active);
3198        assert_eq!(builder.adjustment_is_ratio, mode.is_ratio());
3199        assert_eq!(builder.adjustment_mode, mode);
3200    }
3201
3202    #[rstest]
3203    fn test_bar_builder_set_adjustment_mode_switch_resets_flags(equity_aapl: Equity) {
3204        let instrument = InstrumentAny::Equity(equity_aapl);
3205        let bar_type = BarType::new(
3206            instrument.id(),
3207            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3208            AggregationSource::Internal,
3209        );
3210        let mut builder = BarBuilder::new(bar_type, 2, 0);
3211
3212        // ratio -> spread: subsequent update must shift, not scale.
3213        builder.set_adjustment(
3214            Decimal::new(150, 2), // 1.50
3215            ContinuousFutureAdjustmentType::BackwardRatio,
3216        );
3217        builder.set_adjustment(
3218            Decimal::new(50, 2), // +0.50
3219            ContinuousFutureAdjustmentType::BackwardSpread,
3220        );
3221        assert!(!builder.adjustment_is_ratio);
3222        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3223        assert_eq!(builder.build_now().close, Price::from("100.50"));
3224
3225        // spread -> ratio: subsequent update must scale, not shift.
3226        builder.set_adjustment(
3227            Decimal::new(11, 1), // 1.1
3228            ContinuousFutureAdjustmentType::ForwardRatio,
3229        );
3230        assert!(builder.adjustment_is_ratio);
3231        builder.update(Price::from("100.00"), Quantity::from(1), 2_000.into());
3232        assert_eq!(builder.build_now().close, Price::from("110.00"));
3233    }
3234
3235    #[rstest]
3236    fn test_bar_builder_update_applies_backward_spread_adjustment(equity_aapl: Equity) {
3237        let instrument = InstrumentAny::Equity(equity_aapl);
3238        let bar_type = BarType::new(
3239            instrument.id(),
3240            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3241            AggregationSource::Internal,
3242        );
3243        let mut builder = BarBuilder::new(bar_type, 2, 0);
3244
3245        builder.set_adjustment(
3246            Decimal::new(250, 2), // +2.50
3247            ContinuousFutureAdjustmentType::BackwardSpread,
3248        );
3249
3250        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3251        builder.update(Price::from("99.00"), Quantity::from(1), 2_000.into());
3252        builder.update(Price::from("101.00"), Quantity::from(1), 3_000.into());
3253
3254        let bar = builder.build_now();
3255        assert_eq!(bar.open, Price::from("102.50"));
3256        assert_eq!(bar.high, Price::from("103.50"));
3257        assert_eq!(bar.low, Price::from("101.50"));
3258        assert_eq!(bar.close, Price::from("103.50"));
3259    }
3260
3261    #[rstest]
3262    fn test_bar_builder_update_applies_forward_ratio_adjustment(equity_aapl: Equity) {
3263        let instrument = InstrumentAny::Equity(equity_aapl);
3264        let bar_type = BarType::new(
3265            instrument.id(),
3266            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3267            AggregationSource::Internal,
3268        );
3269        let mut builder = BarBuilder::new(bar_type, 2, 0);
3270
3271        builder.set_adjustment(
3272            Decimal::new(11, 1), // 1.1
3273            ContinuousFutureAdjustmentType::ForwardRatio,
3274        );
3275
3276        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3277        builder.update(Price::from("90.00"), Quantity::from(1), 2_000.into());
3278        builder.update(Price::from("110.00"), Quantity::from(1), 3_000.into());
3279
3280        let bar = builder.build_now();
3281        assert_eq!(bar.open, Price::from("110.00"));
3282        assert_eq!(bar.high, Price::from("121.00"));
3283        assert_eq!(bar.low, Price::from("99.00"));
3284        assert_eq!(bar.close, Price::from("121.00"));
3285    }
3286
3287    #[rstest]
3288    fn test_bar_builder_update_bar_applies_adjustment_to_ohlc(equity_aapl: Equity) {
3289        let instrument = InstrumentAny::Equity(equity_aapl);
3290        let bar_type = BarType::new(
3291            instrument.id(),
3292            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3293            AggregationSource::Internal,
3294        );
3295        let mut builder = BarBuilder::new(bar_type, 2, 0);
3296
3297        builder.set_adjustment(
3298            Decimal::new(-100, 2), // -1.00
3299            ContinuousFutureAdjustmentType::BackwardSpread,
3300        );
3301
3302        let input = Bar::new(
3303            bar_type,
3304            Price::from("100.00"),
3305            Price::from("105.00"),
3306            Price::from("99.00"),
3307            Price::from("102.00"),
3308            Quantity::from(10),
3309            UnixNanos::from(1_000),
3310            UnixNanos::from(1_000),
3311        );
3312        builder.update_bar(input, input.volume, input.ts_init);
3313
3314        let bar = builder.build_now();
3315        assert_eq!(bar.open, Price::from("99.00"));
3316        assert_eq!(bar.high, Price::from("104.00"));
3317        assert_eq!(bar.low, Price::from("98.00"));
3318        assert_eq!(bar.close, Price::from("101.00"));
3319    }
3320
3321    #[rstest]
3322    fn test_bar_builder_reset_retains_adjustment(equity_aapl: Equity) {
3323        let instrument = InstrumentAny::Equity(equity_aapl);
3324        let bar_type = BarType::new(
3325            instrument.id(),
3326            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3327            AggregationSource::Internal,
3328        );
3329        let mut builder = BarBuilder::new(bar_type, 2, 0);
3330
3331        builder.set_adjustment(
3332            Decimal::new(500, 2), // +5.00
3333            ContinuousFutureAdjustmentType::BackwardSpread,
3334        );
3335        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3336        let bar_one = builder.build_now();
3337        assert_eq!(bar_one.close, Price::from("105.00"));
3338
3339        // Adjustment must persist across the reset triggered by build_now.
3340        assert!(builder.adjustment_active);
3341
3342        builder.update(Price::from("110.00"), Quantity::from(1), 2_000.into());
3343        let bar_two = builder.build_now();
3344        assert_eq!(bar_two.close, Price::from("115.00"));
3345    }
3346
3347    #[rstest]
3348    fn test_bar_builder_update_bar_applies_ratio_adjustment(equity_aapl: Equity) {
3349        let instrument = InstrumentAny::Equity(equity_aapl);
3350        let bar_type = BarType::new(
3351            instrument.id(),
3352            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3353            AggregationSource::Internal,
3354        );
3355        let mut builder = BarBuilder::new(bar_type, 2, 0);
3356
3357        builder.set_adjustment(
3358            Decimal::new(11, 1), // 1.1
3359            ContinuousFutureAdjustmentType::ForwardRatio,
3360        );
3361
3362        let input = Bar::new(
3363            bar_type,
3364            Price::from("100.00"),
3365            Price::from("110.00"),
3366            Price::from("90.00"),
3367            Price::from("105.00"),
3368            Quantity::from(10),
3369            UnixNanos::from(1_000),
3370            UnixNanos::from(1_000),
3371        );
3372        builder.update_bar(input, input.volume, input.ts_init);
3373
3374        let bar = builder.build_now();
3375        assert_eq!(bar.open, Price::from("110.00"));
3376        assert_eq!(bar.high, Price::from("121.00"));
3377        assert_eq!(bar.low, Price::from("99.00"));
3378        assert_eq!(bar.close, Price::from("115.50"));
3379    }
3380
3381    #[rstest]
3382    fn test_bar_builder_spread_below_zero_representable(equity_aapl: Equity) {
3383        // Backward-spread offsets that push prices below zero must stay representable in PriceRaw
3384        let instrument = InstrumentAny::Equity(equity_aapl);
3385        let bar_type = BarType::new(
3386            instrument.id(),
3387            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3388            AggregationSource::Internal,
3389        );
3390        let mut builder = BarBuilder::new(bar_type, 2, 0);
3391
3392        builder.set_adjustment(
3393            Decimal::new(-15000, 2), // -150.00
3394            ContinuousFutureAdjustmentType::BackwardSpread,
3395        );
3396
3397        builder.update(Price::from("100.00"), Quantity::from(1), 1_000.into());
3398        let bar = builder.build_now();
3399        assert_eq!(bar.close, Price::from("-50.00"));
3400        assert!(bar.close.raw < 0);
3401        assert_eq!(bar.close.precision, 2);
3402    }
3403
3404    #[rstest]
3405    fn test_bar_builder_build_promotes_close_above_high_from_previous_close(equity_aapl: Equity) {
3406        let instrument = InstrumentAny::Equity(equity_aapl);
3407        let bar_type = BarType::new(
3408            instrument.id(),
3409            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3410            AggregationSource::Internal,
3411        );
3412        let mut builder = BarBuilder::new(bar_type, 2, 0);
3413
3414        builder.update(
3415            Price::from("110.00"),
3416            Quantity::from(1),
3417            UnixNanos::from(100),
3418        );
3419        builder.build_now();
3420
3421        builder.update(
3422            Price::from("100.00"),
3423            Quantity::from(1),
3424            UnixNanos::from(200),
3425        );
3426        builder.update(
3427            Price::from("101.00"),
3428            Quantity::from(1),
3429            UnixNanos::from(300),
3430        );
3431        builder.update(
3432            Price::from("200.00"),
3433            Quantity::from(1),
3434            UnixNanos::from(400),
3435        );
3436
3437        let bar = builder.build_now();
3438        assert_eq!(bar.open, Price::from("100.00"));
3439        assert_eq!(bar.high, Price::from("200.00"));
3440        assert_eq!(bar.low, Price::from("100.00"));
3441        assert_eq!(bar.close, Price::from("200.00"));
3442    }
3443
3444    #[rstest]
3445    fn test_bar_builder_build_clamps_low_to_close(equity_aapl: Equity) {
3446        // On `build`, if `close < low` the low is pulled down to close.
3447        // Reaching this branch requires bypassing `update`'s low tracking (e.g. via bar updates where
3448        // a later bar's close is below the accumulated low). We simulate by direct field assignment.
3449        let instrument = InstrumentAny::Equity(equity_aapl);
3450        let bar_type = BarType::new(
3451            instrument.id(),
3452            BarSpecification::new(3, BarAggregation::Tick, PriceType::Last),
3453            AggregationSource::Internal,
3454        );
3455        let mut builder = BarBuilder::new(bar_type, 2, 0);
3456
3457        builder.update(
3458            Price::from("100.00"),
3459            Quantity::from(1),
3460            UnixNanos::from(100),
3461        );
3462        builder.close = Some(Price::from("50.00"));
3463
3464        let bar = builder.build_now();
3465        assert_eq!(bar.low, Price::from("50.00"));
3466        assert_eq!(bar.close, Price::from("50.00"));
3467        assert!(bar.low <= bar.open);
3468    }
3469
3470    #[rstest]
3471    fn test_tick_bar_aggregator_handle_trade_when_step_count_below_threshold(equity_aapl: Equity) {
3472        let instrument = InstrumentAny::Equity(equity_aapl);
3473        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3474        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3475        let handler = Arc::new(Mutex::new(Vec::new()));
3476        let handler_clone = Arc::clone(&handler);
3477
3478        let mut aggregator = TickBarAggregator::new(
3479            bar_type,
3480            instrument.price_precision(),
3481            instrument.size_precision(),
3482            move |bar: Bar| {
3483                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3484                handler_guard.push(bar);
3485            },
3486        );
3487
3488        let trade = TradeTick::default();
3489        aggregator.handle_trade(trade);
3490
3491        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3492        assert_eq!(handler_guard.len(), 0);
3493    }
3494
3495    #[rstest]
3496    fn test_tick_bar_aggregator_handle_trade_when_step_count_reached(equity_aapl: Equity) {
3497        let instrument = InstrumentAny::Equity(equity_aapl);
3498        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3499        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3500        let handler = Arc::new(Mutex::new(Vec::new()));
3501        let handler_clone = Arc::clone(&handler);
3502
3503        let mut aggregator = TickBarAggregator::new(
3504            bar_type,
3505            instrument.price_precision(),
3506            instrument.size_precision(),
3507            move |bar: Bar| {
3508                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3509                handler_guard.push(bar);
3510            },
3511        );
3512
3513        let trade = TradeTick::default();
3514        aggregator.handle_trade(trade);
3515        aggregator.handle_trade(trade);
3516        aggregator.handle_trade(trade);
3517
3518        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3519        let bar = handler_guard.first().unwrap();
3520        assert_eq!(handler_guard.len(), 1);
3521        assert_eq!(bar.open, trade.price);
3522        assert_eq!(bar.high, trade.price);
3523        assert_eq!(bar.low, trade.price);
3524        assert_eq!(bar.close, trade.price);
3525        assert_eq!(bar.volume, Quantity::from(300000));
3526        assert_eq!(bar.ts_event, trade.ts_event);
3527        assert_eq!(bar.ts_init, trade.ts_init);
3528    }
3529
3530    #[rstest]
3531    fn test_tick_bar_aggregator_aggregates_to_step_size(equity_aapl: Equity) {
3532        let instrument = InstrumentAny::Equity(equity_aapl);
3533        let bar_spec = BarSpecification::new(3, BarAggregation::Tick, PriceType::Last);
3534        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3535        let handler = Arc::new(Mutex::new(Vec::new()));
3536        let handler_clone = Arc::clone(&handler);
3537
3538        let mut aggregator = TickBarAggregator::new(
3539            bar_type,
3540            instrument.price_precision(),
3541            instrument.size_precision(),
3542            move |bar: Bar| {
3543                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3544                handler_guard.push(bar);
3545            },
3546        );
3547
3548        aggregator.update(
3549            Price::from("1.00001"),
3550            Quantity::from(1),
3551            UnixNanos::default(),
3552        );
3553        aggregator.update(
3554            Price::from("1.00002"),
3555            Quantity::from(1),
3556            UnixNanos::from(1000),
3557        );
3558        aggregator.update(
3559            Price::from("1.00003"),
3560            Quantity::from(1),
3561            UnixNanos::from(2000),
3562        );
3563
3564        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3565        assert_eq!(handler_guard.len(), 1);
3566
3567        let bar = handler_guard.first().unwrap();
3568        assert_eq!(bar.open, Price::from("1.00001"));
3569        assert_eq!(bar.high, Price::from("1.00003"));
3570        assert_eq!(bar.low, Price::from("1.00001"));
3571        assert_eq!(bar.close, Price::from("1.00003"));
3572        assert_eq!(bar.volume, Quantity::from(3));
3573    }
3574
3575    #[rstest]
3576    fn test_tick_bar_aggregator_resets_after_bar_created(equity_aapl: Equity) {
3577        let instrument = InstrumentAny::Equity(equity_aapl);
3578        let bar_spec = BarSpecification::new(2, BarAggregation::Tick, PriceType::Last);
3579        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3580        let handler = Arc::new(Mutex::new(Vec::new()));
3581        let handler_clone = Arc::clone(&handler);
3582
3583        let mut aggregator = TickBarAggregator::new(
3584            bar_type,
3585            instrument.price_precision(),
3586            instrument.size_precision(),
3587            move |bar: Bar| {
3588                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3589                handler_guard.push(bar);
3590            },
3591        );
3592
3593        aggregator.update(
3594            Price::from("1.00001"),
3595            Quantity::from(1),
3596            UnixNanos::default(),
3597        );
3598        aggregator.update(
3599            Price::from("1.00002"),
3600            Quantity::from(1),
3601            UnixNanos::from(1000),
3602        );
3603        aggregator.update(
3604            Price::from("1.00003"),
3605            Quantity::from(1),
3606            UnixNanos::from(2000),
3607        );
3608        aggregator.update(
3609            Price::from("1.00004"),
3610            Quantity::from(1),
3611            UnixNanos::from(3000),
3612        );
3613
3614        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3615        assert_eq!(handler_guard.len(), 2);
3616
3617        let bar1 = &handler_guard[0];
3618        assert_eq!(bar1.open, Price::from("1.00001"));
3619        assert_eq!(bar1.close, Price::from("1.00002"));
3620        assert_eq!(bar1.volume, Quantity::from(2));
3621
3622        let bar2 = &handler_guard[1];
3623        assert_eq!(bar2.open, Price::from("1.00003"));
3624        assert_eq!(bar2.close, Price::from("1.00004"));
3625        assert_eq!(bar2.volume, Quantity::from(2));
3626    }
3627
3628    #[rstest]
3629    fn test_non_time_bar_aggregators_use_historical_handler(
3630        equity_aapl: Equity,
3631        audusd_sim: CurrencyPair,
3632    ) {
3633        let instrument = InstrumentAny::Equity(equity_aapl);
3634        let instrument_id = instrument.id();
3635        let price_precision = instrument.price_precision();
3636        let size_precision = instrument.size_precision();
3637        let make_sink = |bars: Arc<Mutex<Vec<Bar>>>| {
3638            move |bar: Bar| {
3639                bars.lock().expect(MUTEX_POISONED).push(bar);
3640            }
3641        };
3642        let make_trade = |price: &str, size: i64, ts: u64| TradeTick {
3643            instrument_id,
3644            price: Price::from(price),
3645            size: Quantity::from(size),
3646            aggressor_side: AggressorSide::Buy,
3647            ts_event: UnixNanos::from(ts),
3648            ts_init: UnixNanos::from(ts),
3649            ..TradeTick::default()
3650        };
3651
3652        macro_rules! assert_historical_sink_receives {
3653            ($name:expr, $aggregator:expr, $update:expr) => {{
3654                let initial_bars = Arc::new(Mutex::new(Vec::new()));
3655                let historical_bars = Arc::new(Mutex::new(Vec::new()));
3656                let mut aggregator = $aggregator(Arc::clone(&initial_bars));
3657                aggregator
3658                    .set_historical_mode(true, Box::new(make_sink(Arc::clone(&historical_bars))));
3659                {
3660                    let aggregator: &mut dyn BarAggregator = &mut aggregator;
3661                    $update(aggregator);
3662                }
3663
3664                assert_eq!(
3665                    initial_bars.lock().expect(MUTEX_POISONED).len(),
3666                    0,
3667                    "{}",
3668                    $name,
3669                );
3670                assert_eq!(
3671                    historical_bars.lock().expect(MUTEX_POISONED).len(),
3672                    1,
3673                    "{}",
3674                    $name,
3675                );
3676            }};
3677        }
3678
3679        let tick_type = BarType::new(
3680            instrument_id,
3681            BarSpecification::new(1, BarAggregation::Tick, PriceType::Last),
3682            AggregationSource::Internal,
3683        );
3684        assert_historical_sink_receives!(
3685            "TickBarAggregator",
3686            |bars| TickBarAggregator::new(
3687                tick_type,
3688                price_precision,
3689                size_precision,
3690                make_sink(bars)
3691            ),
3692            |aggregator: &mut dyn BarAggregator| {
3693                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3694            }
3695        );
3696
3697        let tick_imbalance_type = BarType::new(
3698            instrument_id,
3699            BarSpecification::new(1, BarAggregation::TickImbalance, PriceType::Last),
3700            AggregationSource::Internal,
3701        );
3702        assert_historical_sink_receives!(
3703            "TickImbalanceBarAggregator",
3704            |bars| TickImbalanceBarAggregator::new(
3705                tick_imbalance_type,
3706                price_precision,
3707                size_precision,
3708                make_sink(bars),
3709            ),
3710            |aggregator: &mut dyn BarAggregator| {
3711                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3712            }
3713        );
3714
3715        let tick_runs_type = BarType::new(
3716            instrument_id,
3717            BarSpecification::new(1, BarAggregation::TickRuns, PriceType::Last),
3718            AggregationSource::Internal,
3719        );
3720        assert_historical_sink_receives!(
3721            "TickRunsBarAggregator",
3722            |bars| TickRunsBarAggregator::new(
3723                tick_runs_type,
3724                price_precision,
3725                size_precision,
3726                make_sink(bars),
3727            ),
3728            |aggregator: &mut dyn BarAggregator| {
3729                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3730            }
3731        );
3732
3733        let volume_type = BarType::new(
3734            instrument_id,
3735            BarSpecification::new(1, BarAggregation::Volume, PriceType::Last),
3736            AggregationSource::Internal,
3737        );
3738        assert_historical_sink_receives!(
3739            "VolumeBarAggregator",
3740            |bars| VolumeBarAggregator::new(
3741                volume_type,
3742                price_precision,
3743                size_precision,
3744                make_sink(bars),
3745            ),
3746            |aggregator: &mut dyn BarAggregator| {
3747                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3748            }
3749        );
3750
3751        let volume_imbalance_type = BarType::new(
3752            instrument_id,
3753            BarSpecification::new(1, BarAggregation::VolumeImbalance, PriceType::Last),
3754            AggregationSource::Internal,
3755        );
3756        assert_historical_sink_receives!(
3757            "VolumeImbalanceBarAggregator",
3758            |bars| VolumeImbalanceBarAggregator::new(
3759                volume_imbalance_type,
3760                price_precision,
3761                size_precision,
3762                make_sink(bars),
3763            ),
3764            |aggregator: &mut dyn BarAggregator| {
3765                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3766            }
3767        );
3768
3769        let volume_runs_type = BarType::new(
3770            instrument_id,
3771            BarSpecification::new(1, BarAggregation::VolumeRuns, PriceType::Last),
3772            AggregationSource::Internal,
3773        );
3774        assert_historical_sink_receives!(
3775            "VolumeRunsBarAggregator",
3776            |bars| VolumeRunsBarAggregator::new(
3777                volume_runs_type,
3778                price_precision,
3779                size_precision,
3780                make_sink(bars),
3781            ),
3782            |aggregator: &mut dyn BarAggregator| {
3783                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3784            }
3785        );
3786
3787        let value_type = BarType::new(
3788            instrument_id,
3789            BarSpecification::new(100, BarAggregation::Value, PriceType::Last),
3790            AggregationSource::Internal,
3791        );
3792        assert_historical_sink_receives!(
3793            "ValueBarAggregator",
3794            |bars| ValueBarAggregator::new(
3795                value_type,
3796                price_precision,
3797                size_precision,
3798                make_sink(bars)
3799            ),
3800            |aggregator: &mut dyn BarAggregator| {
3801                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3802            }
3803        );
3804
3805        let value_imbalance_type = BarType::new(
3806            instrument_id,
3807            BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last),
3808            AggregationSource::Internal,
3809        );
3810        assert_historical_sink_receives!(
3811            "ValueImbalanceBarAggregator",
3812            |bars| ValueImbalanceBarAggregator::new(
3813                value_imbalance_type,
3814                price_precision,
3815                size_precision,
3816                make_sink(bars),
3817            ),
3818            |aggregator: &mut dyn BarAggregator| {
3819                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3820            }
3821        );
3822
3823        let value_runs_type = BarType::new(
3824            instrument_id,
3825            BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last),
3826            AggregationSource::Internal,
3827        );
3828        assert_historical_sink_receives!(
3829            "ValueRunsBarAggregator",
3830            |bars| ValueRunsBarAggregator::new(
3831                value_runs_type,
3832                price_precision,
3833                size_precision,
3834                make_sink(bars),
3835            ),
3836            |aggregator: &mut dyn BarAggregator| {
3837                aggregator.handle_trade(make_trade("100.00", 1, 1_000));
3838            }
3839        );
3840
3841        let fx = InstrumentAny::CurrencyPair(audusd_sim);
3842        let renko_type = BarType::new(
3843            fx.id(),
3844            BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid),
3845            AggregationSource::Internal,
3846        );
3847        let fx_price_precision = fx.price_precision();
3848        let fx_size_precision = fx.size_precision();
3849        let fx_price_increment = fx.price_increment();
3850        assert_historical_sink_receives!(
3851            "RenkoBarAggregator",
3852            |bars| RenkoBarAggregator::new(
3853                renko_type,
3854                fx_price_precision,
3855                fx_size_precision,
3856                fx_price_increment,
3857                make_sink(bars),
3858            ),
3859            |aggregator: &mut dyn BarAggregator| {
3860                aggregator.update(
3861                    Price::from("1.00000"),
3862                    Quantity::from(1),
3863                    UnixNanos::from(1_000),
3864                );
3865                aggregator.update(
3866                    Price::from("1.00010"),
3867                    Quantity::from(1),
3868                    UnixNanos::from(2_000),
3869                );
3870            }
3871        );
3872    }
3873
3874    #[rstest]
3875    fn test_tick_imbalance_bar_aggregator_emits_at_threshold(equity_aapl: Equity) {
3876        let instrument = InstrumentAny::Equity(equity_aapl);
3877        let bar_spec = BarSpecification::new(2, BarAggregation::TickImbalance, PriceType::Last);
3878        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3879        let handler = Arc::new(Mutex::new(Vec::new()));
3880        let handler_clone = Arc::clone(&handler);
3881
3882        let mut aggregator = TickImbalanceBarAggregator::new(
3883            bar_type,
3884            instrument.price_precision(),
3885            instrument.size_precision(),
3886            move |bar: Bar| {
3887                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3888                handler_guard.push(bar);
3889            },
3890        );
3891
3892        let trade = TradeTick::default();
3893        aggregator.handle_trade(trade);
3894        aggregator.handle_trade(trade);
3895
3896        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3897        assert_eq!(handler_guard.len(), 1);
3898        let bar = handler_guard.first().unwrap();
3899        assert_eq!(bar.volume, Quantity::from(200000));
3900    }
3901
3902    #[rstest]
3903    fn test_tick_imbalance_bar_aggregator_handles_seller_direction(equity_aapl: Equity) {
3904        let instrument = InstrumentAny::Equity(equity_aapl);
3905        let bar_spec = BarSpecification::new(1, BarAggregation::TickImbalance, PriceType::Last);
3906        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3907        let handler = Arc::new(Mutex::new(Vec::new()));
3908        let handler_clone = Arc::clone(&handler);
3909
3910        let mut aggregator = TickImbalanceBarAggregator::new(
3911            bar_type,
3912            instrument.price_precision(),
3913            instrument.size_precision(),
3914            move |bar: Bar| {
3915                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3916                handler_guard.push(bar);
3917            },
3918        );
3919
3920        let sell = TradeTick {
3921            aggressor_side: AggressorSide::Sell,
3922            ..TradeTick::default()
3923        };
3924
3925        aggregator.handle_trade(sell);
3926
3927        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3928        assert_eq!(handler_guard.len(), 1);
3929    }
3930
3931    #[rstest]
3932    fn test_tick_runs_bar_aggregator_resets_on_side_change(equity_aapl: Equity) {
3933        let instrument = InstrumentAny::Equity(equity_aapl);
3934        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
3935        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3936        let handler = Arc::new(Mutex::new(Vec::new()));
3937        let handler_clone = Arc::clone(&handler);
3938
3939        let mut aggregator = TickRunsBarAggregator::new(
3940            bar_type,
3941            instrument.price_precision(),
3942            instrument.size_precision(),
3943            move |bar: Bar| {
3944                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3945                handler_guard.push(bar);
3946            },
3947        );
3948
3949        let buy = TradeTick::default();
3950        let sell = TradeTick {
3951            aggressor_side: AggressorSide::Sell,
3952            ..buy
3953        };
3954
3955        aggregator.handle_trade(buy);
3956        aggregator.handle_trade(buy);
3957        aggregator.handle_trade(sell);
3958        aggregator.handle_trade(sell);
3959
3960        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3961        assert_eq!(handler_guard.len(), 2);
3962    }
3963
3964    #[rstest]
3965    fn test_tick_runs_bar_aggregator_volume_conservation(equity_aapl: Equity) {
3966        let instrument = InstrumentAny::Equity(equity_aapl);
3967        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
3968        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
3969        let handler = Arc::new(Mutex::new(Vec::new()));
3970        let handler_clone = Arc::clone(&handler);
3971
3972        let mut aggregator = TickRunsBarAggregator::new(
3973            bar_type,
3974            instrument.price_precision(),
3975            instrument.size_precision(),
3976            move |bar: Bar| {
3977                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
3978                handler_guard.push(bar);
3979            },
3980        );
3981
3982        let buy = TradeTick {
3983            size: Quantity::from(1),
3984            ..TradeTick::default()
3985        };
3986        let sell = TradeTick {
3987            aggressor_side: AggressorSide::Sell,
3988            size: Quantity::from(1),
3989            ..buy
3990        };
3991
3992        aggregator.handle_trade(buy);
3993        aggregator.handle_trade(buy);
3994        aggregator.handle_trade(sell);
3995        aggregator.handle_trade(sell);
3996
3997        let handler_guard = handler.lock().expect(MUTEX_POISONED);
3998        assert_eq!(handler_guard.len(), 2);
3999        assert_eq!(handler_guard[0].volume, Quantity::from(2));
4000        assert_eq!(handler_guard[1].volume, Quantity::from(2));
4001    }
4002
4003    #[rstest]
4004    fn test_volume_bar_aggregator_builds_multiple_bars_from_large_update(equity_aapl: Equity) {
4005        let instrument = InstrumentAny::Equity(equity_aapl);
4006        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
4007        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4008        let handler = Arc::new(Mutex::new(Vec::new()));
4009        let handler_clone = Arc::clone(&handler);
4010
4011        let mut aggregator = VolumeBarAggregator::new(
4012            bar_type,
4013            instrument.price_precision(),
4014            instrument.size_precision(),
4015            move |bar: Bar| {
4016                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4017                handler_guard.push(bar);
4018            },
4019        );
4020
4021        aggregator.update(
4022            Price::from("1.00001"),
4023            Quantity::from(25),
4024            UnixNanos::default(),
4025        );
4026
4027        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4028        assert_eq!(handler_guard.len(), 2);
4029        let bar1 = &handler_guard[0];
4030        assert_eq!(bar1.volume, Quantity::from(10));
4031        let bar2 = &handler_guard[1];
4032        assert_eq!(bar2.volume, Quantity::from(10));
4033    }
4034
4035    #[rstest]
4036    fn test_volume_bar_aggregator_zero_size_update_is_noop(equity_aapl: Equity) {
4037        let instrument = InstrumentAny::Equity(equity_aapl);
4038        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
4039        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4040        let handler = Arc::new(Mutex::new(Vec::new()));
4041        let handler_clone = Arc::clone(&handler);
4042
4043        let mut aggregator = VolumeBarAggregator::new(
4044            bar_type,
4045            instrument.price_precision(),
4046            instrument.size_precision(),
4047            move |bar: Bar| {
4048                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4049                handler_guard.push(bar);
4050            },
4051        );
4052
4053        aggregator.update(
4054            Price::from("100.00"),
4055            Quantity::from(0),
4056            UnixNanos::default(),
4057        );
4058
4059        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4060        assert_eq!(handler_guard.len(), 0);
4061    }
4062
4063    #[rstest]
4064    fn test_volume_bar_aggregator_ignores_out_of_order_update(equity_aapl: Equity) {
4065        let instrument = InstrumentAny::Equity(equity_aapl);
4066        let bar_spec = BarSpecification::new(2, BarAggregation::Volume, PriceType::Last);
4067        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4068        let handler = Arc::new(Mutex::new(Vec::new()));
4069        let handler_clone = Arc::clone(&handler);
4070
4071        let mut aggregator = VolumeBarAggregator::new(
4072            bar_type,
4073            instrument.price_precision(),
4074            instrument.size_precision(),
4075            move |bar: Bar| {
4076                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4077                handler_guard.push(bar);
4078            },
4079        );
4080
4081        aggregator.update(
4082            Price::from("100.00"),
4083            Quantity::from(1),
4084            UnixNanos::from(1_000),
4085        );
4086        aggregator.update(
4087            Price::from("200.00"),
4088            Quantity::from(3),
4089            UnixNanos::from(500),
4090        );
4091
4092        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4093        assert!(handler_guard.is_empty());
4094        assert_eq!(aggregator.core.builder.count, 1);
4095        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4096        assert_eq!(aggregator.core.builder.close, Some(Price::from("100.00")));
4097        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
4098    }
4099
4100    #[rstest]
4101    fn test_volume_bar_aggregator_ignores_out_of_order_bar(equity_aapl: Equity) {
4102        let instrument = InstrumentAny::Equity(equity_aapl);
4103        let bar_spec = BarSpecification::new(2, BarAggregation::Volume, PriceType::Last);
4104        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4105        let handler = Arc::new(Mutex::new(Vec::new()));
4106        let handler_clone = Arc::clone(&handler);
4107
4108        let mut aggregator = VolumeBarAggregator::new(
4109            bar_type,
4110            instrument.price_precision(),
4111            instrument.size_precision(),
4112            move |bar: Bar| {
4113                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4114                handler_guard.push(bar);
4115            },
4116        );
4117
4118        aggregator.update(
4119            Price::from("100.00"),
4120            Quantity::from(1),
4121            UnixNanos::from(1_000),
4122        );
4123        let stale_bar = Bar::new(
4124            bar_type,
4125            Price::from("200.00"),
4126            Price::from("201.00"),
4127            Price::from("199.00"),
4128            Price::from("200.50"),
4129            Quantity::from(3),
4130            UnixNanos::from(500),
4131            UnixNanos::from(500),
4132        );
4133        aggregator.update_bar(stale_bar, stale_bar.volume, stale_bar.ts_init);
4134
4135        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4136        assert!(handler_guard.is_empty());
4137        assert_eq!(aggregator.core.builder.count, 1);
4138        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4139        assert_eq!(aggregator.core.builder.close, Some(Price::from("100.00")));
4140        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
4141    }
4142
4143    #[rstest]
4144    fn test_volume_imbalance_bar_aggregator_ignores_out_of_order_trade(equity_aapl: Equity) {
4145        let instrument = InstrumentAny::Equity(equity_aapl);
4146        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeImbalance, PriceType::Last);
4147        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4148        let handler = Arc::new(Mutex::new(Vec::new()));
4149        let handler_clone = Arc::clone(&handler);
4150        let mut aggregator = VolumeImbalanceBarAggregator::new(
4151            bar_type,
4152            instrument.price_precision(),
4153            instrument.size_precision(),
4154            move |bar: Bar| {
4155                handler_clone.lock().expect(MUTEX_POISONED).push(bar);
4156            },
4157        );
4158        let first = TradeTick {
4159            price: Price::from("100.00"),
4160            size: Quantity::from(1),
4161            aggressor_side: AggressorSide::Buy,
4162            ts_init: UnixNanos::from(1_000),
4163            ..TradeTick::default()
4164        };
4165        let stale = TradeTick {
4166            price: Price::from("200.00"),
4167            size: Quantity::from(2),
4168            aggressor_side: AggressorSide::Buy,
4169            ts_init: UnixNanos::from(500),
4170            ..TradeTick::default()
4171        };
4172
4173        aggregator.handle_trade(first);
4174        aggregator.handle_trade(stale);
4175
4176        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
4177        assert_eq!(aggregator.imbalance_raw, Quantity::from(1).raw as i128);
4178        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4179        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
4180    }
4181
4182    #[rstest]
4183    fn test_volume_bar_aggregator_exact_threshold_emits_single_bar(equity_aapl: Equity) {
4184        let instrument = InstrumentAny::Equity(equity_aapl);
4185        let bar_spec = BarSpecification::new(10, BarAggregation::Volume, PriceType::Last);
4186        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4187        let handler = Arc::new(Mutex::new(Vec::new()));
4188        let handler_clone = Arc::clone(&handler);
4189
4190        let mut aggregator = VolumeBarAggregator::new(
4191            bar_type,
4192            instrument.price_precision(),
4193            instrument.size_precision(),
4194            move |bar: Bar| {
4195                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4196                handler_guard.push(bar);
4197            },
4198        );
4199
4200        aggregator.update(
4201            Price::from("100.00"),
4202            Quantity::from(7),
4203            UnixNanos::from(1_000),
4204        );
4205        aggregator.update(
4206            Price::from("101.00"),
4207            Quantity::from(3),
4208            UnixNanos::from(2_000),
4209        );
4210
4211        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4212        assert_eq!(handler_guard.len(), 1);
4213        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4214        assert_eq!(handler_guard[0].close, Price::from("101.00"));
4215    }
4216
4217    #[rstest]
4218    fn test_volume_bar_aggregator_step_of_one_emits_per_unit(equity_aapl: Equity) {
4219        let instrument = InstrumentAny::Equity(equity_aapl);
4220        let bar_spec = BarSpecification::new(1, BarAggregation::Volume, PriceType::Last);
4221        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4222        let handler = Arc::new(Mutex::new(Vec::new()));
4223        let handler_clone = Arc::clone(&handler);
4224
4225        let mut aggregator = VolumeBarAggregator::new(
4226            bar_type,
4227            instrument.price_precision(),
4228            instrument.size_precision(),
4229            move |bar: Bar| {
4230                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4231                handler_guard.push(bar);
4232            },
4233        );
4234
4235        aggregator.update(
4236            Price::from("100.00"),
4237            Quantity::from(1),
4238            UnixNanos::default(),
4239        );
4240
4241        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4242        assert_eq!(handler_guard.len(), 1);
4243        assert_eq!(handler_guard[0].volume, Quantity::from(1));
4244    }
4245
4246    #[rstest]
4247    fn test_volume_runs_bar_aggregator_side_change_resets(equity_aapl: Equity) {
4248        let instrument = InstrumentAny::Equity(equity_aapl);
4249        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeRuns, PriceType::Last);
4250        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4251        let handler = Arc::new(Mutex::new(Vec::new()));
4252        let handler_clone = Arc::clone(&handler);
4253
4254        let mut aggregator = VolumeRunsBarAggregator::new(
4255            bar_type,
4256            instrument.price_precision(),
4257            instrument.size_precision(),
4258            move |bar: Bar| {
4259                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4260                handler_guard.push(bar);
4261            },
4262        );
4263
4264        let buy = TradeTick {
4265            instrument_id: instrument.id(),
4266            price: Price::from("1.0"),
4267            size: Quantity::from(1),
4268            ..TradeTick::default()
4269        };
4270        let sell = TradeTick {
4271            aggressor_side: AggressorSide::Sell,
4272            ..buy
4273        };
4274
4275        aggregator.handle_trade(buy);
4276        aggregator.handle_trade(buy); // emit first bar at 2
4277        aggregator.handle_trade(sell);
4278        aggregator.handle_trade(sell); // emit second bar at 2 sell-side
4279
4280        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4281        assert!(handler_guard.len() >= 2);
4282        assert!(
4283            (handler_guard[0].volume.as_f64() - handler_guard[1].volume.as_f64()).abs()
4284                < f64::EPSILON
4285        );
4286    }
4287
4288    #[rstest]
4289    fn test_volume_runs_bar_aggregator_handles_large_single_trade(equity_aapl: Equity) {
4290        let instrument = InstrumentAny::Equity(equity_aapl);
4291        let bar_spec = BarSpecification::new(3, BarAggregation::VolumeRuns, PriceType::Last);
4292        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4293        let handler = Arc::new(Mutex::new(Vec::new()));
4294        let handler_clone = Arc::clone(&handler);
4295
4296        let mut aggregator = VolumeRunsBarAggregator::new(
4297            bar_type,
4298            instrument.price_precision(),
4299            instrument.size_precision(),
4300            move |bar: Bar| {
4301                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4302                handler_guard.push(bar);
4303            },
4304        );
4305
4306        let trade = TradeTick {
4307            instrument_id: instrument.id(),
4308            price: Price::from("1.0"),
4309            size: Quantity::from(5),
4310            ..TradeTick::default()
4311        };
4312
4313        aggregator.handle_trade(trade);
4314
4315        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4316        assert!(!handler_guard.is_empty());
4317        assert!(handler_guard[0].volume.as_f64() > 0.0);
4318        assert!(handler_guard[0].volume.as_f64() < trade.size.as_f64());
4319    }
4320
4321    #[rstest]
4322    fn test_volume_imbalance_bar_aggregator_splits_large_trade(equity_aapl: Equity) {
4323        let instrument = InstrumentAny::Equity(equity_aapl);
4324        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeImbalance, PriceType::Last);
4325        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4326        let handler = Arc::new(Mutex::new(Vec::new()));
4327        let handler_clone = Arc::clone(&handler);
4328
4329        let mut aggregator = VolumeImbalanceBarAggregator::new(
4330            bar_type,
4331            instrument.price_precision(),
4332            instrument.size_precision(),
4333            move |bar: Bar| {
4334                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4335                handler_guard.push(bar);
4336            },
4337        );
4338
4339        let trade_small = TradeTick {
4340            instrument_id: instrument.id(),
4341            price: Price::from("1.0"),
4342            size: Quantity::from(1),
4343            ..TradeTick::default()
4344        };
4345        let trade_large = TradeTick {
4346            size: Quantity::from(3),
4347            ..trade_small
4348        };
4349
4350        aggregator.handle_trade(trade_small);
4351        aggregator.handle_trade(trade_large);
4352
4353        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4354        assert_eq!(handler_guard.len(), 2);
4355        let total_output = handler_guard
4356            .iter()
4357            .map(|bar| bar.volume.as_f64())
4358            .sum::<f64>();
4359        let total_input = trade_small.size.as_f64() + trade_large.size.as_f64();
4360        assert!((total_output - total_input).abs() < f64::EPSILON);
4361    }
4362
4363    #[rstest]
4364    fn test_value_bar_aggregator_builds_at_value_threshold(equity_aapl: Equity) {
4365        let instrument = InstrumentAny::Equity(equity_aapl);
4366        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last); // $1000 value step
4367        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4368        let handler = Arc::new(Mutex::new(Vec::new()));
4369        let handler_clone = Arc::clone(&handler);
4370
4371        let mut aggregator = ValueBarAggregator::new(
4372            bar_type,
4373            instrument.price_precision(),
4374            instrument.size_precision(),
4375            move |bar: Bar| {
4376                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4377                handler_guard.push(bar);
4378            },
4379        );
4380
4381        // Updates to reach value threshold: 100 * 5 + 100 * 5 = $1000
4382        aggregator.update(
4383            Price::from("100.00"),
4384            Quantity::from(5),
4385            UnixNanos::default(),
4386        );
4387        aggregator.update(
4388            Price::from("100.00"),
4389            Quantity::from(5),
4390            UnixNanos::from(1000),
4391        );
4392
4393        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4394        assert_eq!(handler_guard.len(), 1);
4395        let bar = handler_guard.first().unwrap();
4396        assert_eq!(bar.volume, Quantity::from(10));
4397    }
4398
4399    #[rstest]
4400    fn test_value_bar_aggregator_handles_large_update(equity_aapl: Equity) {
4401        let instrument = InstrumentAny::Equity(equity_aapl);
4402        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4403        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4404        let handler = Arc::new(Mutex::new(Vec::new()));
4405        let handler_clone = Arc::clone(&handler);
4406
4407        let mut aggregator = ValueBarAggregator::new(
4408            bar_type,
4409            instrument.price_precision(),
4410            instrument.size_precision(),
4411            move |bar: Bar| {
4412                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4413                handler_guard.push(bar);
4414            },
4415        );
4416
4417        // Single large update: $100 * 25 = $2500 (should create 2 bars)
4418        aggregator.update(
4419            Price::from("100.00"),
4420            Quantity::from(25),
4421            UnixNanos::default(),
4422        );
4423
4424        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4425        assert_eq!(handler_guard.len(), 2);
4426        let remaining_value = aggregator.get_cumulative_value();
4427        assert!(remaining_value < Decimal::from(1_000)); // Should be less than threshold
4428    }
4429
4430    #[rstest]
4431    fn test_value_bar_aggregator_handles_zero_price(equity_aapl: Equity) {
4432        let instrument = InstrumentAny::Equity(equity_aapl);
4433        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4434        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4435        let handler = Arc::new(Mutex::new(Vec::new()));
4436        let handler_clone = Arc::clone(&handler);
4437
4438        let mut aggregator = ValueBarAggregator::new(
4439            bar_type,
4440            instrument.price_precision(),
4441            instrument.size_precision(),
4442            move |bar: Bar| {
4443                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4444                handler_guard.push(bar);
4445            },
4446        );
4447
4448        // Update with zero price should not cause division by zero
4449        aggregator.update(
4450            Price::from("0.00"),
4451            Quantity::from(100),
4452            UnixNanos::default(),
4453        );
4454
4455        // No bars should be emitted since value is zero
4456        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4457        assert_eq!(handler_guard.len(), 0);
4458
4459        // Cumulative value should remain zero
4460        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4461    }
4462
4463    #[rstest]
4464    fn test_value_bar_aggregator_handles_zero_size(equity_aapl: Equity) {
4465        let instrument = InstrumentAny::Equity(equity_aapl);
4466        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4467        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4468        let handler = Arc::new(Mutex::new(Vec::new()));
4469        let handler_clone = Arc::clone(&handler);
4470
4471        let mut aggregator = ValueBarAggregator::new(
4472            bar_type,
4473            instrument.price_precision(),
4474            instrument.size_precision(),
4475            move |bar: Bar| {
4476                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4477                handler_guard.push(bar);
4478            },
4479        );
4480
4481        // Update with zero size should not cause issues
4482        aggregator.update(
4483            Price::from("100.00"),
4484            Quantity::from(0),
4485            UnixNanos::default(),
4486        );
4487
4488        // No bars should be emitted
4489        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4490        assert_eq!(handler_guard.len(), 0);
4491
4492        // Cumulative value should remain zero
4493        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4494    }
4495
4496    #[rstest]
4497    fn test_value_bar_aggregator_conserves_volume_across_rounded_chunks(equity_aapl: Equity) {
4498        let instrument = InstrumentAny::Equity(equity_aapl);
4499        let bar_spec = BarSpecification::new(10, BarAggregation::Value, PriceType::Last);
4500        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4501        let handler = Arc::new(Mutex::new(Vec::new()));
4502        let handler_clone = Arc::clone(&handler);
4503
4504        let mut aggregator = ValueBarAggregator::new(
4505            bar_type,
4506            instrument.price_precision(),
4507            instrument.size_precision(),
4508            move |bar: Bar| {
4509                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4510                handler_guard.push(bar);
4511            },
4512        );
4513
4514        // Step 10 at price 3.00 needs fractional 3.33... chunks; the rounded
4515        // 3-unit chunks must still conserve the 10 input units (3 + 3 + 3 + 1)
4516        aggregator.update(
4517            Price::from("3.00"),
4518            Quantity::from(10),
4519            UnixNanos::from(1_000),
4520        );
4521
4522        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4523        assert_eq!(handler_guard.len(), 3);
4524        for bar in handler_guard.iter() {
4525            assert_eq!(bar.volume, Quantity::from(3));
4526        }
4527        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4528    }
4529
4530    #[rstest]
4531    fn test_value_bar_aggregator_update_bar_conserves_volume_across_rounded_chunks(
4532        equity_aapl: Equity,
4533    ) {
4534        let instrument = InstrumentAny::Equity(equity_aapl);
4535        let bar_spec = BarSpecification::new(10, BarAggregation::Value, PriceType::Last);
4536        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4537        let handler = Arc::new(Mutex::new(Vec::new()));
4538        let handler_clone = Arc::clone(&handler);
4539
4540        let mut aggregator = ValueBarAggregator::new(
4541            bar_type,
4542            instrument.price_precision(),
4543            instrument.size_precision(),
4544            move |bar: Bar| {
4545                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4546                handler_guard.push(bar);
4547            },
4548        );
4549
4550        // Average price 3.00 with volume 10 mirrors the tick-path conservation case
4551        let input_bar = Bar::new(
4552            bar_type,
4553            Price::from("3.00"),
4554            Price::from("3.00"),
4555            Price::from("3.00"),
4556            Price::from("3.00"),
4557            Quantity::from(10),
4558            UnixNanos::from(1_000),
4559            UnixNanos::from(1_000),
4560        );
4561        aggregator.handle_bar(input_bar);
4562
4563        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4564        assert_eq!(handler_guard.len(), 3);
4565        for bar in handler_guard.iter() {
4566            assert_eq!(bar.volume, Quantity::from(3));
4567        }
4568        assert_eq!(aggregator.core.builder.volume, Quantity::from(1));
4569    }
4570
4571    #[rstest]
4572    fn test_value_bar_aggregator_exact_threshold_emits_one_bar(equity_aapl: Equity) {
4573        let instrument = InstrumentAny::Equity(equity_aapl);
4574        let bar_spec = BarSpecification::new(1000, BarAggregation::Value, PriceType::Last);
4575        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4576        let handler = Arc::new(Mutex::new(Vec::new()));
4577        let handler_clone = Arc::clone(&handler);
4578
4579        let mut aggregator = ValueBarAggregator::new(
4580            bar_type,
4581            instrument.price_precision(),
4582            instrument.size_precision(),
4583            move |bar: Bar| {
4584                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4585                handler_guard.push(bar);
4586            },
4587        );
4588
4589        aggregator.update(
4590            Price::from("100.00"),
4591            Quantity::from(5),
4592            UnixNanos::from(1_000),
4593        );
4594        aggregator.update(
4595            Price::from("100.00"),
4596            Quantity::from(5),
4597            UnixNanos::from(2_000),
4598        );
4599
4600        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4601        assert_eq!(handler_guard.len(), 1);
4602        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4603        assert_eq!(aggregator.get_cumulative_value(), Decimal::ZERO);
4604    }
4605
4606    #[rstest]
4607    fn test_value_bar_aggregator_precision_boundary_min_size_clamp(equity_aapl: Equity) {
4608        // step=100, price=100 per-unit value=100 with size_precision=0 lands the divided
4609        // size_chunk at the precision floor. Verifies the min-size clamp branch in update()
4610        // emits one bar per unit rather than looping on zero-volume chunks.
4611        let instrument = InstrumentAny::Equity(equity_aapl);
4612        let bar_spec = BarSpecification::new(100, BarAggregation::Value, PriceType::Last);
4613        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4614        let handler = Arc::new(Mutex::new(Vec::new()));
4615        let handler_clone = Arc::clone(&handler);
4616
4617        let mut aggregator = ValueBarAggregator::new(
4618            bar_type,
4619            instrument.price_precision(),
4620            instrument.size_precision(),
4621            move |bar: Bar| {
4622                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4623                handler_guard.push(bar);
4624            },
4625        );
4626
4627        // 4 units at $100 = $400 value, with step $100 gives 4 bars exactly.
4628        aggregator.update(
4629            Price::from("100.00"),
4630            Quantity::from(4),
4631            UnixNanos::default(),
4632        );
4633
4634        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4635        assert_eq!(handler_guard.len(), 4);
4636        for bar in handler_guard.iter() {
4637            assert_eq!(bar.volume, Quantity::from(1));
4638        }
4639    }
4640
4641    #[rstest]
4642    fn test_value_imbalance_bar_aggregator_emits_on_opposing_overflow(equity_aapl: Equity) {
4643        let instrument = InstrumentAny::Equity(equity_aapl);
4644        let bar_spec = BarSpecification::new(10, BarAggregation::ValueImbalance, PriceType::Last);
4645        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4646        let handler = Arc::new(Mutex::new(Vec::new()));
4647        let handler_clone = Arc::clone(&handler);
4648
4649        let mut aggregator = ValueImbalanceBarAggregator::new(
4650            bar_type,
4651            instrument.price_precision(),
4652            instrument.size_precision(),
4653            move |bar: Bar| {
4654                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4655                handler_guard.push(bar);
4656            },
4657        );
4658
4659        let buy = TradeTick {
4660            price: Price::from("5.0"),
4661            size: Quantity::from(2), // value 10, should emit one bar
4662            instrument_id: instrument.id(),
4663            ..TradeTick::default()
4664        };
4665        let sell = TradeTick {
4666            price: Price::from("5.0"),
4667            size: Quantity::from(2), // value 10, should emit another bar
4668            aggressor_side: AggressorSide::Sell,
4669            instrument_id: instrument.id(),
4670            ..buy
4671        };
4672
4673        aggregator.handle_trade(buy);
4674        aggregator.handle_trade(sell);
4675
4676        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4677        assert_eq!(handler_guard.len(), 2);
4678    }
4679
4680    #[rstest]
4681    fn test_value_runs_bar_aggregator_emits_on_consecutive_side(equity_aapl: Equity) {
4682        let instrument = InstrumentAny::Equity(equity_aapl);
4683        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4684        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4685        let handler = Arc::new(Mutex::new(Vec::new()));
4686        let handler_clone = Arc::clone(&handler);
4687
4688        let mut aggregator = ValueRunsBarAggregator::new(
4689            bar_type,
4690            instrument.price_precision(),
4691            instrument.size_precision(),
4692            move |bar: Bar| {
4693                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4694                handler_guard.push(bar);
4695            },
4696        );
4697
4698        let trade = TradeTick {
4699            price: Price::from("10.0"),
4700            size: Quantity::from(5),
4701            instrument_id: instrument.id(),
4702            ..TradeTick::default()
4703        };
4704
4705        aggregator.handle_trade(trade);
4706        aggregator.handle_trade(trade);
4707
4708        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4709        assert_eq!(handler_guard.len(), 1);
4710        let bar = handler_guard.first().unwrap();
4711        assert_eq!(bar.volume, Quantity::from(10));
4712    }
4713
4714    #[rstest]
4715    fn test_value_runs_bar_aggregator_resets_on_side_change(equity_aapl: Equity) {
4716        let instrument = InstrumentAny::Equity(equity_aapl);
4717        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4718        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4719        let handler = Arc::new(Mutex::new(Vec::new()));
4720        let handler_clone = Arc::clone(&handler);
4721
4722        let mut aggregator = ValueRunsBarAggregator::new(
4723            bar_type,
4724            instrument.price_precision(),
4725            instrument.size_precision(),
4726            move |bar: Bar| {
4727                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4728                handler_guard.push(bar);
4729            },
4730        );
4731
4732        let buy = TradeTick {
4733            price: Price::from("10.0"),
4734            size: Quantity::from(5),
4735            instrument_id: instrument.id(),
4736            ..TradeTick::default()
4737        }; // value 50
4738        let sell = TradeTick {
4739            price: Price::from("10.0"),
4740            size: Quantity::from(10),
4741            aggressor_side: AggressorSide::Sell,
4742            ..buy
4743        }; // value 100
4744
4745        aggregator.handle_trade(buy);
4746        aggregator.handle_trade(sell);
4747
4748        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4749        assert_eq!(handler_guard.len(), 1);
4750        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4751    }
4752
4753    #[rstest]
4754    fn test_tick_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4755        let instrument = InstrumentAny::Equity(equity_aapl);
4756        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
4757        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4758        let handler = Arc::new(Mutex::new(Vec::new()));
4759        let handler_clone = Arc::clone(&handler);
4760
4761        let mut aggregator = TickRunsBarAggregator::new(
4762            bar_type,
4763            instrument.price_precision(),
4764            instrument.size_precision(),
4765            move |bar: Bar| {
4766                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4767                handler_guard.push(bar);
4768            },
4769        );
4770
4771        let buy = TradeTick::default();
4772
4773        aggregator.handle_trade(buy);
4774        aggregator.handle_trade(buy); // Emit bar 1 (run complete)
4775        aggregator.handle_trade(buy); // Start new run
4776        aggregator.handle_trade(buy); // Emit bar 2 (new run complete)
4777
4778        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4779        assert_eq!(handler_guard.len(), 2);
4780    }
4781
4782    #[rstest]
4783    fn test_tick_runs_bar_aggregator_handles_no_aggressor_trades(equity_aapl: Equity) {
4784        let instrument = InstrumentAny::Equity(equity_aapl);
4785        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
4786        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4787        let handler = Arc::new(Mutex::new(Vec::new()));
4788        let handler_clone = Arc::clone(&handler);
4789
4790        let mut aggregator = TickRunsBarAggregator::new(
4791            bar_type,
4792            instrument.price_precision(),
4793            instrument.size_precision(),
4794            move |bar: Bar| {
4795                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4796                handler_guard.push(bar);
4797            },
4798        );
4799
4800        let buy = TradeTick::default();
4801        let no_aggressor = TradeTick {
4802            aggressor_side: AggressorSide::NoAggressor,
4803            ..buy
4804        };
4805
4806        aggregator.handle_trade(buy);
4807        aggregator.handle_trade(no_aggressor); // Should not affect run count
4808        aggregator.handle_trade(no_aggressor); // Should not affect run count
4809        aggregator.handle_trade(buy); // Continue run to threshold
4810
4811        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4812        assert_eq!(handler_guard.len(), 1);
4813    }
4814
4815    #[rstest]
4816    fn test_volume_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4817        let instrument = InstrumentAny::Equity(equity_aapl);
4818        let bar_spec = BarSpecification::new(2, BarAggregation::VolumeRuns, PriceType::Last);
4819        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4820        let handler = Arc::new(Mutex::new(Vec::new()));
4821        let handler_clone = Arc::clone(&handler);
4822
4823        let mut aggregator = VolumeRunsBarAggregator::new(
4824            bar_type,
4825            instrument.price_precision(),
4826            instrument.size_precision(),
4827            move |bar: Bar| {
4828                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4829                handler_guard.push(bar);
4830            },
4831        );
4832
4833        let buy = TradeTick {
4834            instrument_id: instrument.id(),
4835            price: Price::from("1.0"),
4836            size: Quantity::from(1),
4837            ..TradeTick::default()
4838        };
4839
4840        aggregator.handle_trade(buy);
4841        aggregator.handle_trade(buy); // Emit bar 1 (2.0 volume reached)
4842        aggregator.handle_trade(buy); // Start new run
4843        aggregator.handle_trade(buy); // Emit bar 2 (new 2.0 volume reached)
4844
4845        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4846        assert_eq!(handler_guard.len(), 2);
4847        assert_eq!(handler_guard[0].volume, Quantity::from(2));
4848        assert_eq!(handler_guard[1].volume, Quantity::from(2));
4849    }
4850
4851    #[rstest]
4852    fn test_value_runs_bar_aggregator_continues_run_after_bar_emission(equity_aapl: Equity) {
4853        let instrument = InstrumentAny::Equity(equity_aapl);
4854        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
4855        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4856        let handler = Arc::new(Mutex::new(Vec::new()));
4857        let handler_clone = Arc::clone(&handler);
4858
4859        let mut aggregator = ValueRunsBarAggregator::new(
4860            bar_type,
4861            instrument.price_precision(),
4862            instrument.size_precision(),
4863            move |bar: Bar| {
4864                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4865                handler_guard.push(bar);
4866            },
4867        );
4868
4869        let buy = TradeTick {
4870            instrument_id: instrument.id(),
4871            price: Price::from("10.0"),
4872            size: Quantity::from(5),
4873            ..TradeTick::default()
4874        }; // value 50 per trade
4875
4876        aggregator.handle_trade(buy);
4877        aggregator.handle_trade(buy); // Emit bar 1 (100 value reached)
4878        aggregator.handle_trade(buy); // Start new run
4879        aggregator.handle_trade(buy); // Emit bar 2 (new 100 value reached)
4880
4881        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4882        assert_eq!(handler_guard.len(), 2);
4883        assert_eq!(handler_guard[0].volume, Quantity::from(10));
4884        assert_eq!(handler_guard[1].volume, Quantity::from(10));
4885    }
4886
4887    #[rstest]
4888    fn test_time_bar_aggregator_builds_at_interval(equity_aapl: Equity) {
4889        let instrument = InstrumentAny::Equity(equity_aapl);
4890        // One second bars
4891        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4892        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4893        let handler = Arc::new(Mutex::new(Vec::new()));
4894        let handler_clone = Arc::clone(&handler);
4895        let clock = Rc::new(RefCell::new(TestClock::new()));
4896
4897        let mut aggregator = TimeBarAggregator::new(
4898            bar_type,
4899            instrument.price_precision(),
4900            instrument.size_precision(),
4901            clock.clone(),
4902            move |bar: Bar| {
4903                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4904                handler_guard.push(bar);
4905            },
4906            true,  // build_with_no_updates
4907            false, // timestamp_on_close
4908            BarIntervalType::LeftOpen,
4909            None,  // time_bars_origin_offset
4910            15,    // bar_build_delay
4911            false, // skip_first_non_full_bar
4912        );
4913
4914        aggregator.update(
4915            Price::from("100.00"),
4916            Quantity::from(1),
4917            UnixNanos::default(),
4918        );
4919
4920        let next_sec = UnixNanos::from(1_000_000_000);
4921        clock.borrow_mut().set_time(next_sec);
4922
4923        let event = TimeEvent::new(
4924            Ustr::from("1-SECOND-LAST"),
4925            UUID4::new(),
4926            next_sec,
4927            next_sec,
4928        );
4929        aggregator.build_bar(&event);
4930
4931        let handler_guard = handler.lock().expect(MUTEX_POISONED);
4932        assert_eq!(handler_guard.len(), 1);
4933        let bar = handler_guard.first().unwrap();
4934        assert_eq!(bar.ts_event, UnixNanos::default());
4935        assert_eq!(bar.ts_init, next_sec);
4936    }
4937
4938    #[rstest]
4939    fn test_time_bar_aggregator_stop_clears_timer_and_allows_restart(equity_aapl: Equity) {
4940        let instrument = InstrumentAny::Equity(equity_aapl);
4941        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4942        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4943        let timer_name = format!("TIME_BAR_{bar_type}");
4944        let clock = Rc::new(RefCell::new(TestClock::new()));
4945
4946        let aggregator = TimeBarAggregator::new(
4947            bar_type,
4948            instrument.price_precision(),
4949            instrument.size_precision(),
4950            clock.clone(),
4951            |_bar: Bar| {},
4952            true,
4953            false,
4954            BarIntervalType::LeftOpen,
4955            None,
4956            15,
4957            false,
4958        );
4959
4960        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
4961        let rc = Rc::new(RefCell::new(boxed));
4962
4963        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
4964        assert_eq!(clock.borrow().timer_names(), vec![timer_name.as_str()]);
4965
4966        rc.borrow_mut().stop();
4967        assert!(clock.borrow().timer_names().is_empty());
4968
4969        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
4970        assert_eq!(clock.borrow().timer_names(), vec![timer_name.as_str()]);
4971    }
4972
4973    #[rstest]
4974    fn test_time_bar_aggregator_left_open_interval(equity_aapl: Equity) {
4975        let instrument = InstrumentAny::Equity(equity_aapl);
4976        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
4977        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
4978        let handler = Arc::new(Mutex::new(Vec::new()));
4979        let handler_clone = Arc::clone(&handler);
4980        let clock = Rc::new(RefCell::new(TestClock::new()));
4981
4982        let mut aggregator = TimeBarAggregator::new(
4983            bar_type,
4984            instrument.price_precision(),
4985            instrument.size_precision(),
4986            clock.clone(),
4987            move |bar: Bar| {
4988                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
4989                handler_guard.push(bar);
4990            },
4991            true, // build_with_no_updates
4992            true, // timestamp_on_close - changed to true to verify left-open behavior
4993            BarIntervalType::LeftOpen,
4994            None,
4995            15,
4996            false, // skip_first_non_full_bar
4997        );
4998
4999        // Update in first interval
5000        aggregator.update(
5001            Price::from("100.00"),
5002            Quantity::from(1),
5003            UnixNanos::default(),
5004        );
5005
5006        // First interval close
5007        let ts1 = UnixNanos::from(1_000_000_000);
5008        clock.borrow_mut().set_time(ts1);
5009        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5010        aggregator.build_bar(&event);
5011
5012        // Update in second interval
5013        aggregator.update(Price::from("101.00"), Quantity::from(1), ts1);
5014
5015        // Second interval close
5016        let ts2 = UnixNanos::from(2_000_000_000);
5017        clock.borrow_mut().set_time(ts2);
5018        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5019        aggregator.build_bar(&event);
5020
5021        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5022        assert_eq!(handler_guard.len(), 2);
5023
5024        let bar1 = &handler_guard[0];
5025        assert_eq!(bar1.ts_event, ts1); // For left-open with timestamp_on_close=true
5026        assert_eq!(bar1.ts_init, ts1);
5027        assert_eq!(bar1.close, Price::from("100.00"));
5028        let bar2 = &handler_guard[1];
5029        assert_eq!(bar2.ts_event, ts2);
5030        assert_eq!(bar2.ts_init, ts2);
5031        assert_eq!(bar2.close, Price::from("101.00"));
5032    }
5033
5034    #[rstest]
5035    fn test_time_bar_aggregator_right_open_interval(equity_aapl: Equity) {
5036        let instrument = InstrumentAny::Equity(equity_aapl);
5037        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
5038        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5039        let handler = Arc::new(Mutex::new(Vec::new()));
5040        let handler_clone = Arc::clone(&handler);
5041        let clock = Rc::new(RefCell::new(TestClock::new()));
5042        let mut aggregator = TimeBarAggregator::new(
5043            bar_type,
5044            instrument.price_precision(),
5045            instrument.size_precision(),
5046            clock.clone(),
5047            move |bar: Bar| {
5048                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5049                handler_guard.push(bar);
5050            },
5051            true, // build_with_no_updates
5052            true, // timestamp_on_close
5053            BarIntervalType::RightOpen,
5054            None,
5055            15,
5056            false, // skip_first_non_full_bar
5057        );
5058
5059        // Update in first interval
5060        aggregator.update(
5061            Price::from("100.00"),
5062            Quantity::from(1),
5063            UnixNanos::default(),
5064        );
5065
5066        // First interval close
5067        let ts1 = UnixNanos::from(1_000_000_000);
5068        clock.borrow_mut().set_time(ts1);
5069        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5070        aggregator.build_bar(&event);
5071
5072        // Update in second interval
5073        aggregator.update(Price::from("101.00"), Quantity::from(1), ts1);
5074
5075        // Second interval close
5076        let ts2 = UnixNanos::from(2_000_000_000);
5077        clock.borrow_mut().set_time(ts2);
5078        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5079        aggregator.build_bar(&event);
5080
5081        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5082        assert_eq!(handler_guard.len(), 2);
5083
5084        let bar1 = &handler_guard[0];
5085        assert_eq!(bar1.ts_event, UnixNanos::default()); // Right-open interval starts inclusive
5086        assert_eq!(bar1.ts_init, ts1);
5087        assert_eq!(bar1.close, Price::from("100.00"));
5088
5089        let bar2 = &handler_guard[1];
5090        assert_eq!(bar2.ts_event, ts1);
5091        assert_eq!(bar2.ts_init, ts2);
5092        assert_eq!(bar2.close, Price::from("101.00"));
5093    }
5094
5095    #[rstest]
5096    fn test_time_bar_aggregator_no_updates_behavior(equity_aapl: Equity) {
5097        let instrument = InstrumentAny::Equity(equity_aapl);
5098        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
5099        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5100        let handler = Arc::new(Mutex::new(Vec::new()));
5101        let handler_clone = Arc::clone(&handler);
5102        let clock = Rc::new(RefCell::new(TestClock::new()));
5103
5104        // First test with build_with_no_updates = false
5105        let mut aggregator = TimeBarAggregator::new(
5106            bar_type,
5107            instrument.price_precision(),
5108            instrument.size_precision(),
5109            clock.clone(),
5110            move |bar: Bar| {
5111                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5112                handler_guard.push(bar);
5113            },
5114            false, // build_with_no_updates disabled
5115            true,  // timestamp_on_close
5116            BarIntervalType::LeftOpen,
5117            None,
5118            15,
5119            false, // skip_first_non_full_bar
5120        );
5121
5122        // No updates, just interval close
5123        let ts1 = UnixNanos::from(1_000_000_000);
5124        clock.borrow_mut().set_time(ts1);
5125        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5126        aggregator.build_bar(&event);
5127
5128        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5129        assert_eq!(handler_guard.len(), 0); // No bar should be built without updates
5130        drop(handler_guard);
5131
5132        // Now test with build_with_no_updates = true
5133        let handler = Arc::new(Mutex::new(Vec::new()));
5134        let handler_clone = Arc::clone(&handler);
5135        let mut aggregator = TimeBarAggregator::new(
5136            bar_type,
5137            instrument.price_precision(),
5138            instrument.size_precision(),
5139            clock.clone(),
5140            move |bar: Bar| {
5141                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5142                handler_guard.push(bar);
5143            },
5144            true, // build_with_no_updates enabled
5145            true, // timestamp_on_close
5146            BarIntervalType::LeftOpen,
5147            None,
5148            15,
5149            false, // skip_first_non_full_bar
5150        );
5151
5152        aggregator.update(
5153            Price::from("100.00"),
5154            Quantity::from(1),
5155            UnixNanos::default(),
5156        );
5157
5158        // First interval with update
5159        let ts1 = UnixNanos::from(1_000_000_000);
5160        clock.borrow_mut().set_time(ts1);
5161        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts1, ts1);
5162        aggregator.build_bar(&event);
5163
5164        // Second interval without updates
5165        let ts2 = UnixNanos::from(2_000_000_000);
5166        clock.borrow_mut().set_time(ts2);
5167        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5168        aggregator.build_bar(&event);
5169
5170        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5171        assert_eq!(handler_guard.len(), 2); // Both bars should be built
5172        let bar1 = &handler_guard[0];
5173        assert_eq!(bar1.close, Price::from("100.00"));
5174        let bar2 = &handler_guard[1];
5175        assert_eq!(bar2.close, Price::from("100.00")); // Should use last close
5176    }
5177
5178    #[rstest]
5179    fn test_time_bar_aggregator_respects_timestamp_on_close(equity_aapl: Equity) {
5180        let instrument = InstrumentAny::Equity(equity_aapl);
5181        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
5182        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5183        let clock = Rc::new(RefCell::new(TestClock::new()));
5184        let handler = Arc::new(Mutex::new(Vec::new()));
5185        let handler_clone = Arc::clone(&handler);
5186
5187        let mut aggregator = TimeBarAggregator::new(
5188            bar_type,
5189            instrument.price_precision(),
5190            instrument.size_precision(),
5191            clock.clone(),
5192            move |bar: Bar| {
5193                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5194                handler_guard.push(bar);
5195            },
5196            true, // build_with_no_updates
5197            true, // timestamp_on_close
5198            BarIntervalType::RightOpen,
5199            None,
5200            15,
5201            false, // skip_first_non_full_bar
5202        );
5203
5204        let ts1 = UnixNanos::from(1_000_000_000);
5205        aggregator.update(Price::from("100.00"), Quantity::from(1), ts1);
5206
5207        let ts2 = UnixNanos::from(2_000_000_000);
5208        clock.borrow_mut().set_time(ts2);
5209
5210        // Simulate timestamp on close
5211        let event = TimeEvent::new(Ustr::from("1-SECOND-LAST"), UUID4::new(), ts2, ts2);
5212        aggregator.build_bar(&event);
5213
5214        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5215        let bar = handler_guard.first().unwrap();
5216        assert_eq!(bar.ts_event, UnixNanos::default());
5217        assert_eq!(bar.ts_init, ts2);
5218    }
5219
5220    #[rstest]
5221    fn test_renko_bar_aggregator_initialization(audusd_sim: CurrencyPair) {
5222        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5223        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5224        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5225        let handler = Arc::new(Mutex::new(Vec::new()));
5226        let handler_clone = Arc::clone(&handler);
5227
5228        let aggregator = RenkoBarAggregator::new(
5229            bar_type,
5230            instrument.price_precision(),
5231            instrument.size_precision(),
5232            instrument.price_increment(),
5233            move |bar: Bar| {
5234                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5235                handler_guard.push(bar);
5236            },
5237        );
5238
5239        assert_eq!(aggregator.bar_type(), bar_type);
5240        assert!(!aggregator.is_running());
5241        // 10 pips * price_increment.raw (depends on precision mode)
5242        let expected_brick_size = 10 * instrument.price_increment().raw;
5243        assert_eq!(aggregator.brick_size, expected_brick_size);
5244    }
5245
5246    #[rstest]
5247    fn test_renko_bar_aggregator_update_below_brick_size_no_bar(audusd_sim: CurrencyPair) {
5248        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5249        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5250        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5251        let handler = Arc::new(Mutex::new(Vec::new()));
5252        let handler_clone = Arc::clone(&handler);
5253
5254        let mut aggregator = RenkoBarAggregator::new(
5255            bar_type,
5256            instrument.price_precision(),
5257            instrument.size_precision(),
5258            instrument.price_increment(),
5259            move |bar: Bar| {
5260                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5261                handler_guard.push(bar);
5262            },
5263        );
5264
5265        // Small price movement (5 pips, less than 10 pip brick size)
5266        aggregator.update(
5267            Price::from("1.00000"),
5268            Quantity::from(1),
5269            UnixNanos::default(),
5270        );
5271        aggregator.update(
5272            Price::from("1.00005"),
5273            Quantity::from(1),
5274            UnixNanos::from(1000),
5275        );
5276
5277        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5278        assert_eq!(handler_guard.len(), 0); // No bar created yet
5279    }
5280
5281    #[rstest]
5282    fn test_renko_bar_aggregator_ignores_out_of_order_bar(audusd_sim: CurrencyPair) {
5283        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5284        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid);
5285        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5286        let handler = Arc::new(Mutex::new(Vec::new()));
5287        let handler_clone = Arc::clone(&handler);
5288        let mut aggregator = RenkoBarAggregator::new(
5289            bar_type,
5290            instrument.price_precision(),
5291            instrument.size_precision(),
5292            instrument.price_increment(),
5293            move |bar: Bar| {
5294                handler_clone.lock().expect(MUTEX_POISONED).push(bar);
5295            },
5296        );
5297        let first = Bar::new(
5298            bar_type,
5299            Price::from("1.00000"),
5300            Price::from("1.00000"),
5301            Price::from("1.00000"),
5302            Price::from("1.00000"),
5303            Quantity::from(1),
5304            UnixNanos::from(1_000),
5305            UnixNanos::from(1_000),
5306        );
5307        let stale = Bar::new(
5308            bar_type,
5309            Price::from("1.00020"),
5310            Price::from("1.00020"),
5311            Price::from("1.00020"),
5312            Price::from("1.00020"),
5313            Quantity::from(1),
5314            UnixNanos::from(500),
5315            UnixNanos::from(500),
5316        );
5317
5318        aggregator.update_bar(first, first.volume, first.ts_init);
5319        aggregator.update_bar(stale, stale.volume, stale.ts_init);
5320
5321        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
5322        assert_eq!(aggregator.last_close, Some(Price::from("1.00000")));
5323        assert_eq!(aggregator.core.builder.ts_last, UnixNanos::from(1_000));
5324    }
5325
5326    #[rstest]
5327    fn test_renko_bar_aggregator_update_exceeds_brick_size_creates_bar(audusd_sim: CurrencyPair) {
5328        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5329        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5330        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5331        let handler = Arc::new(Mutex::new(Vec::new()));
5332        let handler_clone = Arc::clone(&handler);
5333
5334        let mut aggregator = RenkoBarAggregator::new(
5335            bar_type,
5336            instrument.price_precision(),
5337            instrument.size_precision(),
5338            instrument.price_increment(),
5339            move |bar: Bar| {
5340                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5341                handler_guard.push(bar);
5342            },
5343        );
5344
5345        // Price movement exceeding brick size (15 pips)
5346        aggregator.update(
5347            Price::from("1.00000"),
5348            Quantity::from(1),
5349            UnixNanos::default(),
5350        );
5351        aggregator.update(
5352            Price::from("1.00015"),
5353            Quantity::from(1),
5354            UnixNanos::from(1000),
5355        );
5356
5357        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5358        assert_eq!(handler_guard.len(), 1);
5359
5360        let bar = handler_guard.first().unwrap();
5361        assert_eq!(bar.open, Price::from("1.00000"));
5362        assert_eq!(bar.high, Price::from("1.00010"));
5363        assert_eq!(bar.low, Price::from("1.00000"));
5364        assert_eq!(bar.close, Price::from("1.00010"));
5365        assert_eq!(bar.volume, Quantity::from(2));
5366        assert_eq!(bar.ts_event, UnixNanos::from(1000));
5367        assert_eq!(bar.ts_init, UnixNanos::from(1000));
5368    }
5369
5370    #[rstest]
5371    fn test_renko_bar_aggregator_multiple_bricks_in_one_update(audusd_sim: CurrencyPair) {
5372        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5373        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5374        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5375        let handler = Arc::new(Mutex::new(Vec::new()));
5376        let handler_clone = Arc::clone(&handler);
5377
5378        let mut aggregator = RenkoBarAggregator::new(
5379            bar_type,
5380            instrument.price_precision(),
5381            instrument.size_precision(),
5382            instrument.price_increment(),
5383            move |bar: Bar| {
5384                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5385                handler_guard.push(bar);
5386            },
5387        );
5388
5389        // Large price movement creating multiple bricks (25 pips = 2 bricks)
5390        aggregator.update(
5391            Price::from("1.00000"),
5392            Quantity::from(1),
5393            UnixNanos::default(),
5394        );
5395        aggregator.update(
5396            Price::from("1.00025"),
5397            Quantity::from(1),
5398            UnixNanos::from(1000),
5399        );
5400
5401        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5402        assert_eq!(handler_guard.len(), 2);
5403
5404        let bar1 = &handler_guard[0];
5405        assert_eq!(bar1.open, Price::from("1.00000"));
5406        assert_eq!(bar1.high, Price::from("1.00010"));
5407        assert_eq!(bar1.low, Price::from("1.00000"));
5408        assert_eq!(bar1.close, Price::from("1.00010"));
5409
5410        let bar2 = &handler_guard[1];
5411        assert_eq!(bar2.open, Price::from("1.00010"));
5412        assert_eq!(bar2.high, Price::from("1.00020"));
5413        assert_eq!(bar2.low, Price::from("1.00010"));
5414        assert_eq!(bar2.close, Price::from("1.00020"));
5415    }
5416
5417    #[rstest]
5418    fn test_renko_bar_aggregator_downward_movement(audusd_sim: CurrencyPair) {
5419        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5420        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5421        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5422        let handler = Arc::new(Mutex::new(Vec::new()));
5423        let handler_clone = Arc::clone(&handler);
5424
5425        let mut aggregator = RenkoBarAggregator::new(
5426            bar_type,
5427            instrument.price_precision(),
5428            instrument.size_precision(),
5429            instrument.price_increment(),
5430            move |bar: Bar| {
5431                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5432                handler_guard.push(bar);
5433            },
5434        );
5435
5436        // Start at higher price and move down
5437        aggregator.update(
5438            Price::from("1.00020"),
5439            Quantity::from(1),
5440            UnixNanos::default(),
5441        );
5442        aggregator.update(
5443            Price::from("1.00005"),
5444            Quantity::from(1),
5445            UnixNanos::from(1000),
5446        );
5447
5448        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5449        assert_eq!(handler_guard.len(), 1);
5450
5451        let bar = handler_guard.first().unwrap();
5452        assert_eq!(bar.open, Price::from("1.00020"));
5453        assert_eq!(bar.high, Price::from("1.00020"));
5454        assert_eq!(bar.low, Price::from("1.00010"));
5455        assert_eq!(bar.close, Price::from("1.00010"));
5456        assert_eq!(bar.volume, Quantity::from(2));
5457    }
5458
5459    #[rstest]
5460    fn test_renko_bar_aggregator_handle_bar_below_brick_size(audusd_sim: CurrencyPair) {
5461        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5462        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5463        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5464        let handler = Arc::new(Mutex::new(Vec::new()));
5465        let handler_clone = Arc::clone(&handler);
5466
5467        let mut aggregator = RenkoBarAggregator::new(
5468            bar_type,
5469            instrument.price_precision(),
5470            instrument.size_precision(),
5471            instrument.price_increment(),
5472            move |bar: Bar| {
5473                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5474                handler_guard.push(bar);
5475            },
5476        );
5477
5478        // Create a bar with small price movement (5 pips)
5479        let input_bar = Bar::new(
5480            BarType::new(
5481                instrument.id(),
5482                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5483                AggregationSource::Internal,
5484            ),
5485            Price::from("1.00000"),
5486            Price::from("1.00005"),
5487            Price::from("0.99995"),
5488            Price::from("1.00005"), // 5 pip move up (less than 10 pip brick)
5489            Quantity::from(100),
5490            UnixNanos::default(),
5491            UnixNanos::from(1000),
5492        );
5493
5494        aggregator.handle_bar(input_bar);
5495
5496        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5497        assert_eq!(handler_guard.len(), 0); // No bar created yet
5498    }
5499
5500    #[rstest]
5501    fn test_renko_bar_aggregator_handle_bar_exceeds_brick_size(audusd_sim: CurrencyPair) {
5502        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5503        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5504        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5505        let handler = Arc::new(Mutex::new(Vec::new()));
5506        let handler_clone = Arc::clone(&handler);
5507
5508        let mut aggregator = RenkoBarAggregator::new(
5509            bar_type,
5510            instrument.price_precision(),
5511            instrument.size_precision(),
5512            instrument.price_increment(),
5513            move |bar: Bar| {
5514                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5515                handler_guard.push(bar);
5516            },
5517        );
5518
5519        // First bar to establish baseline
5520        let bar1 = Bar::new(
5521            BarType::new(
5522                instrument.id(),
5523                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5524                AggregationSource::Internal,
5525            ),
5526            Price::from("1.00000"),
5527            Price::from("1.00005"),
5528            Price::from("0.99995"),
5529            Price::from("1.00000"),
5530            Quantity::from(100),
5531            UnixNanos::default(),
5532            UnixNanos::default(),
5533        );
5534
5535        // Second bar with price movement exceeding brick size (10 pips)
5536        let bar2 = Bar::new(
5537            BarType::new(
5538                instrument.id(),
5539                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5540                AggregationSource::Internal,
5541            ),
5542            Price::from("1.00000"),
5543            Price::from("1.00015"),
5544            Price::from("0.99995"),
5545            Price::from("1.00010"), // 10 pip move up (exactly 1 brick)
5546            Quantity::from(50),
5547            UnixNanos::from(60_000_000_000),
5548            UnixNanos::from(60_000_000_000),
5549        );
5550
5551        aggregator.handle_bar(bar1);
5552        aggregator.handle_bar(bar2);
5553
5554        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5555        assert_eq!(handler_guard.len(), 1);
5556
5557        let bar = handler_guard.first().unwrap();
5558        assert_eq!(bar.open, Price::from("1.00000"));
5559        assert_eq!(bar.high, Price::from("1.00010"));
5560        assert_eq!(bar.low, Price::from("1.00000"));
5561        assert_eq!(bar.close, Price::from("1.00010"));
5562        assert_eq!(bar.volume, Quantity::from(150));
5563    }
5564
5565    #[rstest]
5566    fn test_renko_bar_aggregator_handle_bar_multiple_bricks(audusd_sim: CurrencyPair) {
5567        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5568        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5569        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5570        let handler = Arc::new(Mutex::new(Vec::new()));
5571        let handler_clone = Arc::clone(&handler);
5572
5573        let mut aggregator = RenkoBarAggregator::new(
5574            bar_type,
5575            instrument.price_precision(),
5576            instrument.size_precision(),
5577            instrument.price_increment(),
5578            move |bar: Bar| {
5579                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5580                handler_guard.push(bar);
5581            },
5582        );
5583
5584        // First bar to establish baseline
5585        let bar1 = Bar::new(
5586            BarType::new(
5587                instrument.id(),
5588                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5589                AggregationSource::Internal,
5590            ),
5591            Price::from("1.00000"),
5592            Price::from("1.00005"),
5593            Price::from("0.99995"),
5594            Price::from("1.00000"),
5595            Quantity::from(100),
5596            UnixNanos::default(),
5597            UnixNanos::default(),
5598        );
5599
5600        // Second bar with large price movement (30 pips = 3 bricks)
5601        let bar2 = Bar::new(
5602            BarType::new(
5603                instrument.id(),
5604                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5605                AggregationSource::Internal,
5606            ),
5607            Price::from("1.00000"),
5608            Price::from("1.00035"),
5609            Price::from("0.99995"),
5610            Price::from("1.00030"), // 30 pip move up (exactly 3 bricks)
5611            Quantity::from(50),
5612            UnixNanos::from(60_000_000_000),
5613            UnixNanos::from(60_000_000_000),
5614        );
5615
5616        aggregator.handle_bar(bar1);
5617        aggregator.handle_bar(bar2);
5618
5619        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5620        assert_eq!(handler_guard.len(), 3);
5621
5622        let bar1 = &handler_guard[0];
5623        assert_eq!(bar1.open, Price::from("1.00000"));
5624        assert_eq!(bar1.close, Price::from("1.00010"));
5625
5626        let bar2 = &handler_guard[1];
5627        assert_eq!(bar2.open, Price::from("1.00010"));
5628        assert_eq!(bar2.close, Price::from("1.00020"));
5629
5630        let bar3 = &handler_guard[2];
5631        assert_eq!(bar3.open, Price::from("1.00020"));
5632        assert_eq!(bar3.close, Price::from("1.00030"));
5633    }
5634
5635    #[rstest]
5636    fn test_renko_bar_aggregator_handle_bar_downward_movement(audusd_sim: CurrencyPair) {
5637        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5638        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5639        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5640        let handler = Arc::new(Mutex::new(Vec::new()));
5641        let handler_clone = Arc::clone(&handler);
5642
5643        let mut aggregator = RenkoBarAggregator::new(
5644            bar_type,
5645            instrument.price_precision(),
5646            instrument.size_precision(),
5647            instrument.price_increment(),
5648            move |bar: Bar| {
5649                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5650                handler_guard.push(bar);
5651            },
5652        );
5653
5654        // First bar to establish baseline
5655        let bar1 = Bar::new(
5656            BarType::new(
5657                instrument.id(),
5658                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5659                AggregationSource::Internal,
5660            ),
5661            Price::from("1.00020"),
5662            Price::from("1.00025"),
5663            Price::from("1.00015"),
5664            Price::from("1.00020"),
5665            Quantity::from(100),
5666            UnixNanos::default(),
5667            UnixNanos::default(),
5668        );
5669
5670        // Second bar with downward price movement (10 pips down)
5671        let bar2 = Bar::new(
5672            BarType::new(
5673                instrument.id(),
5674                BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
5675                AggregationSource::Internal,
5676            ),
5677            Price::from("1.00020"),
5678            Price::from("1.00025"),
5679            Price::from("1.00005"),
5680            Price::from("1.00010"), // 10 pip move down (exactly 1 brick)
5681            Quantity::from(50),
5682            UnixNanos::from(60_000_000_000),
5683            UnixNanos::from(60_000_000_000),
5684        );
5685
5686        aggregator.handle_bar(bar1);
5687        aggregator.handle_bar(bar2);
5688
5689        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5690        assert_eq!(handler_guard.len(), 1);
5691
5692        let bar = handler_guard.first().unwrap();
5693        assert_eq!(bar.open, Price::from("1.00020"));
5694        assert_eq!(bar.high, Price::from("1.00020"));
5695        assert_eq!(bar.low, Price::from("1.00010"));
5696        assert_eq!(bar.close, Price::from("1.00010"));
5697        assert_eq!(bar.volume, Quantity::from(150));
5698    }
5699
5700    #[rstest]
5701    fn test_renko_bar_aggregator_brick_size_calculation(audusd_sim: CurrencyPair) {
5702        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5703
5704        // Test different brick sizes
5705        let bar_spec_5 = BarSpecification::new(5, BarAggregation::Renko, PriceType::Mid); // 5 pip brick size
5706        let bar_type_5 = BarType::new(instrument.id(), bar_spec_5, AggregationSource::Internal);
5707        let handler = Arc::new(Mutex::new(Vec::new()));
5708        let handler_clone = Arc::clone(&handler);
5709
5710        let aggregator_5 = RenkoBarAggregator::new(
5711            bar_type_5,
5712            instrument.price_precision(),
5713            instrument.size_precision(),
5714            instrument.price_increment(),
5715            move |_bar: Bar| {
5716                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5717                handler_guard.push(_bar);
5718            },
5719        );
5720
5721        // 5 pips * price_increment.raw (depends on precision mode)
5722        let expected_brick_size_5 = 5 * instrument.price_increment().raw;
5723        assert_eq!(aggregator_5.brick_size, expected_brick_size_5);
5724
5725        let bar_spec_20 = BarSpecification::new(20, BarAggregation::Renko, PriceType::Mid); // 20 pip brick size
5726        let bar_type_20 = BarType::new(instrument.id(), bar_spec_20, AggregationSource::Internal);
5727        let handler2 = Arc::new(Mutex::new(Vec::new()));
5728        let handler2_clone = Arc::clone(&handler2);
5729
5730        let aggregator_20 = RenkoBarAggregator::new(
5731            bar_type_20,
5732            instrument.price_precision(),
5733            instrument.size_precision(),
5734            instrument.price_increment(),
5735            move |_bar: Bar| {
5736                let mut handler_guard = handler2_clone.lock().expect(MUTEX_POISONED);
5737                handler_guard.push(_bar);
5738            },
5739        );
5740
5741        // 20 pips * price_increment.raw (depends on precision mode)
5742        let expected_brick_size_20 = 20 * instrument.price_increment().raw;
5743        assert_eq!(aggregator_20.brick_size, expected_brick_size_20);
5744    }
5745
5746    #[rstest]
5747    fn test_renko_bar_aggregator_sequential_updates(audusd_sim: CurrencyPair) {
5748        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5749        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5750        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5751        let handler = Arc::new(Mutex::new(Vec::new()));
5752        let handler_clone = Arc::clone(&handler);
5753
5754        let mut aggregator = RenkoBarAggregator::new(
5755            bar_type,
5756            instrument.price_precision(),
5757            instrument.size_precision(),
5758            instrument.price_increment(),
5759            move |bar: Bar| {
5760                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5761                handler_guard.push(bar);
5762            },
5763        );
5764
5765        // Sequential updates creating multiple bars
5766        aggregator.update(
5767            Price::from("1.00000"),
5768            Quantity::from(1),
5769            UnixNanos::from(1000),
5770        );
5771        aggregator.update(
5772            Price::from("1.00010"),
5773            Quantity::from(1),
5774            UnixNanos::from(2000),
5775        ); // First brick
5776        aggregator.update(
5777            Price::from("1.00020"),
5778            Quantity::from(1),
5779            UnixNanos::from(3000),
5780        ); // Second brick
5781        aggregator.update(
5782            Price::from("1.00025"),
5783            Quantity::from(1),
5784            UnixNanos::from(4000),
5785        ); // Partial third brick
5786        aggregator.update(
5787            Price::from("1.00030"),
5788            Quantity::from(1),
5789            UnixNanos::from(5000),
5790        ); // Complete third brick
5791
5792        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5793        assert_eq!(handler_guard.len(), 3);
5794
5795        let bar1 = &handler_guard[0];
5796        assert_eq!(bar1.open, Price::from("1.00000"));
5797        assert_eq!(bar1.close, Price::from("1.00010"));
5798
5799        let bar2 = &handler_guard[1];
5800        assert_eq!(bar2.open, Price::from("1.00010"));
5801        assert_eq!(bar2.close, Price::from("1.00020"));
5802
5803        let bar3 = &handler_guard[2];
5804        assert_eq!(bar3.open, Price::from("1.00020"));
5805        assert_eq!(bar3.close, Price::from("1.00030"));
5806    }
5807
5808    #[rstest]
5809    fn test_renko_bar_aggregator_mixed_direction_movement(audusd_sim: CurrencyPair) {
5810        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5811        let bar_spec = BarSpecification::new(10, BarAggregation::Renko, PriceType::Mid); // 10 pip brick size
5812        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5813        let handler = Arc::new(Mutex::new(Vec::new()));
5814        let handler_clone = Arc::clone(&handler);
5815
5816        let mut aggregator = RenkoBarAggregator::new(
5817            bar_type,
5818            instrument.price_precision(),
5819            instrument.size_precision(),
5820            instrument.price_increment(),
5821            move |bar: Bar| {
5822                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5823                handler_guard.push(bar);
5824            },
5825        );
5826
5827        // Mixed direction movement: up then down
5828        aggregator.update(
5829            Price::from("1.00000"),
5830            Quantity::from(1),
5831            UnixNanos::from(1000),
5832        );
5833        aggregator.update(
5834            Price::from("1.00010"),
5835            Quantity::from(1),
5836            UnixNanos::from(2000),
5837        ); // Up brick
5838        aggregator.update(
5839            Price::from("0.99990"),
5840            Quantity::from(1),
5841            UnixNanos::from(3000),
5842        ); // Down 2 bricks (20 pips)
5843
5844        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5845        assert_eq!(handler_guard.len(), 3);
5846
5847        let bar1 = &handler_guard[0]; // Up brick
5848        assert_eq!(bar1.open, Price::from("1.00000"));
5849        assert_eq!(bar1.high, Price::from("1.00010"));
5850        assert_eq!(bar1.low, Price::from("1.00000"));
5851        assert_eq!(bar1.close, Price::from("1.00010"));
5852
5853        let bar2 = &handler_guard[1]; // First down brick
5854        assert_eq!(bar2.open, Price::from("1.00010"));
5855        assert_eq!(bar2.high, Price::from("1.00010"));
5856        assert_eq!(bar2.low, Price::from("1.00000"));
5857        assert_eq!(bar2.close, Price::from("1.00000"));
5858
5859        let bar3 = &handler_guard[2]; // Second down brick
5860        assert_eq!(bar3.open, Price::from("1.00000"));
5861        assert_eq!(bar3.high, Price::from("1.00000"));
5862        assert_eq!(bar3.low, Price::from("0.99990"));
5863        assert_eq!(bar3.close, Price::from("0.99990"));
5864    }
5865
5866    #[rstest]
5867    fn test_tick_imbalance_bar_aggregator_mixed_trades_cancel_out(equity_aapl: Equity) {
5868        let instrument = InstrumentAny::Equity(equity_aapl);
5869        let bar_spec = BarSpecification::new(3, BarAggregation::TickImbalance, PriceType::Last);
5870        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5871        let handler = Arc::new(Mutex::new(Vec::new()));
5872        let handler_clone = Arc::clone(&handler);
5873
5874        let mut aggregator = TickImbalanceBarAggregator::new(
5875            bar_type,
5876            instrument.price_precision(),
5877            instrument.size_precision(),
5878            move |bar: Bar| {
5879                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5880                handler_guard.push(bar);
5881            },
5882        );
5883
5884        let buy = TradeTick {
5885            aggressor_side: AggressorSide::Buy,
5886            ..TradeTick::default()
5887        };
5888        let sell = TradeTick {
5889            aggressor_side: AggressorSide::Sell,
5890            ..TradeTick::default()
5891        };
5892
5893        aggregator.handle_trade(buy);
5894        aggregator.handle_trade(sell);
5895        aggregator.handle_trade(buy);
5896
5897        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5898        assert_eq!(handler_guard.len(), 0);
5899    }
5900
5901    #[rstest]
5902    fn test_tick_imbalance_bar_aggregator_no_aggressor_ignored(equity_aapl: Equity) {
5903        let instrument = InstrumentAny::Equity(equity_aapl);
5904        let bar_spec = BarSpecification::new(2, BarAggregation::TickImbalance, PriceType::Last);
5905        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5906        let handler = Arc::new(Mutex::new(Vec::new()));
5907        let handler_clone = Arc::clone(&handler);
5908
5909        let mut aggregator = TickImbalanceBarAggregator::new(
5910            bar_type,
5911            instrument.price_precision(),
5912            instrument.size_precision(),
5913            move |bar: Bar| {
5914                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5915                handler_guard.push(bar);
5916            },
5917        );
5918
5919        let buy = TradeTick {
5920            aggressor_side: AggressorSide::Buy,
5921            ..TradeTick::default()
5922        };
5923        let no_aggressor = TradeTick {
5924            aggressor_side: AggressorSide::NoAggressor,
5925            ..TradeTick::default()
5926        };
5927
5928        aggregator.handle_trade(buy);
5929        aggregator.handle_trade(no_aggressor);
5930        aggregator.handle_trade(buy);
5931
5932        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5933        assert_eq!(handler_guard.len(), 1);
5934    }
5935
5936    #[rstest]
5937    fn test_tick_runs_bar_aggregator_multiple_consecutive_runs(equity_aapl: Equity) {
5938        let instrument = InstrumentAny::Equity(equity_aapl);
5939        let bar_spec = BarSpecification::new(2, BarAggregation::TickRuns, PriceType::Last);
5940        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5941        let handler = Arc::new(Mutex::new(Vec::new()));
5942        let handler_clone = Arc::clone(&handler);
5943
5944        let mut aggregator = TickRunsBarAggregator::new(
5945            bar_type,
5946            instrument.price_precision(),
5947            instrument.size_precision(),
5948            move |bar: Bar| {
5949                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5950                handler_guard.push(bar);
5951            },
5952        );
5953
5954        let buy = TradeTick {
5955            aggressor_side: AggressorSide::Buy,
5956            ..TradeTick::default()
5957        };
5958        let sell = TradeTick {
5959            aggressor_side: AggressorSide::Sell,
5960            ..TradeTick::default()
5961        };
5962
5963        aggregator.handle_trade(buy);
5964        aggregator.handle_trade(buy);
5965        aggregator.handle_trade(sell);
5966        aggregator.handle_trade(sell);
5967
5968        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5969        assert_eq!(handler_guard.len(), 2);
5970    }
5971
5972    #[rstest]
5973    fn test_volume_imbalance_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
5974        let instrument = InstrumentAny::Equity(equity_aapl);
5975        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeImbalance, PriceType::Last);
5976        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
5977        let handler = Arc::new(Mutex::new(Vec::new()));
5978        let handler_clone = Arc::clone(&handler);
5979
5980        let mut aggregator = VolumeImbalanceBarAggregator::new(
5981            bar_type,
5982            instrument.price_precision(),
5983            instrument.size_precision(),
5984            move |bar: Bar| {
5985                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
5986                handler_guard.push(bar);
5987            },
5988        );
5989
5990        let large_trade = TradeTick {
5991            size: Quantity::from(25),
5992            aggressor_side: AggressorSide::Buy,
5993            ..TradeTick::default()
5994        };
5995
5996        aggregator.handle_trade(large_trade);
5997
5998        let handler_guard = handler.lock().expect(MUTEX_POISONED);
5999        assert_eq!(handler_guard.len(), 2);
6000    }
6001
6002    #[rstest]
6003    fn test_volume_imbalance_bar_aggregator_no_aggressor_does_not_affect_imbalance(
6004        equity_aapl: Equity,
6005    ) {
6006        let instrument = InstrumentAny::Equity(equity_aapl);
6007        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeImbalance, PriceType::Last);
6008        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6009        let handler = Arc::new(Mutex::new(Vec::new()));
6010        let handler_clone = Arc::clone(&handler);
6011
6012        let mut aggregator = VolumeImbalanceBarAggregator::new(
6013            bar_type,
6014            instrument.price_precision(),
6015            instrument.size_precision(),
6016            move |bar: Bar| {
6017                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6018                handler_guard.push(bar);
6019            },
6020        );
6021
6022        let buy = TradeTick {
6023            size: Quantity::from(5),
6024            aggressor_side: AggressorSide::Buy,
6025            ..TradeTick::default()
6026        };
6027        let no_aggressor = TradeTick {
6028            size: Quantity::from(3),
6029            aggressor_side: AggressorSide::NoAggressor,
6030            ..TradeTick::default()
6031        };
6032
6033        aggregator.handle_trade(buy);
6034        aggregator.handle_trade(no_aggressor);
6035        aggregator.handle_trade(buy);
6036
6037        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6038        assert_eq!(handler_guard.len(), 1);
6039    }
6040
6041    #[rstest]
6042    fn test_volume_runs_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
6043        let instrument = InstrumentAny::Equity(equity_aapl);
6044        let bar_spec = BarSpecification::new(10, BarAggregation::VolumeRuns, PriceType::Last);
6045        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6046        let handler = Arc::new(Mutex::new(Vec::new()));
6047        let handler_clone = Arc::clone(&handler);
6048
6049        let mut aggregator = VolumeRunsBarAggregator::new(
6050            bar_type,
6051            instrument.price_precision(),
6052            instrument.size_precision(),
6053            move |bar: Bar| {
6054                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6055                handler_guard.push(bar);
6056            },
6057        );
6058
6059        let large_trade = TradeTick {
6060            size: Quantity::from(25),
6061            aggressor_side: AggressorSide::Buy,
6062            ..TradeTick::default()
6063        };
6064
6065        aggregator.handle_trade(large_trade);
6066
6067        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6068        assert_eq!(handler_guard.len(), 2);
6069    }
6070
6071    #[rstest]
6072    fn test_value_runs_bar_aggregator_large_trade_spans_bars(equity_aapl: Equity) {
6073        let instrument = InstrumentAny::Equity(equity_aapl);
6074        let bar_spec = BarSpecification::new(50, BarAggregation::ValueRuns, PriceType::Last);
6075        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6076        let handler = Arc::new(Mutex::new(Vec::new()));
6077        let handler_clone = Arc::clone(&handler);
6078
6079        let mut aggregator = ValueRunsBarAggregator::new(
6080            bar_type,
6081            instrument.price_precision(),
6082            instrument.size_precision(),
6083            move |bar: Bar| {
6084                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6085                handler_guard.push(bar);
6086            },
6087        );
6088
6089        let large_trade = TradeTick {
6090            price: Price::from("5.00"),
6091            size: Quantity::from(25),
6092            aggressor_side: AggressorSide::Buy,
6093            ..TradeTick::default()
6094        };
6095
6096        aggregator.handle_trade(large_trade);
6097
6098        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6099        assert_eq!(handler_guard.len(), 2);
6100    }
6101
6102    #[rstest]
6103    fn test_value_runs_bar_aggregator_keeps_leftover_volume_for_same_side_run(equity_aapl: Equity) {
6104        let instrument = InstrumentAny::Equity(equity_aapl);
6105        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
6106        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6107        let handler = Arc::new(Mutex::new(Vec::new()));
6108        let handler_clone = Arc::clone(&handler);
6109
6110        let mut aggregator = ValueRunsBarAggregator::new(
6111            bar_type,
6112            instrument.price_precision(),
6113            instrument.size_precision(),
6114            move |bar: Bar| {
6115                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6116                handler_guard.push(bar);
6117            },
6118        );
6119
6120        // First trade spans one bar (value 150 = step 100 + 50 leftover), the
6121        // leftover 5 units must survive as the start of a new same-side run.
6122        let first = TradeTick {
6123            price: Price::from("10.00"),
6124            size: Quantity::from(15),
6125            aggressor_side: AggressorSide::Sell,
6126            ts_event: UnixNanos::from(1_000),
6127            ts_init: UnixNanos::from(1_000),
6128            ..TradeTick::default()
6129        };
6130        aggregator.handle_trade(first);
6131
6132        // Second same-side trade completes the run (50 + 50 >= 100).
6133        let second = TradeTick {
6134            price: Price::from("10.00"),
6135            size: Quantity::from(5),
6136            aggressor_side: AggressorSide::Sell,
6137            ts_event: UnixNanos::from(2_000),
6138            ts_init: UnixNanos::from(2_000),
6139            ..TradeTick::default()
6140        };
6141        aggregator.handle_trade(second);
6142
6143        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6144        assert_eq!(handler_guard.len(), 2);
6145        assert_eq!(handler_guard[0].volume, Quantity::from(10));
6146        assert_eq!(handler_guard[1].volume, Quantity::from(10));
6147    }
6148
6149    #[rstest]
6150    fn test_value_bar_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
6151        let instrument = InstrumentAny::Equity(equity_aapl);
6152        let bar_spec = BarSpecification::new(100, BarAggregation::Value, PriceType::Last);
6153        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6154        let handler = Arc::new(Mutex::new(Vec::new()));
6155        let handler_clone = Arc::clone(&handler);
6156
6157        let mut aggregator = ValueBarAggregator::new(
6158            bar_type,
6159            instrument.price_precision(),
6160            instrument.size_precision(),
6161            move |bar: Bar| {
6162                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6163                handler_guard.push(bar);
6164            },
6165        );
6166
6167        // price=1000, size=3, value=3000, step=100 → size_chunk=0.1 rounds to 0 at precision 0
6168        aggregator.update(
6169            Price::from("1000.00"),
6170            Quantity::from(3),
6171            UnixNanos::default(),
6172        );
6173
6174        // 3 bars (one per min-size unit), not 30 zero-volume bars
6175        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6176        assert_eq!(handler_guard.len(), 3);
6177        for bar in handler_guard.iter() {
6178            assert_eq!(bar.volume, Quantity::from(1));
6179        }
6180    }
6181
6182    #[rstest]
6183    fn test_value_imbalance_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
6184        let instrument = InstrumentAny::Equity(equity_aapl);
6185        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6186        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6187        let handler = Arc::new(Mutex::new(Vec::new()));
6188        let handler_clone = Arc::clone(&handler);
6189
6190        let mut aggregator = ValueImbalanceBarAggregator::new(
6191            bar_type,
6192            instrument.price_precision(),
6193            instrument.size_precision(),
6194            move |bar: Bar| {
6195                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6196                handler_guard.push(bar);
6197            },
6198        );
6199
6200        let trade = TradeTick {
6201            price: Price::from("1000.00"),
6202            size: Quantity::from(3),
6203            aggressor_side: AggressorSide::Buy,
6204            instrument_id: instrument.id(),
6205            ..TradeTick::default()
6206        };
6207
6208        aggregator.handle_trade(trade);
6209
6210        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6211        assert_eq!(handler_guard.len(), 3);
6212        for bar in handler_guard.iter() {
6213            assert_eq!(bar.volume, Quantity::from(1));
6214        }
6215    }
6216
6217    #[rstest]
6218    fn test_value_imbalance_opposite_side_overshoot_emits_bar(equity_aapl: Equity) {
6219        let instrument = InstrumentAny::Equity(equity_aapl);
6220        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6221        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6222        let handler = Arc::new(Mutex::new(Vec::new()));
6223        let handler_clone = Arc::clone(&handler);
6224
6225        let mut aggregator = ValueImbalanceBarAggregator::new(
6226            bar_type,
6227            instrument.price_precision(),
6228            instrument.size_precision(),
6229            move |bar: Bar| {
6230                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6231                handler_guard.push(bar);
6232            },
6233        );
6234
6235        // Build seller imbalance of -50 (below step=100, no bar yet)
6236        let sell_tick = TradeTick {
6237            price: Price::from("10.00"),
6238            size: Quantity::from(5),
6239            aggressor_side: AggressorSide::Sell,
6240            instrument_id: instrument.id(),
6241            ..TradeTick::default()
6242        };
6243
6244        // Opposite-side buyer: flatten amount 50/1000=0.05 < min_size (1),
6245        // clamp overshoots imbalance from -50 to +950, crossing threshold
6246        let buy_tick = TradeTick {
6247            price: Price::from("1000.00"),
6248            size: Quantity::from(1),
6249            aggressor_side: AggressorSide::Buy,
6250            instrument_id: instrument.id(),
6251            ts_init: UnixNanos::from(1),
6252            ts_event: UnixNanos::from(1),
6253            ..TradeTick::default()
6254        };
6255
6256        aggregator.handle_trade(sell_tick);
6257        aggregator.handle_trade(buy_tick);
6258
6259        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6260        assert_eq!(handler_guard.len(), 1);
6261        assert_eq!(handler_guard[0].volume, Quantity::from(6));
6262    }
6263
6264    #[rstest]
6265    fn test_value_runs_high_price_low_step_no_zero_volume_bars(equity_aapl: Equity) {
6266        let instrument = InstrumentAny::Equity(equity_aapl);
6267        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
6268        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6269        let handler = Arc::new(Mutex::new(Vec::new()));
6270        let handler_clone = Arc::clone(&handler);
6271
6272        let mut aggregator = ValueRunsBarAggregator::new(
6273            bar_type,
6274            instrument.price_precision(),
6275            instrument.size_precision(),
6276            move |bar: Bar| {
6277                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6278                handler_guard.push(bar);
6279            },
6280        );
6281
6282        let trade = TradeTick {
6283            price: Price::from("1000.00"),
6284            size: Quantity::from(3),
6285            aggressor_side: AggressorSide::Buy,
6286            instrument_id: instrument.id(),
6287            ..TradeTick::default()
6288        };
6289
6290        aggregator.handle_trade(trade);
6291
6292        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6293        assert_eq!(handler_guard.len(), 3);
6294        for bar in handler_guard.iter() {
6295            assert_eq!(bar.volume, Quantity::from(1));
6296        }
6297    }
6298
6299    #[rstest]
6300    fn test_value_imbalance_bar_aggregator_exact_below_step_retains_pending() {
6301        // step=9_007_199_254; a single buy of 9007199253.999999999 @ price 1 has a notional
6302        // exactly one raw unit below the step. Exact Decimal arithmetic must NOT emit a bar; the
6303        // prior f64 path rounded the size up to 9007199254.0 and emitted early.
6304        let instrument_id = InstrumentId::from("AAPL.XNAS");
6305        let bar_spec = BarSpecification::new(
6306            9_007_199_254,
6307            BarAggregation::ValueImbalance,
6308            PriceType::Last,
6309        );
6310        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6311        let handler = Arc::new(Mutex::new(Vec::new()));
6312        let handler_clone = Arc::clone(&handler);
6313
6314        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 0, 9, move |bar: Bar| {
6315            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6316        });
6317
6318        let below_step = TradeTick {
6319            instrument_id,
6320            price: Price::from("1"),
6321            size: Quantity::from("9007199253.999999999"),
6322            aggressor_side: AggressorSide::Buy,
6323            ..TradeTick::default()
6324        };
6325        aggregator.handle_trade(below_step);
6326
6327        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
6328        assert_eq!(
6329            aggregator.core.builder.volume,
6330            Quantity::from("9007199253.999999999"),
6331        );
6332
6333        // One additional raw unit lifts the notional to exactly the step, emitting one bar whose
6334        // volume is the exact total raw input.
6335        let one_raw_unit = TradeTick {
6336            instrument_id,
6337            price: Price::from("1"),
6338            size: Quantity::from("0.000000001"),
6339            aggressor_side: AggressorSide::Buy,
6340            ts_event: UnixNanos::from(1),
6341            ts_init: UnixNanos::from(1),
6342            ..TradeTick::default()
6343        };
6344        aggregator.handle_trade(one_raw_unit);
6345
6346        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6347        assert_eq!(handler_guard.len(), 1);
6348        assert_eq!(
6349            handler_guard[0].volume,
6350            Quantity::from("9007199254.000000000")
6351        );
6352        assert_eq!(aggregator.core.builder.volume, Quantity::zero(9));
6353    }
6354
6355    #[rstest]
6356    fn test_value_imbalance_bar_aggregator_conserves_volume_across_split_bars() {
6357        // step=4, price=1: a same-side buy of 10.000000003 splits into two full bars of value 4
6358        // and leaves a fractional 2.000000003 pending. Emitted plus pending volume must equal the
6359        // exact input across the several split bars.
6360        let instrument_id = InstrumentId::from("AAPL.XNAS");
6361        let bar_spec = BarSpecification::new(4, BarAggregation::ValueImbalance, PriceType::Last);
6362        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6363        let handler = Arc::new(Mutex::new(Vec::new()));
6364        let handler_clone = Arc::clone(&handler);
6365
6366        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 0, 9, move |bar: Bar| {
6367            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6368        });
6369
6370        let input = Quantity::from("10.000000003");
6371        let trade = TradeTick {
6372            instrument_id,
6373            price: Price::from("1"),
6374            size: input,
6375            aggressor_side: AggressorSide::Buy,
6376            ..TradeTick::default()
6377        };
6378        aggregator.handle_trade(trade);
6379
6380        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6381        assert_eq!(handler_guard.len(), 2);
6382        for bar in handler_guard.iter() {
6383            assert_eq!(bar.volume, Quantity::from("4.000000000"));
6384        }
6385        assert_eq!(
6386            aggregator.core.builder.volume,
6387            Quantity::from("2.000000003"),
6388        );
6389        let emitted_plus_pending = handler_guard
6390            .iter()
6391            .map(|bar| bar.volume.as_decimal())
6392            .sum::<Decimal>()
6393            + aggregator.core.builder.volume.as_decimal();
6394        assert_eq!(emitted_plus_pending, input.as_decimal());
6395    }
6396
6397    #[rstest]
6398    fn test_value_runs_bar_aggregator_exact_below_step_retains_pending() {
6399        // step=9_007_199_254; a single buy of 9007199253.999999999 @ price 1 sits one raw unit
6400        // below the step. Exact Decimal arithmetic must NOT emit a bar; the prior f64 path rounded
6401        // the size up and emitted early.
6402        let instrument_id = InstrumentId::from("AAPL.XNAS");
6403        let bar_spec =
6404            BarSpecification::new(9_007_199_254, BarAggregation::ValueRuns, PriceType::Last);
6405        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6406        let handler = Arc::new(Mutex::new(Vec::new()));
6407        let handler_clone = Arc::clone(&handler);
6408
6409        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 0, 9, move |bar: Bar| {
6410            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6411        });
6412
6413        let below_step = TradeTick {
6414            instrument_id,
6415            price: Price::from("1"),
6416            size: Quantity::from("9007199253.999999999"),
6417            aggressor_side: AggressorSide::Buy,
6418            ..TradeTick::default()
6419        };
6420        aggregator.handle_trade(below_step);
6421
6422        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
6423        assert_eq!(
6424            aggregator.core.builder.volume,
6425            Quantity::from("9007199253.999999999"),
6426        );
6427
6428        // One additional same-side raw unit completes the run at exactly the step, emitting one bar
6429        // whose volume is the exact total raw input.
6430        let one_raw_unit = TradeTick {
6431            instrument_id,
6432            price: Price::from("1"),
6433            size: Quantity::from("0.000000001"),
6434            aggressor_side: AggressorSide::Buy,
6435            ts_event: UnixNanos::from(1),
6436            ts_init: UnixNanos::from(1),
6437            ..TradeTick::default()
6438        };
6439        aggregator.handle_trade(one_raw_unit);
6440
6441        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6442        assert_eq!(handler_guard.len(), 1);
6443        assert_eq!(
6444            handler_guard[0].volume,
6445            Quantity::from("9007199254.000000000")
6446        );
6447        assert_eq!(aggregator.core.builder.volume, Quantity::zero(9));
6448    }
6449
6450    #[rstest]
6451    fn test_value_runs_bar_aggregator_conserves_volume_across_split_bars() {
6452        // step=4, price=1: a same-side buy of 10.000000003 splits into two full bars of value 4 and
6453        // keeps a fractional 2.000000003 as the leftover of the same-side run. Emitted plus pending
6454        // volume must equal the exact input across the several split bars.
6455        let instrument_id = InstrumentId::from("AAPL.XNAS");
6456        let bar_spec = BarSpecification::new(4, BarAggregation::ValueRuns, PriceType::Last);
6457        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6458        let handler = Arc::new(Mutex::new(Vec::new()));
6459        let handler_clone = Arc::clone(&handler);
6460
6461        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 0, 9, move |bar: Bar| {
6462            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6463        });
6464
6465        let input = Quantity::from("10.000000003");
6466        let trade = TradeTick {
6467            instrument_id,
6468            price: Price::from("1"),
6469            size: input,
6470            aggressor_side: AggressorSide::Buy,
6471            ..TradeTick::default()
6472        };
6473        aggregator.handle_trade(trade);
6474
6475        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6476        assert_eq!(handler_guard.len(), 2);
6477        for bar in handler_guard.iter() {
6478            assert_eq!(bar.volume, Quantity::from("4.000000000"));
6479        }
6480        assert_eq!(
6481            aggregator.core.builder.volume,
6482            Quantity::from("2.000000003"),
6483        );
6484        let emitted_plus_pending = handler_guard
6485            .iter()
6486            .map(|bar| bar.volume.as_decimal())
6487            .sum::<Decimal>()
6488            + aggregator.core.builder.volume.as_decimal();
6489        assert_eq!(emitted_plus_pending, input.as_decimal());
6490    }
6491
6492    #[rstest]
6493    fn test_value_imbalance_bar_aggregator_no_aggressor_and_zero_price_fall_back_to_plain_volume() {
6494        // NoAggressor and zero-price trades carry no usable side signal, so they bypass imbalance
6495        // splitting and accumulate as plain builder volume without emitting a bar.
6496        let instrument_id = InstrumentId::from("AAPL.XNAS");
6497        let bar_spec = BarSpecification::new(100, BarAggregation::ValueImbalance, PriceType::Last);
6498        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6499        let handler = Arc::new(Mutex::new(Vec::new()));
6500        let handler_clone = Arc::clone(&handler);
6501
6502        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 2, 0, move |bar: Bar| {
6503            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6504        });
6505
6506        let no_aggressor = TradeTick {
6507            instrument_id,
6508            price: Price::from("10.00"),
6509            size: Quantity::from(3),
6510            aggressor_side: AggressorSide::NoAggressor,
6511            ..TradeTick::default()
6512        };
6513        let zero_price = TradeTick {
6514            instrument_id,
6515            price: Price::from("0.00"),
6516            size: Quantity::from(4),
6517            aggressor_side: AggressorSide::Buy,
6518            ts_event: UnixNanos::from(1),
6519            ts_init: UnixNanos::from(1),
6520            ..TradeTick::default()
6521        };
6522        aggregator.handle_trade(no_aggressor);
6523        aggregator.handle_trade(zero_price);
6524
6525        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
6526        assert_eq!(aggregator.core.builder.volume, Quantity::from(7));
6527    }
6528
6529    #[rstest]
6530    fn test_value_runs_bar_aggregator_no_aggressor_and_zero_price_fall_back_to_plain_volume() {
6531        // NoAggressor and zero-price trades carry no usable side signal, so they bypass the run
6532        // splitting and accumulate as plain builder volume without emitting a bar or resetting.
6533        let instrument_id = InstrumentId::from("AAPL.XNAS");
6534        let bar_spec = BarSpecification::new(100, BarAggregation::ValueRuns, PriceType::Last);
6535        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6536        let handler = Arc::new(Mutex::new(Vec::new()));
6537        let handler_clone = Arc::clone(&handler);
6538
6539        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 2, 0, move |bar: Bar| {
6540            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6541        });
6542
6543        let no_aggressor = TradeTick {
6544            instrument_id,
6545            price: Price::from("10.00"),
6546            size: Quantity::from(3),
6547            aggressor_side: AggressorSide::NoAggressor,
6548            ..TradeTick::default()
6549        };
6550        let zero_price = TradeTick {
6551            instrument_id,
6552            price: Price::from("0.00"),
6553            size: Quantity::from(4),
6554            aggressor_side: AggressorSide::Buy,
6555            ts_event: UnixNanos::from(1),
6556            ts_init: UnixNanos::from(1),
6557            ..TradeTick::default()
6558        };
6559        aggregator.handle_trade(no_aggressor);
6560        aggregator.handle_trade(zero_price);
6561
6562        assert!(handler.lock().expect(MUTEX_POISONED).is_empty());
6563        assert_eq!(aggregator.core.builder.volume, Quantity::from(7));
6564    }
6565
6566    #[rstest]
6567    fn test_value_imbalance_bar_aggregator_conserves_volume_with_indivisible_price() {
6568        // step=1, price=3, size precision 1: the ideal split 1/3 rounds to 0.3, so each emitted bar
6569        // carries a notional of 0.9 (below the step) exactly as the reference ValueBarAggregator
6570        // does with a non-dividing price. Per-bar notional is approximate by design, but total
6571        // volume (emitted plus pending) must still equal the exact input.
6572        let instrument_id = InstrumentId::from("AAPL.XNAS");
6573        let bar_spec = BarSpecification::new(1, BarAggregation::ValueImbalance, PriceType::Last);
6574        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6575        let handler = Arc::new(Mutex::new(Vec::new()));
6576        let handler_clone = Arc::clone(&handler);
6577
6578        let mut aggregator = ValueImbalanceBarAggregator::new(bar_type, 2, 1, move |bar: Bar| {
6579            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6580        });
6581
6582        let input = Quantity::from("1.0");
6583        let trade = TradeTick {
6584            instrument_id,
6585            price: Price::from("3.00"),
6586            size: input,
6587            aggressor_side: AggressorSide::Buy,
6588            ..TradeTick::default()
6589        };
6590        aggregator.handle_trade(trade);
6591
6592        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6593        assert_eq!(handler_guard.len(), 3);
6594        for bar in handler_guard.iter() {
6595            assert_eq!(bar.volume, Quantity::from("0.3"));
6596        }
6597        assert_eq!(aggregator.core.builder.volume, Quantity::from("0.1"));
6598        let emitted_plus_pending = handler_guard
6599            .iter()
6600            .map(|bar| bar.volume.as_decimal())
6601            .sum::<Decimal>()
6602            + aggregator.core.builder.volume.as_decimal();
6603        assert_eq!(emitted_plus_pending, input.as_decimal());
6604    }
6605
6606    #[rstest]
6607    fn test_value_runs_bar_aggregator_conserves_volume_with_indivisible_price() {
6608        // step=1, price=3, size precision 1: the ideal split 1/3 rounds to 0.3, so each emitted bar
6609        // carries a notional of 0.9 (below the step) exactly as the reference ValueBarAggregator
6610        // does with a non-dividing price. Per-bar notional is approximate by design, but total
6611        // volume (emitted plus pending) must still equal the exact input.
6612        let instrument_id = InstrumentId::from("AAPL.XNAS");
6613        let bar_spec = BarSpecification::new(1, BarAggregation::ValueRuns, PriceType::Last);
6614        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::Internal);
6615        let handler = Arc::new(Mutex::new(Vec::new()));
6616        let handler_clone = Arc::clone(&handler);
6617
6618        let mut aggregator = ValueRunsBarAggregator::new(bar_type, 2, 1, move |bar: Bar| {
6619            handler_clone.lock().expect(MUTEX_POISONED).push(bar);
6620        });
6621
6622        let input = Quantity::from("1.0");
6623        let trade = TradeTick {
6624            instrument_id,
6625            price: Price::from("3.00"),
6626            size: input,
6627            aggressor_side: AggressorSide::Buy,
6628            ..TradeTick::default()
6629        };
6630        aggregator.handle_trade(trade);
6631
6632        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6633        assert_eq!(handler_guard.len(), 3);
6634        for bar in handler_guard.iter() {
6635            assert_eq!(bar.volume, Quantity::from("0.3"));
6636        }
6637        assert_eq!(aggregator.core.builder.volume, Quantity::from("0.1"));
6638        let emitted_plus_pending = handler_guard
6639            .iter()
6640            .map(|bar| bar.volume.as_decimal())
6641            .sum::<Decimal>()
6642            + aggregator.core.builder.volume.as_decimal();
6643        assert_eq!(emitted_plus_pending, input.as_decimal());
6644    }
6645
6646    #[rstest]
6647    #[case(1000_u64)]
6648    #[case(1500_u64)]
6649    fn test_volume_imbalance_bar_aggregator_large_step_no_overflow(
6650        equity_aapl: Equity,
6651        #[case] step: u64,
6652    ) {
6653        let instrument = InstrumentAny::Equity(equity_aapl);
6654        let bar_spec = BarSpecification::new(
6655            step as usize,
6656            BarAggregation::VolumeImbalance,
6657            PriceType::Last,
6658        );
6659        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6660        let handler = Arc::new(Mutex::new(Vec::new()));
6661        let handler_clone = Arc::clone(&handler);
6662
6663        let mut aggregator = VolumeImbalanceBarAggregator::new(
6664            bar_type,
6665            instrument.price_precision(),
6666            instrument.size_precision(),
6667            move |bar: Bar| {
6668                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6669                handler_guard.push(bar);
6670            },
6671        );
6672
6673        let trade = TradeTick {
6674            size: Quantity::from(step * 2),
6675            aggressor_side: AggressorSide::Buy,
6676            ..TradeTick::default()
6677        };
6678
6679        aggregator.handle_trade(trade);
6680
6681        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6682        assert_eq!(handler_guard.len(), 2);
6683        for bar in handler_guard.iter() {
6684            assert_eq!(bar.volume.as_f64(), step as f64);
6685        }
6686    }
6687
6688    #[rstest]
6689    fn test_volume_imbalance_bar_aggregator_different_large_steps_produce_different_bar_counts(
6690        equity_aapl: Equity,
6691    ) {
6692        let instrument = InstrumentAny::Equity(equity_aapl);
6693        let total_volume = 3000_u64;
6694        let mut results = Vec::new();
6695
6696        for step in [1000_usize, 1500] {
6697            let bar_spec =
6698                BarSpecification::new(step, BarAggregation::VolumeImbalance, PriceType::Last);
6699            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6700            let handler = Arc::new(Mutex::new(Vec::new()));
6701            let handler_clone = Arc::clone(&handler);
6702
6703            let mut aggregator = VolumeImbalanceBarAggregator::new(
6704                bar_type,
6705                instrument.price_precision(),
6706                instrument.size_precision(),
6707                move |bar: Bar| {
6708                    let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6709                    handler_guard.push(bar);
6710                },
6711            );
6712
6713            let trade = TradeTick {
6714                size: Quantity::from(total_volume),
6715                aggressor_side: AggressorSide::Buy,
6716                ..TradeTick::default()
6717            };
6718
6719            aggregator.handle_trade(trade);
6720
6721            let handler_guard = handler.lock().expect(MUTEX_POISONED);
6722            results.push(handler_guard.len());
6723        }
6724
6725        assert_eq!(results[0], 3); // 3000 / 1000
6726        assert_eq!(results[1], 2); // 3000 / 1500
6727        assert_ne!(results[0], results[1]);
6728    }
6729
6730    #[rstest]
6731    #[case(1000_u64)]
6732    #[case(1500_u64)]
6733    fn test_volume_runs_bar_aggregator_large_step_no_overflow(
6734        equity_aapl: Equity,
6735        #[case] step: u64,
6736    ) {
6737        let instrument = InstrumentAny::Equity(equity_aapl);
6738        let bar_spec =
6739            BarSpecification::new(step as usize, BarAggregation::VolumeRuns, PriceType::Last);
6740        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6741        let handler = Arc::new(Mutex::new(Vec::new()));
6742        let handler_clone = Arc::clone(&handler);
6743
6744        let mut aggregator = VolumeRunsBarAggregator::new(
6745            bar_type,
6746            instrument.price_precision(),
6747            instrument.size_precision(),
6748            move |bar: Bar| {
6749                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6750                handler_guard.push(bar);
6751            },
6752        );
6753
6754        let trade = TradeTick {
6755            size: Quantity::from(step * 2),
6756            aggressor_side: AggressorSide::Buy,
6757            ..TradeTick::default()
6758        };
6759
6760        aggregator.handle_trade(trade);
6761
6762        let handler_guard = handler.lock().expect(MUTEX_POISONED);
6763        assert_eq!(handler_guard.len(), 2);
6764        for bar in handler_guard.iter() {
6765            assert_eq!(bar.volume.as_f64(), step as f64);
6766        }
6767    }
6768
6769    #[rstest]
6770    fn test_volume_runs_bar_aggregator_different_large_steps_produce_different_bar_counts(
6771        equity_aapl: Equity,
6772    ) {
6773        let instrument = InstrumentAny::Equity(equity_aapl);
6774        let total_volume = 3000_u64;
6775        let mut results = Vec::new();
6776
6777        for step in [1000_usize, 1500] {
6778            let bar_spec = BarSpecification::new(step, BarAggregation::VolumeRuns, PriceType::Last);
6779            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6780            let handler = Arc::new(Mutex::new(Vec::new()));
6781            let handler_clone = Arc::clone(&handler);
6782
6783            let mut aggregator = VolumeRunsBarAggregator::new(
6784                bar_type,
6785                instrument.price_precision(),
6786                instrument.size_precision(),
6787                move |bar: Bar| {
6788                    let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
6789                    handler_guard.push(bar);
6790                },
6791            );
6792
6793            let trade = TradeTick {
6794                size: Quantity::from(total_volume),
6795                aggressor_side: AggressorSide::Buy,
6796                ..TradeTick::default()
6797            };
6798
6799            aggregator.handle_trade(trade);
6800
6801            let handler_guard = handler.lock().expect(MUTEX_POISONED);
6802            results.push(handler_guard.len());
6803        }
6804
6805        assert_eq!(results[0], 3); // 3000 / 1000
6806        assert_eq!(results[1], 2); // 3000 / 1500
6807        assert_ne!(results[0], results[1]);
6808    }
6809
6810    /// Historical time-bar: event at `ts_init` is deferred until after the update.
6811    #[rstest]
6812    fn test_time_bar_historical_defers_event_at_ts_init_until_after_update(equity_aapl: Equity) {
6813        let instrument = InstrumentAny::Equity(equity_aapl);
6814        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
6815        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
6816        let handler = Arc::new(Mutex::new(Vec::new()));
6817        let handler_clone = Arc::clone(&handler);
6818        let clock = Rc::new(RefCell::new(TestClock::new()));
6819
6820        let mut agg = TimeBarAggregator::new(
6821            bar_type,
6822            instrument.price_precision(),
6823            instrument.size_precision(),
6824            clock.clone(),
6825            move |bar: Bar| {
6826                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
6827                h.push(bar);
6828            },
6829            true,
6830            true,
6831            BarIntervalType::LeftOpen,
6832            None,
6833            0,
6834            false,
6835        );
6836        agg.historical_mode = true;
6837        agg.set_clock_internal(clock);
6838        let boxed: Box<dyn BarAggregator> = Box::new(agg);
6839        let rc = Rc::new(RefCell::new(boxed));
6840        rc.borrow_mut().set_aggregator_weak(Rc::downgrade(&rc));
6841
6842        rc.borrow_mut().update(
6843            Price::from("100.00"),
6844            Quantity::from(1),
6845            UnixNanos::default(),
6846        );
6847        rc.borrow_mut().update(
6848            Price::from("100.00"),
6849            Quantity::from(1),
6850            UnixNanos::from(1_000_000_000),
6851        );
6852
6853        let bars = handler.lock().expect(MUTEX_POISONED);
6854        assert!(
6855            !bars.is_empty(),
6856            "deferred event at ts_init should produce a bar that includes the update"
6857        );
6858        let last_bar = bars.last().unwrap();
6859        assert_eq!(last_bar.close, Price::from("100.00"));
6860        assert!(
6861            last_bar.volume.as_f64() >= 1.0,
6862            "bar built after deferred event should include the update at ts_init"
6863        );
6864    }
6865
6866    #[rstest]
6867    fn test_spread_quote_quote_driven_emits_when_all_legs_received(equity_aapl: Equity) {
6868        let instrument = InstrumentAny::Equity(equity_aapl);
6869        let leg1 = instrument.id();
6870        let leg2 = InstrumentId::from("MSFT.XNAS");
6871        let spread_id = InstrumentId::from("SPREAD.XNAS");
6872        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6873        let handler = Arc::new(Mutex::new(Vec::new()));
6874        let handler_clone = Arc::clone(&handler);
6875        let clock = Rc::new(RefCell::new(TestClock::new()));
6876
6877        let mut agg = SpreadQuoteAggregator::new(
6878            spread_id,
6879            &legs,
6880            true,
6881            instrument.price_precision(),
6882            0,
6883            Box::new(move |q: QuoteTick| {
6884                handler_clone.lock().expect(MUTEX_POISONED).push(q);
6885            }),
6886            clock,
6887            false,
6888            None,
6889            0,
6890            false,
6891            60,
6892            None,
6893            None,
6894        );
6895
6896        let ts = UnixNanos::from(1_000_000_000);
6897        agg.handle_quote_tick(QuoteTick::new(
6898            leg1,
6899            Price::from("100.00"),
6900            Price::from("100.10"),
6901            Quantity::from(10),
6902            Quantity::from(10),
6903            ts,
6904            ts,
6905        ));
6906        assert_eq!(handler.lock().expect(MUTEX_POISONED).len(), 0);
6907
6908        agg.handle_quote_tick(QuoteTick::new(
6909            leg2,
6910            Price::from("99.00"),
6911            Price::from("99.10"),
6912            Quantity::from(10),
6913            Quantity::from(10),
6914            ts,
6915            ts,
6916        ));
6917        let quotes = handler.lock().expect(MUTEX_POISONED);
6918        assert_eq!(quotes.len(), 1);
6919        assert_eq!(quotes[0].instrument_id, spread_id);
6920        assert!(quotes[0].bid_price < quotes[0].ask_price);
6921    }
6922
6923    #[rstest]
6924    fn test_spread_quote_futures_pricing_signed_ratios(equity_aapl: Equity) {
6925        let instrument = InstrumentAny::Equity(equity_aapl);
6926        let leg1 = instrument.id();
6927        let leg2 = InstrumentId::from("MSFT.XNAS");
6928        let spread_id = InstrumentId::from("SPREAD.XNAS");
6929        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
6930        let handler = Arc::new(Mutex::new(Vec::new()));
6931        let handler_clone = Arc::clone(&handler);
6932        let clock = Rc::new(RefCell::new(TestClock::new()));
6933
6934        let mut agg = SpreadQuoteAggregator::new(
6935            spread_id,
6936            &legs,
6937            true,
6938            instrument.price_precision(),
6939            0,
6940            Box::new(move |q: QuoteTick| {
6941                handler_clone.lock().expect(MUTEX_POISONED).push(q);
6942            }),
6943            clock,
6944            false,
6945            None,
6946            0,
6947            false,
6948            60,
6949            None,
6950            None,
6951        );
6952
6953        let ts = UnixNanos::from(1_000_000_000);
6954        agg.handle_quote_tick(QuoteTick::new(
6955            leg1,
6956            Price::from("10.00"),
6957            Price::from("10.10"),
6958            Quantity::from(100),
6959            Quantity::from(100),
6960            ts,
6961            ts,
6962        ));
6963        agg.handle_quote_tick(QuoteTick::new(
6964            leg2,
6965            Price::from("20.00"),
6966            Price::from("20.10"),
6967            Quantity::from(100),
6968            Quantity::from(100),
6969            ts,
6970            ts,
6971        ));
6972        let quotes = handler.lock().expect(MUTEX_POISONED);
6973        assert_eq!(quotes.len(), 1);
6974        let q = &quotes[0];
6975        assert_eq!(q.instrument_id, spread_id);
6976        assert_eq!(q.bid_price, Price::from("-10.10"));
6977        assert_eq!(q.ask_price, Price::from("-9.90"));
6978    }
6979
6980    #[rstest]
6981    fn test_spread_quote_size_calculation_non_unit_ratios(equity_aapl: Equity) {
6982        let instrument = InstrumentAny::Equity(equity_aapl);
6983        let leg1 = instrument.id();
6984        let leg2 = InstrumentId::from("MSFT.XNAS");
6985        let spread_id = InstrumentId::from("SPREAD.XNAS");
6986        let legs = vec![(leg1, 2_i64), (leg2, -1_i64)];
6987        let handler = Arc::new(Mutex::new(Vec::new()));
6988        let handler_clone = Arc::clone(&handler);
6989        let clock = Rc::new(RefCell::new(TestClock::new()));
6990
6991        let mut agg = SpreadQuoteAggregator::new(
6992            spread_id,
6993            &legs,
6994            true,
6995            instrument.price_precision(),
6996            0,
6997            Box::new(move |q: QuoteTick| {
6998                handler_clone.lock().expect(MUTEX_POISONED).push(q);
6999            }),
7000            clock,
7001            false,
7002            None,
7003            0,
7004            false,
7005            60,
7006            None,
7007            None,
7008        );
7009
7010        let ts = UnixNanos::from(1_000_000_000);
7011        agg.handle_quote_tick(QuoteTick::new(
7012            leg1,
7013            Price::from("10.00"),
7014            Price::from("10.10"),
7015            Quantity::from(100),
7016            Quantity::from(40),
7017            ts,
7018            ts,
7019        ));
7020        agg.handle_quote_tick(QuoteTick::new(
7021            leg2,
7022            Price::from("10.00"),
7023            Price::from("10.10"),
7024            Quantity::from(50),
7025            Quantity::from(30),
7026            ts,
7027            ts,
7028        ));
7029        let quotes = handler.lock().expect(MUTEX_POISONED);
7030        assert_eq!(quotes.len(), 1);
7031        let q = &quotes[0];
7032        assert_eq!(q.bid_size.as_f64(), 30.0);
7033        assert_eq!(q.ask_size.as_f64(), 20.0);
7034    }
7035
7036    #[rstest]
7037    fn test_spread_quote_timer_driven_emission_cadence(equity_aapl: Equity) {
7038        let instrument = InstrumentAny::Equity(equity_aapl);
7039        let leg1 = instrument.id();
7040        let leg2 = InstrumentId::from("MSFT.XNAS");
7041        let spread_id = InstrumentId::from("SPREAD.XNAS");
7042        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7043        let handler = Arc::new(Mutex::new(Vec::new()));
7044        let handler_clone = Arc::clone(&handler);
7045        let clock = Rc::new(RefCell::new(TestClock::new()));
7046        clock.borrow_mut().set_time(UnixNanos::from(0));
7047
7048        let agg = SpreadQuoteAggregator::new(
7049            spread_id,
7050            &legs,
7051            true,
7052            instrument.price_precision(),
7053            0,
7054            Box::new(move |q: QuoteTick| {
7055                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7056            }),
7057            clock.clone(),
7058            false,
7059            Some(1),
7060            0,
7061            false,
7062            60,
7063            None,
7064            None,
7065        );
7066        let rc = Rc::new(RefCell::new(agg));
7067        rc.borrow_mut().prepare_for_timer_mode(&rc);
7068        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7069
7070        for event in clock.borrow_mut().advance_time(UnixNanos::from(0), true) {
7071            rc.borrow_mut().on_timer_fire(event.ts_event);
7072        }
7073        assert_eq!(handler.lock().expect(MUTEX_POISONED).len(), 0);
7074
7075        let ts1 = UnixNanos::from(1_000_000_000);
7076        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7077            leg1,
7078            Price::from("100.00"),
7079            Price::from("100.10"),
7080            Quantity::from(10),
7081            Quantity::from(10),
7082            ts1,
7083            ts1,
7084        ));
7085        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7086            leg2,
7087            Price::from("99.00"),
7088            Price::from("99.10"),
7089            Quantity::from(10),
7090            Quantity::from(10),
7091            ts1,
7092            ts1,
7093        ));
7094
7095        for event in clock.borrow_mut().advance_time(ts1, true) {
7096            rc.borrow_mut().on_timer_fire(event.ts_event);
7097        }
7098
7099        {
7100            let quotes = handler.lock().expect(MUTEX_POISONED);
7101            assert_eq!(quotes.len(), 1);
7102            assert_eq!(quotes[0].ts_event, ts1);
7103            assert_eq!(quotes[0].ts_init, ts1);
7104        }
7105
7106        let ts2 = UnixNanos::from(2_000_000_000);
7107        for event in clock.borrow_mut().advance_time(ts2, true) {
7108            rc.borrow_mut().on_timer_fire(event.ts_event);
7109        }
7110
7111        let quotes = handler.lock().expect(MUTEX_POISONED);
7112        assert_eq!(quotes.len(), 1);
7113    }
7114
7115    #[rstest]
7116    fn test_spread_quote_historical_timer_waits_for_all_legs(equity_aapl: Equity) {
7117        let instrument = InstrumentAny::Equity(equity_aapl);
7118        let leg1 = instrument.id();
7119        let leg2 = InstrumentId::from("MSFT.XNAS");
7120        let spread_id = InstrumentId::from("SPREAD.XNAS");
7121        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7122        let handler = Arc::new(Mutex::new(Vec::new()));
7123        let handler_clone = Arc::clone(&handler);
7124        let clock = Rc::new(RefCell::new(TestClock::new()));
7125
7126        let agg = SpreadQuoteAggregator::new(
7127            spread_id,
7128            &legs,
7129            true,
7130            instrument.price_precision(),
7131            0,
7132            Box::new(move |q: QuoteTick| {
7133                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7134            }),
7135            // need clock for set_clock after
7136            clock.clone(),
7137            true,
7138            Some(1),
7139            0,
7140            false,
7141            60,
7142            None,
7143            None,
7144        );
7145        let rc = Rc::new(RefCell::new(agg));
7146        rc.borrow_mut().prepare_for_timer_mode(&rc);
7147        rc.borrow_mut().set_clock(clock);
7148
7149        let ts1 = UnixNanos::from(1_000_000_000);
7150        let ts2 = UnixNanos::from(2_000_000_000);
7151        let ts3 = UnixNanos::from(3_000_000_000);
7152        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7153            leg1,
7154            Price::from("100.00"),
7155            Price::from("100.10"),
7156            Quantity::from(10),
7157            Quantity::from(10),
7158            ts1,
7159            ts1,
7160        ));
7161        assert_eq!(handler.lock().expect(MUTEX_POISONED).len(), 0);
7162
7163        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7164            leg2,
7165            Price::from("99.00"),
7166            Price::from("99.10"),
7167            Quantity::from(10),
7168            Quantity::from(10),
7169            ts2,
7170            ts2,
7171        ));
7172        assert_eq!(handler.lock().expect(MUTEX_POISONED).len(), 0);
7173
7174        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7175            leg1,
7176            Price::from("100.00"),
7177            Price::from("100.10"),
7178            Quantity::from(10),
7179            Quantity::from(10),
7180            ts3,
7181            ts3,
7182        ));
7183        let quotes = handler.lock().expect(MUTEX_POISONED);
7184        assert_eq!(
7185            quotes.len(),
7186            1,
7187            "deferred event at ts2 is processed when we have all legs and advance to ts3"
7188        );
7189    }
7190
7191    #[rstest]
7192    fn test_spread_quote_historical_flush_emits_pending_final_quote(equity_aapl: Equity) {
7193        let instrument = InstrumentAny::Equity(equity_aapl);
7194        let leg1 = instrument.id();
7195        let leg2 = InstrumentId::from("MSFT.XNAS");
7196        let spread_id = InstrumentId::from("SPREAD.XNAS");
7197        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7198        let handler = Arc::new(Mutex::new(Vec::new()));
7199        let handler_clone = Arc::clone(&handler);
7200        let clock = Rc::new(RefCell::new(TestClock::new()));
7201
7202        let agg = SpreadQuoteAggregator::new(
7203            spread_id,
7204            &legs,
7205            true,
7206            instrument.price_precision(),
7207            0,
7208            Box::new(move |q: QuoteTick| {
7209                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7210            }),
7211            // need clock for set_clock after
7212            clock.clone(),
7213            true,
7214            Some(1),
7215            0,
7216            false,
7217            60,
7218            None,
7219            None,
7220        );
7221        let rc = Rc::new(RefCell::new(agg));
7222        rc.borrow_mut().prepare_for_timer_mode(&rc);
7223        rc.borrow_mut().set_clock(clock);
7224
7225        let ts1 = UnixNanos::from(1_000_000_000);
7226        let ts2 = UnixNanos::from(2_000_000_000);
7227        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7228            leg1,
7229            Price::from("100.00"),
7230            Price::from("100.10"),
7231            Quantity::from(10),
7232            Quantity::from(10),
7233            ts1,
7234            ts1,
7235        ));
7236        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7237            leg2,
7238            Price::from("99.00"),
7239            Price::from("99.10"),
7240            Quantity::from(10),
7241            Quantity::from(10),
7242            ts2,
7243            ts2,
7244        ));
7245
7246        assert_eq!(handler.lock().expect(MUTEX_POISONED).len(), 0);
7247
7248        rc.borrow_mut().flush_pending_historical_quote();
7249
7250        let quotes = handler.lock().expect(MUTEX_POISONED);
7251        assert_eq!(
7252            quotes.len(),
7253            1,
7254            "final historical quote should be emitted when the deferred event is flushed",
7255        );
7256        assert_eq!(quotes[0].ts_event, ts2);
7257    }
7258
7259    #[rstest]
7260    fn test_spread_quote_option_vega_weighting(equity_aapl: Equity) {
7261        let instrument = InstrumentAny::Equity(equity_aapl);
7262        let leg1 = instrument.id();
7263        let leg2 = InstrumentId::from("MSFT.XNAS");
7264        let spread_id = InstrumentId::from("SPREAD.XNAS");
7265        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7266        let handler = Arc::new(Mutex::new(Vec::new()));
7267        let handler_clone = Arc::clone(&handler);
7268        let clock = Rc::new(RefCell::new(TestClock::new()));
7269
7270        let mut vega_provider = MapVegaProvider::new();
7271        vega_provider.insert(leg1, 0.15);
7272        vega_provider.insert(leg2, 0.12);
7273
7274        let mut agg = SpreadQuoteAggregator::new(
7275            spread_id,
7276            &legs,
7277            false,
7278            instrument.price_precision(),
7279            0,
7280            Box::new(move |q: QuoteTick| {
7281                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7282            }),
7283            clock,
7284            false,
7285            None,
7286            0,
7287            false,
7288            60,
7289            Some(Box::new(vega_provider)),
7290            None,
7291        );
7292
7293        let ts = UnixNanos::from(1_000_000_000);
7294        agg.handle_quote_tick(QuoteTick::new(
7295            leg1,
7296            Price::from("10.00"),
7297            Price::from("10.20"),
7298            Quantity::from(100),
7299            Quantity::from(100),
7300            ts,
7301            ts,
7302        ));
7303        agg.handle_quote_tick(QuoteTick::new(
7304            leg2,
7305            Price::from("11.00"),
7306            Price::from("11.20"),
7307            Quantity::from(100),
7308            Quantity::from(100),
7309            ts,
7310            ts,
7311        ));
7312        let quotes = handler.lock().expect(MUTEX_POISONED);
7313        assert_eq!(quotes.len(), 1);
7314        let q = &quotes[0];
7315        assert!(q.bid_price < q.ask_price);
7316        assert!(q.ask_price.as_f64() - q.bid_price.as_f64() > 0.0);
7317    }
7318
7319    #[rstest]
7320    fn test_spread_quote_all_zero_vega_fallback(equity_aapl: Equity) {
7321        let instrument = InstrumentAny::Equity(equity_aapl);
7322        let leg1 = instrument.id();
7323        let leg2 = InstrumentId::from("MSFT.XNAS");
7324        let spread_id = InstrumentId::from("SPREAD.XNAS");
7325        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7326        let handler = Arc::new(Mutex::new(Vec::new()));
7327        let handler_clone = Arc::clone(&handler);
7328        let clock = Rc::new(RefCell::new(TestClock::new()));
7329
7330        let mut vega_provider = MapVegaProvider::new();
7331        vega_provider.insert(leg1, 0.0);
7332        vega_provider.insert(leg2, 0.0);
7333
7334        let agg = SpreadQuoteAggregator::new(
7335            spread_id,
7336            &legs,
7337            false,
7338            instrument.price_precision(),
7339            0,
7340            Box::new(move |q: QuoteTick| {
7341                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7342            }),
7343            clock.clone(),
7344            false,
7345            None,
7346            0,
7347            false,
7348            1,
7349            Some(Box::new(vega_provider)),
7350            None,
7351        );
7352        let rc = Rc::new(RefCell::new(agg));
7353        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7354
7355        let ts = UnixNanos::from(1_000_000_000);
7356        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7357            leg1,
7358            Price::from("10.00"),
7359            Price::from("10.10"),
7360            Quantity::from(100),
7361            Quantity::from(100),
7362            ts,
7363            ts,
7364        ));
7365        rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7366            leg2,
7367            Price::from("20.00"),
7368            Price::from("20.10"),
7369            Quantity::from(100),
7370            Quantity::from(100),
7371            ts,
7372            ts,
7373        ));
7374        {
7375            let quotes = handler.lock().expect(MUTEX_POISONED);
7376            assert_eq!(quotes.len(), 1);
7377            let q = &quotes[0];
7378            assert_eq!(q.bid_price, Price::from("-10.10"));
7379            assert_eq!(q.ask_price, Price::from("-9.90"));
7380        }
7381        assert!(rc.borrow().vega_pricing_temporarily_disabled);
7382
7383        let timeout_name = rc.borrow().vega_pricing_timeout_timer_name.clone();
7384        assert!(
7385            clock
7386                .borrow()
7387                .timer_names()
7388                .contains(&timeout_name.as_str())
7389        );
7390
7391        let events = clock
7392            .borrow_mut()
7393            .advance_time(UnixNanos::from(2_000_000_000), true);
7394
7395        for handler in clock.borrow().match_handlers(events) {
7396            handler.run();
7397        }
7398
7399        assert!(!rc.borrow().vega_pricing_temporarily_disabled);
7400
7401        let cancel_handler = Arc::new(Mutex::new(Vec::new()));
7402        let cancel_handler_clone = Arc::clone(&cancel_handler);
7403        let mut cancel_vega_provider = MapVegaProvider::new();
7404        cancel_vega_provider.insert(leg1, 0.0);
7405        cancel_vega_provider.insert(leg2, 0.0);
7406        let cancel_agg = SpreadQuoteAggregator::new(
7407            spread_id,
7408            &legs,
7409            false,
7410            instrument.price_precision(),
7411            0,
7412            Box::new(move |q: QuoteTick| {
7413                cancel_handler_clone.lock().expect(MUTEX_POISONED).push(q);
7414            }),
7415            clock.clone(),
7416            false,
7417            None,
7418            0,
7419            false,
7420            10,
7421            Some(Box::new(cancel_vega_provider)),
7422            None,
7423        );
7424        let cancel_rc = Rc::new(RefCell::new(cancel_agg));
7425        cancel_rc
7426            .borrow_mut()
7427            .start_timer(Some(Rc::clone(&cancel_rc)));
7428        cancel_rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7429            leg1,
7430            Price::from("10.00"),
7431            Price::from("10.10"),
7432            Quantity::from(100),
7433            Quantity::from(100),
7434            ts,
7435            ts,
7436        ));
7437        cancel_rc.borrow_mut().handle_quote_tick(QuoteTick::new(
7438            leg2,
7439            Price::from("20.00"),
7440            Price::from("20.10"),
7441            Quantity::from(100),
7442            Quantity::from(100),
7443            ts,
7444            ts,
7445        ));
7446        let cancel_timeout_name = cancel_rc.borrow().vega_pricing_timeout_timer_name.clone();
7447        assert!(
7448            clock
7449                .borrow()
7450                .timer_names()
7451                .contains(&cancel_timeout_name.as_str())
7452        );
7453        cancel_rc.borrow_mut().stop_timer();
7454        assert!(
7455            !clock
7456                .borrow()
7457                .timer_names()
7458                .contains(&cancel_timeout_name.as_str())
7459        );
7460
7461        let permanent_handler = Arc::new(Mutex::new(Vec::new()));
7462        let permanent_handler_clone = Arc::clone(&permanent_handler);
7463        let mut permanent_vega_provider = MapVegaProvider::new();
7464        permanent_vega_provider.insert(leg1, 0.15);
7465        permanent_vega_provider.insert(leg2, 0.12);
7466        let mut permanent_agg = SpreadQuoteAggregator::new(
7467            spread_id,
7468            &legs,
7469            false,
7470            instrument.price_precision(),
7471            0,
7472            Box::new(move |q: QuoteTick| {
7473                permanent_handler_clone
7474                    .lock()
7475                    .expect(MUTEX_POISONED)
7476                    .push(q);
7477            }),
7478            Rc::new(RefCell::new(TestClock::new())),
7479            false,
7480            None,
7481            0,
7482            true,
7483            1,
7484            Some(Box::new(permanent_vega_provider)),
7485            None,
7486        );
7487
7488        permanent_agg.handle_quote_tick(QuoteTick::new(
7489            leg1,
7490            Price::from("10.00"),
7491            Price::from("10.10"),
7492            Quantity::from(100),
7493            Quantity::from(100),
7494            ts,
7495            ts,
7496        ));
7497        permanent_agg.handle_quote_tick(QuoteTick::new(
7498            leg2,
7499            Price::from("20.00"),
7500            Price::from("20.10"),
7501            Quantity::from(100),
7502            Quantity::from(100),
7503            ts,
7504            ts,
7505        ));
7506
7507        let permanent_quotes = permanent_handler.lock().expect(MUTEX_POISONED);
7508        assert_eq!(permanent_quotes.len(), 1);
7509        assert_eq!(permanent_quotes[0].bid_price, Price::from("-10.10"));
7510        assert_eq!(permanent_quotes[0].ask_price, Price::from("-9.90"));
7511        assert!(!permanent_agg.vega_pricing_temporarily_disabled);
7512    }
7513
7514    #[rstest]
7515    fn test_spread_quote_negative_prices_tick_scheme(equity_aapl: Equity) {
7516        let instrument = InstrumentAny::Equity(equity_aapl);
7517        let leg1 = instrument.id();
7518        let leg2 = InstrumentId::from("MSFT.XNAS");
7519        let spread_id = InstrumentId::from("SPREAD.XNAS");
7520        let legs = vec![(leg1, 1_i64), (leg2, -1_i64)];
7521        let handler = Arc::new(Mutex::new(Vec::new()));
7522        let handler_clone = Arc::clone(&handler);
7523        let clock = Rc::new(RefCell::new(TestClock::new()));
7524        let rounder = FixedTickSchemeRounder::new(0.01).unwrap();
7525
7526        let mut agg = SpreadQuoteAggregator::new(
7527            spread_id,
7528            &legs,
7529            true,
7530            2,
7531            0,
7532            Box::new(move |q: QuoteTick| {
7533                handler_clone.lock().expect(MUTEX_POISONED).push(q);
7534            }),
7535            clock,
7536            false,
7537            None,
7538            0,
7539            false,
7540            60,
7541            None,
7542            Some(Box::new(rounder)),
7543        );
7544
7545        let ts = UnixNanos::from(1_000_000_000);
7546        agg.handle_quote_tick(QuoteTick::new(
7547            leg1,
7548            Price::from("10.00"),
7549            Price::from("10.10"),
7550            Quantity::from(100),
7551            Quantity::from(100),
7552            ts,
7553            ts,
7554        ));
7555        agg.handle_quote_tick(QuoteTick::new(
7556            leg2,
7557            Price::from("20.00"),
7558            Price::from("20.10"),
7559            Quantity::from(100),
7560            Quantity::from(100),
7561            ts,
7562            ts,
7563        ));
7564        let quotes = handler.lock().expect(MUTEX_POISONED);
7565        assert_eq!(quotes.len(), 1);
7566        let q = &quotes[0];
7567        assert!(q.bid_price.as_f64() < 0.0);
7568        assert!(q.ask_price.as_f64() < 0.0);
7569        assert!(q.bid_price < q.ask_price);
7570    }
7571
7572    #[rstest]
7573    #[case(BarIntervalType::LeftOpen)]
7574    #[case(BarIntervalType::RightOpen)]
7575    fn test_time_bar_skip_first_non_full_bar_noop_on_boundary(
7576        equity_aapl: Equity,
7577        #[case] interval_type: BarIntervalType,
7578    ) {
7579        // When the clock sits on a bar boundary, fire_immediately=true and
7580        // first_close_ns equals that boundary. Every subsequent bar closes
7581        // strictly after first_close_ns, so skip_first_non_full_bar never
7582        // triggers and both bars emit.
7583        let instrument = InstrumentAny::Equity(equity_aapl);
7584        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7585        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7586        let handler = Arc::new(Mutex::new(Vec::new()));
7587        let handler_clone = Arc::clone(&handler);
7588        let clock = Rc::new(RefCell::new(TestClock::new()));
7589        clock.borrow_mut().set_time(UnixNanos::from(1_000_000_000));
7590        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7591
7592        let aggregator = TimeBarAggregator::new(
7593            bar_type,
7594            instrument.price_precision(),
7595            instrument.size_precision(),
7596            clock,
7597            move |bar: Bar| {
7598                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7599                h.push(bar);
7600            },
7601            false,
7602            false,
7603            interval_type,
7604            None,
7605            0,
7606            true, // skip_first_non_full_bar
7607        );
7608
7609        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7610        let rc = Rc::new(RefCell::new(boxed));
7611        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7612
7613        rc.borrow_mut().update(
7614            Price::from("100.00"),
7615            Quantity::from(1),
7616            UnixNanos::from(1_000_000_000),
7617        );
7618        rc.borrow_mut().build_bar(&TimeEvent::new(
7619            event_name,
7620            UUID4::new(),
7621            UnixNanos::from(2_000_000_000),
7622            UnixNanos::from(2_000_000_000),
7623        ));
7624        rc.borrow_mut().update(
7625            Price::from("101.00"),
7626            Quantity::from(1),
7627            UnixNanos::from(2_500_000_000),
7628        );
7629        rc.borrow_mut().build_bar(&TimeEvent::new(
7630            event_name,
7631            UUID4::new(),
7632            UnixNanos::from(3_000_000_000),
7633            UnixNanos::from(3_000_000_000),
7634        ));
7635
7636        let bars = handler.lock().expect(MUTEX_POISONED);
7637        assert_eq!(bars.len(), 2);
7638        assert_eq!(bars[0].close, Price::from("100.00"));
7639        assert_eq!(bars[1].close, Price::from("101.00"));
7640    }
7641
7642    #[rstest]
7643    #[case(BarIntervalType::LeftOpen)]
7644    #[case(BarIntervalType::RightOpen)]
7645    fn test_time_bar_skip_first_non_full_bar_drops_partial_bar(
7646        equity_aapl: Equity,
7647        #[case] interval_type: BarIntervalType,
7648    ) {
7649        // When the clock starts past a boundary (mid-interval), first_close_ns
7650        // is the upcoming boundary. The bar closing at first_close_ns is partial,
7651        // so skip_first_non_full_bar drops it; subsequent full bars emit.
7652        let instrument = InstrumentAny::Equity(equity_aapl);
7653        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7654        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7655        let handler = Arc::new(Mutex::new(Vec::new()));
7656        let handler_clone = Arc::clone(&handler);
7657        let clock = Rc::new(RefCell::new(TestClock::new()));
7658        clock.borrow_mut().set_time(UnixNanos::from(1_500_000_000));
7659        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7660
7661        let aggregator = TimeBarAggregator::new(
7662            bar_type,
7663            instrument.price_precision(),
7664            instrument.size_precision(),
7665            clock,
7666            move |bar: Bar| {
7667                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7668                h.push(bar);
7669            },
7670            false,
7671            false,
7672            interval_type,
7673            None,
7674            0,
7675            true, // skip_first_non_full_bar
7676        );
7677
7678        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7679        let rc = Rc::new(RefCell::new(boxed));
7680        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7681
7682        rc.borrow_mut().update(
7683            Price::from("100.00"),
7684            Quantity::from(1),
7685            UnixNanos::from(1_500_000_000),
7686        );
7687        rc.borrow_mut().build_bar(&TimeEvent::new(
7688            event_name,
7689            UUID4::new(),
7690            UnixNanos::from(2_000_000_000),
7691            UnixNanos::from(2_000_000_000),
7692        ));
7693        rc.borrow_mut().update(
7694            Price::from("101.00"),
7695            Quantity::from(1),
7696            UnixNanos::from(2_500_000_000),
7697        );
7698        rc.borrow_mut().build_bar(&TimeEvent::new(
7699            event_name,
7700            UUID4::new(),
7701            UnixNanos::from(3_000_000_000),
7702            UnixNanos::from(3_000_000_000),
7703        ));
7704
7705        let bars = handler.lock().expect(MUTEX_POISONED);
7706        assert_eq!(bars.len(), 1);
7707        assert_eq!(bars[0].close, Price::from("101.00"));
7708    }
7709
7710    #[rstest]
7711    fn test_time_bar_skip_first_non_full_bar_skips_every_call_before_first_close(
7712        equity_aapl: Equity,
7713    ) {
7714        // The flag must remain set across every build_and_send call whose
7715        // ts_init <= first_close_ns, and only flip once a bar actually emits.
7716        // Catches a mutation that flips skip_first_non_full_bar early.
7717        let instrument = InstrumentAny::Equity(equity_aapl);
7718        let bar_spec = BarSpecification::new(10, BarAggregation::Second, PriceType::Last);
7719        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7720        let handler = Arc::new(Mutex::new(Vec::new()));
7721        let handler_clone = Arc::clone(&handler);
7722        let clock = Rc::new(RefCell::new(TestClock::new()));
7723        clock.borrow_mut().set_time(UnixNanos::from(5_000_000_000));
7724        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7725
7726        let aggregator = TimeBarAggregator::new(
7727            bar_type,
7728            instrument.price_precision(),
7729            instrument.size_precision(),
7730            clock,
7731            move |bar: Bar| {
7732                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7733                h.push(bar);
7734            },
7735            false,
7736            false,
7737            BarIntervalType::LeftOpen,
7738            None,
7739            0,
7740            true, // skip_first_non_full_bar
7741        );
7742
7743        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7744        let rc = Rc::new(RefCell::new(boxed));
7745        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7746
7747        // first_close_ns is 10_000_000_000 (first 10s boundary after start).
7748        // Drive three build_bar calls at ts <= first_close_ns, each preceded by a
7749        // distinct update. Every one of them must be skipped.
7750        for (price, update_ts, event_ts) in [
7751            ("100.00", 5_500_000_000_u64, 7_000_000_000_u64),
7752            ("101.00", 7_500_000_000_u64, 8_000_000_000_u64),
7753            ("102.00", 9_000_000_000_u64, 10_000_000_000_u64),
7754        ] {
7755            rc.borrow_mut().update(
7756                Price::from(price),
7757                Quantity::from(1),
7758                UnixNanos::from(update_ts),
7759            );
7760            rc.borrow_mut().build_bar(&TimeEvent::new(
7761                event_name,
7762                UUID4::new(),
7763                UnixNanos::from(event_ts),
7764                UnixNanos::from(event_ts),
7765            ));
7766        }
7767
7768        // Final update + build past first_close_ns emits for the first time.
7769        rc.borrow_mut().update(
7770            Price::from("103.00"),
7771            Quantity::from(1),
7772            UnixNanos::from(10_500_000_000),
7773        );
7774        rc.borrow_mut().build_bar(&TimeEvent::new(
7775            event_name,
7776            UUID4::new(),
7777            UnixNanos::from(11_000_000_000),
7778            UnixNanos::from(11_000_000_000),
7779        ));
7780
7781        let bars = handler.lock().expect(MUTEX_POISONED);
7782        assert_eq!(bars.len(), 1);
7783        assert_eq!(bars[0].close, Price::from("103.00"));
7784    }
7785
7786    #[rstest]
7787    fn test_time_bar_skip_first_non_full_bar_skips_when_build_delay_shifts_start(
7788        equity_aapl: Equity,
7789    ) {
7790        // When bar_build_delay > 0 pushes start_time past a boundary (even if `now` is on a
7791        // boundary), first_close_ns is set and the first bar is skipped. A `now > start_time`
7792        // guard would incorrectly keep this first bar.
7793        let instrument = InstrumentAny::Equity(equity_aapl);
7794        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7795        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7796        let handler = Arc::new(Mutex::new(Vec::new()));
7797        let handler_clone = Arc::clone(&handler);
7798        let clock = Rc::new(RefCell::new(TestClock::new()));
7799        clock.borrow_mut().set_time(UnixNanos::from(2_000_000_000));
7800        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7801
7802        let aggregator = TimeBarAggregator::new(
7803            bar_type,
7804            instrument.price_precision(),
7805            instrument.size_precision(),
7806            clock,
7807            move |bar: Bar| {
7808                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7809                h.push(bar);
7810            },
7811            false,
7812            false,
7813            BarIntervalType::LeftOpen,
7814            None,
7815            100,  // bar_build_delay (microseconds)
7816            true, // skip_first_non_full_bar
7817        );
7818
7819        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7820        let rc = Rc::new(RefCell::new(boxed));
7821        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7822
7823        // start_time = 2s + 100us = 2_000_100_000 ns; first_close_ns = 3_000_100_000 ns.
7824        rc.borrow_mut().update(
7825            Price::from("100.00"),
7826            Quantity::from(1),
7827            UnixNanos::from(2_500_000_000),
7828        );
7829        rc.borrow_mut().build_bar(&TimeEvent::new(
7830            event_name,
7831            UUID4::new(),
7832            UnixNanos::from(3_000_100_000),
7833            UnixNanos::from(3_000_100_000),
7834        ));
7835        rc.borrow_mut().update(
7836            Price::from("101.00"),
7837            Quantity::from(1),
7838            UnixNanos::from(3_500_000_000),
7839        );
7840        rc.borrow_mut().build_bar(&TimeEvent::new(
7841            event_name,
7842            UUID4::new(),
7843            UnixNanos::from(4_000_100_000),
7844            UnixNanos::from(4_000_100_000),
7845        ));
7846
7847        let bars = handler.lock().expect(MUTEX_POISONED);
7848        assert_eq!(bars.len(), 1);
7849        assert_eq!(bars[0].close, Price::from("101.00"));
7850    }
7851
7852    #[rstest]
7853    #[case(
7854        BarAggregation::Month,
7855        1_735_689_600_000_000_000_u64,
7856        1_733_011_200_000_000_000_u64
7857    )]
7858    #[case(
7859        BarAggregation::Year,
7860        1_735_689_600_000_000_000_u64,
7861        1_704_067_200_000_000_000_u64
7862    )]
7863    fn test_time_bar_fire_immediately_month_year_stored_open_points_to_previous_period(
7864        equity_aapl: Equity,
7865        #[case] aggregation: BarAggregation,
7866        #[case] start_ns: u64,
7867        #[case] expected_stored_open_ns: u64,
7868    ) {
7869        // When the clock is exactly on a month/year boundary, fire_immediately=true.
7870        // stored_open_ns must resolve to one step before start_time (close_time - step)
7871        // so the first bar's open timestamp marks the true start of the in-progress interval.
7872        let instrument = InstrumentAny::Equity(equity_aapl);
7873        let bar_spec = BarSpecification::new(1, aggregation, PriceType::Last);
7874        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7875        let handler = Arc::new(Mutex::new(Vec::new()));
7876        let handler_clone = Arc::clone(&handler);
7877        let clock = Rc::new(RefCell::new(TestClock::new()));
7878        clock.borrow_mut().set_time(UnixNanos::from(start_ns));
7879        let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
7880
7881        let aggregator = TimeBarAggregator::new(
7882            bar_type,
7883            instrument.price_precision(),
7884            instrument.size_precision(),
7885            clock,
7886            move |bar: Bar| {
7887                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7888                h.push(bar);
7889            },
7890            false,
7891            false,
7892            BarIntervalType::RightOpen, // ts_event = stored_open_ns
7893            None,
7894            0,
7895            false, // skip_first_non_full_bar
7896        );
7897
7898        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
7899        let rc = Rc::new(RefCell::new(boxed));
7900        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
7901
7902        rc.borrow_mut().update(
7903            Price::from("100.00"),
7904            Quantity::from(1),
7905            UnixNanos::from(start_ns),
7906        );
7907        rc.borrow_mut().build_bar(&TimeEvent::new(
7908            event_name,
7909            UUID4::new(),
7910            UnixNanos::from(start_ns),
7911            UnixNanos::from(start_ns),
7912        ));
7913
7914        let bars = handler.lock().expect(MUTEX_POISONED);
7915        assert_eq!(bars.len(), 1);
7916        assert_eq!(bars[0].ts_event, UnixNanos::from(expected_stored_open_ns));
7917        assert_eq!(bars[0].ts_init, UnixNanos::from(start_ns));
7918    }
7919
7920    #[rstest]
7921    fn test_time_bar_historical_prevents_bars_for_timer_before_last_data(equity_aapl: Equity) {
7922        let instrument = InstrumentAny::Equity(equity_aapl);
7923        let bar_spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
7924        let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
7925        let handler = Arc::new(Mutex::new(Vec::new()));
7926        let handler_clone = Arc::clone(&handler);
7927        let clock = Rc::new(RefCell::new(TestClock::new()));
7928
7929        let mut agg = TimeBarAggregator::new(
7930            bar_type,
7931            instrument.price_precision(),
7932            instrument.size_precision(),
7933            clock.clone(),
7934            move |bar: Bar| {
7935                let mut h = handler_clone.lock().expect(MUTEX_POISONED);
7936                h.push(bar);
7937            },
7938            true,
7939            true,
7940            BarIntervalType::LeftOpen,
7941            None,
7942            0,
7943            false,
7944        );
7945        agg.historical_mode = true;
7946        agg.set_clock_internal(clock);
7947        let boxed: Box<dyn BarAggregator> = Box::new(agg);
7948        let rc = Rc::new(RefCell::new(boxed));
7949        rc.borrow_mut().set_aggregator_weak(Rc::downgrade(&rc));
7950
7951        let ts1 = UnixNanos::from(2_000_000_000);
7952        rc.borrow_mut()
7953            .update(Price::from("100.00"), Quantity::from(1), ts1);
7954
7955        let ts2 = UnixNanos::from(3_000_000_000);
7956        rc.borrow_mut()
7957            .update(Price::from("101.00"), Quantity::from(1), ts2);
7958
7959        let bars = handler.lock().expect(MUTEX_POISONED);
7960        assert!(
7961            !bars.is_empty(),
7962            "advancing time from ts1 to ts2 should produce at least one bar"
7963        );
7964        assert_eq!(bars[0].close, Price::from("100.00"));
7965    }
7966
7967    #[rstest]
7968    #[case(BarAggregation::Tick)]
7969    #[case(BarAggregation::TickImbalance)]
7970    #[case(BarAggregation::TickRuns)]
7971    #[case(BarAggregation::Volume)]
7972    #[case(BarAggregation::VolumeImbalance)]
7973    #[case(BarAggregation::VolumeRuns)]
7974    #[case(BarAggregation::Value)]
7975    #[case(BarAggregation::ValueImbalance)]
7976    #[case(BarAggregation::ValueRuns)]
7977    #[case(BarAggregation::Renko)]
7978    fn test_aggregators_standardize_composite_bar_type(
7979        equity_aapl: Equity,
7980        #[case] aggregation: BarAggregation,
7981    ) {
7982        let instrument = InstrumentAny::Equity(equity_aapl);
7983        let bar_type = BarType::new_composite(
7984            instrument.id(),
7985            BarSpecification::new(10, aggregation, PriceType::Last),
7986            AggregationSource::Internal,
7987            1,
7988            BarAggregation::Minute,
7989            AggregationSource::External,
7990        );
7991        let handler = |_: Bar| {};
7992
7993        let aggregator: Box<dyn BarAggregator> = match aggregation {
7994            BarAggregation::Tick => Box::new(TickBarAggregator::new(
7995                bar_type,
7996                instrument.price_precision(),
7997                instrument.size_precision(),
7998                handler,
7999            )),
8000            BarAggregation::TickImbalance => Box::new(TickImbalanceBarAggregator::new(
8001                bar_type,
8002                instrument.price_precision(),
8003                instrument.size_precision(),
8004                handler,
8005            )),
8006            BarAggregation::TickRuns => Box::new(TickRunsBarAggregator::new(
8007                bar_type,
8008                instrument.price_precision(),
8009                instrument.size_precision(),
8010                handler,
8011            )),
8012            BarAggregation::Volume => Box::new(VolumeBarAggregator::new(
8013                bar_type,
8014                instrument.price_precision(),
8015                instrument.size_precision(),
8016                handler,
8017            )),
8018            BarAggregation::VolumeImbalance => Box::new(VolumeImbalanceBarAggregator::new(
8019                bar_type,
8020                instrument.price_precision(),
8021                instrument.size_precision(),
8022                handler,
8023            )),
8024            BarAggregation::VolumeRuns => Box::new(VolumeRunsBarAggregator::new(
8025                bar_type,
8026                instrument.price_precision(),
8027                instrument.size_precision(),
8028                handler,
8029            )),
8030            BarAggregation::Value => Box::new(ValueBarAggregator::new(
8031                bar_type,
8032                instrument.price_precision(),
8033                instrument.size_precision(),
8034                handler,
8035            )),
8036            BarAggregation::ValueImbalance => Box::new(ValueImbalanceBarAggregator::new(
8037                bar_type,
8038                instrument.price_precision(),
8039                instrument.size_precision(),
8040                handler,
8041            )),
8042            BarAggregation::ValueRuns => Box::new(ValueRunsBarAggregator::new(
8043                bar_type,
8044                instrument.price_precision(),
8045                instrument.size_precision(),
8046                handler,
8047            )),
8048            BarAggregation::Renko => Box::new(RenkoBarAggregator::new(
8049                bar_type,
8050                instrument.price_precision(),
8051                instrument.size_precision(),
8052                Price::from("0.01"),
8053                handler,
8054            )),
8055            _ => unreachable!(),
8056        };
8057
8058        assert!(aggregator.bar_type().is_standard());
8059        assert_eq!(aggregator.bar_type(), bar_type.standard());
8060    }
8061
8062    #[rstest]
8063    fn test_composite_tick_bar_aggregator_emits_standard_bar_type(equity_aapl: Equity) {
8064        let instrument = InstrumentAny::Equity(equity_aapl);
8065        let bar_type = BarType::new_composite(
8066            instrument.id(),
8067            BarSpecification::new(1, BarAggregation::Tick, PriceType::Last),
8068            AggregationSource::Internal,
8069            1,
8070            BarAggregation::Minute,
8071            AggregationSource::External,
8072        );
8073        let handler = Arc::new(Mutex::new(Vec::new()));
8074        let handler_clone = Arc::clone(&handler);
8075
8076        let mut aggregator = TickBarAggregator::new(
8077            bar_type,
8078            instrument.price_precision(),
8079            instrument.size_precision(),
8080            move |bar: Bar| {
8081                let mut handler_guard = handler_clone.lock().expect(MUTEX_POISONED);
8082                handler_guard.push(bar);
8083            },
8084        );
8085
8086        let input_bar = Bar::new(
8087            bar_type.composite(),
8088            Price::from("100.00"),
8089            Price::from("101.00"),
8090            Price::from("99.00"),
8091            Price::from("100.50"),
8092            Quantity::from(10),
8093            UnixNanos::from(1_000),
8094            UnixNanos::from(1_000),
8095        );
8096        aggregator.handle_bar(input_bar);
8097
8098        let handler_guard = handler.lock().expect(MUTEX_POISONED);
8099        assert_eq!(handler_guard.len(), 1);
8100        assert_eq!(handler_guard[0].bar_type, bar_type.standard());
8101    }
8102
8103    #[rstest]
8104    fn test_composite_time_bar_aggregator_uses_standard_timer_name(equity_aapl: Equity) {
8105        let instrument = InstrumentAny::Equity(equity_aapl);
8106        let bar_type = BarType::new_composite(
8107            instrument.id(),
8108            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
8109            AggregationSource::Internal,
8110            1,
8111            BarAggregation::Minute,
8112            AggregationSource::External,
8113        );
8114        let clock = Rc::new(RefCell::new(TestClock::new()));
8115
8116        let aggregator = TimeBarAggregator::new(
8117            bar_type,
8118            instrument.price_precision(),
8119            instrument.size_precision(),
8120            clock.clone(),
8121            |_: Bar| {},
8122            false,
8123            true,
8124            BarIntervalType::LeftOpen,
8125            None,
8126            0,
8127            false,
8128        );
8129
8130        let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
8131        let rc = Rc::new(RefCell::new(boxed));
8132        rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
8133
8134        let expected = format!("TIME_BAR_{}", bar_type.standard());
8135        assert!(
8136            clock.borrow().timer_names().contains(&expected.as_str()),
8137            "timer names {:?} should contain {expected}",
8138            clock.borrow().timer_names(),
8139        );
8140    }
8141}
8142
8143#[cfg(test)]
8144mod property_tests {
8145    use std::{
8146        cell::RefCell,
8147        rc::Rc,
8148        sync::{Arc, Mutex},
8149    };
8150
8151    use nautilus_common::{clock::TestClock, timer::TimeEvent};
8152    use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
8153    use nautilus_model::{
8154        data::{Bar, BarSpecification, BarType, TradeTick, bar::get_bar_interval_ns},
8155        enums::{AggregationSource, AggressorSide, BarAggregation, BarIntervalType, PriceType},
8156        instruments::{Instrument, InstrumentAny, stubs::equity_aapl},
8157        types::{Price, Quantity},
8158    };
8159    use proptest::prelude::*;
8160    use rstest::rstest;
8161    use ustr::Ustr;
8162
8163    use super::*;
8164
8165    fn time_bar_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
8166        prop_oneof![
8167            (Just(BarAggregation::Second), 1usize..=5),
8168            (Just(BarAggregation::Minute), 1usize..=5),
8169            (Just(BarAggregation::Hour), 1usize..=4),
8170        ]
8171    }
8172
8173    fn interval_type_strategy() -> impl Strategy<Value = BarIntervalType> {
8174        prop_oneof![
8175            Just(BarIntervalType::LeftOpen),
8176            Just(BarIntervalType::RightOpen),
8177        ]
8178    }
8179
8180    proptest! {
8181        #[rstest]
8182        fn prop_skip_first_drops_partial_then_emits(
8183            (aggregation, step) in time_bar_spec_strategy(),
8184            interval_type in interval_type_strategy(),
8185            skip_first in any::<bool>(),
8186        ) {
8187            let instrument = InstrumentAny::Equity(equity_aapl());
8188            let bar_spec = BarSpecification::new(step, aggregation, PriceType::Last);
8189            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8190            let interval_ns = get_bar_interval_ns(&bar_type).as_u64();
8191
8192            // Anchor the clock one full interval past epoch plus a half-interval offset
8193            // so start_time lands mid-interval and fire_immediately is false.
8194            let now_ns = interval_ns + interval_ns / 2;
8195
8196            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8197            let handler_clone = Arc::clone(&handler);
8198            let clock = Rc::new(RefCell::new(TestClock::new()));
8199            clock.borrow_mut().set_time(UnixNanos::from(now_ns));
8200            let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
8201
8202            let aggregator = TimeBarAggregator::new(
8203                bar_type,
8204                instrument.price_precision(),
8205                instrument.size_precision(),
8206                clock,
8207                move |bar: Bar| {
8208                    let mut h = handler_clone.lock().expect(MUTEX_POISONED);
8209                    h.push(bar);
8210                },
8211                false,
8212                false,
8213                interval_type,
8214                None,
8215                0,
8216                skip_first,
8217            );
8218
8219            let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
8220            let rc = Rc::new(RefCell::new(boxed));
8221            rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
8222
8223            // First tick + first close event. start_time = 1 * interval, first_close
8224            // = 2 * interval. ts_init == first_close_ns: partial bar.
8225            rc.borrow_mut().update(
8226                Price::from("100.00"),
8227                Quantity::from(1),
8228                UnixNanos::from(now_ns),
8229            );
8230            let first_close = 2 * interval_ns;
8231            rc.borrow_mut().build_bar(&TimeEvent::new(
8232                event_name,
8233                UUID4::new(),
8234                UnixNanos::from(first_close),
8235                UnixNanos::from(first_close),
8236            ));
8237
8238            // Second tick + later close; emits unconditionally.
8239            rc.borrow_mut().update(
8240                Price::from("101.00"),
8241                Quantity::from(1),
8242                UnixNanos::from(first_close + interval_ns / 2),
8243            );
8244            let second_close = first_close + interval_ns;
8245            rc.borrow_mut().build_bar(&TimeEvent::new(
8246                event_name,
8247                UUID4::new(),
8248                UnixNanos::from(second_close),
8249                UnixNanos::from(second_close),
8250            ));
8251
8252            let bars = handler.lock().expect(MUTEX_POISONED);
8253            let expected = if skip_first { 1 } else { 2 };
8254            prop_assert_eq!(bars.len(), expected);
8255            prop_assert_eq!(bars.last().unwrap().close, Price::from("101.00"));
8256            for bar in bars.iter() {
8257                prop_assert!(bar.high >= bar.open);
8258                prop_assert!(bar.high >= bar.close);
8259                prop_assert!(bar.low <= bar.open);
8260                prop_assert!(bar.low <= bar.close);
8261            }
8262        }
8263
8264        #[rstest]
8265        fn prop_skip_first_noop_on_exact_boundary(
8266            (aggregation, step) in time_bar_spec_strategy(),
8267            interval_type in interval_type_strategy(),
8268        ) {
8269            let instrument = InstrumentAny::Equity(equity_aapl());
8270            let bar_spec = BarSpecification::new(step, aggregation, PriceType::Last);
8271            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8272            let interval_ns = get_bar_interval_ns(&bar_type).as_u64();
8273
8274            // Clock exactly on a bar boundary: fire_immediately=true, so the first
8275            // bar that reaches build_and_send must emit regardless of skip_first.
8276            let now_ns = interval_ns;
8277            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8278            let handler_clone = Arc::clone(&handler);
8279            let clock = Rc::new(RefCell::new(TestClock::new()));
8280            clock.borrow_mut().set_time(UnixNanos::from(now_ns));
8281            let event_name = Ustr::from(&format!("TIME_BAR_{bar_type}"));
8282
8283            let aggregator = TimeBarAggregator::new(
8284                bar_type,
8285                instrument.price_precision(),
8286                instrument.size_precision(),
8287                clock,
8288                move |bar: Bar| {
8289                    let mut h = handler_clone.lock().expect(MUTEX_POISONED);
8290                    h.push(bar);
8291                },
8292                false,
8293                false,
8294                interval_type,
8295                None,
8296                0,
8297                true, // skip_first_non_full_bar
8298            );
8299
8300            let boxed: Box<dyn BarAggregator> = Box::new(aggregator);
8301            let rc = Rc::new(RefCell::new(boxed));
8302            rc.borrow_mut().start_timer(Some(Rc::clone(&rc)));
8303
8304            rc.borrow_mut().update(
8305                Price::from("100.00"),
8306                Quantity::from(1),
8307                UnixNanos::from(now_ns),
8308            );
8309            let next_close = now_ns + interval_ns;
8310            rc.borrow_mut().build_bar(&TimeEvent::new(
8311                event_name,
8312                UUID4::new(),
8313                UnixNanos::from(next_close),
8314                UnixNanos::from(next_close),
8315            ));
8316
8317            let bars = handler.lock().expect(MUTEX_POISONED);
8318            prop_assert_eq!(bars.len(), 1);
8319            prop_assert_eq!(bars[0].close, Price::from("100.00"));
8320        }
8321
8322        #[rstest]
8323        fn prop_bar_builder_ohlc_invariants(
8324            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=1_000u64), 1..=50),
8325        ) {
8326            let instrument = InstrumentAny::Equity(equity_aapl());
8327            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8328            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8329            let mut builder = BarBuilder::new(bar_type, 2, 0);
8330
8331            let mut total_volume: u64 = 0;
8332
8333            for (i, (price_cents, size)) in updates.iter().enumerate() {
8334                let price = Price::new((*price_cents as f64) / 100.0, 2);
8335                let qty = Quantity::new(*size as f64, 0);
8336                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8337                total_volume += *size;
8338                builder.update(price, qty, ts);
8339            }
8340
8341            let bar = builder.build_now();
8342            prop_assert!(bar.low <= bar.open);
8343            prop_assert!(bar.low <= bar.close);
8344            prop_assert!(bar.high >= bar.open);
8345            prop_assert!(bar.high >= bar.close);
8346            prop_assert!(bar.low <= bar.high);
8347            prop_assert_eq!(bar.volume.as_f64(), total_volume as f64);
8348        }
8349
8350        #[rstest]
8351        fn prop_tick_bar_aggregator_volume_conservation(
8352            ticks in prop::collection::vec((1i64..=1_000i64, 1u64..=100u64), 3..=60),
8353            step in 1usize..=5,
8354        ) {
8355            let instrument = InstrumentAny::Equity(equity_aapl());
8356            let bar_spec = BarSpecification::new(step, BarAggregation::Tick, PriceType::Last);
8357            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8358            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8359            let handler_clone = Arc::clone(&handler);
8360
8361            let mut aggregator = TickBarAggregator::new(
8362                bar_type,
8363                instrument.price_precision(),
8364                instrument.size_precision(),
8365                move |bar: Bar| {
8366                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8367                },
8368            );
8369
8370            let mut total_input: u64 = 0;
8371
8372            for (i, (price_cents, size)) in ticks.iter().enumerate() {
8373                let price = Price::new((*price_cents as f64) / 100.0, 2);
8374                let qty = Quantity::new(*size as f64, 0);
8375                aggregator.update(price, qty, UnixNanos::from((i as u64 + 1) * 1_000));
8376                total_input += *size;
8377            }
8378
8379            let bars = handler.lock().expect(MUTEX_POISONED);
8380            let emitted_count = bars.len();
8381            prop_assert_eq!(emitted_count, ticks.len() / step);
8382
8383            let mut sum_emitted: f64 = 0.0;
8384
8385            for bar in bars.iter() {
8386                prop_assert!(bar.low <= bar.open);
8387                prop_assert!(bar.low <= bar.close);
8388                prop_assert!(bar.high >= bar.open);
8389                prop_assert!(bar.high >= bar.close);
8390                sum_emitted += bar.volume.as_f64();
8391            }
8392
8393            // Unemitted pending size remains in the builder for the remainder `ticks.len() % step` ticks.
8394            let pending_size: u64 = ticks.iter()
8395                .skip(emitted_count * step)
8396                .map(|(_, s)| *s)
8397                .sum();
8398            prop_assert!((sum_emitted + pending_size as f64 - total_input as f64).abs() < 1e-6);
8399        }
8400
8401        #[rstest]
8402        fn prop_volume_bar_aggregator_conservation(
8403            sizes in prop::collection::vec(1u64..=50u64, 3..=40),
8404            step in 2u64..=10u64,
8405        ) {
8406            let instrument = InstrumentAny::Equity(equity_aapl());
8407            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Volume, PriceType::Last);
8408            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8409            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8410            let handler_clone = Arc::clone(&handler);
8411
8412            let mut aggregator = VolumeBarAggregator::new(
8413                bar_type,
8414                instrument.price_precision(),
8415                instrument.size_precision(),
8416                move |bar: Bar| {
8417                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8418                },
8419            );
8420
8421            let mut total_input: u64 = 0;
8422
8423            for (i, size) in sizes.iter().enumerate() {
8424                aggregator.update(
8425                    Price::from("100.00"),
8426                    Quantity::new(*size as f64, 0),
8427                    UnixNanos::from((i as u64 + 1) * 1_000),
8428                );
8429                total_input += *size;
8430            }
8431
8432            let bars = handler.lock().expect(MUTEX_POISONED);
8433
8434            // Every emitted bar has exactly `step` volume and OHLC ordering holds.
8435            for bar in bars.iter() {
8436                prop_assert_eq!(bar.volume, Quantity::from(step));
8437                prop_assert!(bar.low <= bar.open);
8438                prop_assert!(bar.low <= bar.close);
8439                prop_assert!(bar.high >= bar.open);
8440                prop_assert!(bar.high >= bar.close);
8441            }
8442
8443            // Conservation: total emitted + pending builder volume equals total input.
8444            let emitted_total: u64 = bars.len() as u64 * step;
8445            let pending = aggregator.core.builder.volume.as_f64();
8446            prop_assert!((emitted_total as f64 + pending - total_input as f64).abs() < 1e-6);
8447        }
8448
8449        #[rstest]
8450        fn prop_volume_bar_matches_unit_trade_reference(
8451            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=8u64, 0u64..=30u64), 1..=30),
8452            step in 1usize..=5,
8453        ) {
8454            let instrument = InstrumentAny::Equity(equity_aapl());
8455            let bar_spec = BarSpecification::new(step, BarAggregation::Volume, PriceType::Last);
8456            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8457            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8458            let handler_clone = Arc::clone(&handler);
8459            let mut aggregator = VolumeBarAggregator::new(
8460                bar_type,
8461                instrument.price_precision(),
8462                instrument.size_precision(),
8463                move |bar: Bar| {
8464                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8465                },
8466            );
8467            let price = |cents| {
8468                Price::from_decimal_dp(Decimal::new(cents, 2), 2)
8469                    .expect("bounded cents must produce a valid price")
8470            };
8471            let mut last_timestamp = UnixNanos::default();
8472            let mut pending_units = Vec::new();
8473            let mut expected_bars = Vec::new();
8474
8475            for (price_cents, size, timestamp) in &updates {
8476                let timestamp = UnixNanos::from(*timestamp);
8477                aggregator.update(price(*price_cents), Quantity::from(*size), timestamp);
8478
8479                if timestamp < last_timestamp {
8480                    continue;
8481                }
8482
8483                last_timestamp = timestamp;
8484                for _ in 0..*size {
8485                    pending_units.push((*price_cents, timestamp));
8486                }
8487
8488                while pending_units.len() >= step {
8489                    let units: Vec<_> = pending_units.drain(..step).collect();
8490                    let first = units.first().unwrap();
8491                    let last = units.last().unwrap();
8492                    let low = units.iter().map(|(cents, _)| *cents).min().unwrap();
8493                    let high = units.iter().map(|(cents, _)| *cents).max().unwrap();
8494                    expected_bars.push((
8495                        price(first.0),
8496                        price(high),
8497                        price(low),
8498                        price(last.0),
8499                        Quantity::from(step as u64),
8500                        last.1,
8501                    ));
8502                }
8503            }
8504
8505            let bars = handler.lock().expect(MUTEX_POISONED);
8506            prop_assert_eq!(bars.len(), expected_bars.len());
8507            for (actual, (open, high, low, close, volume, timestamp))
8508                in bars.iter().zip(expected_bars)
8509            {
8510                prop_assert_eq!(actual.open, open);
8511                prop_assert_eq!(actual.high, high);
8512                prop_assert_eq!(actual.low, low);
8513                prop_assert_eq!(actual.close, close);
8514                prop_assert_eq!(actual.volume, volume);
8515                prop_assert_eq!(actual.ts_event, timestamp);
8516                prop_assert_eq!(actual.ts_init, timestamp);
8517            }
8518
8519            prop_assert_eq!(aggregator.core.builder.volume, Quantity::from(pending_units.len() as u64));
8520            prop_assert_eq!(aggregator.core.builder.ts_last, last_timestamp);
8521
8522            if let Some((first, rest)) = pending_units.split_first() {
8523                let last = rest.last().unwrap_or(first);
8524                let low = pending_units.iter().map(|(cents, _)| *cents).min().unwrap();
8525                let high = pending_units.iter().map(|(cents, _)| *cents).max().unwrap();
8526                prop_assert_eq!(aggregator.core.builder.open, Some(price(first.0)));
8527                prop_assert_eq!(aggregator.core.builder.high, Some(price(high)));
8528                prop_assert_eq!(aggregator.core.builder.low, Some(price(low)));
8529                prop_assert_eq!(aggregator.core.builder.close, Some(price(last.0)));
8530            } else {
8531                prop_assert_eq!(aggregator.core.builder.open, None);
8532                prop_assert_eq!(aggregator.core.builder.high, None);
8533                prop_assert_eq!(aggregator.core.builder.low, None);
8534                prop_assert_eq!(aggregator.core.builder.close, None);
8535            }
8536        }
8537
8538        #[rstest]
8539        fn prop_bar_builder_spread_adjustment_is_additive(
8540            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8541            spread_cents in -10_000i64..=10_000i64,
8542            backward in any::<bool>(),
8543        ) {
8544            let instrument = InstrumentAny::Equity(equity_aapl());
8545            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8546            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8547            let mut builder = BarBuilder::new(bar_type, 2, 0);
8548
8549            let spread = Decimal::new(spread_cents, 2);
8550            let mode = if backward {
8551                ContinuousFutureAdjustmentType::BackwardSpread
8552            } else {
8553                ContinuousFutureAdjustmentType::ForwardSpread
8554            };
8555            builder.set_adjustment(spread, mode);
8556
8557            let mut min_cents = i64::MAX;
8558            let mut max_cents = i64::MIN;
8559
8560            for (i, (price_cents, size)) in updates.iter().enumerate() {
8561                if *price_cents < min_cents {
8562                    min_cents = *price_cents;
8563                }
8564
8565                if *price_cents > max_cents {
8566                    max_cents = *price_cents;
8567                }
8568
8569                builder.update(
8570                    Price::new((*price_cents as f64) / 100.0, 2),
8571                    Quantity::new(*size as f64, 0),
8572                    UnixNanos::from((i as u64 + 1) * 1_000),
8573                );
8574            }
8575
8576            let bar = builder.build_now();
8577            let first_decimal = Decimal::new(updates.first().unwrap().0, 2);
8578            let last_decimal = Decimal::new(updates.last().unwrap().0, 2);
8579            let min_decimal = Decimal::new(min_cents, 2);
8580            let max_decimal = Decimal::new(max_cents, 2);
8581
8582            prop_assert_eq!(bar.open.as_decimal(), first_decimal + spread);
8583            prop_assert_eq!(bar.close.as_decimal(), last_decimal + spread);
8584            prop_assert_eq!(bar.low.as_decimal(), min_decimal + spread);
8585            prop_assert_eq!(bar.high.as_decimal(), max_decimal + spread);
8586        }
8587
8588        #[rstest]
8589        fn prop_bar_builder_inactive_adjustment_is_identity(
8590            updates in prop::collection::vec((1i64..=100_000i64, 1u64..=1_000u64), 1..=20),
8591            use_ratio in any::<bool>(),
8592        ) {
8593            let instrument = InstrumentAny::Equity(equity_aapl());
8594            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8595            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8596
8597            let mut adjusted = BarBuilder::new(bar_type, 2, 0);
8598            let mut baseline = BarBuilder::new(bar_type, 2, 0);
8599
8600            // Inactive in either mode: ZERO spread or ONE ratio.
8601            let (input, mode) = if use_ratio {
8602                (Decimal::ONE, ContinuousFutureAdjustmentType::BackwardRatio)
8603            } else {
8604                (Decimal::ZERO, ContinuousFutureAdjustmentType::BackwardSpread)
8605            };
8606            adjusted.set_adjustment(input, mode);
8607
8608            for (i, (price_cents, size)) in updates.iter().enumerate() {
8609                let price = Price::new((*price_cents as f64) / 100.0, 2);
8610                let qty = Quantity::new(*size as f64, 0);
8611                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8612                adjusted.update(price, qty, ts);
8613                baseline.update(price, qty, ts);
8614            }
8615
8616            let bar_adjusted = adjusted.build_now();
8617            let bar_baseline = baseline.build_now();
8618            prop_assert_eq!(bar_adjusted.open, bar_baseline.open);
8619            prop_assert_eq!(bar_adjusted.high, bar_baseline.high);
8620            prop_assert_eq!(bar_adjusted.low, bar_baseline.low);
8621            prop_assert_eq!(bar_adjusted.close, bar_baseline.close);
8622            prop_assert_eq!(bar_adjusted.volume, bar_baseline.volume);
8623        }
8624
8625        #[rstest]
8626        fn prop_bar_builder_spread_preserves_raw_arithmetic(
8627            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8628            // Sub-precision spread: scale 4 versus price precision 2. Locks in that
8629            // spread mode performs raw addition without rounding to price precision.
8630            spread_micro in -10_000i64..=10_000i64,
8631        ) {
8632            let instrument = InstrumentAny::Equity(equity_aapl());
8633            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8634            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8635            let mut builder = BarBuilder::new(bar_type, 2, 0);
8636
8637            let spread = Decimal::new(spread_micro, 4);
8638            builder.set_adjustment(spread, ContinuousFutureAdjustmentType::BackwardSpread);
8639
8640            let adjustment_raw_i128 = mantissa_exponent_to_fixed_i128(
8641                spread.mantissa(),
8642                -(spread.scale() as i8),
8643                FIXED_PRECISION,
8644            )
8645            .expect("scale within range");
8646            #[allow(
8647                clippy::useless_conversion,
8648                reason = "i128 to PriceRaw is real when not high-precision"
8649            )]
8650            let expected_adjustment_raw: PriceRaw =
8651                adjustment_raw_i128.try_into().expect("within PriceRaw range");
8652
8653            let mut min_cents = i64::MAX;
8654            let mut max_cents = i64::MIN;
8655            let mut last_price = Price::new(0.0, 2);
8656            let mut first_price = Price::new(0.0, 2);
8657
8658            for (i, (price_cents, size)) in updates.iter().enumerate() {
8659                if *price_cents < min_cents {
8660                    min_cents = *price_cents;
8661                }
8662
8663                if *price_cents > max_cents {
8664                    max_cents = *price_cents;
8665                }
8666
8667                let price = Price::new((*price_cents as f64) / 100.0, 2);
8668
8669                if i == 0 {
8670                    first_price = price;
8671                }
8672
8673                last_price = price;
8674                builder.update(
8675                    price,
8676                    Quantity::new(*size as f64, 0),
8677                    UnixNanos::from((i as u64 + 1) * 1_000),
8678                );
8679            }
8680
8681            let bar = builder.build_now();
8682            let min_price = Price::new((min_cents as f64) / 100.0, 2);
8683            let max_price = Price::new((max_cents as f64) / 100.0, 2);
8684            prop_assert_eq!(bar.open.raw, first_price.raw + expected_adjustment_raw);
8685            prop_assert_eq!(bar.close.raw, last_price.raw + expected_adjustment_raw);
8686            prop_assert_eq!(bar.low.raw, min_price.raw + expected_adjustment_raw);
8687            prop_assert_eq!(bar.high.raw, max_price.raw + expected_adjustment_raw);
8688            prop_assert_eq!(bar.open.precision, 2);
8689            prop_assert_eq!(bar.high.precision, 2);
8690            prop_assert_eq!(bar.low.precision, 2);
8691            prop_assert_eq!(bar.close.precision, 2);
8692        }
8693
8694        #[rstest]
8695        fn prop_bar_builder_active_ratio_scales_each_ohlc(
8696            updates in prop::collection::vec((1_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8697            // Ratio in [0.50, 2.00] excluding exactly 1.00 to stay on the active path.
8698            ratio_centi in prop_oneof![50i64..=99i64, 101i64..=200i64],
8699            backward in any::<bool>(),
8700        ) {
8701            let instrument = InstrumentAny::Equity(equity_aapl());
8702            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8703            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8704            let mut builder = BarBuilder::new(bar_type, 2, 0);
8705
8706            let ratio_decimal = Decimal::new(ratio_centi, 2);
8707            let ratio_f64 = (ratio_centi as f64) / 100.0;
8708            let mode = if backward {
8709                ContinuousFutureAdjustmentType::BackwardRatio
8710            } else {
8711                ContinuousFutureAdjustmentType::ForwardRatio
8712            };
8713            builder.set_adjustment(ratio_decimal, mode);
8714
8715            let mut min_cents = i64::MAX;
8716            let mut max_cents = i64::MIN;
8717            let mut first_cents = 0i64;
8718            let mut last_cents = 0i64;
8719
8720            for (i, (price_cents, size)) in updates.iter().enumerate() {
8721                if *price_cents < min_cents {
8722                    min_cents = *price_cents;
8723                }
8724
8725                if *price_cents > max_cents {
8726                    max_cents = *price_cents;
8727                }
8728
8729                if i == 0 {
8730                    first_cents = *price_cents;
8731                }
8732
8733                last_cents = *price_cents;
8734                builder.update(
8735                    Price::new((*price_cents as f64) / 100.0, 2),
8736                    Quantity::new(*size as f64, 0),
8737                    UnixNanos::from((i as u64 + 1) * 1_000),
8738                );
8739            }
8740
8741            let bar = builder.build_now();
8742            // Recompute via the same float math as the hot path so equality is exact.
8743            let expect = |cents: i64| Price::new((cents as f64) / 100.0 * ratio_f64, 2);
8744            prop_assert_eq!(bar.open, expect(first_cents));
8745            prop_assert_eq!(bar.close, expect(last_cents));
8746            // Ratio with positive ratio_f64 preserves ordering, so min/max map directly.
8747            prop_assert_eq!(bar.low, expect(min_cents));
8748            prop_assert_eq!(bar.high, expect(max_cents));
8749        }
8750
8751        #[rstest]
8752        fn prop_bar_builder_spread_mode_direction_is_metadata_only(
8753            updates in prop::collection::vec((10_000i64..=100_000i64, 1u64..=100u64), 1..=20),
8754            spread_cents in -10_000i64..=10_000i64,
8755        ) {
8756            let instrument = InstrumentAny::Equity(equity_aapl());
8757            let bar_spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
8758            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8759
8760            let spread = Decimal::new(spread_cents, 2);
8761            let mut backward = BarBuilder::new(bar_type, 2, 0);
8762            let mut forward = BarBuilder::new(bar_type, 2, 0);
8763            backward.set_adjustment(spread, ContinuousFutureAdjustmentType::BackwardSpread);
8764            forward.set_adjustment(spread, ContinuousFutureAdjustmentType::ForwardSpread);
8765
8766            for (i, (price_cents, size)) in updates.iter().enumerate() {
8767                let price = Price::new((*price_cents as f64) / 100.0, 2);
8768                let qty = Quantity::new(*size as f64, 0);
8769                let ts = UnixNanos::from((i as u64 + 1) * 1_000);
8770                backward.update(price, qty, ts);
8771                forward.update(price, qty, ts);
8772            }
8773
8774            let bar_backward = backward.build_now();
8775            let bar_forward = forward.build_now();
8776            prop_assert_eq!(bar_backward.open, bar_forward.open);
8777            prop_assert_eq!(bar_backward.high, bar_forward.high);
8778            prop_assert_eq!(bar_backward.low, bar_forward.low);
8779            prop_assert_eq!(bar_backward.close, bar_forward.close);
8780        }
8781
8782        #[rstest]
8783        fn prop_value_bar_aggregator_ohlc_invariants(
8784            ticks in prop::collection::vec((50i64..=500i64, 1u64..=20u64), 2..=30),
8785            step in 100u64..=2_000u64,
8786        ) {
8787            let instrument = InstrumentAny::Equity(equity_aapl());
8788            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Value, PriceType::Last);
8789            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8790            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8791            let handler_clone = Arc::clone(&handler);
8792
8793            let mut aggregator = ValueBarAggregator::new(
8794                bar_type,
8795                instrument.price_precision(),
8796                instrument.size_precision(),
8797                move |bar: Bar| {
8798                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8799                },
8800            );
8801
8802            for (i, (price_cents, size)) in ticks.iter().enumerate() {
8803                aggregator.update(
8804                    Price::new((*price_cents as f64) / 100.0, 2),
8805                    Quantity::new(*size as f64, 0),
8806                    UnixNanos::from((i as u64 + 1) * 1_000),
8807                );
8808            }
8809
8810            let bars = handler.lock().expect(MUTEX_POISONED);
8811            for bar in bars.iter() {
8812                prop_assert!(bar.low <= bar.open);
8813                prop_assert!(bar.low <= bar.close);
8814                prop_assert!(bar.high >= bar.open);
8815                prop_assert!(bar.high >= bar.close);
8816                prop_assert!(bar.volume.as_f64() > 0.0);
8817            }
8818        }
8819
8820        #[rstest]
8821        fn prop_renko_brick_chain(
8822            moves in prop::collection::vec(-500i64..=500i64, 1..=60),
8823            step in 1usize..=10,
8824        ) {
8825            let instrument = InstrumentAny::Equity(equity_aapl());
8826            let bar_spec = BarSpecification::new(step, BarAggregation::Renko, PriceType::Last);
8827            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8828            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8829            let handler_clone = Arc::clone(&handler);
8830
8831            let price_increment = Price::from("0.01");
8832            let mut aggregator = RenkoBarAggregator::new(
8833                bar_type,
8834                2,
8835                0,
8836                price_increment,
8837                move |bar: Bar| {
8838                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8839                },
8840            );
8841            let brick_size = aggregator.brick_size;
8842
8843            let base_raw = Price::from("1000.00").raw;
8844            let mut cum_increments: i64 = 0;
8845            let mut first_price: Option<Price> = None;
8846
8847            for (i, delta) in moves.iter().enumerate() {
8848                cum_increments += delta;
8849                let price = Price::from_raw(
8850                    base_raw + PriceRaw::from(cum_increments) * price_increment.raw,
8851                    2,
8852                );
8853
8854                if first_price.is_none() {
8855                    first_price = Some(price);
8856                }
8857
8858                aggregator.update(price, Quantity::from(1), UnixNanos::from((i as u64 + 1) * 1_000));
8859            }
8860
8861            let bars = handler.lock().expect(MUTEX_POISONED);
8862            let mut expected_open = first_price.unwrap();
8863
8864            for bar in bars.iter() {
8865                // Bricks chain: each opens at the previous close.
8866                prop_assert_eq!(bar.open, expected_open);
8867                // Every brick spans exactly one brick size.
8868                prop_assert_eq!((bar.close.raw - bar.open.raw).abs(), brick_size);
8869                // High/low are the brick endpoints.
8870                prop_assert_eq!(bar.high, bar.open.max(bar.close));
8871                prop_assert_eq!(bar.low, bar.open.min(bar.close));
8872                expected_open = bar.close;
8873            }
8874        }
8875
8876        #[rstest]
8877        fn prop_volume_imbalance_one_sided_conservation(
8878            sizes in prop::collection::vec(1u64..=50u64, 1..=40),
8879            step in 2u64..=10u64,
8880            buyer in any::<bool>(),
8881        ) {
8882            let instrument = InstrumentAny::Equity(equity_aapl());
8883            let bar_spec = BarSpecification::new(
8884                step as usize,
8885                BarAggregation::VolumeImbalance,
8886                PriceType::Last,
8887            );
8888            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8889            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8890            let handler_clone = Arc::clone(&handler);
8891
8892            let mut aggregator = VolumeImbalanceBarAggregator::new(
8893                bar_type,
8894                instrument.price_precision(),
8895                instrument.size_precision(),
8896                move |bar: Bar| {
8897                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8898                },
8899            );
8900
8901            let side = if buyer { AggressorSide::Buy } else { AggressorSide::Sell };
8902            let mut total_input: u64 = 0;
8903
8904            for (i, size) in sizes.iter().enumerate() {
8905                let trade = TradeTick {
8906                    instrument_id: instrument.id(),
8907                    price: Price::from("100.00"),
8908                    size: Quantity::from(*size),
8909                    aggressor_side: side,
8910                    ts_event: UnixNanos::from((i as u64 + 1) * 1_000),
8911                    ts_init: UnixNanos::from((i as u64 + 1) * 1_000),
8912                    ..TradeTick::default()
8913                };
8914                aggregator.handle_trade(trade);
8915                total_input += *size;
8916            }
8917
8918            let bars = handler.lock().expect(MUTEX_POISONED);
8919
8920            // One-sided flow: every emitted bar carries exactly `step` volume.
8921            for bar in bars.iter() {
8922                prop_assert_eq!(bar.volume, Quantity::from(step));
8923            }
8924
8925            // Conservation: emitted volume plus pending builder volume equals input.
8926            let emitted: u64 = bars.len() as u64 * step;
8927            let pending = aggregator.core.builder.volume.as_f64();
8928            prop_assert!((emitted as f64 + pending - total_input as f64).abs() < 1e-9);
8929        }
8930
8931        #[rstest]
8932        fn prop_volume_runs_one_sided_conservation(
8933            sizes in prop::collection::vec(1u64..=50u64, 1..=40),
8934            step in 2u64..=10u64,
8935            buyer in any::<bool>(),
8936        ) {
8937            let instrument = InstrumentAny::Equity(equity_aapl());
8938            let bar_spec = BarSpecification::new(
8939                step as usize,
8940                BarAggregation::VolumeRuns,
8941                PriceType::Last,
8942            );
8943            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8944            let handler = Arc::new(Mutex::new(Vec::<Bar>::new()));
8945            let handler_clone = Arc::clone(&handler);
8946
8947            let mut aggregator = VolumeRunsBarAggregator::new(
8948                bar_type,
8949                instrument.price_precision(),
8950                instrument.size_precision(),
8951                move |bar: Bar| {
8952                    handler_clone.lock().expect(MUTEX_POISONED).push(bar);
8953                },
8954            );
8955
8956            let side = if buyer { AggressorSide::Buy } else { AggressorSide::Sell };
8957            let mut total_input: u64 = 0;
8958
8959            for (i, size) in sizes.iter().enumerate() {
8960                let trade = TradeTick {
8961                    instrument_id: instrument.id(),
8962                    price: Price::from("100.00"),
8963                    size: Quantity::from(*size),
8964                    aggressor_side: side,
8965                    ts_event: UnixNanos::from((i as u64 + 1) * 1_000),
8966                    ts_init: UnixNanos::from((i as u64 + 1) * 1_000),
8967                    ..TradeTick::default()
8968                };
8969                aggregator.handle_trade(trade);
8970                total_input += *size;
8971            }
8972
8973            let bars = handler.lock().expect(MUTEX_POISONED);
8974
8975            // A single-sided run never resets, so every bar carries exactly `step` volume.
8976            for bar in bars.iter() {
8977                prop_assert_eq!(bar.volume, Quantity::from(step));
8978            }
8979
8980            let emitted: u64 = bars.len() as u64 * step;
8981            let pending = aggregator.core.builder.volume.as_f64();
8982            prop_assert!((emitted as f64 + pending - total_input as f64).abs() < 1e-9);
8983        }
8984
8985        #[rstest]
8986        fn prop_value_bar_cum_value_stays_below_step(
8987            ticks in prop::collection::vec((50i64..=500i64, 1u64..=20u64), 1..=30),
8988            step in 100u64..=2_000u64,
8989        ) {
8990            let instrument = InstrumentAny::Equity(equity_aapl());
8991            let bar_spec = BarSpecification::new(step as usize, BarAggregation::Value, PriceType::Last);
8992            let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::Internal);
8993            let step_decimal = Decimal::from(step);
8994
8995            let mut aggregator = ValueBarAggregator::new(
8996                bar_type,
8997                instrument.price_precision(),
8998                instrument.size_precision(),
8999                |_: Bar| {},
9000            );
9001
9002            for (i, (price_cents, size)) in ticks.iter().enumerate() {
9003                aggregator.update(
9004                    Price::new((*price_cents as f64) / 100.0, 2),
9005                    Quantity::new(*size as f64, 0),
9006                    UnixNanos::from((i as u64 + 1) * 1_000),
9007                );
9008
9009                // Invariant: the accumulator is always strictly below the step threshold,
9010                // which also guarantees the loop division never sees a zero divisor.
9011                prop_assert!(aggregator.get_cumulative_value() < step_decimal);
9012            }
9013        }
9014    }
9015}