|
| 1 | +from decimal import Decimal |
| 2 | + |
| 3 | +import pandas as pd |
| 4 | + |
| 5 | +from nautilus_trader.backtest.engine import BacktestEngine |
| 6 | +from nautilus_trader.config import BacktestEngineConfig |
| 7 | +from nautilus_trader.core.datetime import dt_to_unix_nanos |
| 8 | +from nautilus_trader.core.nautilus_pyo3 import NANOSECONDS_IN_SECOND |
| 9 | +from nautilus_trader.model.currencies import USD |
| 10 | +from nautilus_trader.model.data import Bar |
| 11 | +from nautilus_trader.model.data import BarType |
| 12 | +from nautilus_trader.model.enums import AccountType |
| 13 | +from nautilus_trader.model.enums import OmsType |
| 14 | +from nautilus_trader.model.identifiers import TraderId |
| 15 | +from nautilus_trader.model.identifiers import Venue |
| 16 | +from nautilus_trader.model.objects import Money |
| 17 | +from nautilus_trader.model.objects import Quantity |
| 18 | +from nautilus_trader.test_kit.providers import TestDataGenerator |
| 19 | +from nautilus_trader.test_kit.providers import TestInstrumentProvider |
| 20 | +from nautilus_trader.trading.strategy import Strategy |
| 21 | + |
| 22 | + |
| 23 | +class BarAggregationStrategy(Strategy): |
| 24 | + """ |
| 25 | + A simple strategy that tracks bar aggregation from 1-minute to 5-minute bars. |
| 26 | + """ |
| 27 | + |
| 28 | + def __init__(self, bar_type_1min_external: BarType, bar_type_5min_composite_internal: BarType): |
| 29 | + super().__init__() |
| 30 | + |
| 31 | + # Traded instrument |
| 32 | + self.instrument_id = bar_type_1min_external.instrument_id |
| 33 | + |
| 34 | + # Bar types |
| 35 | + self.bar_type_1min = bar_type_1min_external |
| 36 | + self.bar_type_5min = bar_type_5min_composite_internal |
| 37 | + |
| 38 | + # Collected bars for verification |
| 39 | + self.received_1min_bars: list[Bar] = [] |
| 40 | + self.received_5min_bars: list[Bar] = [] |
| 41 | + |
| 42 | + def on_start(self): |
| 43 | + """ |
| 44 | + Subscribe to both 1-minute bars and aggregated 5-minute bars. |
| 45 | + """ |
| 46 | + # Subscribe to 1-minute bars |
| 47 | + self.subscribe_bars(self.bar_type_1min) |
| 48 | + |
| 49 | + # Subscribe to 5-minute bars (aggregated from 1-minute bars) |
| 50 | + self.subscribe_bars( |
| 51 | + BarType.from_str( |
| 52 | + f"{self.instrument_id}-5-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL", |
| 53 | + ), |
| 54 | + ) |
| 55 | + |
| 56 | + def on_bar(self, bar: Bar): |
| 57 | + # Record received bars based on their type. |
| 58 | + if bar.bar_type == self.bar_type_1min: |
| 59 | + self.received_1min_bars.append(bar) |
| 60 | + elif bar.bar_type == self.bar_type_5min: |
| 61 | + self.received_5min_bars.append(bar) |
| 62 | + |
| 63 | + |
| 64 | +def test_time_bar_aggregation(): |
| 65 | + """ |
| 66 | + Test that verifies the basic functionality of aggregating 1-minute bars into |
| 67 | + 5-minute bars. |
| 68 | +
|
| 69 | + This test focuses specifically on the aggregation process and verifies it works |
| 70 | + without errors. |
| 71 | +
|
| 72 | + """ |
| 73 | + # Create a backtest engine |
| 74 | + engine = BacktestEngine( |
| 75 | + config=BacktestEngineConfig( |
| 76 | + trader_id=TraderId("TESTER-000"), |
| 77 | + ), |
| 78 | + ) |
| 79 | + |
| 80 | + # Add a test venue |
| 81 | + venue_name = "XCME" |
| 82 | + engine.add_venue( |
| 83 | + venue=Venue(venue_name), |
| 84 | + oms_type=OmsType.NETTING, |
| 85 | + account_type=AccountType.MARGIN, |
| 86 | + base_currency=USD, |
| 87 | + starting_balances=[Money(1_000_000, USD)], |
| 88 | + default_leverage=Decimal(1), |
| 89 | + ) |
| 90 | + |
| 91 | + # Add test instrument (6E futures contract) |
| 92 | + instrument = TestInstrumentProvider.eurusd_future( |
| 93 | + expiry_year=2024, |
| 94 | + expiry_month=3, |
| 95 | + venue_name=venue_name, |
| 96 | + ) |
| 97 | + engine.add_instrument(instrument) |
| 98 | + |
| 99 | + # Create bar types for 1-minute and 5-minute bars |
| 100 | + bar_type_1min = BarType.from_str(f"{instrument.id}-1-MINUTE-LAST-EXTERNAL") |
| 101 | + bar_type_5min = BarType.from_str(f"{instrument.id}-5-MINUTE-LAST-INTERNAL") |
| 102 | + |
| 103 | + # Create and add test strategy |
| 104 | + strategy = BarAggregationStrategy( |
| 105 | + bar_type_1min_external=bar_type_1min, |
| 106 | + bar_type_5min_composite_internal=bar_type_5min, |
| 107 | + ) |
| 108 | + engine.add_strategy(strategy) |
| 109 | + |
| 110 | + # Set up backtest time range |
| 111 | + start_time = pd.Timestamp("2024-01-01 00:01:00", tz="UTC") |
| 112 | + end_time = pd.Timestamp("2024-01-01 01:00:00", tz="UTC") # 1 hour later |
| 113 | + |
| 114 | + # Create first bar with values matching the example |
| 115 | + first_bar = Bar( |
| 116 | + bar_type=bar_type_1min, |
| 117 | + open=instrument.make_price(1.1020), |
| 118 | + high=instrument.make_price(1.1025), |
| 119 | + low=instrument.make_price(1.0995), |
| 120 | + close=instrument.make_price(1.1000), |
| 121 | + volume=Quantity.from_int(999999), # unlimited volume |
| 122 | + ts_event=dt_to_unix_nanos(start_time), |
| 123 | + ts_init=dt_to_unix_nanos(start_time), |
| 124 | + ) |
| 125 | + |
| 126 | + # Generate synthetic 1-minute bar data |
| 127 | + bars = TestDataGenerator.generate_monotonic_bars( |
| 128 | + instrument=instrument, |
| 129 | + first_bar=first_bar, |
| 130 | + bar_count=60, # Generate 60 one-minute bars for one hour |
| 131 | + time_change_nanos=60 * NANOSECONDS_IN_SECOND, # 1 minute in nanoseconds |
| 132 | + ) |
| 133 | + |
| 134 | + # Add data to the engine |
| 135 | + engine.add_data(bars) |
| 136 | + |
| 137 | + # Run the backtest with explicit time range |
| 138 | + engine.run(start=start_time, end=end_time) |
| 139 | + |
| 140 | + # ASSERTS |
| 141 | + |
| 142 | + # Verify we received the expected number of bars |
| 143 | + assert len(strategy.received_1min_bars) == 60, "Should receive 60x 1-minute bars (in 1 hour)" |
| 144 | + assert len(strategy.received_5min_bars) == 12, "Should receive 12x 5-minute bars (in 1 hour)" |
| 145 | + |
| 146 | + # Verify the 5-minute bars are 100% correctly aggregated |
| 147 | + for i in range(len(strategy.received_5min_bars)): |
| 148 | + five_min_bar = strategy.received_5min_bars[i] |
| 149 | + # Each 5-minute bar should correspond to 5x 1-minute bars |
| 150 | + corresponding_1min_bars = strategy.received_1min_bars[i * 5 : (i + 1) * 5] |
| 151 | + |
| 152 | + # Basic validation of 5-minute bar properties |
| 153 | + assert ( |
| 154 | + five_min_bar.open == corresponding_1min_bars[0].open |
| 155 | + ), "5-min bar should open at first 1-min bar" |
| 156 | + assert ( |
| 157 | + five_min_bar.close == corresponding_1min_bars[-1].close |
| 158 | + ), "5-min bar should close at last 1-min bar" |
| 159 | + assert five_min_bar.high == max( |
| 160 | + bar.high for bar in corresponding_1min_bars |
| 161 | + ), "5-min high should be == max of 1-min highs" |
| 162 | + assert five_min_bar.low == min( |
| 163 | + bar.low for bar in corresponding_1min_bars |
| 164 | + ), "5-min low should be == min of 1-min lows" |
| 165 | + |
| 166 | + # Cleanup |
| 167 | + engine.dispose() |
0 commit comments