Files
2026-07-13 12:07:23 +08:00

945 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)