Files
easy_tdx_max/src/easy_tdx/backtest/strategy.py
T
Justin Gu 06b2617ebc 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
2026-06-11 01:44:39 +08:00

458 lines
15 KiB
Python
Raw 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.
"""策略基类和数据代理。
提供策略编写的声明式 API
- StrategyDataProxy: K线数据访问层,支持 OHLCV + 自定义指标列
- Strategy: 策略基类,init() 注册指标,next() 生成信号
- crossover: 金叉检测辅助函数
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
import numpy as np
import numpy.typing as npt
import pandas as pd
from easy_tdx.backtest.types import Signal
if TYPE_CHECKING:
from collections.abc import Callable
NDArray = npt.NDArray[np.float64]
else:
NDArray = np.ndarray
# ── 数据序列访问器 ─────────────────────────────────────────────────────────────
class _SeriesAccessor:
"""数据序列访问器,支持相对索引和 numpy 数组转换。
Examples:
>>> close = data.close
>>> close[0] # 当前 bar 的收盘价
>>> close[-1] # 前一根 bar 的收盘价
>>> ma = MyTT.MA(close.raw, 20) # 传入 numpy 数组
"""
__slots__ = ("_series", "_bar_index")
def __init__(self, series: NDArray, bar_index: int) -> None:
self._series = series
self._bar_index = bar_index
def __getitem__(self, key: int) -> float:
"""获取相对索引的值。
Args:
key: 0=当前值, -1=前一根, -2=前两根,依此类推
Returns:
对应位置的 float 值
"""
idx = self._bar_index + key
if idx < 0:
raise IndexError(f"索引 {key} 超出范围(bar_index={self._bar_index}")
return float(self._series[idx])
def __len__(self) -> int:
"""返回数组长度。"""
return len(self._series)
def __array__(self) -> NDArray:
"""允许传入 MyTT 函数(自动解包为 numpy 数组)。"""
return self._series
@property
def raw(self) -> NDArray:
"""获取完整原始 numpy 数组。"""
return self._series
# ── K线数据代理 ────────────────────────────────────────────────────────────────
class StrategyDataProxy:
"""K线数据代理,将 DataFrame 转为高效的 numpy 数组访问。
内部将所有列(除 datetime)转为 numpy 数组,通过 _SeriesAccessor
提供相对索引访问([0] 当前, [-1] 前一根)。
支持标准 OHLCV 列和任意自定义列(如 MACD_DIF, BOLL_UPPER)。
"""
__slots__ = ("_arrays", "_bar_index")
def __init__(self, df: pd.DataFrame) -> None:
"""初始化数据代理。
Args:
df: K线 DataFrame,必须包含 datetime, open, close, high, low, vol, amount
可包含额外列(如 MACD_DIF, BOLL_UPPER
"""
self._arrays: dict[str, NDArray] = {}
self._bar_index = 0
# 将所有列(除 datetime)转为 numpy 数组
for col in df.columns:
if col == "datetime":
continue
arr = df[col].to_numpy()
if len(arr) > 0 and isinstance(arr[0], np.datetime64 | pd.Timestamp):
# datetime 列转为 int (YYYYMMDD)
self._arrays[col] = _datetime_to_int(arr)
else:
self._arrays[col] = arr.astype(np.float64)
def _set_index(self, idx: int) -> None:
"""设置当前 bar 索引(引擎调用)。"""
self._bar_index = idx
@property
def open(self) -> _SeriesAccessor:
"""开盘价序列。"""
return _SeriesAccessor(self._arrays["open"], self._bar_index)
@property
def close(self) -> _SeriesAccessor:
"""收盘价序列。"""
return _SeriesAccessor(self._arrays["close"], self._bar_index)
@property
def high(self) -> _SeriesAccessor:
"""最高价序列。"""
return _SeriesAccessor(self._arrays["high"], self._bar_index)
@property
def low(self) -> _SeriesAccessor:
"""最低价序列。"""
return _SeriesAccessor(self._arrays["low"], self._bar_index)
@property
def vol(self) -> _SeriesAccessor:
"""成交量序列。"""
return _SeriesAccessor(self._arrays["vol"], self._bar_index)
@property
def amount(self) -> _SeriesAccessor:
"""成交额序列。"""
return _SeriesAccessor(self._arrays["amount"], self._bar_index)
def __getattr__(self, name: str) -> _SeriesAccessor:
"""访问额外列(如 MACD_DIF, BOLL_UPPER)。
Raises:
AttributeError: 列不存在
"""
if name not in self._arrays:
raise AttributeError(f"列 '{name}' 不存在于数据中")
return _SeriesAccessor(self._arrays[name], self._bar_index)
# ── 金叉检测 ─────────────────────────────────────────────────────────────────────
def crossover(
a: NDArray | pd.Series | _SeriesAccessor,
b: NDArray | pd.Series | _SeriesAccessor,
) -> NDArray:
"""检测 a 从下方穿越 b(金叉)。
Args:
a: 快线序列
b: 慢线序列
Returns:
bool 数组,True 表示发生金叉
Examples:
>>> ma5 = MyTT.MA(data.close.raw, 5)
>>> ma20 = MyTT.MA(data.close.raw, 20)
>>> cross = crossover(ma5, ma20) # ma5 上穿 ma20
>>> if cross[bar_index]:
... strategy.buy(size=100)
"""
# 解包 _SeriesAccessor
if isinstance(a, _SeriesAccessor):
a = a.raw
if isinstance(b, _SeriesAccessor):
b = b.raw
# pd.Series 转 numpy
if isinstance(a, pd.Series):
a = a.to_numpy().astype(np.float64)
if isinstance(b, pd.Series):
b = b.to_numpy().astype(np.float64)
# 金叉:前一根 a <= b,当前 a > b
mask = np.zeros(len(a), dtype=bool)
mask[1:] = (a[:-1] <= b[:-1]) & (a[1:] > b[1:])
return mask
# ── 策略基类 ───────────────────────────────────────────────────────────────────
class Strategy(ABC):
"""策略基类,提供声明式回测 API。
用户子类实现:
- init(): 注册指标(通过 self.I()
- next(): 生成交易信号(通过 self.buy()/self.sell()
内部状态:
- self.data: StrategyDataProxy,访问 K线数据
- self.position: {"size": float},当前持仓
- self.chanlun: 缠论分析结果(预留)
示例:
>>> class MyStrategy(Strategy):
... def init(self):
... self.ma5 = self.I(MyTT.MA, self.data.close, 5)
... self.ma20 = self.I(MyTT.MA, self.data.close, 20)
... self.cross = crossover(self.ma5, self.ma20)
...
... def next(self):
... if self.cross[self._bar_index]:
... self.buy(size=100)
... elif self.position["size"] > 0:
... self.sell(size=0)
"""
def __init__(self) -> None:
"""初始化策略内部状态。"""
self._data_proxy: StrategyDataProxy | None = None
self._bar_index = 0
self._signals: list[Signal] = []
self._indicators: dict[str, NDArray] = {}
self._chanlun_result: dict[str, Any] | None = None
self._position_size = 0.0
self._cash = 0.0
self._datetime_array: NDArray | None = None
# ── 用户实现方法 ─────────────────────────────────────────────────────────────
@abstractmethod
def init(self) -> None:
"""注册指标。
在回测开始前调用一次,用户通过 self.I() 注册技术指标。
"""
@abstractmethod
def next(self) -> None:
"""生成交易信号。
每根 bar 调用一次,用户通过 self.buy()/self.sell() 生成信号。
"""
# ── 指标注册 ───────────────────────────────────────────────────────────────
def I( # noqa: E743
self, func: Callable[..., NDArray], *args: Any, **kwargs: Any
) -> NDArray:
"""注册指标。
将 _SeriesAccessor 参数自动解包为 numpy 数组,调用 func 计算指标。
返回的数组存入 self._indicators,可在 next() 中访问。
Args:
func: 指标函数(如 MyTT.MA
*args: 参数(可能是 _SeriesAccessor,会自动解包)
**kwargs: 关键字参数
Returns:
指标值数组(numpy ndarray
Examples:
>>> ma5 = self.I(MyTT.MA, self.data.close, 5)
>>> macd = self.I(MyTT.MACD, self.data.close, self.data.low, self.data.high)
"""
# 解包 _SeriesAccessor 参数
unpacked_args = []
for arg in args:
if isinstance(arg, _SeriesAccessor):
unpacked_args.append(arg.raw)
else:
unpacked_args.append(arg)
# 调用函数
result = func(*unpacked_args, **kwargs)
# 存储指标(用于调试/日志)
func_name = getattr(func, "__name__", str(func))
self._indicators[func_name] = result
return result
# ── 交易信号生成 ─────────────────────────────────────────────────────────────
def buy(
self,
size: float = 0,
price: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
) -> None:
"""生成买入信号。
Args:
size: 交易数量(0 = 全仓,由引擎计算)
price: 限价(None = 市价单)
stop_loss: 止损价(None = 不设置)
take_profit: 止盈价(None = 不设置)
"""
if self._data_proxy is None:
raise RuntimeError("策略未绑定数据,请先调用 _bind_data()")
if self._datetime_array is None:
raise RuntimeError("数据未正确初始化")
signal = Signal(
datetime=int(self._datetime_array[self._bar_index]),
direction="BUY",
size=size,
price=price,
stop_loss=stop_loss,
take_profit=take_profit,
)
self._signals.append(signal)
def sell(
self,
size: float = 0,
price: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
) -> None:
"""生成卖出信号。
Args:
size: 交易数量(0 = 全仓,由引擎计算)
price: 限价(None = 市价单)
stop_loss: 止损价(None = 不设置)
take_profit: 止盈价(None = 不设置)
"""
if self._data_proxy is None:
raise RuntimeError("策略未绑定数据,请先调用 _bind_data()")
if self._datetime_array is None:
raise RuntimeError("数据未正确初始化")
signal = Signal(
datetime=int(self._datetime_array[self._bar_index]),
direction="SELL",
size=size,
price=price,
stop_loss=stop_loss,
take_profit=take_profit,
)
self._signals.append(signal)
# ── 状态访问 ─────────────────────────────────────────────────────────────────
@property
def data(self) -> StrategyDataProxy:
"""K线数据代理。"""
if self._data_proxy is None:
raise RuntimeError("策略未绑定数据,请先调用 _bind_data()")
return self._data_proxy
@property
def position(self) -> dict[str, float]:
"""当前持仓(简化 dict 格式)。
Returns:
{"size": float},正=多头,负=空头,0=空仓
"""
return {"size": self._position_size}
@property
def chanlun(self) -> dict[str, Any] | None:
"""缠论分析结果(预留)。"""
return self._chanlun_result
# ── 内部方法(引擎调用) ─────────────────────────────────────────────────────
def _bind_data(self, df: pd.DataFrame) -> None:
"""绑定 K线数据(引擎调用)。
Args:
df: K线 DataFrame
"""
self._data_proxy = StrategyDataProxy(df)
# 提取 datetime 数组
if "datetime" in df.columns:
dt_arr = df["datetime"].to_numpy()
self._datetime_array = _datetime_to_int(dt_arr)
else:
raise ValueError("DataFrame 必须包含 datetime 列")
def _call_init(self) -> None:
"""调用用户 init() 方法(引擎调用)。"""
self.init()
def _set_bar_index(self, idx: int) -> None:
"""设置当前 bar 索引(引擎调用)。
Args:
idx: bar 索引
"""
self._bar_index = idx
if self._data_proxy is not None:
self._data_proxy._set_index(idx)
def _call_next(self) -> None:
"""调用用户 next() 方法(引擎调用)。"""
self.next()
def _get_datetime(self) -> int:
"""获取当前 bar 的 datetime(引擎调用)。
Returns:
datetime int (YYYYMMDD)
"""
if self._datetime_array is None:
raise RuntimeError("数据未正确初始化")
return int(self._datetime_array[self._bar_index])
def _clear_signals(self) -> list[Signal]:
"""清空并返回已生成的信号(引擎调用)。
Returns:
当前累积的信号列表
"""
signals = self._signals
self._signals = []
return signals
# ── 辅助函数 ─────────────────────────────────────────────────────────────────────
def _datetime_to_int(arr: NDArray) -> NDArray:
"""将 datetime 数组转为 int (YYYYMMDD)。
向量化实现,自动处理 datetime64、objectTimestamp)和数值类型。
Args:
arr: datetime 数组(np.datetime64、pd.Timestamp 或数值)
Returns:
float64 数组,格式 YYYYMMDD
"""
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)