mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
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:
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/unit/ -v --tb=short
|
||||
- run: python -m pytest tests/unit/ -v --tb=short --cov src/easy_tdx --cov-fail-under=50
|
||||
- run: ruff check src/ tests/
|
||||
- run: ruff format --check src/ tests/
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""计算最大回撤持续时间。
|
||||
|
||||
|
||||
@@ -433,18 +433,25 @@ class Strategy(ABC):
|
||||
def _datetime_to_int(arr: NDArray) -> NDArray:
|
||||
"""将 datetime 数组转为 int (YYYYMMDD)。
|
||||
|
||||
向量化实现,自动处理 datetime64、object(Timestamp)和数值类型。
|
||||
|
||||
Args:
|
||||
arr: datetime 数组(np.datetime64 或 pd.Timestamp)
|
||||
arr: datetime 数组(np.datetime64、pd.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)
|
||||
|
||||
@@ -47,11 +47,12 @@ def _make_trades() -> pd.DataFrame:
|
||||
"""创建测试用交易记录。
|
||||
|
||||
Returns:
|
||||
包含 direction, pnl, rejected 的 DataFrame
|
||||
4 条交易: BUY@100, SELL@105(pnl=500), BUY@95, SELL@90(pnl=-500)
|
||||
包含 datetime, direction, pnl, rejected 的 DataFrame
|
||||
4 条交易: BUY@20240101, SELL@20240106(pnl=500), BUY@20240110, SELL@20240115(pnl=-500)
|
||||
"""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": [20240101, 20240106, 20240110, 20240115],
|
||||
"direction": ["BUY", "SELL", "BUY", "SELL"],
|
||||
"pnl": [0, 500, 0, -500],
|
||||
"rejected": [False, False, False, False],
|
||||
@@ -323,18 +324,77 @@ def test_win_trades_and_lose_trades_count() -> None:
|
||||
assert metrics["lose_trades"] == 1
|
||||
|
||||
|
||||
def test_avg_holding_days() -> None:
|
||||
"""测试平均持仓天数(固定值)。"""
|
||||
def test_avg_holding_days_fifo() -> None:
|
||||
"""测试平均持仓天数(FIFO 配对计算)。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
trades = _make_trades()
|
||||
|
||||
analyzer = PerformanceAnalyzer(equity, trades)
|
||||
metrics = analyzer.compute()
|
||||
|
||||
# 平均持仓天数应为固定值 5.0
|
||||
# BUY@20240101 → SELL@20240106: 5 天
|
||||
# BUY@20240110 → SELL@20240115: 5 天
|
||||
# 平均 = (5 + 5) / 2 = 5.0
|
||||
assert metrics["avg_holding_days"] == 5.0
|
||||
|
||||
|
||||
def test_avg_holding_days_weighted() -> None:
|
||||
"""测试加权平均持仓天数(不同持仓期)。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
trades = pd.DataFrame(
|
||||
{
|
||||
"datetime": [20240101, 20240111, 20240120, 20240123],
|
||||
"direction": ["BUY", "SELL", "BUY", "SELL"],
|
||||
"pnl": [0, 500, 0, -200],
|
||||
"rejected": [False, False, False, False],
|
||||
}
|
||||
)
|
||||
|
||||
analyzer = PerformanceAnalyzer(equity, trades)
|
||||
metrics = analyzer.compute()
|
||||
|
||||
# BUY@20240101 → SELL@20240111: 10 天
|
||||
# BUY@20240120 → SELL@20240123: 3 天
|
||||
# 平均 = (10 + 3) / 2 = 6.5
|
||||
assert metrics["avg_holding_days"] == 6.5
|
||||
|
||||
|
||||
def test_avg_holding_days_no_datetime() -> None:
|
||||
"""测试 trades 没有 datetime 列时返回 0.0。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
# 不含 datetime 列的交易记录
|
||||
trades = pd.DataFrame(
|
||||
{
|
||||
"direction": ["BUY", "SELL"],
|
||||
"pnl": [0, 500],
|
||||
"rejected": [False, False],
|
||||
}
|
||||
)
|
||||
|
||||
analyzer = PerformanceAnalyzer(equity, trades)
|
||||
metrics = analyzer.compute()
|
||||
|
||||
assert metrics["avg_holding_days"] == 0.0
|
||||
|
||||
|
||||
def test_avg_holding_days_only_buys() -> None:
|
||||
"""测试只有买入没有卖出时返回 0.0。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
trades = pd.DataFrame(
|
||||
{
|
||||
"datetime": [20240101, 20240105],
|
||||
"direction": ["BUY", "BUY"],
|
||||
"pnl": [0, 0],
|
||||
"rejected": [False, False],
|
||||
}
|
||||
)
|
||||
|
||||
analyzer = PerformanceAnalyzer(equity, trades)
|
||||
metrics = analyzer.compute()
|
||||
|
||||
assert metrics["avg_holding_days"] == 0.0
|
||||
|
||||
|
||||
def test_max_dd_duration() -> None:
|
||||
"""测试最大回撤持续时间计算。"""
|
||||
equity = _make_equity_curve(n=252, total_return=0.1)
|
||||
|
||||
Reference in New Issue
Block a user