Skip to content

Commit

Permalink
0.9.61 WeightBacktest 支持多空分离的收益统计
Browse files Browse the repository at this point in the history
  • Loading branch information
zengbin93 committed Dec 5, 2024
1 parent c030b70 commit 05bcf78
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 16 deletions.
12 changes: 6 additions & 6 deletions czsc/eda.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def cross_sectional_strategy(df, factor, weight="weight", long=0.3, short=0.3, *
- factor_direction: str, 因子方向,positive 或 negative
- logger: loguru.logger, 日志记录器
- norm: bool, 是否对 weight 进行截面持仓标准化,默认为 False
- norm: bool, 是否对 weight 进行截面持仓标准化,默认为 True
:return: pd.DataFrame, 包含 weight 列的数据
"""
Expand All @@ -111,12 +111,12 @@ def cross_sectional_strategy(df, factor, weight="weight", long=0.3, short=0.3, *
if factor_direction == "negative":
df[factor] = -df[factor]

df[weight] = 0
df[weight] = 0.0
rows = []

for dt, dfg in df.groupby("dt"):
long_num = long if long >= 1 else int(len(dfg) * long)
short_num = short if short >= 1 else int(len(dfg) * short)
long_num = int(long) if long >= 1 else int(len(dfg) * long)
short_num = int(short) if short >= 1 else int(len(dfg) * short)

if long_num == 0 and short_num == 0:
logger.warning(f"{dt} 多空目前持仓数量都为0; long: {long}, short: {short}")
Expand All @@ -132,8 +132,8 @@ def cross_sectional_strategy(df, factor, weight="weight", long=0.3, short=0.3, *
long_symbols = list(set(long_symbols) - union_symbols)
short_symbols = list(set(short_symbols) - union_symbols)

dfg.loc[dfg['symbol'].isin(long_symbols), weight] = 1 / long_num if norm else 1
dfg.loc[dfg['symbol'].isin(short_symbols), weight] = -1 / short_num if norm else -1
dfg.loc[dfg['symbol'].isin(long_symbols), weight] = 1 / long_num if norm else 1.0
dfg.loc[dfg['symbol'].isin(short_symbols), weight] = -1 / short_num if norm else -1.0
rows.append(dfg)

dfx = pd.concat(rows, ignore_index=True)
Expand Down
130 changes: 123 additions & 7 deletions czsc/traders/weight_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,14 @@ class WeightBacktest:
1. 新增 yearly_days 参数,用于指定每年的交易日天数,默认为 252。
#### 20241205
1. 新增 weight_type 参数,用于指定输入的持仓权重类别,ts 表示 time series,时序策略;。
"""

version = "V241125"
version = "20241205"

def __init__(self, dfw, digits=2, **kwargs) -> None:
def __init__(self, dfw, digits=2, weight_type="ts", **kwargs) -> None:
"""持仓权重回测
初始化函数逻辑:
Expand Down Expand Up @@ -272,6 +275,7 @@ def __init__(self, dfw, digits=2, **kwargs) -> None:
=================== ======== ======== =======
:param digits: int, 权重列保留小数位数
:param weight_type: str, default 'ts',持仓权重类别,可选值包括:'ts'、'cs',分别表示时序策略、截面策略
:param kwargs:
- fee_rate: float,单边交易成本,包括手续费与冲击成本, 默认为 0.0002
Expand All @@ -283,7 +287,9 @@ def __init__(self, dfw, digits=2, **kwargs) -> None:
self.dfw["dt"] = pd.to_datetime(self.dfw["dt"])
if self.dfw.isnull().sum().sum() > 0:
raise ValueError("dfw 中存在空值, 请先处理")

self.digits = digits
self.weight_type = weight_type.lower()
self.fee_rate = kwargs.get("fee_rate", 0.0002)
self.dfw["weight"] = self.dfw["weight"].astype("float").round(digits)
self.symbols = list(self.dfw["symbol"].unique().tolist())
Expand Down Expand Up @@ -350,6 +356,56 @@ def bench_stats(self):
stats["结束日期"] = df["date"].max().strftime("%Y-%m-%d")
return stats

@property
def long_daily_return(self):
"""多头每日收益率"""
df = self.dailys.copy()
dfv = pd.pivot_table(df, index="date", columns="symbol", values="long_return").fillna(0)

if self.weight_type == "ts":
dfv["total"] = dfv.mean(axis=1)
elif self.weight_type == "cs":
dfv["total"] = dfv.sum(axis=1)
else:
raise ValueError(f"weight_type {self.weight_type} not supported")

dfv = dfv.reset_index(drop=False)
return dfv

@property
def short_daily_return(self):
"""空头每日收益率"""
df = self.dailys.copy()
dfv = pd.pivot_table(df, index="date", columns="symbol", values="short_return").fillna(0)

if self.weight_type == "ts":
dfv["total"] = dfv.mean(axis=1)
elif self.weight_type == "cs":
dfv["total"] = dfv.sum(axis=1)
else:
raise ValueError(f"weight_type {self.weight_type} not supported")

dfv = dfv.reset_index(drop=False)
return dfv

@property
def long_stats(self):
"""多头收益统计"""
df = self.long_daily_return.copy()
stats = czsc.daily_performance(df["total"].to_list(), yearly_days=self.yearly_days)
stats["开始日期"] = df["date"].min().strftime("%Y-%m-%d")
stats["结束日期"] = df["date"].max().strftime("%Y-%m-%d")
return stats

@property
def short_stats(self):
"""空头收益统计"""
df = self.short_daily_return.copy()
stats = czsc.daily_performance(df["total"].to_list(), yearly_days=self.yearly_days)
stats["开始日期"] = df["date"].min().strftime("%Y-%m-%d")
stats["结束日期"] = df["date"].max().strftime("%Y-%m-%d")
return stats

def get_symbol_daily(self, symbol):
"""获取某个合约的每日收益率
Expand All @@ -373,6 +429,8 @@ def get_symbol_daily(self, symbol):
symbol 合约代码,
n1b 品种每日收益率,
edge 策略每日收益率,
long_edge 多头每日收益率,
short_edge 空头每日收益率,
return 策略每日收益率减去交易成本后的真实收益,
cost 交易成本
turnover 当日的单边换手率
Expand All @@ -394,15 +452,64 @@ def get_symbol_daily(self, symbol):
dfs["edge"] = dfs["weight"] * dfs["n1b"]
dfs["turnover"] = abs(dfs["weight"].shift(1) - dfs["weight"])
dfs["cost"] = dfs["turnover"] * self.fee_rate
dfs["edge_post_fee"] = dfs["edge"] - dfs["cost"]
dfs["return"] = dfs["edge"] - dfs["cost"]

# 分别计算多头和空头的收益
dfs["long_weight"] = np.where(dfs["weight"] > 0, dfs["weight"], 0)
dfs["short_weight"] = np.where(dfs["weight"] < 0, dfs["weight"], 0)
dfs["long_edge"] = dfs["long_weight"] * dfs["n1b"]
dfs["short_edge"] = dfs["short_weight"] * dfs["n1b"]

dfs["long_turnover"] = abs(dfs["long_weight"].shift(1) - dfs["long_weight"])
dfs["short_turnover"] = abs(dfs["short_weight"].shift(1) - dfs["short_weight"])
dfs["long_cost"] = dfs["long_turnover"] * self.fee_rate
dfs["short_cost"] = dfs["short_turnover"] * self.fee_rate

dfs["long_return"] = dfs["long_edge"] - dfs["long_cost"]
dfs["short_return"] = dfs["short_edge"] - dfs["short_cost"]

daily = (
dfs.groupby(dfs["dt"].dt.date)
.agg({"edge": "sum", "edge_post_fee": "sum", "cost": "sum", "n1b": "sum", "turnover": "sum"})
.agg(
{
"edge": "sum",
"return": "sum",
"cost": "sum",
"n1b": "sum",
"turnover": "sum",
"long_edge": "sum",
"short_edge": "sum",
"long_cost": "sum",
"short_cost": "sum",
"long_turnover": "sum",
"short_turnover": "sum",
"long_return": "sum",
"short_return": "sum",
}
)
.reset_index()
)
daily["symbol"] = symbol
daily.rename(columns={"edge_post_fee": "return", "dt": "date"}, inplace=True)
daily = daily[["date", "symbol", "n1b", "edge", "return", "cost", "turnover"]].copy()
daily.rename(columns={"dt": "date"}, inplace=True)
cols = [
"date",
"symbol",
"edge",
"return",
"cost",
"n1b",
"turnover",
"long_edge",
"long_cost",
"long_return",
"long_turnover",
"short_edge",
"short_cost",
"short_return",
"short_turnover",
]

daily = daily[cols].copy()
return daily

def get_symbol_pairs(self, symbol):
Expand Down Expand Up @@ -557,7 +664,16 @@ def backtest(self, n_jobs=1):

dret = pd.concat([v["daily"] for k, v in res.items() if k in symbols], ignore_index=True)
dret = pd.pivot_table(dret, index="date", columns="symbol", values="return").fillna(0)
dret["total"] = dret[list(res.keys())].mean(axis=1)

if self.weight_type == "ts":
# 时序策略每日收益为各品种收益的等权
dret["total"] = dret[list(res.keys())].mean(axis=1)
elif self.weight_type == "cs":
# 截面策略每日收益为各品种收益的和
dret["total"] = dret[list(res.keys())].sum(axis=1)
else:
raise ValueError(f"weight_type {self.weight_type} not supported, should be 'ts' or 'cs'")

# dret 中的 date 对应的是上一日;date 后移一位,对应的才是当日收益
dret = dret.round(4).reset_index()
res["品种等权日收益"] = dret
Expand Down
59 changes: 56 additions & 3 deletions examples/test_offline/test_weight_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,71 @@
import czsc
import pandas as pd

assert czsc.WeightBacktest.version == "V240627"
# assert czsc.WeightBacktest.version == "V240627"


def run_by_weights():
"""从持仓权重样例数据中回测"""
dfw = pd.read_feather(r"C:\Users\zengb\Downloads\weight_example.feather")
wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0002, n_jobs=1)
wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0, n_jobs=1, weight_type="ts")
# wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0002)
dailys = wb.dailys
print(wb.stats)
print(wb.alpha_stats)
print(wb.bench_stats)
print(wb.long_stats)
print(wb.short_stats)
# 计算等权组合的超额
df1 = dailys.groupby("date").agg({"return": "mean", "n1b": "mean"})
df1["alpha"] = df1["return"] - df1["n1b"]

