Files
easy_tdx_max/docs/superpowers/plans/2026-06-12-v1.11.0-factor-engine.md
T

52 KiB
Raw Blame History

v1.11.0 因子引擎实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现 Factor 基类、注册表、FactorEngine 计算引擎、15 个内置因子和 easy-tdx factor list CLI 命令,使 easy-tdx 具备因子计算和列举能力。

Architecture: 因子系统采用 ABC + 注册表模式(与 indicator.py_REGISTRY 模式一致)。FactorEngine 提供单股多因子计算和跨股票截面计算两种模式。内置因子通过桥接 MyTT 和 ChanlunAnalyser 复用现有能力。所有计算纯 numpy 向量化,不依赖网络。

Tech Stack: Python 3.10+, numpy, pandas, click

Design Spec: docs/superpowers/specs/2026-06-12-quantitative-factor-engine-design.md

Note: 本计划只覆盖 v1.11.0(因子引擎 + 内置因子库 + CLI)。v1.12.0(分析 + 预处理)和 v1.13.0(组合管理)将在后续计划中实现。


File Structure

Action Path Responsibility
Create src/easy_tdx/factor/__init__.py 公开 API 导出
Create src/easy_tdx/factor/base.py Factor ABC + FACTORY_REGISTRY + register_factor
Create src/easy_tdx/factor/engine.py FactorEnginecompute_single / compute_cross_section / compute_forward_returns
Create src/easy_tdx/factor/builtin/__init__.py 自动导入 + list_factors / get_factor
Create src/easy_tdx/factor/builtin/momentum.py momentum_20d, momentum_60d, reversal_5d
Create src/easy_tdx/factor/builtin/volatility.py volatility_20d, atr_14d, turnover_rate
Create src/easy_tdx/factor/builtin/quality.py sharpe_20d, max_drawdown_20d, win_rate_20d
Create src/easy_tdx/factor/builtin/volume.py obv_trend, vol_surge, amount_ma_ratio
Create src/easy_tdx/factor/builtin/technical.py macd_hist_signal, rsi_14, boll_position
Create src/easy_tdx/factor/builtin/chanlun.py chanlun_bi_dir, chanlun_mmd
Create src/easy_tdx/factor/builtin/value.py pe_ratio, pb_ratio(占位)
Create src/easy_tdx/cli/cmd_factor.py easy-tdx factor list CLI 命令
Modify src/easy_tdx/cli/__init__.py 注册 factor 命令
Modify pyproject.toml bump version → 1.11.0
Create tests/unit/test_factor_base.py Factor 基类 + 注册表测试
Create tests/unit/test_factor_engine.py FactorEngine 测试
Create tests/unit/test_factor_builtin.py 内置因子正确性测试

Task 1: Factor 基类与注册表

