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