# ------------------------------------------------------------------------------------
# 查看绩效评价
# ------------------------------------------------------------------------------------
print(wb.results["绩效评价"])
# {'开始日期': '20170103',
# '结束日期': '20230731',
# '年化': 0.093, # 品种等权之后的年化收益率
# '夏普': 1.19, # 品种等权之后的夏普比率
# '最大回撤': 0.1397, # 品种等权之后的最大回撤
# '卡玛': 0.67,
# '日胜率': 0.5228, # 品种等权之后的日胜率
# '年化波动率': 0.0782,
# '非零覆盖': 1.0,
# '盈亏平衡点': 0.9782, # 品种等权之后的盈亏平衡点,这个值越小越好,正常策略的范围应该在 0.85~0.98 之间
# '单笔收益': 25.6, # 将所有品种的单笔汇总之后的平均收益,单位是 BP,即 0.01%
# '交易胜率': 0.3717, # 将所有品种的单笔汇总之后的交易胜率
# '持仓天数': 3.69, # 将所有品种的单笔汇总之后的平均持仓天数
# '持仓K线数': 971.66} # 将所有品种的单笔汇总之后的平均持仓 K 线数

# ------------------------------------------------------------------------------------
# 获取指定品种的回测结果
# ------------------------------------------------------------------------------------
symbol_res = wb.results[wb.symbols[0]]
print(symbol_res)

