from __future__ import annotations
import math
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import pytest
from wbt import WeightBacktest
from wbt._wbt import daily_performance as rust_daily_performance
YEARLY_DAYS = 252
FEE_RATE = 0.0002
DIGITS = 2
def _build_deterministic_dfw() -> pd.DataFrame:
rows = []
base_date = datetime(2024, 1, 2, 9, 30, 0)
weights_a = [
0.3,
0.3,
-0.2,
-0.2,
0.5,
0.5,
-0.1,
-0.1,
0.4,
0.4,
-0.3,
-0.3,
0.2,
0.2,
-0.4,
-0.4,
0.1,
0.1,
-0.5,
-0.5,
]
weights_b = [
-0.2,
-0.2,
0.4,
0.4,
-0.3,
-0.3,
0.2,
0.2,
-0.1,
-0.1,
0.5,
0.5,
-0.4,
-0.4,
0.3,
0.3,
-0.2,
-0.2,
0.1,
0.1,
]
prices_a = [
100.0,
101.0,
100.5,
99.5,
100.0,
102.0,
101.5,
100.0,
101.0,
103.0,
102.0,
100.5,
101.0,
102.5,
101.0,
99.0,
100.0,
101.5,
100.0,
98.5,
]
prices_b = [
50.0,
50.5,
51.0,
50.0,
49.5,
50.0,
51.0,
52.0,
51.5,
51.0,
52.0,
53.0,
52.5,
51.5,
52.0,
53.0,
52.5,
52.0,
53.0,
54.0,
]
for d in range(20):
dt_str = (base_date + timedelta(days=d)).strftime("%Y-%m-%d %H:%M:%S")
wa = round(weights_a[d], DIGITS)
wb_ = round(weights_b[d], DIGITS)
rows.append({"dt": dt_str, "symbol": "SYM_A", "weight": wa, "price": prices_a[d]})
rows.append({"dt": dt_str, "symbol": "SYM_B", "weight": wb_, "price": prices_b[d]})
return pd.DataFrame(rows)
@pytest.fixture(scope="module")
def dfw() -> pd.DataFrame:
return _build_deterministic_dfw()
@pytest.fixture(scope="module")
def bt(dfw: pd.DataFrame) -> WeightBacktest:
return WeightBacktest(dfw, digits=DIGITS, fee_rate=FEE_RATE, n_jobs=1, weight_type="ts", yearly_days=YEARLY_DAYS)
def _python_daily_perf(returns: np.ndarray, yearly_days: int = 252) -> dict:
n = len(returns)
if n == 0:
return {"absolute_return": 0, "annual_returns": 0, "sharpe": 0, "max_drawdown": 0, "daily_win_rate": 0}
cumsum = np.cumsum(returns)
absolute_return = float(cumsum[-1])
mean_r = np.mean(returns)
std_r = np.std(returns, ddof=0)
annual_returns = mean_r * yearly_days
sharpe = (mean_r / std_r * math.sqrt(yearly_days)) if std_r > 1e-15 else 0.0
sharpe = max(-5.0, min(10.0, sharpe))
running_max = np.maximum.accumulate(cumsum)
drawdown = running_max - cumsum
max_dd = float(np.max(drawdown)) if len(drawdown) > 0 else 0.0
win_count = int(np.sum(returns >= 0))
daily_win_rate = win_count / n
return {
"absolute_return": absolute_return,
"annual_returns": annual_returns,
"sharpe": sharpe,
"max_drawdown": max_dd,
"daily_win_rate": daily_win_rate,
}
class TestStatsBasicMetrics:
def test_absolute_return(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
expected = dr["total"].sum()
assert stats["绝对收益"] == pytest.approx(expected, abs=0.001)
def test_annual_return(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
mean_r = np.mean(total_returns)
expected = mean_r * YEARLY_DAYS
assert stats["年化收益"] == pytest.approx(expected, abs=0.001)
def test_sharpe_ratio(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
mean_r = np.mean(total_returns)
std_r = np.std(total_returns, ddof=0)
if std_r > 1e-15:
expected = mean_r / std_r * math.sqrt(YEARLY_DAYS)
expected = max(-5.0, min(10.0, expected))
else:
expected = 0.0
assert stats["夏普比率"] == pytest.approx(expected, abs=0.01)
def test_max_drawdown(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
cumsum = np.cumsum(total_returns)
running_max = np.maximum.accumulate(cumsum)
drawdown = running_max - cumsum
expected = float(np.max(drawdown)) if len(drawdown) > 0 else 0.0
assert stats["最大回撤"] == pytest.approx(expected, abs=0.001)
def test_daily_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
win_count = int(np.sum(total_returns > 0)) + int(np.sum(total_returns == 0))
expected = win_count / len(total_returns)
assert stats["日胜率"] == pytest.approx(expected, abs=0.001)
class TestPeriodWinRates:
def test_week_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
df = dr[["date", "total"]].copy()
df["date"] = pd.to_datetime(df["date"])
df["week_key"] = (
df["date"].dt.isocalendar().year.astype(str) + "-" + df["date"].dt.isocalendar().week.astype(str)
)
weekly = df.groupby("week_key")["total"].sum()
expected = (weekly > 0).sum() / len(weekly) if len(weekly) > 0 else 0.0
assert stats["周胜率"] == pytest.approx(expected, abs=0.001)
def test_month_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
df = dr[["date", "total"]].copy()
df["date"] = pd.to_datetime(df["date"])
df["month_key"] = df["date"].dt.to_period("M")
monthly = df.groupby("month_key")["total"].sum()
expected = (monthly > 0).sum() / len(monthly) if len(monthly) > 0 else 0.0
assert stats["月胜率"] == pytest.approx(expected, abs=0.001)
def test_quarter_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
df = dr[["date", "total"]].copy()
df["date"] = pd.to_datetime(df["date"])
df["q_key"] = df["date"].dt.to_period("Q")
quarterly = df.groupby("q_key")["total"].sum()
expected = (quarterly > 0).sum() / len(quarterly) if len(quarterly) > 0 else 0.0
assert stats["季胜率"] == pytest.approx(expected, abs=0.001)
def test_year_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
n_days = len(dr)
min_days = YEARLY_DAYS // 2
if n_days < min_days:
expected = 0.0
else:
df = dr[["date", "total"]].copy()
df["date"] = pd.to_datetime(df["date"])
df["year"] = df["date"].dt.year
yearly = df.groupby("year").agg(total=("total", "sum"), count=("total", "count"))
qualified = yearly[yearly["count"] >= min_days]
expected = (qualified["total"] > 0).sum() / len(qualified) if len(qualified) > 0 else 0.0
assert stats["年胜率"] == pytest.approx(expected, abs=0.001)
class TestTradeMetrics:
def test_trade_count(self, bt: WeightBacktest) -> None:
stats = bt.stats
pairs = bt.pairs
if len(pairs) > 0 and "持仓数量" in pairs.columns:
expected = int(pairs["持仓数量"].sum())
elif len(pairs) > 0:
expected = len(pairs)
else:
expected = 0
assert stats["交易次数"] == expected
def test_annual_trade_count(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
n_days = len(dr)
trade_count = stats["交易次数"]
if n_days > 0:
expected = trade_count / (n_days / YEARLY_DAYS)
expected = round(expected * 100) / 100
else:
expected = 0.0
assert stats["年化交易次数"] == pytest.approx(expected, rel=1e-3)
def test_trade_win_rate(self, bt: WeightBacktest) -> None:
stats = bt.stats
pairs = bt.pairs
if len(pairs) == 0:
assert stats["交易胜率"] == 0.0
return
if "持仓数量" in pairs.columns:
total = pairs["持仓数量"].sum()
wins = pairs.loc[pairs["盈亏比例"] >= 0, "持仓数量"].sum()
else:
total = len(pairs)
wins = (pairs["盈亏比例"] >= 0).sum()
expected = wins / total if total > 0 else 0.0
assert stats["交易胜率"] == pytest.approx(expected, abs=0.001)
def test_single_profit_loss_ratio(self, bt: WeightBacktest) -> None:
stats = bt.stats
pairs = bt.pairs
if len(pairs) == 0:
assert stats["单笔盈亏比"] == 0.0
return
if "持仓数量" in pairs.columns:
win_mask = pairs["盈亏比例"] >= 0
loss_mask = pairs["盈亏比例"] < 0
win_total = (pairs.loc[win_mask, "盈亏比例"] * pairs.loc[win_mask, "持仓数量"]).sum()
win_count = pairs.loc[win_mask, "持仓数量"].sum()
loss_total = (pairs.loc[loss_mask, "盈亏比例"] * pairs.loc[loss_mask, "持仓数量"]).sum()
loss_count = pairs.loc[loss_mask, "持仓数量"].sum()
avg_win = win_total / win_count if win_count > 0 else 0.0
avg_loss = loss_total / loss_count if loss_count > 0 else 0.0
else:
wins = pairs.loc[pairs["盈亏比例"] >= 0, "盈亏比例"]
losses = pairs.loc[pairs["盈亏比例"] < 0, "盈亏比例"]
avg_win = wins.mean() if len(wins) > 0 else 0.0
avg_loss = losses.mean() if len(losses) > 0 else 0.0
expected = avg_win / abs(avg_loss) if abs(avg_loss) > 1e-10 else 0.0
assert stats["单笔盈亏比"] == pytest.approx(expected, abs=0.01)
def test_single_trade_profit(self, bt: WeightBacktest) -> None:
stats = bt.stats
pairs = bt.pairs
if len(pairs) == 0:
assert stats["单笔收益"] == 0.0
return
if "持仓数量" in pairs.columns:
total_pnl = (pairs["盈亏比例"] * pairs["持仓数量"]).sum()
total_count = pairs["持仓数量"].sum()
else:
total_pnl = pairs["盈亏比例"].sum()
total_count = len(pairs)
expected = total_pnl / total_count if total_count > 0 else 0.0
assert stats["单笔收益"] == pytest.approx(expected, abs=0.1)
class TestLongShortRates:
def test_long_short_rates_sum_le_1(self, bt: WeightBacktest) -> None:
stats = bt.stats
assert stats["多头占比"] + stats["空头占比"] <= 1.0 + 1e-6
def test_long_rate_from_weights(self, bt: WeightBacktest) -> None:
stats = bt.stats
assert 0.0 <= stats["多头占比"] <= 1.0
assert 0.0 <= stats["空头占比"] <= 1.0
def test_long_rate_matches_weight_data(self, bt: WeightBacktest, dfw: pd.DataFrame) -> None:
stats = bt.stats
weights = dfw["weight"].values
long_count = int(np.sum(weights > 0))
short_count = int(np.sum(weights < 0))
total = len(weights)
if total > 0:
expected_long = long_count / total
expected_short = short_count / total
else:
expected_long = 0.0
expected_short = 0.0
assert stats["多头占比"] == pytest.approx(expected_long, abs=0.001)
assert stats["空头占比"] == pytest.approx(expected_short, abs=0.001)
class TestLongShortStats:
def test_long_stats_absolute_return(self, bt: WeightBacktest) -> None:
long_stats = bt.long_stats
long_dr = bt.long_daily_return
expected = long_dr["total"].sum()
assert long_stats["绝对收益"] == pytest.approx(expected, abs=0.001)
def test_short_stats_absolute_return(self, bt: WeightBacktest) -> None:
short_stats = bt.short_stats
short_dr = bt.short_daily_return
expected = short_dr["total"].sum()
assert short_stats["绝对收益"] == pytest.approx(expected, abs=0.001)
def test_long_stats_trade_count_only_long(self, bt: WeightBacktest) -> None:
long_stats = bt.long_stats
pairs = bt.pairs
if len(pairs) == 0 or "交易方向" not in pairs.columns:
pytest.skip("No pairs data")
long_pairs = pairs[pairs["交易方向"] == "多头"]
if "持仓数量" in pairs.columns:
total = long_pairs["持仓数量"].sum()
wins = long_pairs.loc[long_pairs["盈亏比例"] >= 0, "持仓数量"].sum()
else:
total = len(long_pairs)
wins = (long_pairs["盈亏比例"] >= 0).sum()
expected_wr = wins / total if total > 0 else 0.0
assert long_stats["交易胜率"] == pytest.approx(expected_wr, abs=0.001)
def test_short_stats_trade_count_only_short(self, bt: WeightBacktest) -> None:
short_stats = bt.short_stats
pairs = bt.pairs
if len(pairs) == 0 or "交易方向" not in pairs.columns:
pytest.skip("No pairs data")
short_pairs = pairs[pairs["交易方向"] == "空头"]
if "持仓数量" in pairs.columns:
total = short_pairs["持仓数量"].sum()
wins = short_pairs.loc[short_pairs["盈亏比例"] >= 0, "持仓数量"].sum()
else:
total = len(short_pairs)
wins = (short_pairs["盈亏比例"] >= 0).sum()
expected_wr = wins / total if total > 0 else 0.0
assert short_stats["交易胜率"] == pytest.approx(expected_wr, abs=0.001)
def test_long_plus_short_returns_approx_total(self, bt: WeightBacktest) -> None:
long_dr = bt.long_daily_return
short_dr = bt.short_daily_return
total_dr = bt.daily_return
combined = long_dr["total"].values + short_dr["total"].values
np.testing.assert_allclose(combined, total_dr["total"].values, atol=1e-6)
class TestSegmentStats:
@staticmethod
def _python_segment(bt: WeightBacktest, sdt: int | None, edt: int | None, kind: str) -> dict:
dailys = bt.dailys.copy()
pairs = bt.pairs.copy()
dailys["date_int"] = pd.to_datetime(dailys["date"]).dt.strftime("%Y%m%d").astype(int)
actual_sdt = sdt if sdt is not None else int(dailys["date_int"].min())
actual_edt = edt if edt is not None else int(dailys["date_int"].max())
mask = (dailys["date_int"] >= actual_sdt) & (dailys["date_int"] <= actual_edt)
filtered = dailys[mask]
ret_col = {"多头": "long_return", "空头": "short_return"}.get(kind, "return")
daily_agg = filtered.groupby("date_int")[ret_col].mean() returns = daily_agg.values
date_keys = daily_agg.index.values
abs_ret = float(np.sum(returns))
if len(pairs) > 0 and "开仓时间" in pairs.columns:
pairs["open_dk"] = pd.to_datetime(pairs["开仓时间"]).dt.strftime("%Y%m%d").astype(int)
pairs["close_dk"] = pd.to_datetime(pairs["平仓时间"]).dt.strftime("%Y%m%d").astype(int)
p_mask = (pairs["open_dk"] >= actual_sdt) & (pairs["close_dk"] <= actual_edt)
if kind == "多头":
p_mask &= pairs["交易方向"] == "多头"
elif kind == "空头":
p_mask &= pairs["交易方向"] == "空头"
fp = pairs[p_mask]
if "持仓数量" in fp.columns:
trade_count = int(fp["持仓数量"].sum())
win_count = int(fp.loc[fp["盈亏比例"] >= 0, "持仓数量"].sum())
else:
trade_count = len(fp)
win_count = int((fp["盈亏比例"] >= 0).sum())
else:
trade_count = 0
win_count = 0
trade_wr = win_count / trade_count if trade_count > 0 else 0.0
return {
"abs_ret": abs_ret,
"n_dates": len(date_keys),
"trade_count": trade_count,
"trade_wr": trade_wr,
}
def test_full_range_matches_stats(self, bt: WeightBacktest) -> None:
stats = bt.stats
seg = bt.segment_stats()
assert seg["绝对收益"] == pytest.approx(stats["绝对收益"], abs=0.001)
assert seg["年化收益"] == pytest.approx(stats["年化收益"], abs=0.001)
assert seg["夏普比率"] == pytest.approx(stats["夏普比率"], abs=0.01)
assert seg["最大回撤"] == pytest.approx(stats["最大回撤"], abs=0.001)
assert seg["交易次数"] == stats["交易次数"]
assert seg["交易胜率"] == pytest.approx(stats["交易胜率"], abs=0.001)
assert seg["日胜率"] == pytest.approx(stats["日胜率"], abs=0.001)
def test_full_range_long_matches_long_stats(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(kind="多头")
ls = bt.long_stats
assert seg["绝对收益"] == pytest.approx(ls["绝对收益"], abs=0.001)
assert seg["交易次数"] == ls["交易次数"]
assert seg["交易胜率"] == pytest.approx(ls["交易胜率"], abs=0.001)
def test_full_range_short_matches_short_stats(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(kind="空头")
ss = bt.short_stats
assert seg["绝对收益"] == pytest.approx(ss["绝对收益"], abs=0.001)
assert seg["交易次数"] == ss["交易次数"]
assert seg["交易胜率"] == pytest.approx(ss["交易胜率"], abs=0.001)
def test_partial_range_absolute_return(self, bt: WeightBacktest) -> None:
sdt, edt = 20240105, 20240115
seg = bt.segment_stats(sdt=sdt, edt=edt)
ref = self._python_segment(bt, sdt, edt, "多空")
assert seg["绝对收益"] == pytest.approx(ref["abs_ret"], abs=0.001)
def test_partial_range_trade_count(self, bt: WeightBacktest) -> None:
sdt, edt = 20240105, 20240115
seg = bt.segment_stats(sdt=sdt, edt=edt)
ref = self._python_segment(bt, sdt, edt, "多空")
assert seg["交易次数"] == ref["trade_count"]
def test_partial_range_trade_win_rate(self, bt: WeightBacktest) -> None:
sdt, edt = 20240105, 20240115
seg = bt.segment_stats(sdt=sdt, edt=edt)
ref = self._python_segment(bt, sdt, edt, "多空")
assert seg["交易胜率"] == pytest.approx(ref["trade_wr"], abs=0.001)
def test_partial_range_fewer_trades(self, bt: WeightBacktest) -> None:
full_seg = bt.segment_stats()
partial_seg = bt.segment_stats(sdt=20240105, edt=20240115)
assert partial_seg["交易次数"] <= full_seg["交易次数"]
def test_partial_range_long(self, bt: WeightBacktest) -> None:
sdt, edt = 20240105, 20240115
seg = bt.segment_stats(sdt=sdt, edt=edt, kind="多头")
ref = self._python_segment(bt, sdt, edt, "多头")
assert seg["绝对收益"] == pytest.approx(ref["abs_ret"], abs=0.001)
assert seg["交易次数"] == ref["trade_count"]
def test_partial_range_short(self, bt: WeightBacktest) -> None:
sdt, edt = 20240105, 20240115
seg = bt.segment_stats(sdt=sdt, edt=edt, kind="空头")
ref = self._python_segment(bt, sdt, edt, "空头")
assert seg["绝对收益"] == pytest.approx(ref["abs_ret"], abs=0.001)
assert seg["交易次数"] == ref["trade_count"]
def test_sdt_only(self, bt: WeightBacktest) -> None:
sdt = 20240110
seg = bt.segment_stats(sdt=sdt)
ref = self._python_segment(bt, sdt, None, "多空")
assert seg["绝对收益"] == pytest.approx(ref["abs_ret"], abs=0.001)
assert seg["交易次数"] == ref["trade_count"]
def test_edt_only(self, bt: WeightBacktest) -> None:
edt = 20240110
seg = bt.segment_stats(edt=edt)
ref = self._python_segment(bt, None, edt, "多空")
assert seg["绝对收益"] == pytest.approx(ref["abs_ret"], abs=0.001)
assert seg["交易次数"] == ref["trade_count"]
def test_empty_range(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(sdt=20200101, edt=20200102)
assert seg["绝对收益"] == 0.0
assert seg["交易次数"] == 0
def test_single_day(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(sdt=20240105, edt=20240105)
assert seg["绝对收益"] == 0.0
def test_inverted_range(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(sdt=20240115, edt=20240105)
assert seg["绝对收益"] == 0.0
assert seg["交易次数"] == 0
def test_output_has_all_stats_keys(self, bt: WeightBacktest) -> None:
seg = bt.segment_stats(sdt=20240105, edt=20240115)
expected_keys = [
"绝对收益",
"年化收益",
"夏普比率",
"卡玛比率",
"新高占比",
"单笔盈亏比",
"单笔收益",
"日胜率",
"周胜率",
"月胜率",
"季胜率",
"年胜率",
"最大回撤",
"年化波动率",
"下行波动率",
"新高间隔",
"交易次数",
"年化交易次数",
"持仓K线数",
"交易胜率",
]
for k in expected_keys:
assert k in seg, f"Missing key in segment_stats: {k}"
class TestLongAlphaStats:
@staticmethod
def _python_alpha(bt: WeightBacktest) -> dict | None:
long_dr = bt.long_daily_return["total"].values
bench_returns = bt.alpha["基准"].values
yearly_days = bt.yearly_days
long_vol = np.std(long_dr, ddof=0) * math.sqrt(yearly_days)
bench_vol = np.std(bench_returns, ddof=0) * math.sqrt(yearly_days)
if long_vol < 1e-12 or bench_vol < 1e-12:
return None
target = 0.20
long_scale = target / long_vol
bench_scale = target / bench_vol
adj_long = long_dr * long_scale
adj_bench = bench_returns * bench_scale
alpha_daily = adj_long - adj_bench
return {
"long_scale": long_scale,
"bench_scale": bench_scale,
"adj_long": adj_long,
"adj_bench": adj_bench,
"alpha_daily": alpha_daily,
"long_vol": long_vol,
"bench_vol": bench_vol,
}
def test_long_alpha_stats_has_all_keys(self, bt: WeightBacktest) -> None:
alpha_stats = bt.long_alpha_stats
expected_keys = [
"绝对收益",
"年化收益",
"夏普比率",
"卡玛比率",
"新高占比",
"日胜率",
"周胜率",
"月胜率",
"季胜率",
"年胜率",
"最大回撤",
"年化波动率",
"下行波动率",
"新高间隔",
]
for k in expected_keys:
assert k in alpha_stats, f"Missing key: {k}"
def test_scale_factor(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
adj_long_vol = np.std(ref["adj_long"], ddof=0) * math.sqrt(YEARLY_DAYS)
adj_bench_vol = np.std(ref["adj_bench"], ddof=0) * math.sqrt(YEARLY_DAYS)
assert adj_long_vol == pytest.approx(0.20, rel=1e-6)
assert adj_bench_vol == pytest.approx(0.20, rel=1e-6)
def test_alpha_daily_formula(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
np.testing.assert_allclose(
ref["alpha_daily"],
ref["adj_long"] - ref["adj_bench"],
atol=1e-15,
)
def test_absolute_return(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
expected = round(float(np.sum(ref["alpha_daily"])) * 10000) / 10000
assert alpha_stats["绝对收益"] == pytest.approx(expected, abs=0.001)
def test_annual_return(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
expected = float(np.mean(ref["alpha_daily"])) * YEARLY_DAYS
assert alpha_stats["年化收益"] == pytest.approx(expected, abs=0.01)
def test_sharpe_ratio(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
mean_a = np.mean(ad)
std_a = np.std(ad, ddof=0)
expected = max(-5.0, min(10.0, mean_a / std_a * math.sqrt(YEARLY_DAYS))) if std_a > 1e-15 else 0.0
assert alpha_stats["夏普比率"] == pytest.approx(expected, abs=0.01)
def test_max_drawdown(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
cumsum = np.cumsum(ad)
running_max = np.maximum.accumulate(cumsum)
dd = running_max - cumsum
expected = float(np.max(dd)) if len(dd) > 0 else 0.0
assert alpha_stats["最大回撤"] == pytest.approx(expected, abs=0.001)
def test_daily_win_rate(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
win = int(np.sum(ad >= 0))
expected = win / len(ad)
assert alpha_stats["日胜率"] == pytest.approx(expected, abs=0.001)
def test_period_win_rates_on_alpha(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
dr = bt.daily_return
dates = pd.to_datetime(dr["date"])
ad = ref["alpha_daily"]
df_tmp = pd.DataFrame({"date": dates, "alpha": ad})
df_tmp["week_key"] = (
df_tmp["date"].dt.isocalendar().year.astype(str) + "-" + df_tmp["date"].dt.isocalendar().week.astype(str)
)
weekly = df_tmp.groupby("week_key")["alpha"].sum()
expected_week = (weekly > 0).sum() / len(weekly) if len(weekly) > 0 else 0.0
assert alpha_stats["周胜率"] == pytest.approx(expected_week, abs=0.001)
df_tmp["month_key"] = df_tmp["date"].dt.to_period("M")
monthly = df_tmp.groupby("month_key")["alpha"].sum()
expected_month = (monthly > 0).sum() / len(monthly) if len(monthly) > 0 else 0.0
assert alpha_stats["月胜率"] == pytest.approx(expected_month, abs=0.001)
def test_alpha_annual_volatility(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
expected = np.std(ad, ddof=0) * math.sqrt(YEARLY_DAYS)
assert alpha_stats["年化波动率"] == pytest.approx(expected, abs=0.001)
def test_alpha_downside_volatility(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
neg = ad[ad < 0]
expected = np.std(neg, ddof=0) * math.sqrt(YEARLY_DAYS) if len(neg) > 0 else 0.0
assert alpha_stats["下行波动率"] == pytest.approx(expected, abs=0.001)
def test_alpha_calmar_ratio(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
mean_a = np.mean(ad)
annual_ret = mean_a * YEARLY_DAYS
cumsum = np.cumsum(ad)
running_max = np.maximum.accumulate(cumsum)
dd = running_max - cumsum
max_dd = float(np.max(dd)) if len(dd) > 0 else 0.0
expected = max(-10.0, min(20.0, annual_ret / max_dd)) if max_dd > 1e-10 else 10.0
assert alpha_stats["卡玛比率"] == pytest.approx(expected, rel=0.02)
def test_alpha_new_high_ratio(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
cumsum = np.cumsum(ad)
running_max = np.maximum.accumulate(cumsum)
at_high = np.sum(running_max - cumsum <= 0.0)
expected = at_high / len(ad)
assert alpha_stats["新高占比"] == pytest.approx(expected, abs=0.001)
def test_alpha_new_high_interval(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
cumsum = np.cumsum(ad)
running_max = np.maximum.accumulate(cumsum)
underwater = cumsum < running_max
max_streak = 0
streak = 0
for u in underwater:
if u:
streak += 1
if streak > max_streak:
max_streak = streak
else:
streak = 0
assert alpha_stats["新高间隔"] == pytest.approx(float(max_streak), abs=0.001)
def test_alpha_quarter_win_rate(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
dr = bt.daily_return
dates = pd.to_datetime(dr["date"])
df_tmp = pd.DataFrame({"date": dates, "alpha": ad})
df_tmp["q_key"] = df_tmp["date"].dt.to_period("Q")
quarterly = df_tmp.groupby("q_key")["alpha"].sum()
expected = (quarterly > 0).sum() / len(quarterly) if len(quarterly) > 0 else 0.0
assert alpha_stats["季胜率"] == pytest.approx(expected, abs=0.001)
def test_alpha_year_win_rate(self, bt: WeightBacktest) -> None:
ref = self._python_alpha(bt)
if ref is None:
pytest.skip("Vol too small")
alpha_stats = bt.long_alpha_stats
ad = ref["alpha_daily"]
dr = bt.daily_return
dates = pd.to_datetime(dr["date"])
df_tmp = pd.DataFrame({"date": dates, "alpha": ad})
df_tmp["year"] = df_tmp["date"].dt.year
yearly = df_tmp.groupby("year").agg(total=("alpha", "sum"), count=("alpha", "count"))
min_days = YEARLY_DAYS // 2
qualified = yearly[yearly["count"] >= min_days]
expected = (qualified["total"] > 0).sum() / len(qualified) if len(qualified) > 0 else 0.0
assert alpha_stats["年胜率"] == pytest.approx(expected, abs=0.001)
class TestDailyPerformanceStandalone:
def test_known_returns(self) -> None:
returns = np.array([0.01, -0.005, 0.02])
dp = rust_daily_performance(returns, yearly_days=252)
assert dp["绝对收益"] == pytest.approx(0.025, abs=0.001)
assert dp["年化"] == pytest.approx(2.1, abs=0.01)
assert dp["夏普"] == pytest.approx(10.0, abs=0.01)
assert dp["最大回撤"] == pytest.approx(0.005, abs=0.001)
assert dp["日胜率"] == pytest.approx(2 / 3, abs=0.001)
def test_all_negative(self) -> None:
returns = np.array([-0.01, -0.02, -0.005])
dp = rust_daily_performance(returns, yearly_days=252)
assert dp["绝对收益"] < 0
assert dp["年化"] < 0
assert dp["夏普"] < 0
assert dp["最大回撤"] > 0
def test_empty_returns_zero(self) -> None:
returns = np.array([], dtype=np.float64)
dp = rust_daily_performance(returns, yearly_days=252)
assert dp["绝对收益"] == 0.0
assert dp["年化"] == 0.0
class TestInternalConsistency:
def test_dailys_return_sums_to_daily_return_total(self, bt: WeightBacktest) -> None:
dr = bt.daily_return
dailys = bt.dailys
pivot = dailys.pivot_table(index="date", columns="symbol", values="return")
pivot_mean = pivot.mean(axis=1)
pd.to_datetime(dr["date"])
pd.to_datetime(pivot_mean.index)
assert len(dr) == len(pivot_mean)
np.testing.assert_allclose(
dr["total"].values,
pivot_mean.values,
atol=1e-6,
err_msg="daily_return.total should equal mean of per-symbol returns",
)
def test_long_return_plus_short_return_equals_return(self, bt: WeightBacktest) -> None:
dailys = bt.dailys
combined = dailys["long_return"] + dailys["short_return"]
pd.testing.assert_series_equal(dailys["return"], combined, check_names=False, atol=1e-8)
def test_symbols_count(self, bt: WeightBacktest) -> None:
stats = bt.stats
assert stats["品种数量"] == 2
class TestVolatilityMetrics:
def test_annual_volatility(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
std_r = np.std(total_returns, ddof=0)
expected = std_r * math.sqrt(YEARLY_DAYS)
assert stats["年化波动率"] == pytest.approx(expected, abs=0.001)
def test_calmar_ratio(self, bt: WeightBacktest) -> None:
stats = bt.stats
if stats["最大回撤"] > 1e-10:
expected = stats["年化收益"] / stats["最大回撤"]
expected = max(-10.0, min(20.0, expected))
assert stats["卡玛比率"] == pytest.approx(expected, rel=0.02)
else:
assert stats["卡玛比率"] == pytest.approx(10.0, abs=0.01)
def test_downside_volatility(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
neg_returns = total_returns[total_returns < 0]
expected = np.std(neg_returns, ddof=0) * math.sqrt(YEARLY_DAYS) if len(neg_returns) > 0 else 0.0
assert stats["下行波动率"] == pytest.approx(expected, abs=0.001)
def test_new_high_ratio(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
cumsum = np.cumsum(total_returns)
running_max = np.maximum.accumulate(cumsum)
at_new_high = np.sum(running_max - cumsum <= 0.0)
expected = at_new_high / len(total_returns)
assert stats["新高占比"] == pytest.approx(expected, abs=0.001)
def test_new_high_interval(self, bt: WeightBacktest) -> None:
stats = bt.stats
dr = bt.daily_return
total_returns = dr["total"].values
cumsum = np.cumsum(total_returns)
running_max = np.maximum.accumulate(cumsum)
underwater = cumsum < running_max
max_streak = 0
streak = 0
for u in underwater:
if u:
streak += 1
if streak > max_streak:
max_streak = streak
else:
streak = 0
assert stats["新高间隔"] == pytest.approx(float(max_streak), abs=0.001)