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