# wb.report(res_path=r"C:\Users\zengb\Desktop\231005\weight_example")


def run_weights_by_cs():
"""从持仓权重样例数据中回测"""
from czsc import cross_sectional_strategy

dfw = pd.read_feather(r"C:\Users\zengb\Downloads\weight_example.feather")
dfw.rename({"weight": "factor"}, axis=1, inplace=True)
# 仅保留 15:00 的数据
dfw = dfw[dfw["dt"].dt.time == pd.to_datetime("15:00").time()].copy().reset_index(drop=True)
dfw = cross_sectional_strategy(dfw, factor="factor", long=2, short=2, norm=True)

wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0, n_jobs=1, weight_type="cs")
# wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0002)
dailys = wb.dailys
print(wb.stats)
print(wb.alpha_stats)
print(wb.bench_stats)

print(wb.long_stats)
print(wb.short_stats)

# 计算等权组合的超额
df1 = dailys.groupby("date").agg({"return": "mean", "n1b": "mean"})
Expand Down Expand Up @@ -47,7 +100,7 @@ def run_by_weights():
symbol_res = wb.results[wb.symbols[0]]
print(symbol_res)

wb.report(res_path=r"C:\Users\zengb\Desktop\231005\weight_example")
# wb.report(res_path=r"C:\Users\zengb\Desktop\231005\weight_example")


if __name__ == "__main__":
Expand Down

0 comments on commit 05bcf78

Please sign in to comment.