Skip to content

Commit 7191b3e

Browse files
authored
Add test for time-bar aggregation (#2391)
1 parent 1371352 commit 7191b3e

File tree

2 files changed

+243
-0
lines changed

2 files changed

+243
-0
lines changed

nautilus_trader/test_kit/providers.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from nautilus_trader.model.currencies import USDC
4242
from nautilus_trader.model.currencies import USDT
4343
from nautilus_trader.model.currencies import XRP
44+
from nautilus_trader.model.data import Bar
4445
from nautilus_trader.model.data import QuoteTick
4546
from nautilus_trader.model.data import TradeTick
4647
from nautilus_trader.model.enums import AggressorSide
@@ -58,6 +59,7 @@
5859
from nautilus_trader.model.instruments import CurrencyPair
5960
from nautilus_trader.model.instruments import Equity
6061
from nautilus_trader.model.instruments import FuturesContract
62+
from nautilus_trader.model.instruments import Instrument
6163
from nautilus_trader.model.instruments import OptionContract
6264
from nautilus_trader.model.instruments import SyntheticInstrument
6365
from nautilus_trader.model.instruments.betting import null_handicap
@@ -71,6 +73,10 @@
7173
from nautilus_trader.persistence.loaders import ParquetTickDataLoader
7274

7375

76+
# Constants
77+
NANOSECONDS_IN_SECOND = 1_000_000_000 # 1 billion nanoseconds in a second
78+
79+
7480
class TestInstrumentProvider:
7581
"""
7682
Provides instrument template methods for backtesting.
@@ -1033,6 +1039,76 @@ def generate_trade_ticks(
10331039
for idx, row in df.iterrows()
10341040
]
10351041

1042+
@staticmethod
1043+
def generate_monotonic_bars(
1044+
instrument: Instrument,
1045+
first_bar: Bar,
1046+
bar_count: int = 20,
1047+
time_change_nanos: int = 60 * NANOSECONDS_IN_SECOND, # Default to 1 minute
1048+
price_change_ticks: int = 10,
1049+
increasing_series: bool = True,
1050+
) -> list[Bar]:
1051+
"""
1052+
Generate a sequence of bars with monotonic price progression.
1053+
1054+
This function creates a series of bars with consistent price progression
1055+
based on the specified parameters. Each subsequent bar's prices change
1056+
by a constant amount of ticks in the direction specified by `increasing_series`.
1057+
1058+
Parameters
1059+
----------
1060+
instrument : Instrument
1061+
The instrument for which to generate bars
1062+
bar_type : BarType
1063+
The bar type specification for the generated bars
1064+
first_bar : Bar
1065+
The first bar in the sequence with initial OHLCV values
1066+
bar_count : int
1067+
The total number of bars to generate (including first_bar)
1068+
time_change_nanos : int
1069+
The time increment between consecutive bars in nanoseconds
1070+
price_change_ticks : int
1071+
The price increment between consecutive bars in ticks
1072+
increasing_series : bool
1073+
If True, generates a price series with increasing prices;
1074+
if False, generates a series with decreasing prices
1075+
1076+
Returns
1077+
-------
1078+
list[Bar]
1079+
The list of generated bars with the specified progression
1080+
1081+
"""
1082+
# Calculate price change
1083+
tick_size = instrument.price_increment.as_double()
1084+
price_change = tick_size * price_change_ticks
1085+
1086+
# Increasing or decreasing series
1087+
if not increasing_series:
1088+
price_change = -price_change
1089+
1090+
# Collection of all generated artificial bars
1091+
bars = [first_bar]
1092+
1093+
# Generate subsequent bars
1094+
for i in range(bar_count - 1): # -1 because we already have the first bar
1095+
prev_bar = bars[-1] # Get the last bar
1096+
ts_event = prev_bar.ts_event + time_change_nanos
1097+
ts_init = prev_bar.ts_init + time_change_nanos
1098+
next_bar = Bar(
1099+
bar_type=first_bar.bar_type,
1100+
open=instrument.make_price(prev_bar.open + price_change),
1101+
high=instrument.make_price(prev_bar.high + price_change),
1102+
low=instrument.make_price(prev_bar.low + price_change),
1103+
close=instrument.make_price(prev_bar.close + price_change),
1104+
volume=prev_bar.volume,
1105+
ts_event=ts_event,
1106+
ts_init=ts_init,
1107+
)
1108+
bars.append(next_bar)
1109+
1110+
return bars
1111+
10361112

10371113
def get_test_data_large_path() -> Path:
10381114
return (PACKAGE_ROOT / "tests" / "test_data" / "large").resolve()
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)