From e6fba4e938e8cfa14eb8dec2e867d0c48c201119 Mon Sep 17 00:00:00 2001 From: "vn.py" Date: Sun, 4 Oct 2026 13:30:57 +0800 Subject: [PATCH] [Add] add offline unit tests and run pytest in CI --- .github/workflows/pythonapp.yml | 5 + .gitignore | 4 + tests/alpha/test_expression.py | 57 +++++++++++ tests/conftest.py | 29 ++++++ tests/test_alpha101.py | 8 +- tests/test_converter.py | 157 +++++++++++++++++++++++++++++++ tests/test_engine.py | 71 ++++++++++++++ tests/test_event.py | 28 ++++++ tests/test_object.py | 161 ++++++++++++++++++++++++++++++++ tests/test_utility.py | 119 +++++++++++++++++++++++ 10 files changed, 635 insertions(+), 4 deletions(-) create mode 100644 tests/alpha/test_expression.py create mode 100644 tests/conftest.py create mode 100644 tests/test_converter.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_event.py create mode 100644 tests/test_object.py create mode 100644 tests/test_utility.py diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index d7ed36489..b544a3a15 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -37,5 +37,10 @@ jobs: - name: Type check run: mypy vnpy + - name: Test + run: | + python -m pip install pytest + python -m pytest tests + - name: Build run: uv build diff --git a/.gitignore b/.gitignore index 946094b8d..e32782788 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ _templates # Alpha lab/ /.mypy_cache + +# Type checker / linter / test caches +.ruff_cache/ +.pytest_cache/ diff --git a/tests/alpha/test_expression.py b/tests/alpha/test_expression.py new file mode 100644 index 000000000..b10308c2a --- /dev/null +++ b/tests/alpha/test_expression.py @@ -0,0 +1,57 @@ +import math +from datetime import datetime + +import polars as pl + +from vnpy.alpha.dataset.utility import calculate_by_expression + + +def sample_frame() -> pl.DataFrame: + dates: list[datetime] = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ] + return pl.DataFrame( + { + "datetime": dates + dates, + "vt_symbol": ["A", "A", "A", "B", "B", "B"], + "close": [1.0, 3.0, 5.0, 4.0, 2.0, 8.0], + } + ) + + +def data_values(frame: pl.DataFrame) -> list[float | None]: + values: list[float | None] = [] + value: object + for value in frame["data"].to_list(): + if value is None: + values.append(None) + else: + values.append(float(value)) + return values + + +def assert_values(actual: list[float | None], expected: list[float | None]) -> None: + assert len(actual) == len(expected) + got: float | None + want: float | None + for got, want in zip(actual, expected, strict=True): + if want is None: + assert got is None + else: + assert got is not None + assert math.isclose(got, want) + + +def test_delay_mean_and_rank_on_tiny_frame() -> None: + frame: pl.DataFrame = sample_frame() + + delayed: pl.DataFrame = calculate_by_expression(frame, "ts_delay(close, 1)") + averaged: pl.DataFrame = calculate_by_expression(frame, "ts_mean(close, 2)") + ranked: pl.DataFrame = calculate_by_expression(frame, "cs_rank(close)") + + assert delayed["vt_symbol"].to_list() == ["A", "A", "A", "B", "B", "B"] + assert_values(data_values(delayed), [None, 1.0, 3.0, None, 4.0, 2.0]) + assert_values(data_values(averaged), [1.0, 2.0, 4.0, 4.0, 3.0, 5.0]) + assert_values(data_values(ranked), [1.0, 2.0, 1.0, 2.0, 1.0, 2.0]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..293ccfcc7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +import os +import sys +import tempfile +from pathlib import Path + +import pytest + + +REPO_ROOT: Path = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +# vnpy.trader.utility 导入时按当前目录决定 .vntrader 位置,vnpy.trader.setting 导入时读取 vt_setting.json。 +# 必须在第一次 import vnpy 之前切到带空 .vntrader 的临时目录,避免读写用户真实配置。 +ORIGINAL_CWD: str = os.getcwd() +TRADER_TEMP_DIR: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() +Path(TRADER_TEMP_DIR.name).joinpath(".vntrader").mkdir() +os.chdir(TRADER_TEMP_DIR.name) + +from vnpy.trader.setting import SETTINGS # noqa: E402 + +# vnpy.trader.logger 导入时按 log.file 创建日志目录,需在它被导入前关闭。 +SETTINGS["log.file"] = False +SETTINGS["log.console"] = False + + +def pytest_unconfigure(config: pytest.Config) -> None: + os.chdir(ORIGINAL_CWD) + TRADER_TEMP_DIR.cleanup() diff --git a/tests/test_alpha101.py b/tests/test_alpha101.py index 91ff49e2f..31d88fc4c 100644 --- a/tests/test_alpha101.py +++ b/tests/test_alpha101.py @@ -433,7 +433,7 @@ class TestAlpha101: def test_alpha62(self, test_df: pl.DataFrame) -> None: """Test Alpha#62""" - expr = "cast_to_int(cs_rank(ts_corr(vwap, ts_sum(ts_mean(volume, 20), 22), 10)) < cs_rank(cast_to_int((cs_rank(open) + cs_rank(open)) < (cs_rank((high + low) / 2) + cs_rank(high))))) * -1" + expr = "(cs_rank(ts_corr(vwap, ts_sum(ts_mean(volume, 20), 22), 10)) < cs_rank((cs_rank(open) + cs_rank(open)) < (cs_rank((high + low) / 2) + cs_rank(high)))) * -1" result = calculate_by_expression(test_df, expr) assert "data" in result.columns @@ -445,13 +445,13 @@ class TestAlpha101: def test_alpha64(self, test_df: pl.DataFrame) -> None: """Test Alpha#64""" - expr = "cast_to_int(cs_rank(ts_corr(ts_sum(((open * 0.178404) + (low * (1 - 0.178404))), 13), ts_sum(ts_mean(volume, 120), 13), 17)) < cs_rank(ts_delta((((high + low) / 2 * 0.178404) + (vwap * (1 - 0.178404))), 4))) * -1" + expr = "(cs_rank(ts_corr(ts_sum(((open * 0.178404) + (low * (1 - 0.178404))), 13), ts_sum(ts_mean(volume, 120), 13), 17)) < cs_rank(ts_delta((((high + low) / 2 * 0.178404) + (vwap * (1 - 0.178404))), 4))) * -1" result = calculate_by_expression(test_df, expr) assert "data" in result.columns def test_alpha65(self, test_df: pl.DataFrame) -> None: """Test Alpha#65""" - expr = "cast_to_int(cs_rank(ts_corr(((open * 0.00817205) + (vwap * (1 - 0.00817205))), ts_sum(ts_mean(volume, 60), 9), 6)) < cs_rank(open - ts_min(open, 14))) * -1" + expr = "(cs_rank(ts_corr(((open * 0.00817205) + (vwap * (1 - 0.00817205))), ts_sum(ts_mean(volume, 60), 9), 6)) < cs_rank(open - ts_min(open, 14))) * -1" result = calculate_by_expression(test_df, expr) assert "data" in result.columns @@ -469,7 +469,7 @@ class TestAlpha101: def test_alpha68(self, test_df: pl.DataFrame) -> None: """Test Alpha#68""" - expr = "cast_to_int(ts_rank(ts_corr(cs_rank(high), cs_rank(ts_mean(volume, 15)), 9), 14) < cs_rank(ts_delta((close * 0.518371 + low * (1 - 0.518371)), 1))) * -1" + expr = "(ts_rank(ts_corr(cs_rank(high), cs_rank(ts_mean(volume, 15)), 9), 14) < cs_rank(ts_delta((close * 0.518371 + low * (1 - 0.518371)), 1))) * -1" result = calculate_by_expression(test_df, expr) assert "data" in result.columns diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 000000000..5c4928209 --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,157 @@ +from vnpy.trader.constant import Direction, Exchange, Offset, OrderType, Product +from vnpy.trader.converter import OffsetConverter, PositionHolding +from vnpy.trader.object import ContractData, OrderRequest + + +class ContractBook: + def __init__(self, contract: ContractData | None) -> None: + self.contract: ContractData | None = contract + + def get_contract(self, vt_symbol: str) -> ContractData | None: + if self.contract is not None and vt_symbol == self.contract.vt_symbol: + return self.contract + return None + + +def make_contract(exchange: Exchange, net_position: bool = False) -> ContractData: + return ContractData( + gateway_name="FAKE", + symbol="rb2410", + exchange=exchange, + name="rb", + product=Product.FUTURES, + size=10, + pricetick=1, + net_position=net_position, + ) + + +def make_request( + exchange: Exchange, + direction: Direction, + volume: float, + offset: Offset = Offset.CLOSE, +) -> OrderRequest: + return OrderRequest( + symbol="rb2410", + exchange=exchange, + direction=direction, + type=OrderType.LIMIT, + volume=volume, + price=3500, + offset=offset, + ) + + +def shfe_converter( + today: float, + yesterday: float, + today_frozen: float = 0, + yesterday_frozen: float = 0, +) -> OffsetConverter: + contract: ContractData = make_contract(Exchange.SHFE) + converter: OffsetConverter = OffsetConverter(ContractBook(contract)) + holding: PositionHolding | None = converter.get_position_holding(contract.vt_symbol) + assert holding is not None + holding.short_td = today + holding.short_yd = yesterday + holding.short_pos = today + yesterday + holding.short_td_frozen = today_frozen + holding.short_yd_frozen = yesterday_frozen + holding.short_pos_frozen = today_frozen + yesterday_frozen + return converter + + +def order_parts(requests: list[OrderRequest]) -> list[tuple[Offset, float]]: + return [(req.offset, req.volume) for req in requests] + + +def test_shfe_close_splits_today_then_yesterday() -> None: + converter: OffsetConverter = shfe_converter(today=3, yesterday=5) + + today_only: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 2) + assert order_parts(converter.convert_order_request(today_only, lock=False)) == [ + (Offset.CLOSETODAY, 2) + ] + assert today_only.offset == Offset.CLOSE + + split: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 5) + assert order_parts(converter.convert_order_request(split, lock=False)) == [ + (Offset.CLOSETODAY, 3), + (Offset.CLOSEYESTERDAY, 2), + ] + assert split.offset == Offset.CLOSE + + too_large: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 9) + assert converter.convert_order_request(too_large, lock=False) == [] + + opened: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 1, Offset.OPEN) + assert converter.convert_order_request(opened, lock=False) == [opened] + + +def test_shfe_close_uses_yesterday_when_today_is_empty() -> None: + converter: OffsetConverter = shfe_converter(today=0, yesterday=5) + request: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 2) + + assert order_parts(converter.convert_order_request(request, lock=False)) == [ + (Offset.CLOSEYESTERDAY, 2) + ] + + +def test_shfe_close_subtracts_frozen_volume() -> None: + converter: OffsetConverter = shfe_converter( + today=3, + yesterday=5, + today_frozen=1, + yesterday_frozen=2, + ) + request: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 4) + + assert order_parts(converter.convert_order_request(request, lock=False)) == [ + (Offset.CLOSETODAY, 2), + (Offset.CLOSEYESTERDAY, 2), + ] + + blocked: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 6) + assert converter.convert_order_request(blocked, lock=False) == [] + + +def test_shfe_short_close_uses_long_position() -> None: + contract: ContractData = make_contract(Exchange.SHFE) + converter: OffsetConverter = OffsetConverter(ContractBook(contract)) + holding: PositionHolding | None = converter.get_position_holding(contract.vt_symbol) + assert holding is not None + holding.long_td = 4 + holding.long_yd = 6 + holding.long_pos = 10 + + request: OrderRequest = make_request(Exchange.SHFE, Direction.SHORT, 7) + assert order_parts(converter.convert_order_request(request, lock=False)) == [ + (Offset.CLOSETODAY, 4), + (Offset.CLOSEYESTERDAY, 3), + ] + + +def test_dce_close_stays_close() -> None: + contract: ContractData = make_contract(Exchange.DCE) + converter: OffsetConverter = OffsetConverter(ContractBook(contract)) + holding: PositionHolding | None = converter.get_position_holding(contract.vt_symbol) + assert holding is not None + holding.long_td = 10 + holding.long_yd = 0 + holding.long_pos = 10 + + request: OrderRequest = make_request(Exchange.DCE, Direction.SHORT, 4) + assert converter.convert_order_request(request, lock=False, net=False) == [request] + assert request.offset == Offset.CLOSE + + +def test_net_position_and_missing_contract_skip_conversion() -> None: + net_contract: ContractData = make_contract(Exchange.SHFE, net_position=True) + net_converter: OffsetConverter = OffsetConverter(ContractBook(net_contract)) + net_request: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 5) + assert net_converter.convert_order_request(net_request, lock=False) == [net_request] + + missing: OffsetConverter = OffsetConverter(ContractBook(None)) + missing_request: OrderRequest = make_request(Exchange.SHFE, Direction.LONG, 1) + assert missing.convert_order_request(missing_request, lock=False) == [missing_request] diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 000000000..9a73d4fe3 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,71 @@ +from vnpy.event import EventEngine +from vnpy.trader.constant import Direction, Exchange, Offset, OrderType +from vnpy.trader.engine import MainEngine +from vnpy.trader.gateway import BaseGateway +from vnpy.trader.object import CancelRequest, OrderRequest, SubscribeRequest + + +class FakeGateway(BaseGateway): + default_name: str = "FAKE" + exchanges: list[Exchange] = [Exchange.SHFE] + + def __init__(self, event_engine: EventEngine, gateway_name: str) -> None: + super().__init__(event_engine, gateway_name) + self.orders: list[OrderRequest] = [] + self.cancels: list[CancelRequest] = [] + + def connect(self, setting: dict[str, str | int | float | bool]) -> None: + return + + def close(self) -> None: + return + + def subscribe(self, req: SubscribeRequest) -> None: + return + + def send_order(self, req: OrderRequest) -> str: + self.orders.append(req) + return f"{self.gateway_name}.1" + + def cancel_order(self, req: CancelRequest) -> None: + self.cancels.append(req) + + def query_account(self) -> None: + return + + def query_position(self) -> None: + return + + +def test_main_engine_routes_send_and_cancel_to_gateway() -> None: + main_engine: MainEngine = MainEngine() + try: + gateway: BaseGateway = main_engine.add_gateway(FakeGateway) + assert isinstance(gateway, FakeGateway) + assert main_engine.exchanges == [Exchange.SHFE] + + request: OrderRequest = OrderRequest( + symbol="rb2410", + exchange=Exchange.SHFE, + direction=Direction.LONG, + type=OrderType.LIMIT, + volume=1, + price=3000, + offset=Offset.OPEN, + ) + vt_orderid: str = main_engine.send_order(request, FakeGateway.default_name) + assert vt_orderid == "FAKE.1" + assert gateway.orders == [request] + + cancel: CancelRequest = CancelRequest( + orderid="1", + symbol="rb2410", + exchange=Exchange.SHFE, + ) + main_engine.cancel_order(cancel, FakeGateway.default_name) + assert gateway.cancels == [cancel] + + assert main_engine.send_order(request, "MISSING") == "" + assert gateway.orders == [request] + finally: + main_engine.close() diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 000000000..80d3ba700 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,28 @@ +import time + +from vnpy.event import Event, EventEngine + + +def test_event_engine_calls_registered_handler_once() -> None: + engine: EventEngine = EventEngine() + seen: list[int] = [] + + def handler(event: Event) -> None: + data: object = event.data + assert isinstance(data, int) + seen.append(data) + + engine.register("eTest", handler) + engine.register("eTest", handler) + engine.start() + try: + engine.put(Event("eTest", 7)) + deadline: float = time.monotonic() + 2 + while not seen and time.monotonic() < deadline: + time.sleep(0.01) + assert seen == [7] + finally: + engine.stop() + + assert not engine._thread.is_alive() + assert not engine._timer.is_alive() diff --git a/tests/test_object.py b/tests/test_object.py new file mode 100644 index 000000000..c2f9498e8 --- /dev/null +++ b/tests/test_object.py @@ -0,0 +1,161 @@ +from datetime import datetime + +from vnpy.trader.constant import Direction, Exchange, Offset, OrderType, Product, Status +from vnpy.trader.object import ( + AccountData, + BarData, + CancelRequest, + ContractData, + HistoryRequest, + OrderData, + OrderRequest, + PositionData, + QuoteData, + QuoteRequest, + SubscribeRequest, + TickData, + TradeData, +) + + +def test_vt_symbol_joins_symbol_and_exchange() -> None: + moment: datetime = datetime(2024, 1, 2, 9, 0) + tick: TickData = TickData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=moment, + ) + bar: BarData = BarData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=moment, + ) + contract: ContractData = ContractData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + name="rb", + product=Product.FUTURES, + size=10, + pricetick=1, + ) + subscribe: SubscribeRequest = SubscribeRequest(symbol="rb2410", exchange=Exchange.SHFE) + cancel: CancelRequest = CancelRequest(orderid="1001", symbol="rb2410", exchange=Exchange.SHFE) + history: HistoryRequest = HistoryRequest( + symbol="rb2410", + exchange=Exchange.SHFE, + start=moment, + ) + + assert tick.vt_symbol == "rb2410.SHFE" + assert bar.vt_symbol == "rb2410.SHFE" + assert contract.vt_symbol == "rb2410.SHFE" + assert subscribe.vt_symbol == "rb2410.SHFE" + assert cancel.vt_symbol == "rb2410.SHFE" + assert history.vt_symbol == "rb2410.SHFE" + + +def test_order_request_builds_order_identity() -> None: + request: OrderRequest = OrderRequest( + symbol="rb2410", + exchange=Exchange.SHFE, + direction=Direction.LONG, + type=OrderType.LIMIT, + volume=2, + price=3500, + offset=Offset.OPEN, + ) + order: OrderData = request.create_order_data("1001", "CTP") + cancel: CancelRequest = order.create_cancel_request() + + assert request.vt_symbol == "rb2410.SHFE" + assert order.gateway_name == "CTP" + assert order.orderid == "1001" + assert order.vt_symbol == "rb2410.SHFE" + assert order.vt_orderid == "CTP.1001" + assert order.direction == Direction.LONG + assert order.offset == Offset.OPEN + assert order.volume == 2 + assert cancel.orderid == "1001" + assert cancel.symbol == "rb2410" + assert cancel.exchange == Exchange.SHFE + assert cancel.vt_symbol == "rb2410.SHFE" + + +def test_trade_and_quote_ids_include_gateway() -> None: + trade: TradeData = TradeData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + orderid="1001", + tradeid="9", + direction=Direction.LONG, + ) + quote: QuoteData = QuoteData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + quoteid="q1", + ) + quote_request: QuoteRequest = QuoteRequest( + symbol="rb2410", + exchange=Exchange.SHFE, + bid_price=3499, + bid_volume=1, + ask_price=3501, + ask_volume=1, + ) + created: QuoteData = quote_request.create_quote_data("q1", "CTP") + + assert trade.vt_symbol == "rb2410.SHFE" + assert trade.vt_orderid == "CTP.1001" + assert trade.vt_tradeid == "CTP.9" + assert quote.vt_symbol == "rb2410.SHFE" + assert quote.vt_quoteid == "CTP.q1" + assert quote_request.vt_symbol == "rb2410.SHFE" + assert created.vt_quoteid == "CTP.q1" + assert created.vt_symbol == "rb2410.SHFE" + + +def test_position_and_account_ids() -> None: + position: PositionData = PositionData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + direction=Direction.SHORT, + ) + account: AccountData = AccountData( + gateway_name="CTP", + accountid="acc", + balance=1000, + frozen=100, + ) + + assert position.vt_symbol == "rb2410.SHFE" + assert position.vt_positionid == f"CTP.rb2410.SHFE.{Direction.SHORT.value}" + assert account.vt_accountid == "CTP.acc" + assert account.available == 900 + + +def test_order_is_active_for_working_statuses() -> None: + order: OrderData = OrderData( + gateway_name="CTP", + symbol="rb2410", + exchange=Exchange.SHFE, + orderid="1001", + status=Status.SUBMITTING, + ) + + assert order.is_active() + order.status = Status.NOTTRADED + assert order.is_active() + order.status = Status.PARTTRADED + assert order.is_active() + order.status = Status.ALLTRADED + assert not order.is_active() + order.status = Status.CANCELLED + assert not order.is_active() + order.status = Status.REJECTED + assert not order.is_active() diff --git a/tests/test_utility.py b/tests/test_utility.py new file mode 100644 index 000000000..07de58dea --- /dev/null +++ b/tests/test_utility.py @@ -0,0 +1,119 @@ +from datetime import datetime + +from vnpy.trader.constant import Exchange +from vnpy.trader.object import BarData, TickData +from vnpy.trader.utility import ( + BarGenerator, + ceil_to, + extract_vt_symbol, + floor_to, + generate_vt_symbol, + round_to, +) + + +def test_extract_and_generate_vt_symbol() -> None: + symbol, exchange = extract_vt_symbol("rb2410.SHFE") + + assert symbol == "rb2410" + assert exchange == Exchange.SHFE + assert generate_vt_symbol(symbol, exchange) == "rb2410.SHFE" + + option_symbol, option_exchange = extract_vt_symbol("IO2402-C-4000.CFFEX") + assert option_symbol == "IO2402-C-4000" + assert option_exchange == Exchange.CFFEX + + +def test_round_floor_and_ceil_to_price_tick() -> None: + assert round_to(123.456, 0.01) == 123.46 + assert round_to(123.454, 0.01) == 123.45 + assert round_to(100.15, 0.1) == 100.2 + assert floor_to(123.459, 0.01) == 123.45 + assert ceil_to(123.451, 0.01) == 123.46 + + +def test_bar_generator_builds_one_minute_bar_from_ticks() -> None: + finished: list[BarData] = [] + generator: BarGenerator = BarGenerator(finished.append) + + generator.update_tick( + TickData( + gateway_name="FAKE", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=datetime(2024, 1, 2, 9, 0, 5), + last_price=100, + volume=10, + ) + ) + generator.update_tick( + TickData( + gateway_name="FAKE", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=datetime(2024, 1, 2, 9, 0, 20), + last_price=0, + volume=99, + ) + ) + generator.update_tick( + TickData( + gateway_name="FAKE", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=datetime(2024, 1, 2, 9, 0, 40), + last_price=110, + volume=16, + high_price=112, + low_price=99, + ) + ) + + bar: BarData | None = generator.generate() + + assert bar is not None + assert finished == [bar] + assert bar.datetime == datetime(2024, 1, 2, 9, 0) + assert bar.open_price == 100 + assert bar.high_price == 112 + assert bar.low_price == 100 + assert bar.close_price == 110 + assert bar.volume == 6 + assert bar.vt_symbol == "rb2410.SHFE" + + +def test_bar_generator_completes_five_minute_window() -> None: + finished: list[BarData] = [] + generator: BarGenerator = BarGenerator( + on_bar=lambda bar: None, + window=5, + on_window_bar=finished.append, + ) + base: datetime = datetime(2024, 1, 2, 9, 0) + prices: list[float] = [10, 12, 9, 11, 13] + + offset: int + price: float + for offset, price in enumerate(prices): + generator.update_bar( + BarData( + gateway_name="FAKE", + symbol="rb2410", + exchange=Exchange.SHFE, + datetime=base.replace(minute=offset), + open_price=price, + high_price=price + 1, + low_price=price - 1, + close_price=price, + volume=float(offset + 1), + ) + ) + + assert len(finished) == 1 + window_bar: BarData = finished[0] + assert window_bar.open_price == 10 + assert window_bar.high_price == 14 + assert window_bar.low_price == 8 + assert window_bar.close_price == 13 + assert window_bar.volume == 15 + assert window_bar.datetime == datetime(2024, 1, 2, 9, 0)