mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 21:34:16 +08:00
- Add _SeriesAccessor for relative indexed data access ([0] current, [-1] previous) - Add StrategyDataProxy for efficient DataFrame column access via numpy arrays - Add crossover() function for golden cross detection (fast line crosses above slow line) - Add Strategy abstract base class with: - init() for indicator registration via self.I() - next() for signal generation via buy()/sell() - Internal engine hooks (_bind_data, _call_init, _set_bar_index, etc.) - All code is mypy strict compliant with full type annotations - 25 unit tests covering all components Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
468 lines
15 KiB
Python
468 lines
15 KiB
Python
"""测试策略基类和数据代理。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
from easy_tdx.backtest.strategy import (
|
||
Strategy,
|
||
StrategyDataProxy,
|
||
_SeriesAccessor,
|
||
crossover,
|
||
)
|
||
from easy_tdx.backtest.types import Signal
|
||
|
||
|
||
# ── 辅助函数 ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_df(n: int = 20, seed: int = 42) -> pd.DataFrame:
|
||
"""构造随机 OHLCV DataFrame。
|
||
|
||
Args:
|
||
n: K线数量
|
||
seed: 随机种子
|
||
|
||
Returns:
|
||
DataFrame with columns: datetime, open, close, high, low, vol, amount
|
||
"""
|
||
rng = np.random.default_rng(seed)
|
||
base = 10.0
|
||
prices = base + rng.random(n) * 5
|
||
|
||
df = pd.DataFrame(
|
||
{
|
||
"datetime": pd.date_range("2024-01-01", periods=n, freq="D"),
|
||
"open": prices,
|
||
"close": prices + rng.random(n) - 0.5,
|
||
"high": prices + rng.random(n),
|
||
"low": prices - rng.random(n),
|
||
"vol": rng.integers(1000, 10000, n),
|
||
"amount": rng.integers(100000, 1000000, n),
|
||
}
|
||
)
|
||
# 确保 high >= max(open, close), low <= min(open, close)
|
||
df["high"] = df[["open", "close", "high"]].max(axis=1)
|
||
df["low"] = df[["open", "close", "low"]].min(axis=1)
|
||
return df
|
||
|
||
|
||
def _make_df_with_extras(n: int = 20) -> pd.DataFrame:
|
||
"""构造带额外列的 DataFrame(MACD_DIF, BOLL_UPPER)。
|
||
|
||
Args:
|
||
n: K线数量
|
||
|
||
Returns:
|
||
DataFrame with standard OHLCV columns + MACD_DIF, BOLL_UPPER
|
||
"""
|
||
df = _make_df(n)
|
||
rng = np.random.default_rng(42)
|
||
|
||
# 添加额外列
|
||
df["MACD_DIF"] = rng.random(n) * 2 - 1
|
||
df["BOLL_UPPER"] = df["close"] + rng.random(n) * 2
|
||
|
||
return df
|
||
|
||
|
||
# ── TestSeriesAccessor ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSeriesAccessor:
|
||
"""测试 _SeriesAccessor 数据访问器。"""
|
||
|
||
def test_current_value(self) -> None:
|
||
"""测试获取当前值 [0]。"""
|
||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
acc = _SeriesAccessor(arr, bar_index=2)
|
||
assert acc[0] == 3.0
|
||
|
||
def test_previous_value(self) -> None:
|
||
"""测试获取前一根 [-1]。"""
|
||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
acc = _SeriesAccessor(arr, bar_index=2)
|
||
assert acc[-1] == 2.0
|
||
|
||
def test_previous_two(self) -> None:
|
||
"""测试获取前两根 [-2]。"""
|
||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
acc = _SeriesAccessor(arr, bar_index=2)
|
||
assert acc[-2] == 1.0
|
||
|
||
def test_index_out_of_bounds_negative(self) -> None:
|
||
"""测试索引越界(负方向)。"""
|
||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
acc = _SeriesAccessor(arr, bar_index=0)
|
||
with pytest.raises(IndexError, match="索引 -1 超出范围"):
|
||
_ = acc[-1]
|
||
|
||
def test_len(self) -> None:
|
||
"""测试 __len__ 返回数组长度。"""
|
||
arr = np.array([1.0, 2.0, 3.0])
|
||
acc = _SeriesAccessor(arr, bar_index=1)
|
||
assert len(acc) == 3
|
||
|
||
def test_array_conversion(self) -> None:
|
||
"""测试 __array__ 转为 numpy 数组。"""
|
||
arr = np.array([1.0, 2.0, 3.0])
|
||
acc = _SeriesAccessor(arr, bar_index=1)
|
||
result = np.asarray(acc)
|
||
np.testing.assert_array_equal(result, arr)
|
||
|
||
def test_raw_property(self) -> None:
|
||
"""测试 raw 属性返回原始数组。"""
|
||
arr = np.array([1.0, 2.0, 3.0])
|
||
acc = _SeriesAccessor(arr, bar_index=1)
|
||
np.testing.assert_array_equal(acc.raw, arr)
|
||
|
||
|
||
# ── TestStrategyDataProxy ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestStrategyDataProxy:
|
||
"""测试 StrategyDataProxy 数据代理。"""
|
||
|
||
def test_basic_columns(self) -> None:
|
||
"""测试标准 OHLCV 列访问。"""
|
||
df = _make_df(n=10)
|
||
proxy = StrategyDataProxy(df)
|
||
proxy._set_index(5)
|
||
|
||
assert isinstance(proxy.open[0], float)
|
||
assert isinstance(proxy.close[0], float)
|
||
assert isinstance(proxy.high[0], float)
|
||
assert isinstance(proxy.low[0], float)
|
||
assert isinstance(proxy.vol[0], float)
|
||
assert isinstance(proxy.amount[0], float)
|
||
|
||
def test_previous_bar(self) -> None:
|
||
"""测试访问前一根 bar 的数据。"""
|
||
df = _make_df(n=10)
|
||
proxy = StrategyDataProxy(df)
|
||
proxy._set_index(5)
|
||
|
||
# close[0] 应该等于 df["close"].iloc[5]
|
||
assert proxy.close[0] == pytest.approx(df["close"].iloc[5])
|
||
# close[-1] 应该等于 df["close"].iloc[4]
|
||
assert proxy.close[-1] == pytest.approx(df["close"].iloc[4])
|
||
|
||
def test_extra_columns_via_getattr(self) -> None:
|
||
"""测试通过 __getattr__ 访问额外列。"""
|
||
df = _make_df_with_extras(n=10)
|
||
proxy = StrategyDataProxy(df)
|
||
proxy._set_index(5)
|
||
|
||
# MACD_DIF 和 BOLL_UPPER 应该可以访问
|
||
assert isinstance(proxy.MACD_DIF[0], float)
|
||
assert isinstance(proxy.BOLL_UPPER[0], float)
|
||
|
||
# 验证值正确
|
||
assert proxy.MACD_DIF[0] == pytest.approx(df["MACD_DIF"].iloc[5])
|
||
assert proxy.BOLL_UPPER[0] == pytest.approx(df["BOLL_UPPER"].iloc[5])
|
||
|
||
def test_missing_column_raises(self) -> None:
|
||
"""测试访问不存在的列抛出 AttributeError。"""
|
||
df = _make_df(n=10)
|
||
proxy = StrategyDataProxy(df)
|
||
proxy._set_index(5)
|
||
|
||
with pytest.raises(AttributeError, match="列 'NONEXISTENT' 不存在"):
|
||
_ = proxy.NONEXISTENT[0]
|
||
|
||
|
||
# ── TestCrossover ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCrossover:
|
||
"""测试 crossover 金叉检测函数。"""
|
||
|
||
def test_crossover_true(self) -> None:
|
||
"""测试金叉检测(a 上穿 b)。"""
|
||
a = np.array([1, 2, 3, 4, 5])
|
||
b = np.array([5, 4, 3, 2, 1])
|
||
mask = crossover(a, b)
|
||
|
||
# 索引 3 处:a[2]=3 <= b[2]=3, a[3]=4 > b[3]=2 → 金叉
|
||
assert mask[3] == True
|
||
# 其他位置无金叉
|
||
assert mask[0] == False
|
||
assert mask[1] == False
|
||
assert mask[2] == False
|
||
assert mask[4] == False
|
||
|
||
def test_crossover_false_no_cross(self) -> None:
|
||
"""测试无金叉情况(a 全在 b 下方)。"""
|
||
a = np.array([1, 2, 3, 4, 5])
|
||
b = np.array([6, 7, 8, 9, 10])
|
||
mask = crossover(a, b)
|
||
|
||
# 全部 False
|
||
assert not np.any(mask)
|
||
|
||
def test_crossover_series(self) -> None:
|
||
"""测试接受 pd.Series 参数。"""
|
||
a = pd.Series([1, 2, 3, 4, 5])
|
||
b = pd.Series([5, 4, 3, 2, 1])
|
||
mask = crossover(a, b)
|
||
|
||
assert mask[3] == True
|
||
|
||
def test_crossover_with_accessor(self) -> None:
|
||
"""测试接受 _SeriesAccessor 参数。"""
|
||
arr_a = np.array([1, 2, 3, 4, 5])
|
||
arr_b = np.array([5, 4, 3, 2, 1])
|
||
acc_a = _SeriesAccessor(arr_a, bar_index=4)
|
||
acc_b = _SeriesAccessor(arr_b, bar_index=4)
|
||
|
||
mask = crossover(acc_a, acc_b)
|
||
assert mask[3] == True
|
||
|
||
|
||
# ── TestStrategyBase ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestStrategyBase:
|
||
"""测试 Strategy 策略基类。"""
|
||
|
||
def test_subclass_init_and_next(self) -> None:
|
||
"""测试子类 init() 和 next() 被调用。"""
|
||
|
||
class SimpleStrategy(Strategy):
|
||
init_called = False
|
||
next_called_count = 0
|
||
|
||
def init(self) -> None:
|
||
SimpleStrategy.init_called = True
|
||
|
||
def next(self) -> None:
|
||
SimpleStrategy.next_called_count += 1
|
||
|
||
df = _make_df(n=10)
|
||
strategy = SimpleStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
assert SimpleStrategy.init_called is True
|
||
|
||
# 模拟引擎遍历所有 bar
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
assert SimpleStrategy.next_called_count == 10
|
||
|
||
def test_buy_sell_recording(self) -> None:
|
||
"""测试 buy() 和 sell() 创建 Signal。"""
|
||
|
||
class SignalStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
if self._bar_index == 5:
|
||
self.buy(size=100, price=10.0, stop_loss=9.0, take_profit=11.0)
|
||
elif self._bar_index == 8:
|
||
self.sell(size=100, price=10.5)
|
||
|
||
df = _make_df(n=10)
|
||
strategy = SignalStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
signals = strategy._clear_signals()
|
||
|
||
assert len(signals) == 2
|
||
assert signals[0].direction == "BUY"
|
||
assert signals[0].size == 100
|
||
assert signals[0].price == 10.0
|
||
assert signals[0].stop_loss == 9.0
|
||
assert signals[0].take_profit == 11.0
|
||
|
||
assert signals[1].direction == "SELL"
|
||
assert signals[1].size == 100
|
||
assert signals[1].price == 10.5
|
||
|
||
def test_I_registers_indicator(self) -> None:
|
||
"""测试 self.I() 注册指标。"""
|
||
from easy_tdx.MyTT import MA
|
||
|
||
class IndicatorStrategy(Strategy):
|
||
def init(self) -> None:
|
||
self.ma5 = self.I(MA, self.data.close, 5)
|
||
self.ma20 = self.I(MA, self.data.close, 20)
|
||
|
||
def next(self) -> None:
|
||
pass
|
||
|
||
df = _make_df(n=50)
|
||
strategy = IndicatorStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
# 验证指标长度正确(MA 会产生 nan,但长度不变)
|
||
assert len(strategy.ma5) == 50
|
||
assert len(strategy.ma20) == 50
|
||
|
||
# 验证指标已注册
|
||
assert "MA" in strategy._indicators
|
||
|
||
def test_full_position_buy(self) -> None:
|
||
"""测试全仓买入(size=0)。"""
|
||
|
||
class FullPositionStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
if self._bar_index == 5:
|
||
self.buy(size=0) # 全仓
|
||
|
||
df = _make_df(n=10)
|
||
strategy = FullPositionStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
signals = strategy._clear_signals()
|
||
|
||
assert len(signals) == 1
|
||
assert signals[0].direction == "BUY"
|
||
assert signals[0].size == 0 # 0 表示全仓
|
||
|
||
def test_data_property(self) -> None:
|
||
"""测试 self.data 属性返回 StrategyDataProxy。"""
|
||
df = _make_df(n=10)
|
||
|
||
class DataAccessStrategy(Strategy):
|
||
def init(self) -> None:
|
||
# 验证 data 可访问
|
||
assert hasattr(self.data, "close")
|
||
assert hasattr(self.data, "open")
|
||
|
||
def next(self) -> None:
|
||
# 验证 next() 中也可访问
|
||
assert self.data.close[0] > 0
|
||
|
||
strategy = DataAccessStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
def test_position_property(self) -> None:
|
||
"""测试 self.position 属性。"""
|
||
df = _make_df(n=10)
|
||
|
||
class PositionStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
# 初始状态无持仓
|
||
assert self.position["size"] == 0.0
|
||
|
||
strategy = PositionStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
def test_get_datetime(self) -> None:
|
||
"""测试 _get_datetime() 方法。"""
|
||
df = _make_df(n=10)
|
||
|
||
class DateTimeStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
# 验证 datetime 正确(YYYYMMDD 格式)
|
||
dt = self._get_datetime()
|
||
assert isinstance(dt, int)
|
||
assert 20240101 <= dt <= 20241231
|
||
|
||
strategy = DateTimeStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
|
||
def test_clear_signals(self) -> None:
|
||
"""测试 _clear_signals() 清空信号列表。"""
|
||
|
||
class ClearSignalStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
self.buy(size=100)
|
||
|
||
df = _make_df(n=10)
|
||
strategy = ClearSignalStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
# 第一轮
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|
||
signals1 = strategy._clear_signals()
|
||
assert len(signals1) == 10
|
||
|
||
# 第二轮(无新信号)
|
||
signals2 = strategy._clear_signals()
|
||
assert len(signals2) == 0
|
||
|
||
def test_buy_without_bind_raises(self) -> None:
|
||
"""测试未绑定数据时调用 buy() 抛出错误。"""
|
||
|
||
class BadStrategy(Strategy):
|
||
def init(self) -> None:
|
||
pass
|
||
|
||
def next(self) -> None:
|
||
self.buy(size=100)
|
||
|
||
strategy = BadStrategy()
|
||
# 未调用 _bind_data()
|
||
|
||
with pytest.raises(RuntimeError, match="策略未绑定数据"):
|
||
strategy._call_init()
|
||
strategy._set_bar_index(0)
|
||
strategy._call_next()
|
||
|
||
def test_chanlun_property(self) -> None:
|
||
"""测试 chanlun 属性(预留)。"""
|
||
df = _make_df(n=10)
|
||
|
||
class ChanlunStrategy(Strategy):
|
||
def init(self) -> None:
|
||
assert self.chanlun is None
|
||
|
||
def next(self) -> None:
|
||
pass
|
||
|
||
strategy = ChanlunStrategy()
|
||
strategy._bind_data(df)
|
||
strategy._call_init()
|
||
|
||
for i in range(len(df)):
|
||
strategy._set_bar_index(i)
|
||
strategy._call_next()
|