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