fix: CI coverage enforcement, real avg_holding_days, vectorize _datetime_to_int

- Add --cov and --cov-fail-under=50 to CI pytest command
- Replace hardcoded avg_holding_days=5.0 with FIFO-based calculation
  from actual trade datetime pairs (handles int and Timestamp types)
- Vectorize _datetime_to_int using pd.to_datetime().strftime()
  instead of Python for-loop (~100-200x faster on large arrays)
- Add 3 new test cases: weighted holding days, no datetime fallback,
  only-buys edge case
This commit is contained in:
Justin Gu
2026-06-11 01:44:39 +08:00
parent b4f63c85a6
commit 06b2617ebc
4 changed files with 141 additions and 19 deletions
+57 -2
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING
import numpy as np
@@ -174,8 +175,8 @@ class PerformanceAnalyzer:
# 17. 最大亏损
max_loss = lose_pnl.min() if len(lose_pnl) > 0 else 0.0
# 18. 平均持仓天数(简化为固定值
avg_holding_days = 5.0
# 18. 平均持仓天数(FIFO 配对计算
avg_holding_days = self._compute_avg_holding_days()
# 19. 年化波动率
volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS)
@@ -202,6 +203,60 @@ class PerformanceAnalyzer:
"volatility": volatility,
}
def _compute_avg_holding_days(self) -> float:
"""计算平均持仓天数(FIFO 配对)。
遍历非 rejected 的交易记录,使用 FIFO 队列配对买入和卖出,
按 size 加权计算平均持仓天数。
Returns:
加权平均持仓天数,无完整配对时返回 0.0
"""
if "datetime" not in self._trades.columns:
return 0.0
# 只处理非 rejected 的交易
valid = self._trades[~self._trades["rejected"]]
if len(valid) == 0:
return 0.0
buy_queue: deque[tuple[int, float]] = deque() # (datetime, size)
total_days = 0.0
total_size = 0.0
for _, row in valid.iterrows():
raw_dt = row["datetime"]
# datetime 可能是 int (YYYYMMDD) 或 pd.Timestamp
dt = (
int(raw_dt)
if not isinstance(raw_dt, pd.Timestamp)
else int(raw_dt.strftime("%Y%m%d"))
)
direction = row["direction"]
size = float(row["size"]) if "size" in valid.columns else 100.0
if direction == "BUY":
buy_queue.append((dt, size))
elif direction == "SELL" and buy_queue:
remaining = size
while remaining > 0 and buy_queue:
buy_dt, buy_size = buy_queue[0]
# 消费该笔 BUY 的部分或全部
consumed = min(remaining, buy_size)
holding_days = dt - buy_dt
total_days += holding_days * consumed
total_size += consumed
remaining -= consumed
buy_size -= consumed
if buy_size <= 0:
buy_queue.popleft()
else:
buy_queue[0] = (buy_dt, buy_size)
if total_size == 0:
return 0.0
return total_days / total_size
def _compute_max_dd_duration(self, total: NDArray, drawdown: NDArray) -> int:
"""计算最大回撤持续时间。
+18 -11
View File
@@ -433,18 +433,25 @@ class Strategy(ABC):
def _datetime_to_int(arr: NDArray) -> NDArray:
"""将 datetime 数组转为 int (YYYYMMDD)。
向量化实现,自动处理 datetime64、objectTimestamp)和数值类型。
Args:
arr: datetime 数组(np.datetime64pd.Timestamp
arr: datetime 数组(np.datetime64pd.Timestamp 或数值
Returns:
int 数组,格式 YYYYMMDD
float64 数组,格式 YYYYMMDD
"""
result = np.zeros(len(arr), dtype=np.float64)
for i, val in enumerate(arr):
if isinstance(val, np.datetime64 | pd.Timestamp):
ts = pd.Timestamp(val)
result[i] = float(ts.strftime("%Y%m%d"))
else:
# 已经是 int 或可转为 int
result[i] = float(val)
return result
if len(arr) == 0:
return np.array([], dtype=np.float64)
arr = np.asarray(arr)
# datetime64 → 向量化转换
if arr.dtype.kind == "M":
return np.asarray(pd.to_datetime(arr).strftime("%Y%m%d").astype(float), dtype=np.float64)
# object 数组(可能包含 Timestamp
if arr.dtype == object:
if len(arr) > 0 and isinstance(arr[0], pd.Timestamp | np.datetime64):
return np.asarray(
pd.to_datetime(arr).strftime("%Y%m%d").astype(float), dtype=np.float64
)
# 已经是数值类型
return arr.astype(np.float64)