Skip to main content

nautilus_data/
aggregation.rs

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