Files:

  • Create: src/easy_tdx/factor/base.py

  • Create: src/easy_tdx/factor/__init__.py(初始版本,只导出 base

  • Test: tests/unit/test_factor_base.py

  • Step 1: 写测试 — Factor 基类和注册表

# tests/unit/test_factor_base.py
"""Test Factor base class and registry."""
from __future__ import annotations

import pandas as pd
import pytest

from easy_tdx.factor.base import (
    FACTORY_REGISTRY,
    Factor,
    register_factor,
)


class _StubFactor(Factor):
    """测试用因子。"""

    name = "test_stub"
    category = "test"
    description = "stub for testing"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return df["close"].pct_change(1)


class TestFactorABC:
    def test_cannot_instantiate_abc(self):
        with pytest.raises(TypeError):
            Factor()  # type: ignore[abstract]

    def test_subclass_must_define_name(self):
        class NoName(Factor):
            category = "test"
            description = "x"
            inputs = ("close",)

            def compute(self, df):
                return df["close"]

        with pytest.raises(TypeError):
            NoName()

    def test_subclass_must_define_category(self):
        class NoCategory(Factor):
            name = "x"
            description = "x"
            inputs = ("close",)

            def compute(self, df):
                return df["close"]

        with pytest.raises(TypeError):
            NoCategory()

    def test_subclass_must_implement_compute(self):
        class NoCompute(Factor):
            name = "x"
            category = "test"
            description = "x"
            inputs = ("close",)

        with pytest.raises(TypeError):
            NoCompute()

    def test_concrete_subclass_works(self):
        f = _StubFactor()
        assert f.name == "test_stub"
        assert f.category == "test"
        assert f.inputs == ("close",)


class TestRegistry:
    def test_register_factor_decorator(self):
        @register_factor
        class RegFactor(Factor):
            name = "reg_test_factor"
            category = "test"
            description = "registered factor"
            inputs = ("close",)

            def compute(self, df):
                return df["close"]

        assert "reg_test_factor" in FACTORY_REGISTRY
        assert FACTORY_REGISTRY["reg_test_factor"] is RegFactor

    def test_duplicate_name_raises(self):
        @register_factor
        class Dup(Factor):
            name = "dup_test_factor"
            category = "test"
            description = "dup"
            inputs = ("close",)

            def compute(self, df):
                return df["close"]

        with pytest.raises(ValueError, match="已注册"):
            @register_factor
            class Dup2(Factor):
                name = "dup_test_factor"
                category = "test"
                description = "dup2"
                inputs = ("close",)

                def compute(self, df):
                    return df["close"]


class TestFactorCompute:
    def test_compute_returns_series(self):
        f = _StubFactor()
        df = pd.DataFrame({"close": [10.0, 11.0, 10.5, 12.0]})
        result = f.compute(df)
        assert isinstance(result, pd.Series)
        assert len(result) == 4
        assert result.iloc[0] == 0.1  # 11/10 - 1
  • Step 2: 运行测试验证失败

Run: python -m pytest tests/unit/test_factor_base.py -v Expected: FAIL — ModuleNotFoundError: No module named 'easy_tdx.factor'

  • Step 3: 实现 factor/base.py
# src/easy_tdx/factor/base.py
"""因子基类与全局注册表。"""
from __future__ import annotations

from abc import ABC, abstractmethod

import pandas as pd


class Factor(ABC):
    """因子基类 — 所有因子的抽象契约。

    子类必须定义:
        name: str        — 唯一标识,如 "momentum_20d"
        category: str    — 分类:momentum / value / quality / volatility / technical / chanlun
        description: str — 人类可读描述
        inputs: tuple[str, ...] — 需要的列名,如 ("close", "vol")

    并实现 compute(df) -> pd.Series。
    """

    name: str
    category: str
    description: str
    inputs: tuple[str, ...]

    @abstractmethod
    def compute(self, df: pd.DataFrame) -> pd.Series:
        """接收 OHLCV DataFrame,返回因子值序列(与 df 等长)。"""
        ...

    def __init_subclass__(cls, **kwargs: object) -> None:
        super().__init_subclass__(**kwargs)
        # 验证子类定义了必要的类属性(跳过抽象子类)
        if getattr(cls, "compute", None) is not None and not getattr(
            cls.compute, "__isabstractmethod__", False
        ):
            for attr in ("name", "category", "description", "inputs"):
                if not hasattr(cls, attr):
                    raise TypeError(
                        f"Factor 子类 {cls.__name__} 必须定义类属性 '{attr}'"
                    )


FACTORY_REGISTRY: dict[str, type[Factor]] = {}


def register_factor(cls: type[Factor]) -> type[Factor]:
    """类装饰器,将 Factor 子类注册到全局表。

    Raises:
        ValueError: 如果 name 已被注册。
    """
    if cls.name in FACTORY_REGISTRY:
        raise ValueError(
            f"因子 '{cls.name}' 已注册(类: {FACTORY_REGISTRY[cls.name].__name__}"
        )
    FACTORY_REGISTRY[cls.name] = cls
    return cls
  • Step 4: 实现 factor/__init__.py(初始版本)
# src/easy_tdx/factor/__init__.py
"""因子研究模块。"""

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor, register_factor

__all__ = ["Factor", "register_factor", "FACTORY_REGISTRY"]
  • Step 5: 运行测试验证通过

Run: python -m pytest tests/unit/test_factor_base.py -v Expected: 全部 PASS

  • Step 6: 提交
git add src/easy_tdx/factor/base.py src/easy_tdx/factor/__init__.py tests/unit/test_factor_base.py
git commit -m "feat(factor): add Factor base class and registry"

Task 2: FactorEngine 计算引擎

Files:

  • Create: src/easy_tdx/factor/engine.py

  • Test: tests/unit/test_factor_engine.py

  • Modify: src/easy_tdx/factor/__init__.py(添加导出)

  • Step 1: 写测试 — FactorEngine

# tests/unit/test_factor_engine.py
"""Test FactorEngine."""
from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from easy_tdx.factor.base import Factor
from easy_tdx.factor.engine import FactorEngine


def _make_df(n: int = 60, seed: int = 42) -> pd.DataFrame:
    """生成合成 OHLCV 数据。"""
    rng = np.random.default_rng(seed)
    close = 10.0 + np.cumsum(rng.normal(0, 0.5, n))
    close = np.maximum(close, 1.0)  # 确保正值
    high = close + rng.uniform(0, 0.5, n)
    low = close - rng.uniform(0, 0.5, n)
    low = np.maximum(low, 0.1)
    open_ = low + rng.uniform(0, high - low, n)
    vol = rng.integers(100_000, 10_000_000, n).astype(float)
    amount = close * vol

    dates = pd.date_range("2024-01-01", periods=n, freq="D")
    return pd.DataFrame({
        "datetime": dates,
        "open": open_,
        "high": high,
        "low": low,
        "close": close,
        "vol": vol,
        "amount": amount,
    })


class _SimpleMomentum(Factor):
    name = "simple_momentum"
    category = "momentum"
    description = "5 日动量"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return df["close"].pct_change(5)


class _SimpleVolatility(Factor):
    name = "simple_volatility"
    category = "volatility"
    description = "5 日波动率"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        ret = df["close"].pct_change()
        return ret.rolling(5).std()


class TestComputeSingle:
    def test_single_factor(self):
        engine = FactorEngine()
        df = _make_df()
        result = engine.compute_single(df, ["simple_momentum"])
        assert "simple_momentum" in result.columns
        assert len(result) == len(df)

    def test_multiple_factors(self):
        engine = FactorEngine()
        df = _make_df()
        result = engine.compute_single(df, ["simple_momentum", "simple_volatility"])
        assert "simple_momentum" in result.columns
        assert "simple_volatility" in result.columns
        assert len(result) == len(df)

    def test_factor_instance(self):
        engine = FactorEngine()
        df = _make_df()
        f = _SimpleMomentum()
        result = engine.compute_single(df, [f])
        assert "simple_momentum" in result.columns

    def test_preserves_original_columns(self):
        engine = FactorEngine()
        df = _make_df()
        result = engine.compute_single(df, ["simple_momentum"])
        assert "close" in result.columns
        assert "datetime" in result.columns

    def test_unknown_factor_raises(self):
        engine = FactorEngine()
        df = _make_df()
        with pytest.raises(ValueError, match="未知因子"):
            engine.compute_single(df, ["nonexistent_factor"])


class TestComputeCrossSection:
    def test_cross_section_basic(self):
        engine = FactorEngine()
        data = {
            "000001": _make_df(60, seed=1),
            "000002": _make_df(60, seed=2),
            "600036": _make_df(60, seed=3),
        }
        result = engine.compute_cross_section(data, ["simple_momentum"])
        assert isinstance(result, pd.DataFrame)
        assert "date" in result.columns
        assert "code" in result.columns
        assert "simple_momentum" in result.columns
        # 应有 60 天 × 3 只股票 = 180 行
        assert len(result) == 180

    def test_cross_section_latest_date(self):
        engine = FactorEngine()
        data = {
            "000001": _make_df(60, seed=1),
            "000002": _make_df(60, seed=2),
        }
        result = engine.compute_cross_section(data, ["simple_momentum"], date=None)
        assert len(result) == 2  # 最新一天,2 只股票

    def test_cross_section_specific_date(self):
        engine = FactorEngine()
        df = _make_df(60, seed=1)
        data = {"000001": df}
        target_date = int(df["datetime"].iloc[-5].strftime("%Y%m%d"))
        result = engine.compute_cross_section(data, ["simple_momentum"], date=target_date)
        assert len(result) == 1
        assert result.iloc[0]["date"] == target_date


class TestComputeForwardReturns:
    def test_forward_returns_basic(self):
        engine = FactorEngine()
        data = {
            "000001": _make_df(60, seed=1),
            "000002": _make_df(60, seed=2),
        }
        result = engine.compute_forward_returns(data, period=5)
        assert "date" in result.columns
        assert "code" in result.columns
        assert "forward_5d" in result.columns
        # 最后 5 行无远期收益,应为 NaN
        code_000001 = result[result["code"] == "000001"]
        assert code_000001["forward_5d"].iloc[-1] != code_000001["forward_5d"].iloc[-1]  # NaN check

    def test_forward_returns_period(self):
        engine = FactorEngine()
        data = {"000001": _make_df(60, seed=1)}
        result = engine.compute_forward_returns(data, period=10)
        assert "forward_10d" in result.columns
  • Step 2: 运行测试验证失败

Run: python -m pytest tests/unit/test_factor_engine.py -v Expected: FAIL — ModuleNotFoundError: No module named 'easy_tdx.factor.engine'

  • Step 3: 实现 factor/engine.py
# src/easy_tdx/factor/engine.py
"""因子计算引擎 — 单股计算与截面批量计算。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor


def _resolve_factor(f: str | Factor) -> Factor:
    """将因子名或实例解析为 Factor 实例。"""
    if isinstance(f, Factor):
        return f
    name = f.strip().lower()
    # 注册表中的 name 是小写
    if name not in FACTORY_REGISTRY:
        raise ValueError(
            f"未知因子: {f!r}。可用因子: {sorted(FACTORY_REGISTRY.keys())}"
        )
    return FACTORY_REGISTRY[name]()


def _datetime_to_int(dt_val: object) -> int:
    """将 datetime 值转为 YYYYMMDD 整数。"""
    if hasattr(dt_val, "strftime"):
        return int(dt_val.strftime("%Y%m%d"))  # type: ignore[union-attr]
    return int(dt_val)


class FactorEngine:
    """批量因子计算引擎。

    支持两种模式:
    1. compute_single: 单股票 × 多因子 → 返回带因子列的 DataFrame
    2. compute_cross_section: 多股票 × 多因子 → 返回长格式 (date, code, factor_1, ...)
    """

    def compute_single(
        self,
        df: pd.DataFrame,
        factors: list[str | Factor],
    ) -> pd.DataFrame:
        """单股票多因子计算。

        Args:
            df: OHLCV DataFrame。
            factors: 因子名称列表或 Factor 实例列表。

        Returns:
            原始 df + 因子列。
        """
        if not factors:
            return df.copy()

        result = df.copy()
        for f in factors:
            factor = _resolve_factor(f)
            col_name = factor.name
            result[col_name] = factor.compute(df)

        return result

    def compute_cross_section(
        self,
        data: dict[str, pd.DataFrame],
        factors: list[str | Factor],
        date: int | None = None,
    ) -> pd.DataFrame:
        """多股票截面因子计算。

        Args:
            data: {code: ohlcv DataFrame}。
            factors: 因子名称列表或 Factor 实例列表。
            date: 指定日期(YYYYMMDD)。None = 返回所有日期(长格式)。

        Returns:
            长格式 DataFrame: columns=[date, code, factor_1, factor_2, ...]
        """
        if not data:
            return pd.DataFrame()

        all_frames: list[pd.DataFrame] = []

        for code, df in data.items():
            if df.empty:
                continue

            # 计算所有因子
            computed = self.compute_single(df, factors)

            # 提取日期列
            computed["_date_int"] = computed["datetime"].apply(_datetime_to_int)

            # 如果指定日期,只保留该日期
            if date is not None:
                computed = computed[computed["_date_int"] == date]

            # 提取需要的列
            factor_names = [
                _resolve_factor(f).name for f in factors
            ]
            keep_cols = ["_date_int"] + factor_names
            sub = computed[keep_cols].copy()
            sub["_code"] = code
            all_frames.append(sub)

        if not all_frames:
            return pd.DataFrame()

        combined = pd.concat(all_frames, ignore_index=True)
        combined = combined.rename(columns={"_date_int": "date", "_code": "code"})

        # 重排列顺序
        col_order = ["date", "code"] + [
            _resolve_factor(f).name for f in factors
        ]
        combined = combined[col_order].sort_values(["date", "code"]).reset_index(drop=True)

        return combined

    def compute_forward_returns(
        self,
        data: dict[str, pd.DataFrame],
        period: int = 5,
    ) -> pd.DataFrame:
        """计算远期收益率。

        Args:
            data: {code: ohlcv DataFrame}。
            period: 远期天数。

        Returns:
            长格式 DataFrame: columns=[date, code, forward_{period}d]
        """
        if not data:
            return pd.DataFrame()

        col_name = f"forward_{period}d"
        all_frames: list[pd.DataFrame] = []

        for code, df in data.items():
            if df.empty or len(df) < period + 1:
                continue

            close = df["close"].to_numpy()
            # 远期收益: close[t+period] / close[t] - 1
            forward = np.full(len(close), np.nan)
            forward[: len(close) - period] = (
                close[period:] / close[: len(close) - period] - 1
            )

            dates = df["datetime"].apply(_datetime_to_int)

            sub = pd.DataFrame({
                "date": dates,
                "code": code,
                col_name: forward,
            })
            all_frames.append(sub)

        if not all_frames:
            return pd.DataFrame(columns=["date", "code", col_name])

        combined = pd.concat(all_frames, ignore_index=True)
        combined = combined.sort_values(["date", "code"]).reset_index(drop=True)
        return combined
  • Step 4: 更新 factor/__init__.py 添加 FactorEngine 导出
# src/easy_tdx/factor/__init__.py
"""因子研究模块。"""

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor, register_factor
from easy_tdx.factor.engine import FactorEngine

__all__ = ["Factor", "register_factor", "FACTORY_REGISTRY", "FactorEngine"]
  • Step 5: 运行测试验证通过

Run: python -m pytest tests/unit/test_factor_engine.py -v Expected: 全部 PASS

  • Step 6: 提交
git add src/easy_tdx/factor/engine.py src/easy_tdx/factor/__init__.py tests/unit/test_factor_engine.py
git commit -m "feat(factor): add FactorEngine with single/cross-section/forward-return compute"

Task 3: 内置因子 — 动量类

Files:

  • Create: src/easy_tdx/factor/builtin/momentum.py

  • Create: src/easy_tdx/factor/builtin/__init__.py(初始版本)

  • Step 1: 实现 momentum.py

# src/easy_tdx/factor/builtin/momentum.py
"""动量类因子。"""
from __future__ import annotations

import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class Momentum20D(Factor):
    """20 日动量(20 日收益率)。"""

    name = "momentum_20d"
    category = "momentum"
    description = "20 日动量(20 日收益率)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return df["close"].pct_change(20)


@register_factor
class Momentum60D(Factor):
    """60 日动量(60 日收益率)。"""

    name = "momentum_60d"
    category = "momentum"
    description = "60 日动量(60 日收益率)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return df["close"].pct_change(60)


@register_factor
class Reversal5D(Factor):
    """5 日反转因子(负 5 日收益率)。"""

    name = "reversal_5d"
    category = "momentum"
    description = "5 日反转因子(负 5 日收益率,值越小越反转)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return -df["close"].pct_change(5)
  • Step 2: 创建 builtin/__init__.py(初始版本)
# src/easy_tdx/factor/builtin/__init__.py
"""内置因子库。"""

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor


def list_factors() -> list[dict[str, str | tuple[str, ...]]]:
    """返回所有已注册因子的元数据。"""
    return [
        {
            "name": cls.name,
            "category": cls.category,
            "description": cls.description,
            "inputs": cls.inputs,
        }
        for cls in FACTORY_REGISTRY.values()
    ]


def get_factor(name: str) -> type[Factor]:
    """按名称获取因子类。

    Raises:
        ValueError: 因子不存在。
    """
    name = name.strip().lower()
    if name not in FACTORY_REGISTRY:
        raise ValueError(
            f"未知因子: {name!r}。可用因子: {sorted(FACTORY_REGISTRY.keys())}"
        )
    return FACTORY_REGISTRY[name]
  • Step 3: 提交
git add src/easy_tdx/factor/builtin/momentum.py src/easy_tdx/factor/builtin/__init__.py
git commit -m "feat(factor): add momentum factors (momentum_20d, momentum_60d, reversal_5d)"

Task 4: 内置因子 — 波动率类

Files:

  • Create: src/easy_tdx/factor/builtin/volatility.py

  • Step 1: 实现 volatility.py

# src/easy_tdx/factor/builtin/volatility.py
"""波动率类因子。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class Volatility20D(Factor):
    """20 日波动率(20 日收益率标准差)。"""

    name = "volatility_20d"
    category = "volatility"
    description = "20 日波动率(20 日收益率标准差)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        ret = df["close"].pct_change()
        return ret.rolling(20).std()


@register_factor
class ATR14D(Factor):
    """14 日平均真实波幅(ATR)。"""

    name = "atr_14d"
    category = "volatility"
    description = "14 日平均真实波幅(ATR"
    inputs = ("high", "low", "close")

    def compute(self, df: pd.DataFrame) -> pd.Series:
        high = df["high"].to_numpy(dtype=np.float64)
        low = df["low"].to_numpy(dtype=np.float64)
        close = df["close"].to_numpy(dtype=np.float64)

        # True Range
        tr = np.maximum(
            high[1:] - low[1:],
            np.maximum(
                np.abs(high[1:] - close[:-1]),
                np.abs(low[1:] - close[:-1]),
            ),
        )
        tr = np.concatenate([[np.nan], tr])

        atr = pd.Series(tr).rolling(14).mean()
        return pd.Series(atr.values, index=df.index)


@register_factor
class TurnoverRate(Factor):
    """换手率代理(成交额 / 收盘价² 的 20 日均值比率)。"""

    name = "turnover_rate"
    category = "volatility"
    description = "换手率代理(当日成交额 / 20 日均成交额)"
    inputs = ("amount",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        amt = df["amount"]
        ma20 = amt.rolling(20).mean()
        # 避免除以零
        result = amt / ma20.replace(0, np.nan)
        return result
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/volatility.py
git commit -m "feat(factor): add volatility factors (volatility_20d, atr_14d, turnover_rate)"

Task 5: 内置因子 — 质量类

Files:

  • Create: src/easy_tdx/factor/builtin/quality.py

  • Step 1: 实现 quality.py

# src/easy_tdx/factor/builtin/quality.py
"""质量类因子。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class Sharpe20D(Factor):
    """20 日夏普比率(收益 / 波动)。"""

    name = "sharpe_20d"
    category = "quality"
    description = "20 日夏普比率(收益率均值 / 收益率标准差)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        ret = df["close"].pct_change()
        rolling_mean = ret.rolling(20).mean()
        rolling_std = ret.rolling(20).std()
        # 避免除以零
        return rolling_mean / rolling_std.replace(0, np.nan)


@register_factor
class MaxDrawdown20D(Factor):
    """20 日最大回撤。"""

    name = "max_drawdown_20d"
    category = "quality"
    description = "20 日滚动最大回撤(负值,0 = 无回撤)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        close = df["close"]
        result = pd.Series(np.nan, index=df.index, dtype=np.float64)

        for i in range(19, len(close)):
            window = close.iloc[i - 19: i + 1]
            peak = window.cummax()
            dd = (window - peak) / peak
            result.iloc[i] = dd.min()

        return result


@register_factor
class WinRate20D(Factor):
    """20 日上涨天数占比。"""

    name = "win_rate_20d"
    category = "quality"
    description = "20 日内上涨天数占比(0-1"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        ret = df["close"].pct_change()
        up = (ret > 0).astype(float)
        return up.rolling(20).mean()
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/quality.py
git commit -m "feat(factor): add quality factors (sharpe_20d, max_drawdown_20d, win_rate_20d)"

Task 6: 内置因子 — 成交量类

Files:

  • Create: src/easy_tdx/factor/builtin/volume.py

  • Step 1: 实现 volume.py

# src/easy_tdx/factor/builtin/volume.py
"""成交量类因子。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class OBVTrend(Factor):
    """OBV 的 20 日线性回归斜率。"""

    name = "obv_trend"
    category = "volume"
    description = "OBV 的 20 日线性回归斜率"
    inputs = ("close", "vol")

    def compute(self, df: pd.DataFrame) -> pd.Series:
        close = df["close"]
        vol = df["vol"]
        # 计算 OBV
        direction = np.sign(close.diff()).fillna(0).values
        obv = (direction * vol).cumsum()
        obv = pd.Series(obv, index=df.index)

        # 20 日滚动线性回归斜率
        result = pd.Series(np.nan, index=df.index, dtype=np.float64)
        window = 20
        x = np.arange(window, dtype=np.float64)
        x_mean = x.mean()
        x_ss = np.sum((x - x_mean) ** 2)

        for i in range(window - 1, len(obv)):
            y = obv.iloc[i - window + 1: i + 1].values.astype(np.float64)
            y_mean = y.mean()
            slope = np.sum((x - x_mean) * (y - y_mean)) / x_ss
            result.iloc[i] = slope

        return result


@register_factor
class VolSurge(Factor):
    """当日量比(当日成交量 / 20 日平均成交量)。"""

    name = "vol_surge"
    category = "volume"
    description = "量比(当日成交量 / 20 日平均成交量)"
    inputs = ("vol",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        vol = df["vol"]
        ma20 = vol.rolling(20).mean()
        return vol / ma20.replace(0, np.nan)


@register_factor
class AmountMARatio(Factor):
    """成交额 MA5 / MA20 比值。"""

    name = "amount_ma_ratio"
    category = "volume"
    description = "成交额 MA5 / MA20 比值"
    inputs = ("amount",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        amt = df["amount"]
        ma5 = amt.rolling(5).mean()
        ma20 = amt.rolling(20).mean()
        return ma5 / ma20.replace(0, np.nan)
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/volume.py
git commit -m "feat(factor): add volume factors (obv_trend, vol_surge, amount_ma_ratio)"

Task 7: 内置因子 — 技术指标桥接

Files:

  • Create: src/easy_tdx/factor/builtin/technical.py

  • Step 1: 实现 technical.py

# src/easy_tdx/factor/builtin/technical.py
"""技术指标因子 — 桥接 MyTT 指标库。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx import MyTT
from easy_tdx.factor.base import Factor, register_factor


@register_factor
class MACDHistSignal(Factor):
    """MACD 柱状线信号(归一化:正值=多头,负值=空头)。"""

    name = "macd_hist_signal"
    category = "technical"
    description = "MACD 柱状线信号(正值=多头区域,负值=空头区域)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        close = df["close"].to_numpy(dtype=np.float64)
        _, _, hist = MyTT.MACD(close, SHORT=12, LONG=26, M=9)
        # 归一化:用 20 日标准差缩放
        hist_series = pd.Series(hist)
        rolling_std = hist_series.abs().rolling(20).mean().replace(0, np.nan)
        return (hist_series / rolling_std).fillna(0)


@register_factor
class RSI14(Factor):
    """RSI(14) 归一化到 [-1, 1] 范围。"""

    name = "rsi_14"
    category = "technical"
    description = "RSI(14) 归一化到 [-1, 1]0 = 中性,正值=超买区域)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        close = df["close"].to_numpy(dtype=np.float64)
        rsi = MyTT.RSI(close, N=14)
        # 从 [0, 100] 映射到 [-1, 1]
        normalized = (pd.Series(rsi) - 50) / 50
        return normalized


@register_factor
class BollPosition(Factor):
    """价格在布林带中的位置(0 = 下轨,1 = 上轨)。"""

    name = "boll_position"
    category = "technical"
    description = "价格在布林带中的相对位置(0=下轨,0.5=中轨,1=上轨)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        close = df["close"].to_numpy(dtype=np.float64)
        upper, mid, lower = MyTT.BOLL(close, N=20, P=2)
        upper = pd.Series(upper)
        lower = pd.Series(lower)
        mid = pd.Series(mid)
        close_s = pd.Series(close)

        # (price - lower) / (upper - lower),裁剪到 [0, 1]
        bandwidth = upper - lower
        bandwidth = bandwidth.replace(0, np.nan)
        position = (close_s - lower) / bandwidth
        return position.clip(0, 1)
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/technical.py
git commit -m "feat(factor): add technical factors (macd_hist_signal, rsi_14, boll_position)"

Task 8: 内置因子 — 缠论桥接

Files:

  • Create: src/easy_tdx/factor/builtin/chanlun.py

  • Step 1: 实现 chanlun.py

# src/easy_tdx/factor/builtin/chanlun.py
"""缠论因子 — 桥接 ChanlunAnalyser。"""
from __future__ import annotations

import numpy as np
import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class ChanlunBiDir(Factor):
    """当前笔方向(+1=向上笔,-1=向下笔,0=无笔)。"""

    name = "chanlun_bi_dir"
    category = "chanlun"
    description = "当前笔方向(+1=向上笔,-1=向下笔,0=无笔)"
    inputs = ("open", "high", "low", "close", "vol", "amount")

    def compute(self, df: pd.DataFrame) -> pd.Series:
        result = pd.Series(0.0, index=df.index, dtype=np.float64)

        try:
            from easy_tdx.chanlun.analyser import ChanlunAnalyser

            analyser = ChanlunAnalyser(frequency="DAILY")
            chanlun_result = analyser.process_klines(df)
            bis = chanlun_result.bis

            if not bis:
                return result

            # 将笔方向映射到 K 线索引
            for bi in bis:
                direction = 1.0 if bi.direction == "up" else -1.0
                # bi.start_index 和 bi.end_index 是 K 线索引
                start = getattr(bi, "start_index", 0)
                end = getattr(bi, "end_index", len(df) - 1)
                lo = max(0, start)
                hi = min(len(df), end + 1)
                result.iloc[lo:hi] = direction

            # 最后一根 K 线的笔方向
            last_bi = bis[-1]
            direction = 1.0 if last_bi.direction == "up" else -1.0
            result.iloc[-1] = direction

        except Exception:
            # 缠论分析失败时返回 0(数据不足等)
            pass

        return result


@register_factor
class ChanlunMMD(Factor):
    """最近买卖点类型编码(+1=一买/+2=二买/+3=三买/-1=一卖/-2=二卖/-3=三卖/0=无)。"""

    name = "chanlun_mmd"
    category = "chanlun"
    description = "最近买卖点类型编码(正=买点,负=卖点,0=无信号)"
    inputs = ("open", "high", "low", "close", "vol", "amount")

    # 买卖点编码映射
    _MMD_MAP: dict[str, float] = {
        "1buy": 1.0,
        "2buy": 2.0,
        "3buy": 3.0,
        "l3buy": 3.0,
        "1sell": -1.0,
        "2sell": -2.0,
        "3sell": -3.0,
        "s3sell": -3.0,
    }

    def compute(self, df: pd.DataFrame) -> pd.Series:
        result = pd.Series(0.0, index=df.index, dtype=np.float64)

        try:
            from easy_tdx.chanlun.analyser import ChanlunAnalyser

            analyser = ChanlunAnalyser(frequency="DAILY")
            chanlun_result = analyser.process_klines(df)
            mmds = chanlun_result.mmds

            if not mmds:
                return result

            for mmd in mmds:
                mmd_type = getattr(mmd, "type", "")
                mmd_index = getattr(mmd, "index", -1)
                value = self._MMD_MAP.get(mmd_type, 0.0)
                if mmd_index >= 0 and mmd_index < len(df):
                    result.iloc[mmd_index] = value

        except Exception:
            pass

        return result
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/chanlun.py
git commit -m "feat(factor): add chanlun factors (chanlun_bi_dir, chanlun_mmd)"

Task 9: 内置因子 — 价值类(占位)

Files:

  • Create: src/easy_tdx/factor/builtin/value.py

  • Step 1: 实现 value.py(占位,raise NotImplementedError

# src/easy_tdx/factor/builtin/value.py
"""价值类因子(需要财务数据扩展,当前为占位实现)。"""
from __future__ import annotations

import pandas as pd

from easy_tdx.factor.base import Factor, register_factor


@register_factor
class PERatio(Factor):
    """市盈率(需要财务数据,当前为占位)。"""

    name = "pe_ratio"
    category = "value"
    description = "市盈率(需要财务数据扩展,当前不可用)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        # 占位:需要接入财务数据后才可计算
        return pd.Series(float("nan"), index=df.index)


@register_factor
class PBRatio(Factor):
    """市净率(需要财务数据,当前为占位)。"""

    name = "pb_ratio"
    category = "value"
    description = "市净率(需要财务数据扩展,当前不可用)"
    inputs = ("close",)

    def compute(self, df: pd.DataFrame) -> pd.Series:
        return pd.Series(float("nan"), index=df.index)
  • Step 2: 提交
git add src/easy_tdx/factor/builtin/value.py
git commit -m "feat(factor): add value factor stubs (pe_ratio, pb_ratio)"

Task 10: 内置因子自动注册 + 导出

Files:

  • Modify: src/easy_tdx/factor/builtin/__init__.py(添加自动导入)

  • Modify: src/easy_tdx/factor/__init__.py(添加 builtin 导出)

  • Test: tests/unit/test_factor_builtin.py

  • Step 1: 写测试 — 内置因子正确性

# tests/unit/test_factor_builtin.py
"""Test built-in factor computation correctness."""
from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from easy_tdx.factor.base import FACTORY_REGISTRY
from easy_tdx.factor.builtin import get_factor, list_factors


def _make_df(n: int = 120, seed: int = 42) -> pd.DataFrame:
    """生成合成 OHLCV 数据(120 行,满足所有因子最小窗口)。"""
    rng = np.random.default_rng(seed)
    close = 10.0 + np.cumsum(rng.normal(0, 0.3, n))
    close = np.maximum(close, 1.0)
    high = close + rng.uniform(0, 0.3, n)
    low = close - rng.uniform(0, 0.3, n)
    low = np.maximum(low, 0.1)
    open_ = low + rng.uniform(0, high - low, n)
    vol = rng.integers(100_000, 10_000_000, n).astype(float)
    amount = close * vol

    dates = pd.date_range("2024-01-01", periods=n, freq="D")
    return pd.DataFrame({
        "datetime": dates,
        "open": open_,
        "high": high,
        "low": low,
        "close": close,
        "vol": vol,
        "amount": amount,
    })


class TestAutoRegistration:
    def test_momentum_factors_registered(self):
        assert "momentum_20d" in FACTORY_REGISTRY
        assert "momentum_60d" in FACTORY_REGISTRY
        assert "reversal_5d" in FACTORY_REGISTRY

    def test_volatility_factors_registered(self):
        assert "volatility_20d" in FACTORY_REGISTRY
        assert "atr_14d" in FACTORY_REGISTRY
        assert "turnover_rate" in FACTORY_REGISTRY

    def test_quality_factors_registered(self):
        assert "sharpe_20d" in FACTORY_REGISTRY
        assert "max_drawdown_20d" in FACTORY_REGISTRY
        assert "win_rate_20d" in FACTORY_REGISTRY

    def test_volume_factors_registered(self):
        assert "obv_trend" in FACTORY_REGISTRY
        assert "vol_surge" in FACTORY_REGISTRY
        assert "amount_ma_ratio" in FACTORY_REGISTRY

    def test_technical_factors_registered(self):
        assert "macd_hist_signal" in FACTORY_REGISTRY
        assert "rsi_14" in FACTORY_REGISTRY
        assert "boll_position" in FACTORY_REGISTRY

    def test_chanlun_factors_registered(self):
        assert "chanlun_bi_dir" in FACTORY_REGISTRY
        assert "chanlun_mmd" in FACTORY_REGISTRY

    def test_value_factors_registered(self):
        assert "pe_ratio" in FACTORY_REGISTRY
        assert "pb_ratio" in FACTORY_REGISTRY

    def test_total_factor_count(self):
        # 3+3+3+3+3+2+2 = 19 个因子
        assert len(FACTORY_REGISTRY) >= 19


class TestListAndGetFactors:
    def test_list_factors_returns_all(self):
        factors = list_factors()
        assert len(factors) >= 19
        # 检查每项都有必要字段
        for f in factors:
            assert "name" in f
            assert "category" in f
            assert "description" in f

    def test_get_factor_existing(self):
        cls = get_factor("momentum_20d")
        assert cls.name == "momentum_20d"

    def test_get_factor_nonexistent(self):
        with pytest.raises(ValueError, match="未知因子"):
            get_factor("nonexistent")


class TestMomentumCompute:
    def test_momentum_20d(self):
        f = get_factor("momentum_20d")()
        df = _make_df()
        result = f.compute(df)
        assert isinstance(result, pd.Series)
        assert len(result) == len(df)
        # 第 20 行应有有效值
        assert not np.isnan(result.iloc[20])

    def test_momentum_60d(self):
        f = get_factor("momentum_60d")()
        df = _make_df()
        result = f.compute(df)
        assert not np.isnan(result.iloc[60])

    def test_reversal_5d_is_negative_return(self):
        f = get_factor("reversal_5d")()
        df = _make_df()
        result = f.compute(df)
        # reversal = -return,所以等于 -pct_change(5)
        expected = -df["close"].pct_change(5)
        pd.testing.assert_series_equal(result, expected, check_names=False)


class TestVolatilityCompute:
    def test_volatility_20d(self):
        f = get_factor("volatility_20d")()
        df = _make_df()
        result = f.compute(df)
        assert result.iloc[20] > 0  # 波动率应为正

    def test_atr_14d(self):
        f = get_factor("atr_14d")()
        df = _make_df()
        result = f.compute(df)
        assert result.iloc[14] > 0

    def test_turnover_rate(self):
        f = get_factor("turnover_rate")()
        df = _make_df()
        result = f.compute(df)
        # 均值附近应接近 1.0
        assert result.iloc[40] > 0


class TestQualityCompute:
    def test_sharpe_20d(self):
        f = get_factor("sharpe_20d")()
        df = _make_df()
        result = f.compute(df)
        assert len(result) == len(df)

    def test_max_drawdown_20d(self):
        f = get_factor("max_drawdown_20d")()
        df = _make_df()
        result = f.compute(df)
        # 回撤应为负值或 0
        valid = result.dropna()
        assert (valid <= 0).all()

    def test_win_rate_20d(self):
        f = get_factor("win_rate_20d")()
        df = _make_df()
        result = f.compute(df)
        valid = result.dropna()
        assert (valid >= 0).all()
        assert (valid <= 1).all()


class TestVolumeCompute:
    def test_vol_surge(self):
        f = get_factor("vol_surge")()
        df = _make_df()
        result = f.compute(df)
        assert result.iloc[20] > 0

    def test_amount_ma_ratio(self):
        f = get_factor("amount_ma_ratio")()
        df = _make_df()
        result = f.compute(df)
        assert len(result) == len(df)


class TestTechnicalCompute:
    def test_rsi_14_range(self):
        f = get_factor("rsi_14")()
        df = _make_df()
        result = f.compute(df)
        valid = result.dropna()
        assert (valid >= -1).all()
        assert (valid <= 1).all()

    def test_boll_position_range(self):
        f = get_factor("boll_position")()
        df = _make_df()
        result = f.compute(df)
        valid = result.dropna()
        assert (valid >= 0).all()
        assert (valid <= 1).all()
  • Step 2: 更新 builtin/__init__.py 添加自动导入
# src/easy_tdx/factor/builtin/__init__.py
"""内置因子库 — 导入子模块触发注册。"""

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor

# 导入所有子模块以触发 @register_factor 装饰器
from easy_tdx.factor.builtin import momentum  # noqa: F401
from easy_tdx.factor.builtin import volatility  # noqa: F401
from easy_tdx.factor.builtin import quality  # noqa: F401
from easy_tdx.factor.builtin import volume  # noqa: F401
from easy_tdx.factor.builtin import technical  # noqa: F401
from easy_tdx.factor.builtin import chanlun  # noqa: F401
from easy_tdx.factor.builtin import value  # noqa: F401


def list_factors() -> list[dict[str, str | tuple[str, ...]]]:
    """返回所有已注册因子的元数据。"""
    return [
        {
            "name": cls.name,
            "category": cls.category,
            "description": cls.description,
            "inputs": cls.inputs,
        }
        for cls in FACTORY_REGISTRY.values()
    ]


def get_factor(name: str) -> type[Factor]:
    """按名称获取因子类。

    Raises:
        ValueError: 因子不存在。
    """
    name = name.strip().lower()
    if name not in FACTORY_REGISTRY:
        raise ValueError(
            f"未知因子: {name!r}。可用因子: {sorted(FACTORY_REGISTRY.keys())}"
        )
    return FACTORY_REGISTRY[name]
  • Step 3: 更新 factor/__init__.py 添加 builtin 导出
# src/easy_tdx/factor/__init__.py
"""因子研究模块。"""

from easy_tdx.factor.base import FACTORY_REGISTRY, Factor, register_factor
from easy_tdx.factor.engine import FactorEngine

# 导入 builtin 触发自动注册
from easy_tdx.factor.builtin import get_factor, list_factors  # noqa: F401

__all__ = [
    "Factor",
    "register_factor",
    "FACTORY_REGISTRY",
    "FactorEngine",
    "list_factors",
    "get_factor",
]
  • Step 4: 运行测试验证通过

Run: python -m pytest tests/unit/test_factor_builtin.py -v Expected: 全部 PASS

  • Step 5: 运行全部已有测试验证无回归

Run: python -m pytest tests/unit/ -v Expected: 全部 PASS(包括原有测试)

  • Step 6: 提交
git add src/easy_tdx/factor/builtin/__init__.py src/easy_tdx/factor/__init__.py tests/unit/test_factor_builtin.py
git commit -m "feat(factor): wire up builtin factor auto-registration and export"

Task 11: CLI 命令 — easy-tdx factor list

Files:

  • Create: src/easy_tdx/cli/cmd_factor.py

  • Modify: src/easy_tdx/cli/__init__.py

  • Step 1: 实现 cmd_factor.py

# src/easy_tdx/cli/cmd_factor.py
"""因子 CLI 命令。"""
from __future__ import annotations

import json

import click


@click.group("factor")
def factor() -> None:
    """因子研究工具。"""
    pass


@factor.command("list")
@click.option("--category", default=None, help="按类别筛选: momentum/volatility/quality/volume/technical/chanlun/value")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
def factor_list(category: str | None, use_table: bool) -> None:
    """列出所有已注册的因子。

    示例:

      easy-tdx factor list

      easy-tdx factor list --category momentum --table
    """
    from easy_tdx.factor.builtin import list_factors

    factors = list_factors()

    if category:
        factors = [f for f in factors if f["category"] == category]

    if use_table:
        try:
            from tabulate import tabulate

            rows = [
                {"name": f["name"], "category": f["category"], "description": f["description"]}
                for f in factors
            ]
            click.echo(tabulate(rows, headers="keys", tablefmt="grid"))
        except ImportError:
            # fallback to simple format
            for f in factors:
                click.echo(f"{f['name']}\t{f['category']}\t{f['description']}")
    else:
        click.echo(json.dumps(factors, ensure_ascii=False, indent=2))
  • Step 2: 在 cli/__init__.py 注册命令

src/easy_tdx/cli/__init__.py 的 import 区添加:

from .cmd_factor import factor

cli.add_command(serve) 之前添加:

cli.add_command(factor)

具体修改位置: 在第 10 行(from .cmd_chanlun import chanlun)后添加 import,在第 88 行(cli.add_command(serve))前添加 cli.add_command(factor)

  • Step 3: 验证 CLI

Run: python -m easy_tdx.cli factor list Expected: JSON 格式输出包含所有 19 个因子的列表

Run: python -m easy_tdx.cli factor list --category momentum --table Expected: 表格输出显示 3 个动量因子

  • Step 4: 提交
git add src/easy_tdx/cli/cmd_factor.py src/easy_tdx/cli/__init__.py
git commit -m "feat(cli): add 'easy-tdx factor list' command"

Task 12: 集成测试 + FactorEngine 与内置因子联调

Files:

  • Modify: tests/unit/test_factor_engine.py(添加集成测试)

  • Step 1: 在 test_factor_engine.py 末尾添加集成测试

# 添加到 tests/unit/test_factor_engine.py 末尾

class TestFactorEngineWithBuiltins:
    """FactorEngine 与内置因子的集成测试。"""

    def test_compute_single_with_builtin(self):
        from easy_tdx.factor.engine import FactorEngine

        engine = FactorEngine()
        df = _make_df(120)
        result = engine.compute_single(df, ["momentum_20d", "volatility_20d", "rsi_14"])
        assert "momentum_20d" in result.columns
        assert "volatility_20d" in result.columns
        assert "rsi_14" in result.columns
        # 因子值应已计算(非全 NaN
        assert result["momentum_20d"].iloc[20] != 0 or result["momentum_20d"].iloc[20] == 0
        assert not result["momentum_20d"].iloc[20:25].isna().all()

    def test_cross_section_with_builtins(self):
        from easy_tdx.factor.engine import FactorEngine

        engine = FactorEngine()
        data = {
            "000001": _make_df(120, seed=1),
            "000002": _make_df(120, seed=2),
        }
        result = engine.compute_cross_section(data, ["momentum_20d", "sharpe_20d"])
        assert "momentum_20d" in result.columns
        assert "sharpe_20d" in result.columns
        # 应有 120 × 2 = 240 行
        assert len(result) == 240

    def test_forward_returns_with_data(self):
        from easy_tdx.factor.engine import FactorEngine

        engine = FactorEngine()
        data = {
            "000001": _make_df(120, seed=1),
        }
        result = engine.compute_forward_returns(data, period=5)
        assert "forward_5d" in result.columns
        assert len(result) == 120
        # 前面的行应有值,最后 5 行为 NaN
        assert not np.isnan(result["forward_5d"].iloc[50])
        assert np.isnan(result["forward_5d"].iloc[-1])

    def test_all_builtin_factors_compute(self):
        """验证所有内置因子都能无报错地计算。"""
        from easy_tdx.factor.engine import FactorEngine
        from easy_tdx.factor.builtin import list_factors

        engine = FactorEngine()
        df = _make_df(200)  # 200 行,满足所有窗口

        for f_info in list_factors():
            name = f_info["name"]
            result = engine.compute_single(df, [name])
            assert name in result.columns, f"因子 {name} 计算失败"
  • Step 2: 运行测试

Run: python -m pytest tests/unit/test_factor_engine.py::TestFactorEngineWithBuiltins -v Expected: 全部 PASS

  • Step 3: 运行全部测试

Run: python -m pytest tests/unit/ -v Expected: 全部 PASS

  • Step 4: 提交
git add tests/unit/test_factor_engine.py
git commit -m "test(factor): add integration tests for FactorEngine with builtins"

Task 13: 版本号更新 + mypy 检查

Files:

  • Modify: pyproject.toml

  • Step 1: 更新版本号

pyproject.toml 中将 version = "1.10.5" 改为 version = "1.11.0"

  • Step 2: 运行 mypy 检查

Run: python -m mypy src/easy_tdx/factor/ Expected: 无错误。如有类型问题,修复。

  • Step 3: 运行 ruff 检查

Run: ruff check src/easy_tdx/factor/ src/easy_tdx/cli/cmd_factor.py Expected: 无错误。如有问题,修复。

Run: ruff format --check src/easy_tdx/factor/ src/easy_tdx/cli/cmd_factor.py Expected: 无错误。如有问题,运行 ruff format 修复。

  • Step 4: 最终全量测试

Run: python -m pytest tests/unit/ -v Expected: 全部 PASS

  • Step 5: 提交
git add pyproject.toml
git commit -m "chore: bump version to v1.11.0"

自检结果

1. Spec 覆盖率

Spec 要求 对应 Task
Factor ABC + 注册表 Task 1
FactorEngine.compute_single Task 2
FactorEngine.compute_cross_section Task 2
FactorEngine.compute_forward_returns Task 2
momentum_20d, momentum_60d, reversal_5d Task 3
volatility_20d, atr_14d, turnover_rate Task 4
sharpe_20d, max_drawdown_20d, win_rate_20d Task 5
obv_trend, vol_surge, amount_ma_ratio Task 6
macd_hist_signal, rsi_14, boll_position Task 7
chanlun_bi_dir, chanlun_mmd Task 8
pe_ratio, pb_ratio(占位) Task 9
list_factors, get_factor Task 10
CLI easy-tdx factor list Task 11
集成测试 Task 12
mypy + ruff + 版本号 Task 13

全部覆盖。

2. 占位符扫描

无 TBD/TODO/"implement later" 等占位内容。价值因子使用 float("nan") 占位是设计意图,不是遗漏。

3. 类型一致性

  • Factor.name → 所有注册表查找使用 .lower() 匹配 → _resolve_factor()get_factor() 一致
  • compute() 返回 pd.SeriesFactorEngine.compute_single() 正确处理
  • compute_cross_section() 返回 [date, code, factor_names] → 与 spec 定义对齐
  • register_factor 装饰器在 Task 1 定义,Task 3-9 一致使用

类型一致。