945 lines
34 KiB
Python
945 lines
34 KiB
Python
from collections import defaultdict
|
||
from datetime import date, datetime
|
||
from copy import copy
|
||
from typing import cast
|
||
import traceback
|
||
|
||
import numpy as np
|
||
import polars as pl
|
||
import plotly.graph_objects as go # type: ignore
|
||
from plotly.subplots import make_subplots # type: ignore
|
||
from tqdm import tqdm
|
||
|
||
from vnpy.trader.constant import Direction, Offset, Interval, Status
|
||
from vnpy.trader.object import OrderData, TradeData, BarData
|
||
from vnpy.trader.utility import round_to, extract_vt_symbol
|
||
|
||
from ..logger import logger
|
||
from ..lab import AlphaLab
|
||
from .template import AlphaStrategy
|
||
|
||
|
||
class BacktestingEngine:
|
||
"""Alpha strategy backtesting engine"""
|
||
|
||
gateway_name: str = "BACKTESTING"
|
||
|
||
def __init__(self, lab: AlphaLab) -> None:
|
||
"""Constructor"""
|
||
self.lab: AlphaLab = lab
|
||
|
||
self.vt_symbols: list[str] = []
|
||
self.start: datetime
|
||
self.end: datetime
|
||
|
||
self.long_rates: dict[str, float] = {}
|
||
self.short_rates: dict[str, float] = {}
|
||
self.sizes: dict[str, float] = {}
|
||
self.priceticks: dict[str, float] = {}
|
||
|
||
self.capital: float = 0
|
||
self.risk_free: float = 0
|
||
self.annual_days: int = 0
|
||
|
||
self.strategy_class: type[AlphaStrategy]
|
||
self.strategy: AlphaStrategy
|
||
self.bars: dict[str, BarData] = {}
|
||
self.datetime: datetime | None = None
|
||
|
||
self.interval: Interval
|
||
self.history_data: dict[tuple, BarData] = {}
|
||
self.dts: set[datetime] = set()
|
||
|
||
self.limit_order_count: int = 0
|
||
self.limit_orders: dict[str, OrderData] = {}
|
||
self.active_limit_orders: dict[str, OrderData] = {}
|
||
|
||
self.trade_count: int = 0
|
||
self.trades: dict[str, TradeData] = {}
|
||
|
||
self.logs: list[str] = []
|
||
|
||
self.daily_results: dict[date, PortfolioDailyResult] = {}
|
||
self.daily_df: pl.DataFrame
|
||
|
||
self.pre_closes: defaultdict = defaultdict(float)
|
||
|
||
self.cash: float = 0
|
||
self.signal_df: pl.DataFrame
|
||
|
||
def set_parameters(
|
||
self,
|
||
vt_symbols: list[str],
|
||
interval: Interval,
|
||
start: datetime,
|
||
end: datetime,
|
||
capital: int = 1_000_000,
|
||
risk_free: float = 0,
|
||
annual_days: int = 240
|
||
) -> None:
|
||
"""Set parameters"""
|
||
self.vt_symbols = vt_symbols
|
||
self.interval = interval
|
||
|
||
self.start = start
|
||
self.end = end
|
||
self.capital = capital
|
||
self.risk_free = risk_free
|
||
self.annual_days = annual_days
|
||
|
||
self.cash = capital
|
||
|
||
contract_settings: dict = self.lab.load_contract_setttings()
|
||
for vt_symbol in vt_symbols:
|
||
setting: dict | None = contract_settings.get(vt_symbol, None)
|
||
if not setting:
|
||
logger.warning(f"找不到合约{vt_symbol}的交易配置,请检查!")
|
||
continue
|
||
|
||
self.long_rates[vt_symbol] = setting["long_rate"]
|
||
self.short_rates[vt_symbol] = setting["short_rate"]
|
||
self.sizes[vt_symbol] = setting["size"]
|
||
self.priceticks[vt_symbol] = setting["pricetick"]
|
||
|
||
def add_strategy(self, strategy_class: type, setting: dict, signal_df: pl.DataFrame) -> None:
|
||
"""Add strategy"""
|
||
self.strategy_class = strategy_class
|
||
self.strategy = strategy_class(
|
||
self, strategy_class.__name__, copy(self.vt_symbols), setting
|
||
)
|
||
self.signal_df = signal_df
|
||
|
||
def load_data(self) -> None:
|
||
"""Load historical data"""
|
||
logger.info("开始加载历史数据")
|
||
|
||
if not self.end:
|
||
self.end = datetime.now()
|
||
|
||
if self.start >= self.end:
|
||
logger.info("起始日期必须小于结束日期")
|
||
return
|
||
|
||
# Clear previously loaded historical data
|
||
self.history_data.clear()
|
||
self.dts.clear()
|
||
|
||
# Load historical data for each symbol
|
||
empty_symbols: list[str] = []
|
||
for vt_symbol in tqdm(self.vt_symbols, total=len(self.vt_symbols)):
|
||
data: list[BarData] = self.lab.load_bar_data(
|
||
vt_symbol,
|
||
self.interval,
|
||
self.start,
|
||
self.end
|
||
)
|
||
|
||
for bar in data:
|
||
self.dts.add(bar.datetime)
|
||
self.history_data[(bar.datetime, vt_symbol)] = bar
|
||
|
||
data_count = len(data)
|
||
if not data_count:
|
||
empty_symbols.append(vt_symbol)
|
||
|
||
if empty_symbols:
|
||
logger.info(f"部分合约历史数据为空:{empty_symbols}")
|
||
|
||
logger.info("所有历史数据加载完成")
|
||
|
||
def run_backtesting(self) -> None:
|
||
"""Start backtesting"""
|
||
self.strategy.on_init()
|
||
logger.info("策略初始化完成")
|
||
|
||
# Use remaining historical data for strategy backtesting
|
||
dts: list = list(self.dts)
|
||
dts.sort()
|
||
|
||
logger.info("开始回放历史数据")
|
||
for dt in dts:
|
||
try:
|
||
self.new_bars(dt)
|
||
except Exception:
|
||
logger.info("触发异常,回测终止")
|
||
logger.info(traceback.format_exc())
|
||
return
|
||
|
||
logger.info("历史数据回放结束")
|
||
|
||
def calculate_result(self) -> pl.DataFrame | None:
|
||
"""Calculate daily mark-to-market profit and loss"""
|
||
logger.info("开始计算逐日盯市盈亏")
|
||
|
||
if not self.trades:
|
||
logger.info("成交记录为空,无法计算")
|
||
return None
|
||
|
||
for trade in self.trades.values():
|
||
if not trade.datetime:
|
||
continue
|
||
|
||
d: date = trade.datetime.date()
|
||
daily_result: PortfolioDailyResult = self.daily_results[d]
|
||
daily_result.add_trade(trade)
|
||
|
||
pre_closes: dict[str, float] = {}
|
||
start_poses: dict[str, float] = {}
|
||
|
||
for daily_result in self.daily_results.values():
|
||
daily_result.calculate_pnl(
|
||
pre_closes,
|
||
start_poses,
|
||
self.sizes,
|
||
self.long_rates,
|
||
self.short_rates
|
||
)
|
||
|
||
pre_closes = daily_result.close_prices
|
||
start_poses = daily_result.end_poses
|
||
|
||
results: dict = defaultdict(list)
|
||
|
||
for daily_result in self.daily_results.values():
|
||
fields: list = [
|
||
"date", "trade_count", "turnover",
|
||
"commission", "trading_pnl",
|
||
"holding_pnl", "total_pnl", "net_pnl"
|
||
]
|
||
for key in fields:
|
||
value = getattr(daily_result, key)
|
||
results[key].append(value)
|
||
|
||
if results:
|
||
self.daily_df = pl.DataFrame([
|
||
pl.Series("date", results["date"], dtype=pl.Date),
|
||
pl.Series("trade_count", results["trade_count"], dtype=pl.Int64),
|
||
pl.Series("turnover", results["turnover"], dtype=pl.Float64),
|
||
pl.Series("commission", results["commission"], dtype=pl.Float64),
|
||
pl.Series("trading_pnl", results["trading_pnl"], dtype=pl.Float64),
|
||
pl.Series("holding_pnl", results["holding_pnl"], dtype=pl.Float64),
|
||
pl.Series("total_pnl", results["total_pnl"], dtype=pl.Float64),
|
||
pl.Series("net_pnl", results["net_pnl"], dtype=pl.Float64),
|
||
])
|
||
|
||
logger.info("逐日盯市盈亏计算完成")
|
||
return self.daily_df
|
||
|
||
def calculate_statistics(self) -> dict:
|
||
"""Calculate strategy statistics"""
|
||
logger.info("开始计算策略统计指标")
|
||
|
||
# Initialize statistics
|
||
start_date: str = ""
|
||
end_date: str = ""
|
||
total_days: int = 0
|
||
profit_days: int = 0
|
||
loss_days: int = 0
|
||
end_balance: float = 0
|
||
max_drawdown: float = 0
|
||
max_ddpercent: float = 0
|
||
max_drawdown_duration: int = 0
|
||
total_net_pnl: float = 0
|
||
daily_net_pnl: float = 0
|
||
total_commission: float = 0
|
||
daily_commission: float = 0
|
||
total_turnover: float = 0
|
||
daily_turnover: float = 0
|
||
total_trade_count: int = 0
|
||
daily_trade_count: float = 0
|
||
total_return: float = 0
|
||
annual_return: float = 0
|
||
daily_return: float = 0
|
||
return_std: float = 0
|
||
sharpe_ratio: float = 0
|
||
return_drawdown_ratio: float = 0
|
||
|
||
# Check if bankruptcy occurred
|
||
positive_balance: bool = False
|
||
|
||
# Calculate capital-related metrics
|
||
df: pl.DataFrame = self.daily_df
|
||
|
||
if df is not None:
|
||
df = df.with_columns(
|
||
# Strategy capital
|
||
balance=pl.col("net_pnl").cum_sum() + self.capital
|
||
).with_columns(
|
||
# Strategy return
|
||
pl.col("balance").pct_change().fill_null(0).alias("return"),
|
||
# Capital high watermark
|
||
highlevel=pl.col("balance").cum_max()
|
||
).with_columns(
|
||
# Capital drawdown
|
||
drawdown=pl.col("balance") - pl.col("highlevel"),
|
||
# Percentage drawdown
|
||
ddpercent=(pl.col("balance") / pl.col("highlevel") - 1) * 100
|
||
)
|
||
|
||
# Check if bankruptcy occurred
|
||
positive_balance = (df["balance"] > 0).all()
|
||
if not positive_balance:
|
||
logger.info("回测中出现爆仓(资金小于等于0),无法计算策略统计指标")
|
||
|
||
# Save data object
|
||
self.daily_df = df
|
||
|
||
# Calculate statistics
|
||
if positive_balance:
|
||
start_date = df["date"][0]
|
||
end_date = df["date"][-1]
|
||
|
||
total_days = len(df)
|
||
profit_days = df.filter(pl.col("net_pnl") > 0).height
|
||
loss_days = df.filter(pl.col("net_pnl") < 0).height
|
||
|
||
end_balance = df["balance"][-1]
|
||
max_drawdown = cast(float, df["drawdown"].min())
|
||
max_ddpercent = cast(float, df["ddpercent"].min())
|
||
|
||
max_drawdown_end_idx = cast(int, df["drawdown"].arg_min())
|
||
max_drawdown_end = df["date"][max_drawdown_end_idx]
|
||
|
||
if isinstance(max_drawdown_end, date):
|
||
max_drawdown_start_idx = cast(int, df.slice(0, max_drawdown_end_idx + 1)["balance"].arg_max())
|
||
max_drawdown_start = df["date"][max_drawdown_start_idx]
|
||
max_drawdown_duration = (max_drawdown_end - max_drawdown_start).days
|
||
else:
|
||
max_drawdown_duration = 0
|
||
|
||
total_net_pnl = cast(float, df["net_pnl"].sum())
|
||
daily_net_pnl = total_net_pnl / total_days
|
||
|
||
total_commission = cast(float, df["commission"].sum())
|
||
daily_commission = total_commission / total_days
|
||
|
||
total_turnover = cast(float, df["turnover"].sum())
|
||
daily_turnover = total_turnover / total_days
|
||
|
||
total_trade_count = cast(int, df["trade_count"].sum())
|
||
daily_trade_count = total_trade_count / total_days
|
||
|
||
total_return = (end_balance / self.capital - 1) * 100
|
||
annual_return = total_return / total_days * self.annual_days
|
||
daily_return = cast(float, df["return"].mean()) * 100
|
||
return_std = cast(float, df["return"].std()) * 100
|
||
|
||
if return_std:
|
||
daily_risk_free = self.risk_free / np.sqrt(self.annual_days)
|
||
sharpe_ratio = (daily_return - daily_risk_free) / return_std * np.sqrt(self.annual_days)
|
||
else:
|
||
sharpe_ratio = 0
|
||
|
||
return_drawdown_ratio = -total_net_pnl / max_drawdown
|
||
|
||
# Output results
|
||
logger.info("-" * 30)
|
||
logger.info(f"首个交易日: {start_date}")
|
||
logger.info(f"最后交易日: {end_date}")
|
||
|
||
logger.info(f"总交易日: {total_days}")
|
||
logger.info(f"盈利交易日: {profit_days}")
|
||
logger.info(f"亏损交易日: {loss_days}")
|
||
|
||
logger.info(f"起始资金: {self.capital:,.2f}")
|
||
logger.info(f"结束资金: {end_balance:,.2f}")
|
||
|
||
logger.info(f"总收益率: {total_return:,.2f}%")
|
||
logger.info(f"年化收益: {annual_return:,.2f}%")
|
||
logger.info(f"最大回撤: {max_drawdown:,.2f}")
|
||
logger.info(f"百分比最大回撤: {max_ddpercent:,.2f}%")
|
||
logger.info(f"最长回撤天数: {max_drawdown_duration}")
|
||
|
||
logger.info(f"总盈亏: {total_net_pnl:,.2f}")
|
||
logger.info(f"总手续费: {total_commission:,.2f}")
|
||
logger.info(f"总成交金额: {total_turnover:,.2f}")
|
||
logger.info(f"总成交笔数: {total_trade_count}")
|
||
|
||
logger.info(f"日均盈亏: {daily_net_pnl:,.2f}")
|
||
logger.info(f"日均手续费: {daily_commission:,.2f}")
|
||
logger.info(f"日均成交金额: {daily_turnover:,.2f}")
|
||
logger.info(f"日均成交笔数: {daily_trade_count}")
|
||
|
||
logger.info(f"日均收益率: {daily_return:,.2f}%")
|
||
logger.info(f"收益标准差: {return_std:,.2f}%")
|
||
logger.info(f"Sharpe Ratio: {sharpe_ratio:,.2f}")
|
||
logger.info(f"收益回撤比: {return_drawdown_ratio:,.2f}")
|
||
|
||
statistics: dict = {
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
"total_days": total_days,
|
||
"profit_days": profit_days,
|
||
"loss_days": loss_days,
|
||
"capital": self.capital,
|
||
"end_balance": end_balance,
|
||
"max_drawdown": max_drawdown,
|
||
"max_ddpercent": max_ddpercent,
|
||
"max_drawdown_duration": max_drawdown_duration,
|
||
"total_net_pnl": total_net_pnl,
|
||
"daily_net_pnl": daily_net_pnl,
|
||
"total_commission": total_commission,
|
||
"daily_commission": daily_commission,
|
||
"total_turnover": total_turnover,
|
||
"daily_turnover": daily_turnover,
|
||
"total_trade_count": total_trade_count,
|
||
"daily_trade_count": daily_trade_count,
|
||
"total_return": total_return,
|
||
"annual_return": annual_return,
|
||
"daily_return": daily_return,
|
||
"return_std": return_std,
|
||
"sharpe_ratio": sharpe_ratio,
|
||
"return_drawdown_ratio": return_drawdown_ratio,
|
||
}
|
||
|
||
# Filter extreme values
|
||
for key, value in statistics.items():
|
||
if value in (np.inf, -np.inf):
|
||
value = 0
|
||
statistics[key] = np.nan_to_num(value)
|
||
|
||
logger.info("策略统计指标计算完成")
|
||
return statistics
|
||
|
||
def show_chart(self) -> None:
|
||
"""Display chart"""
|
||
df: pl.DataFrame = self.daily_df
|
||
|
||
fig = make_subplots(
|
||
rows=4,
|
||
cols=1,
|
||
subplot_titles=["Balance", "Drawdown", "Daily Pnl", "Pnl Distribution"],
|
||
vertical_spacing=0.06
|
||
)
|
||
|
||
balance_line = go.Scatter(
|
||
x=df["date"],
|
||
y=df["balance"],
|
||
mode="lines",
|
||
name="Balance"
|
||
)
|
||
drawdown_scatter = go.Scatter(
|
||
x=df["date"],
|
||
y=df["drawdown"],
|
||
fillcolor="red",
|
||
fill='tozeroy',
|
||
mode="lines",
|
||
name="Drawdown"
|
||
)
|
||
pnl_bar = go.Bar(y=df["net_pnl"], name="Daily Pnl")
|
||
pnl_histogram = go.Histogram(x=df["net_pnl"], nbinsx=100, name="Days")
|
||
|
||
fig.add_trace(balance_line, row=1, col=1)
|
||
fig.add_trace(drawdown_scatter, row=2, col=1)
|
||
fig.add_trace(pnl_bar, row=3, col=1)
|
||
fig.add_trace(pnl_histogram, row=4, col=1)
|
||
|
||
fig.update_layout(height=1000, width=1000)
|
||
fig.show()
|
||
|
||
def show_performance(self, benchmark_symbol: str) -> None:
|
||
"""Display performance metrics"""
|
||
# Load benchmark prices
|
||
benchmark_bars: list[BarData] = self.lab.load_bar_data(benchmark_symbol, self.interval, self.start, self.end)
|
||
|
||
benchmark_prices: list[float] = []
|
||
for bar in benchmark_bars:
|
||
benchmark_prices.append(bar.close_price)
|
||
|
||
# Calculate strategy performance
|
||
performance_df: pl.DataFrame = (
|
||
self.daily_df.with_columns(
|
||
# Cumulative return
|
||
cumulative_return=pl.col("balance").pct_change().cum_sum(),
|
||
# Cumulative cost
|
||
cumulative_cost=(pl.col("commission") / pl.col("balance").shift(1)).cum_sum()
|
||
).with_columns(
|
||
# Benchmark price
|
||
benchmark_price=pl.Series(values=benchmark_prices, dtype=pl.Float64)
|
||
).with_columns(
|
||
# Benchmark return
|
||
benchmark_return=pl.col("benchmark_price").pct_change().cum_sum()
|
||
).with_columns(
|
||
# Excess return
|
||
excess_return=(pl.col("cumulative_return") - pl.col("benchmark_return"))
|
||
).with_columns(
|
||
# Net excess return
|
||
net_excess_return=(pl.col("excess_return") - pl.col("cumulative_cost")),
|
||
).with_columns(
|
||
# Excess return drawdown
|
||
excess_return_drawdown=(pl.col("excess_return") - pl.col("excess_return").cum_max()),
|
||
# Net excess return drawdown
|
||
net_excess_return_drawdown=(pl.col("net_excess_return") - pl.col("net_excess_return").cum_max())
|
||
)
|
||
)
|
||
|
||
# Draw chart
|
||
fig: go.Figure = make_subplots(
|
||
rows=5,
|
||
cols=1,
|
||
subplot_titles=["Return", "Alpha", "Turnover", "Alpha Drawdown", "Alpha Drawdown with Cost"],
|
||
vertical_spacing=0.06
|
||
)
|
||
|
||
strategy_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["cumulative_return"],
|
||
mode="lines",
|
||
name="Strategy"
|
||
)
|
||
net_strategy_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["cumulative_return"] - performance_df["cumulative_cost"],
|
||
mode="lines",
|
||
name="Strategy with Cost"
|
||
)
|
||
benchmark_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["benchmark_return"],
|
||
mode="lines",
|
||
name="Benchmark"
|
||
)
|
||
excess_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["excess_return"],
|
||
mode="lines",
|
||
name="Alpha"
|
||
)
|
||
net_excess_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["net_excess_return"],
|
||
mode="lines",
|
||
name="Alpha with Cost"
|
||
)
|
||
turnover_curve: go.Scatter = go.Scatter(
|
||
x=self.daily_df["date"],
|
||
y=self.daily_df["turnover"] / self.daily_df["balance"].shift(1),
|
||
name="Turnover",
|
||
)
|
||
excess_drawdown_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["excess_return_drawdown"],
|
||
fill='tozeroy',
|
||
mode="lines",
|
||
name="Alpha Drawdown"
|
||
)
|
||
net_excess_drawdown_curve: go.Scatter = go.Scatter(
|
||
x=performance_df["date"],
|
||
y=performance_df["net_excess_return_drawdown"],
|
||
fill='tozeroy',
|
||
mode="lines",
|
||
name="Alpha Drawdown with Cost"
|
||
)
|
||
|
||
fig.add_trace(strategy_curve, row=1, col=1)
|
||
fig.add_trace(net_strategy_curve, row=1, col=1)
|
||
fig.add_trace(benchmark_curve, row=1, col=1)
|
||
fig.add_trace(excess_curve, row=2, col=1)
|
||
fig.add_trace(net_excess_curve, row=2, col=1)
|
||
fig.add_trace(turnover_curve, row=3, col=1)
|
||
fig.add_trace(excess_drawdown_curve, row=4, col=1)
|
||
fig.add_trace(net_excess_drawdown_curve, row=5, col=1)
|
||
|
||
fig.update_layout(
|
||
height=1500,
|
||
width=1200,
|
||
plot_bgcolor="white",
|
||
paper_bgcolor="white",
|
||
xaxis=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
xaxis2=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
xaxis3=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
xaxis4=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
xaxis5=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
yaxis=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
yaxis2=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
yaxis3=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
yaxis4=dict(showgrid=True, gridwidth=1, gridcolor='LightGray'),
|
||
yaxis5=dict(showgrid=True, gridwidth=1, gridcolor='LightGray')
|
||
)
|
||
fig.show()
|
||
|
||
def update_daily_close(self, bars: dict[str, BarData], dt: datetime) -> None:
|
||
"""Update daily closing price"""
|
||
d: date = dt.date()
|
||
|
||
close_prices: dict[str, float] = {}
|
||
for bar in bars.values():
|
||
if not bar.close_price:
|
||
close_prices[bar.vt_symbol] = self.pre_closes[bar.vt_symbol]
|
||
else:
|
||
close_prices[bar.vt_symbol] = bar.close_price
|
||
|
||
daily_result: PortfolioDailyResult | None = self.daily_results.get(d, None)
|
||
|
||
if daily_result:
|
||
daily_result.update_close_prices(close_prices)
|
||
else:
|
||
self.daily_results[d] = PortfolioDailyResult(d, close_prices)
|
||
|
||
def new_bars(self, dt: datetime) -> None:
|
||
"""Push historical data"""
|
||
self.datetime = dt
|
||
|
||
bars: dict[str, BarData] = {}
|
||
for vt_symbol in self.vt_symbols:
|
||
last_bar = self.bars.get(vt_symbol, None)
|
||
if last_bar:
|
||
if last_bar.close_price:
|
||
self.pre_closes[vt_symbol] = last_bar.close_price
|
||
|
||
bar: BarData | None = self.history_data.get((dt, vt_symbol), None)
|
||
|
||
# Check if historical data for the specified time of the contract is obtained
|
||
if bar:
|
||
# Update K-line for order matching
|
||
self.bars[vt_symbol] = bar
|
||
# Cache K-line data for strategy.on_bars update
|
||
bars[vt_symbol] = bar
|
||
# If not available, but there is contract data cached in the self.bars dictionary, use previous data to fill
|
||
elif vt_symbol in self.bars:
|
||
old_bar: BarData = self.bars[vt_symbol]
|
||
|
||
fill_bar: BarData = BarData(
|
||
symbol=old_bar.symbol,
|
||
exchange=old_bar.exchange,
|
||
datetime=dt,
|
||
open_price=old_bar.close_price,
|
||
high_price=old_bar.close_price,
|
||
low_price=old_bar.close_price,
|
||
close_price=old_bar.close_price,
|
||
gateway_name=old_bar.gateway_name
|
||
)
|
||
self.bars[vt_symbol] = fill_bar
|
||
|
||
self.cross_order()
|
||
self.strategy.on_bars(bars)
|
||
|
||
self.update_daily_close(self.bars, dt)
|
||
|
||
def cross_order(self) -> None:
|
||
"""Match limit orders"""
|
||
for order in list(self.active_limit_orders.values()):
|
||
bar: BarData = self.bars[order.vt_symbol]
|
||
|
||
long_cross_price: float = bar.low_price
|
||
short_cross_price: float = bar.high_price
|
||
long_best_price: float = bar.open_price
|
||
short_best_price: float = bar.open_price
|
||
|
||
# Push order status update for unfilled orders
|
||
if order.status == Status.SUBMITTING:
|
||
order.status = Status.NOTTRADED
|
||
self.strategy.update_order(order)
|
||
|
||
# Calculate price limits
|
||
pricetick: float = self.priceticks[order.vt_symbol]
|
||
pre_close: float = self.pre_closes.get(order.vt_symbol, 0)
|
||
|
||
limit_up: float = round_to(pre_close * 1.1, pricetick)
|
||
limit_down: float = round_to(pre_close * 0.9, pricetick)
|
||
|
||
# Check limit orders that can be matched
|
||
long_cross: bool = (
|
||
order.direction == Direction.LONG
|
||
and order.price >= long_cross_price
|
||
and long_cross_price > 0
|
||
and bar.low_price < limit_up # Not a full-day limit-up market
|
||
)
|
||
|
||
short_cross: bool = (
|
||
order.direction == Direction.SHORT
|
||
and order.price <= short_cross_price
|
||
and short_cross_price > 0
|
||
and bar.high_price > limit_down # Not a full-day limit-down market
|
||
)
|
||
|
||
if not long_cross and not short_cross:
|
||
continue
|
||
|
||
# Push order status update for filled orders
|
||
order.traded = order.volume
|
||
order.status = Status.ALLTRADED
|
||
self.strategy.update_order(order)
|
||
|
||
if order.vt_orderid in self.active_limit_orders:
|
||
self.active_limit_orders.pop(order.vt_orderid)
|
||
|
||
# Generate trade information
|
||
self.trade_count += 1
|
||
|
||
if long_cross:
|
||
trade_price = min(order.price, long_best_price)
|
||
else:
|
||
trade_price = max(order.price, short_best_price)
|
||
|
||
trade: TradeData = TradeData(
|
||
symbol=order.symbol,
|
||
exchange=order.exchange,
|
||
orderid=order.orderid,
|
||
tradeid=str(self.trade_count),
|
||
direction=order.direction,
|
||
offset=order.offset,
|
||
price=trade_price,
|
||
volume=order.volume,
|
||
datetime=self.datetime,
|
||
gateway_name=self.gateway_name,
|
||
)
|
||
|
||
# Update available funds
|
||
size: float = self.sizes[trade.vt_symbol]
|
||
|
||
trade_turnover: float = trade.price * trade.volume * size
|
||
|
||
if trade.direction == Direction.LONG:
|
||
trade_commission: float = trade_turnover * self.long_rates[trade.vt_symbol]
|
||
else:
|
||
trade_commission = trade_turnover * self.short_rates[trade.vt_symbol]
|
||
|
||
if trade.direction == Direction.LONG:
|
||
self.cash -= trade_turnover
|
||
else:
|
||
self.cash += trade_turnover
|
||
|
||
self.cash -= trade_commission
|
||
|
||
# Push trade information
|
||
self.strategy.update_trade(trade)
|
||
self.trades[trade.vt_tradeid] = trade
|
||
|
||
def get_signal(self) -> pl.DataFrame:
|
||
"""Get model prediction signal for current time"""
|
||
if not self.datetime:
|
||
self.write_log("尚未开始数据回放,无法加载模型预测值")
|
||
return pl.DataFrame()
|
||
|
||
dt: datetime = self.datetime.replace(tzinfo=None)
|
||
signal: pl.DataFrame = self.signal_df.filter(pl.col("datetime") == dt)
|
||
|
||
if signal.is_empty():
|
||
self.write_log(f"找不到{dt}对应的信号模型预测值")
|
||
|
||
return signal
|
||
|
||
def send_order(
|
||
self,
|
||
strategy: AlphaStrategy,
|
||
vt_symbol: str,
|
||
direction: Direction,
|
||
offset: Offset,
|
||
price: float,
|
||
volume: float,
|
||
) -> list[str]:
|
||
"""Send order"""
|
||
price = round_to(price, self.priceticks[vt_symbol])
|
||
symbol, exchange = extract_vt_symbol(vt_symbol)
|
||
|
||
self.limit_order_count += 1
|
||
|
||
order: OrderData = OrderData(
|
||
symbol=symbol,
|
||
exchange=exchange,
|
||
orderid=str(self.limit_order_count),
|
||
direction=direction,
|
||
offset=offset,
|
||
price=price,
|
||
volume=volume,
|
||
status=Status.SUBMITTING,
|
||
datetime=self.datetime,
|
||
gateway_name=self.gateway_name,
|
||
)
|
||
|
||
self.active_limit_orders[order.vt_orderid] = order
|
||
self.limit_orders[order.vt_orderid] = order
|
||
|
||
return [order.vt_orderid]
|
||
|
||
def cancel_order(self, strategy: AlphaStrategy, vt_orderid: str) -> None:
|
||
"""Cancel order"""
|
||
if vt_orderid not in self.active_limit_orders:
|
||
return
|
||
order: OrderData = self.active_limit_orders.pop(vt_orderid)
|
||
|
||
order.status = Status.CANCELLED
|
||
self.strategy.update_order(order)
|
||
|
||
def write_log(self, msg: str, strategy: AlphaStrategy | None = None) -> None:
|
||
"""Output log message"""
|
||
msg = f"{self.datetime} {msg}"
|
||
self.logs.append(msg)
|
||
|
||
def get_all_trades(self) -> list[TradeData]:
|
||
"""Get all trade information"""
|
||
return list(self.trades.values())
|
||
|
||
def get_all_orders(self) -> list[OrderData]:
|
||
"""Get all order information"""
|
||
return list(self.limit_orders.values())
|
||
|
||
def get_all_daily_results(self) -> list["PortfolioDailyResult"]:
|
||
"""Get all daily profit and loss information"""
|
||
return list(self.daily_results.values())
|
||
|
||
def get_cash_available(self) -> float:
|
||
"""Get current available cash"""
|
||
return self.cash
|
||
|
||
def get_holding_value(self) -> float:
|
||
"""Get current holding market value"""
|
||
holding_value: float = 0
|
||
|
||
for vt_symbol, pos in self.strategy.pos_data.items():
|
||
bar: BarData = self.bars[vt_symbol]
|
||
size: float = self.sizes[vt_symbol]
|
||
|
||
holding_value += bar.close_price * pos * size
|
||
|
||
return holding_value
|
||
|
||
|
||
class ContractDailyResult:
|
||
"""Contract daily profit and loss result"""
|
||
|
||
def __init__(self, result_date: date, close_price: float) -> None:
|
||
"""Constructor"""
|
||
self.date: date = result_date
|
||
self.close_price: float = close_price
|
||
self.pre_close: float = 0
|
||
|
||
self.trades: list[TradeData] = []
|
||
self.trade_count: int = 0
|
||
|
||
self.start_pos: float = 0
|
||
self.end_pos: float = 0
|
||
|
||
self.turnover: float = 0
|
||
self.commission: float = 0
|
||
|
||
self.trading_pnl: float = 0
|
||
self.holding_pnl: float = 0
|
||
self.total_pnl: float = 0
|
||
self.net_pnl: float = 0
|
||
|
||
def add_trade(self, trade: TradeData) -> None:
|
||
"""Add trade information"""
|
||
self.trades.append(trade)
|
||
|
||
def calculate_pnl(
|
||
self,
|
||
pre_close: float,
|
||
start_pos: float,
|
||
size: float,
|
||
long_rate: float,
|
||
short_rate: float
|
||
) -> None:
|
||
"""Calculate profit and loss"""
|
||
# If there is no previous close price, use 1 instead to avoid division error
|
||
if pre_close:
|
||
self.pre_close = pre_close
|
||
# else:
|
||
# self.pre_close = 1
|
||
|
||
# Calculate holding profit and loss
|
||
self.start_pos = start_pos
|
||
self.end_pos = start_pos
|
||
|
||
self.holding_pnl = self.start_pos * (self.close_price - self.pre_close) * size
|
||
|
||
# Calculate trading profit and loss
|
||
self.trade_count = len(self.trades)
|
||
|
||
for trade in self.trades:
|
||
if trade.direction == Direction.LONG:
|
||
pos_change: float = trade.volume
|
||
rate: float = long_rate
|
||
else:
|
||
pos_change = -trade.volume
|
||
rate = short_rate
|
||
|
||
self.end_pos += pos_change
|
||
|
||
turnover: float = trade.volume * size * trade.price
|
||
|
||
self.trading_pnl += pos_change * (self.close_price - trade.price) * size
|
||
self.turnover += turnover
|
||
self.commission += turnover * rate
|
||
|
||
# Calculate daily profit and loss
|
||
self.total_pnl = self.trading_pnl + self.holding_pnl
|
||
self.net_pnl = self.total_pnl - self.commission
|
||
|
||
def update_close_price(self, close_price: float) -> None:
|
||
"""Update daily close price"""
|
||
self.close_price = close_price
|
||
|
||
|
||
class PortfolioDailyResult:
|
||
"""Portfolio daily profit and loss result"""
|
||
|
||
def __init__(self, result_date: date, close_prices: dict[str, float]) -> None:
|
||
"""Constructor"""
|
||
self.date: date = result_date
|
||
self.close_prices: dict[str, float] = close_prices
|
||
self.pre_closes: dict[str, float] = {}
|
||
self.start_poses: dict[str, float] = {}
|
||
self.end_poses: dict[str, float] = {}
|
||
|
||
self.contract_results: dict[str, ContractDailyResult] = {}
|
||
|
||
for vt_symbol, close_price in close_prices.items():
|
||
self.contract_results[vt_symbol] = ContractDailyResult(result_date, close_price)
|
||
|
||
self.trade_count: int = 0
|
||
self.turnover: float = 0
|
||
self.commission: float = 0
|
||
self.trading_pnl: float = 0
|
||
self.holding_pnl: float = 0
|
||
self.total_pnl: float = 0
|
||
self.net_pnl: float = 0
|
||
|
||
def add_trade(self, trade: TradeData) -> None:
|
||
"""Add trade information"""
|
||
contract_result: ContractDailyResult = self.contract_results[trade.vt_symbol]
|
||
contract_result.add_trade(trade)
|
||
|
||
def calculate_pnl(
|
||
self,
|
||
pre_closes: dict[str, float],
|
||
start_poses: dict[str, float],
|
||
sizes: dict[str, float],
|
||
long_rates: dict[str, float],
|
||
short_rates: dict[str, float]
|
||
) -> None:
|
||
"""Calculate profit and loss"""
|
||
self.pre_closes = pre_closes
|
||
self.start_poses = start_poses
|
||
|
||
for vt_symbol, contract_result in self.contract_results.items():
|
||
contract_result.calculate_pnl(
|
||
pre_closes.get(vt_symbol, 0),
|
||
start_poses.get(vt_symbol, 0),
|
||
sizes[vt_symbol],
|
||
long_rates[vt_symbol],
|
||
short_rates[vt_symbol]
|
||
)
|
||
|
||
self.trade_count += contract_result.trade_count
|
||
self.turnover += contract_result.turnover
|
||
self.commission += contract_result.commission
|
||
self.trading_pnl += contract_result.trading_pnl
|
||
self.holding_pnl += contract_result.holding_pnl
|
||
self.total_pnl += contract_result.total_pnl
|
||
self.net_pnl += contract_result.net_pnl
|
||
|
||
self.end_poses[vt_symbol] = contract_result.end_pos
|
||
|
||
def update_close_prices(self, close_prices: dict[str, float]) -> None:
|
||
"""Update daily close prices"""
|
||
self.close_prices.update(close_prices)
|
||
|
||
for vt_symbol, close_price in close_prices.items():
|
||
contract_result: ContractDailyResult | None = self.contract_results.get(vt_symbol, None)
|
||
if contract_result:
|
||
contract_result.update_close_price(close_price)
|
||
else:
|
||
self.contract_results[vt_symbol] = ContractDailyResult(self.date, close_price)
|