233 lines
7.9 KiB
Python
233 lines
7.9 KiB
Python
from datetime import datetime
|
||
from typing import cast
|
||
|
||
import polars as pl
|
||
|
||
from vnpy.alpha.lab import AlphaLab
|
||
from vnpy.alpha.strategy.backtesting import BacktestingEngine
|
||
from vnpy.alpha.strategy.template import AlphaStrategy
|
||
from vnpy.trader.constant import Direction, Exchange, Offset
|
||
from vnpy.trader.object import BarData, OrderData, TradeData
|
||
from vnpy.trader.utility import extract_vt_symbol, round_to
|
||
|
||
|
||
class RecordingStrategy(AlphaStrategy):
|
||
target_volume: float = 1
|
||
price_add: float = 0
|
||
|
||
def on_init(self) -> None:
|
||
self.sent: bool = False
|
||
self.trade_counts: list[int] = []
|
||
self.seen_symbols: list[set[str]] = []
|
||
self.cached_ohlc: list[tuple[float, float, float, float]] = []
|
||
|
||
def on_bars(self, bars: dict[str, BarData]) -> None:
|
||
self.trade_counts.append(len(self.strategy_engine.trades))
|
||
self.seen_symbols.append(set(bars))
|
||
vt_symbol: str = self.vt_symbols[0]
|
||
cached: BarData = self.strategy_engine.bars[vt_symbol]
|
||
self.cached_ohlc.append(
|
||
(cached.open_price, cached.high_price, cached.low_price, cached.close_price)
|
||
)
|
||
if self.sent or vt_symbol not in bars:
|
||
return
|
||
self.set_target(vt_symbol, self.target_volume)
|
||
self.execute_trading(bars, self.price_add)
|
||
self.sent = True
|
||
|
||
def on_trade(self, trade: TradeData) -> None:
|
||
return
|
||
|
||
|
||
def new_engine(vt_symbols: list[str], capital: float) -> BacktestingEngine:
|
||
engine: BacktestingEngine = BacktestingEngine(cast(AlphaLab, object()))
|
||
engine.vt_symbols = list(vt_symbols)
|
||
engine.capital = capital
|
||
engine.cash = capital
|
||
vt_symbol: str
|
||
for vt_symbol in vt_symbols:
|
||
engine.long_rates[vt_symbol] = 0
|
||
engine.short_rates[vt_symbol] = 0
|
||
engine.sizes[vt_symbol] = 1
|
||
engine.priceticks[vt_symbol] = 1
|
||
return engine
|
||
|
||
|
||
def make_bar(
|
||
vt_symbol: str,
|
||
dt: datetime,
|
||
open_price: float,
|
||
high_price: float,
|
||
low_price: float,
|
||
close_price: float,
|
||
) -> BarData:
|
||
symbol: str
|
||
exchange: Exchange
|
||
symbol, exchange = extract_vt_symbol(vt_symbol)
|
||
return BarData(
|
||
gateway_name="BACKTESTING",
|
||
symbol=symbol,
|
||
exchange=exchange,
|
||
datetime=dt,
|
||
open_price=open_price,
|
||
high_price=high_price,
|
||
low_price=low_price,
|
||
close_price=close_price,
|
||
)
|
||
|
||
|
||
def add_history(engine: BacktestingEngine, bars: list[BarData]) -> None:
|
||
bar: BarData
|
||
for bar in bars:
|
||
engine.dts.add(bar.datetime)
|
||
engine.history_data[(bar.datetime, bar.vt_symbol)] = bar
|
||
|
||
|
||
def run_recording(
|
||
engine: BacktestingEngine,
|
||
target_volume: float,
|
||
price_add: float,
|
||
) -> RecordingStrategy:
|
||
engine.add_strategy(
|
||
RecordingStrategy,
|
||
{"target_volume": target_volume, "price_add": price_add},
|
||
pl.DataFrame(),
|
||
)
|
||
engine.run_backtesting()
|
||
assert isinstance(engine.strategy, RecordingStrategy)
|
||
return engine.strategy
|
||
|
||
|
||
def test_order_from_first_bar_fills_on_second_bar() -> None:
|
||
vt_symbol: str = "rb2410.SHFE"
|
||
first_dt: datetime = datetime(2024, 1, 2, 9, 0)
|
||
second_dt: datetime = datetime(2024, 1, 2, 9, 1)
|
||
engine: BacktestingEngine = new_engine([vt_symbol], 1_000_000)
|
||
add_history(
|
||
engine,
|
||
[
|
||
make_bar(vt_symbol, first_dt, 100, 101, 99, 100),
|
||
make_bar(vt_symbol, second_dt, 100, 101, 99, 100),
|
||
],
|
||
)
|
||
|
||
strategy: RecordingStrategy = run_recording(engine, target_volume=1, price_add=0)
|
||
trades: list[TradeData] = engine.get_all_trades()
|
||
|
||
# 第 2 根 on_bars 开始时成交已经在,cross_order 发生在 on_bars 之前。
|
||
assert strategy.trade_counts == [0, 1]
|
||
assert len(trades) == 1
|
||
assert trades[0].datetime == second_dt
|
||
|
||
|
||
def test_long_and_short_cross_prices_use_open() -> None:
|
||
long_symbol: str = "rb2410.SHFE"
|
||
long_first: datetime = datetime(2024, 1, 2, 9, 0)
|
||
long_second: datetime = datetime(2024, 1, 2, 9, 1)
|
||
long_engine: BacktestingEngine = new_engine([long_symbol], 1_000_000)
|
||
add_history(
|
||
long_engine,
|
||
[
|
||
make_bar(long_symbol, long_first, 100, 101, 99, 100),
|
||
make_bar(long_symbol, long_second, 102, 106, 101, 104),
|
||
],
|
||
)
|
||
run_recording(long_engine, target_volume=1, price_add=0.05)
|
||
long_trades: list[TradeData] = long_engine.get_all_trades()
|
||
# 委托价 105 >= low 101 且 low > 0,多头成交价取 min(委托价, open)。
|
||
assert len(long_trades) == 1
|
||
assert long_trades[0].direction == Direction.LONG
|
||
assert long_trades[0].price == 102
|
||
|
||
short_symbol: str = "hc2410.SHFE"
|
||
short_first: datetime = datetime(2024, 1, 2, 9, 0)
|
||
short_second: datetime = datetime(2024, 1, 2, 9, 1)
|
||
short_engine: BacktestingEngine = new_engine([short_symbol], 1_000_000)
|
||
add_history(
|
||
short_engine,
|
||
[
|
||
make_bar(short_symbol, short_first, 100, 101, 99, 100),
|
||
make_bar(short_symbol, short_second, 104, 106, 98, 103),
|
||
],
|
||
)
|
||
run_recording(short_engine, target_volume=-1, price_add=0)
|
||
short_trades: list[TradeData] = short_engine.get_all_trades()
|
||
# 委托价 100 <= high 106 且 high > 0,空头成交价取 max(委托价, open)。
|
||
assert len(short_trades) == 1
|
||
assert short_trades[0].direction == Direction.SHORT
|
||
assert short_trades[0].price == 104
|
||
|
||
|
||
def test_long_fill_cash_deducts_turnover_and_rate() -> None:
|
||
vt_symbol: str = "rb2410.SHFE"
|
||
capital: float = 1_000_000
|
||
first_dt: datetime = datetime(2024, 1, 2, 9, 0)
|
||
second_dt: datetime = datetime(2024, 1, 2, 9, 1)
|
||
engine: BacktestingEngine = new_engine([vt_symbol], capital)
|
||
engine.long_rates[vt_symbol] = 0.125
|
||
engine.short_rates[vt_symbol] = 0.5
|
||
engine.sizes[vt_symbol] = 10
|
||
add_history(
|
||
engine,
|
||
[
|
||
make_bar(vt_symbol, first_dt, 100, 101, 99, 100),
|
||
make_bar(vt_symbol, second_dt, 100, 101, 99, 100),
|
||
],
|
||
)
|
||
|
||
run_recording(engine, target_volume=2, price_add=0)
|
||
trades: list[TradeData] = engine.get_all_trades()
|
||
|
||
assert len(trades) == 1
|
||
assert trades[0].price == 100
|
||
assert trades[0].volume == 2
|
||
# turnover = 100 * 2 * 10;cash = capital - turnover - turnover * long_rate。
|
||
assert engine.cash == 997_750
|
||
assert engine.capital == capital
|
||
|
||
|
||
def test_execute_trading_price_add_rounds_to_tick() -> None:
|
||
vt_symbol: str = "rb2410.SHFE"
|
||
close_price: float = 100.3
|
||
pricetick: float = 0.2
|
||
engine: BacktestingEngine = new_engine([vt_symbol], 1_000_000)
|
||
engine.priceticks[vt_symbol] = pricetick
|
||
add_history(
|
||
engine,
|
||
[make_bar(vt_symbol, datetime(2024, 1, 2, 9, 0), 100, 101, 99, close_price)],
|
||
)
|
||
|
||
run_recording(engine, target_volume=1, price_add=0.05)
|
||
orders: list[OrderData] = engine.get_all_orders()
|
||
|
||
assert len(orders) == 1
|
||
assert orders[0].offset == Offset.OPEN
|
||
assert orders[0].price == round_to(close_price * 1.05, pricetick)
|
||
assert orders[0].price == 105.4
|
||
|
||
|
||
def test_missing_bar_is_filled_for_cross_but_hidden_from_on_bars() -> None:
|
||
target_symbol: str = "rb2410.SHFE"
|
||
other_symbol: str = "hc2410.SHFE"
|
||
first_dt: datetime = datetime(2024, 1, 2)
|
||
second_dt: datetime = datetime(2024, 1, 3)
|
||
engine: BacktestingEngine = new_engine([target_symbol, other_symbol], 1_000_000)
|
||
add_history(
|
||
engine,
|
||
[
|
||
make_bar(target_symbol, first_dt, 110, 112, 108, 100),
|
||
make_bar(other_symbol, first_dt, 50, 51, 49, 50),
|
||
make_bar(other_symbol, second_dt, 50, 52, 48, 51),
|
||
],
|
||
)
|
||
|
||
strategy: RecordingStrategy = run_recording(engine, target_volume=1, price_add=0.05)
|
||
trades: list[TradeData] = engine.get_all_trades()
|
||
|
||
assert strategy.seen_symbols[0] == {target_symbol, other_symbol}
|
||
assert strategy.seen_symbols[1] == {other_symbol}
|
||
assert strategy.cached_ohlc[1] == (100, 100, 100, 100)
|
||
assert len(trades) == 1
|
||
assert trades[0].datetime == second_dt
|
||
assert trades[0].price == 100
|