diff --git a/pyproject.toml b/pyproject.toml index fb15205..1aa321c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.12.0" +version = "1.13.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py index 57c45d7..6049d7f 100644 --- a/src/easy_tdx/cli/__init__.py +++ b/src/easy_tdx/cli/__init__.py @@ -20,6 +20,7 @@ from .cmd_capital import capital_flow from .cmd_chanlun import chanlun from .cmd_ex import ex from .cmd_factor import factor +from .cmd_pfactor import pfactor from .cmd_finance import f10, fund_flow from .cmd_indicator import indicator, indicator_list from .cmd_info import server_info, symbol_info @@ -83,6 +84,7 @@ cli.add_command(indicator_list) cli.add_command(offline) cli.add_command(chanlun) cli.add_command(factor) +cli.add_command(pfactor) cli.add_command(backtest) cli.add_command(portfolio) cli.add_command(run_all) diff --git a/src/easy_tdx/cli/cmd_pfactor.py b/src/easy_tdx/cli/cmd_pfactor.py new file mode 100644 index 0000000..f8a8d7f --- /dev/null +++ b/src/easy_tdx/cli/cmd_pfactor.py @@ -0,0 +1,58 @@ +"""组合因子选股 CLI 命令。""" + +from __future__ import annotations + +import json + +import click + + +@click.group("pfactor") +def pfactor() -> None: + """组合因子选股工具。""" + pass + + +@pfactor.command("backtest") +@click.argument("factor_name") +@click.option("--n-stocks", default=50, type=int, help="持仓数量") +@click.option("--rebalance-freq", default="M", help="调仓频率: W/M/Q") +@click.option( + "--optimizer", + "opt_name", + default="equal", + help="优化器: equal/factor_weighted/risk_parity/mean_variance", +) +@click.option("--cash", default=1000000.0, type=float, help="初始资金") +def pfactor_backtest( + factor_name: str, n_stocks: int, rebalance_freq: str, opt_name: str, cash: float +) -> None: + """运行组合因子回测。 + + 示例: + + easy-tdx pfactor backtest momentum_20d + + easy-tdx pfactor backtest rsi_14 --n-stocks 10 --optimizer factor_weighted + """ + click.echo( + json.dumps( + { + "message": "pfactor backtest 需要行情数据,请使用 Python API", + "example": ( + f"from easy_tdx.portfolio import RebalanceEngine, EqualWeightOptimizer\n" + f"engine = RebalanceEngine(\n" + f" optimizer=EqualWeightOptimizer(),\n" + f" factor_name='{factor_name}',\n" + f" n_stocks={n_stocks},\n" + f" rebalance_freq='{rebalance_freq}',\n" + f" cash={cash},\n" + f")\n" + f"result = engine.run(data, start_date=20230101, end_date=20240101)\n" + f"print(f'年化收益={{result.performance[\"annual_return\"]:.2%}}')" + ), + }, + ensure_ascii=False, + indent=2, + ) + ) diff --git a/src/easy_tdx/portfolio/__init__.py b/src/easy_tdx/portfolio/__init__.py index b17be90..838240e 100644 --- a/src/easy_tdx/portfolio/__init__.py +++ b/src/easy_tdx/portfolio/__init__.py @@ -1 +1,26 @@ """组合管理模块。""" + +from easy_tdx.portfolio.optimizer import ( + EqualWeightOptimizer, + FactorWeightedOptimizer, + MeanVarianceOptimizer, + RiskParityOptimizer, + WeightOptimizer, + get_optimizer, +) +from easy_tdx.portfolio.rebalance import RebalanceEngine +from easy_tdx.portfolio.risk import RiskModel +from easy_tdx.portfolio.types import PortfolioState, RebalanceResult + +__all__ = [ + "WeightOptimizer", + "EqualWeightOptimizer", + "FactorWeightedOptimizer", + "RiskParityOptimizer", + "MeanVarianceOptimizer", + "get_optimizer", + "RiskModel", + "RebalanceEngine", + "PortfolioState", + "RebalanceResult", +] diff --git a/src/easy_tdx/portfolio/optimizer.py b/src/easy_tdx/portfolio/optimizer.py index 8f9cb4b..b2bbed5 100644 --- a/src/easy_tdx/portfolio/optimizer.py +++ b/src/easy_tdx/portfolio/optimizer.py @@ -1,4 +1,5 @@ """权重优化器。""" + from __future__ import annotations from abc import ABC, abstractmethod @@ -26,9 +27,11 @@ _OPTIMIZER_REGISTRY: dict[str, type[WeightOptimizer]] = {} def register_optimizer(name: str) -> type[WeightOptimizer]: """注册优化器。""" + def wrapper(cls: type[WeightOptimizer]) -> type[WeightOptimizer]: _OPTIMIZER_REGISTRY[name] = cls return cls + return wrapper @@ -122,6 +125,7 @@ class MeanVarianceOptimizer(WeightOptimizer): ) -> dict[str, float]: try: from scipy.optimize import minimize # noqa: F401 + return self._optimize_with_scipy(factor_scores, n_stocks) except ImportError: fallback = EqualWeightOptimizer() diff --git a/src/easy_tdx/portfolio/rebalance.py b/src/easy_tdx/portfolio/rebalance.py index 5b58f06..d856082 100644 --- a/src/easy_tdx/portfolio/rebalance.py +++ b/src/easy_tdx/portfolio/rebalance.py @@ -1,4 +1,5 @@ """多期调仓回测引擎。""" + from __future__ import annotations import numpy as np @@ -111,21 +112,38 @@ class RebalanceEngine: if holdings[c] > 0 and c in prices: weights[c] = holdings[c] * prices[c] / total_value - states.append(PortfolioState( - date=date_int, weights=weights, holdings=dict(holdings), - cash=cash, total_value=total_value, - positions_count=len([s for s in holdings.values() if s > 0]), - )) - equity_records.append({"datetime": date_int, "total": total_value, "cash": cash, "position_value": position_value}) + states.append( + PortfolioState( + date=date_int, + weights=weights, + holdings=dict(holdings), + cash=cash, + total_value=total_value, + positions_count=len([s for s in holdings.values() if s > 0]), + ) + ) + equity_records.append( + { + "datetime": date_int, + "total": total_value, + "cash": cash, + "position_value": position_value, + } + ) equity_curve = pd.DataFrame(equity_records) - trades_df = pd.DataFrame(trades_list) if trades_list else pd.DataFrame( - columns=["datetime", "direction", "code", "shares", "price", "cost"] + trades_df = ( + pd.DataFrame(trades_list) + if trades_list + else pd.DataFrame(columns=["datetime", "direction", "code", "shares", "price", "cost"]) ) performance = self._compute_performance(equity_curve) return RebalanceResult( - rebalance_dates=rebalance_dates, states=states, trades=trades_df, - equity_curve=equity_curve, performance=performance, + rebalance_dates=rebalance_dates, + states=states, + trades=trades_df, + equity_curve=equity_curve, + performance=performance, ) def _rebalance( @@ -145,7 +163,16 @@ class RebalanceEngine: sell_value = holdings[code] * price cost = sell_value * (self._commission + self._slippage) cash += sell_value - cost - trades_list.append({"datetime": date_int, "direction": "SELL", "code": code, "shares": holdings[code], "price": price, "cost": cost}) + trades_list.append( + { + "datetime": date_int, + "direction": "SELL", + "code": code, + "shares": holdings[code], + "price": price, + "cost": cost, + } + ) del holdings[code] new_holdings: dict[str, float] = {} @@ -159,7 +186,16 @@ class RebalanceEngine: new_holdings[code] = shares trade_value = shares * price cost = trade_value * (self._commission + self._slippage) - trades_list.append({"datetime": date_int, "direction": "BUY", "code": code, "shares": shares, "price": price, "cost": cost}) + trades_list.append( + { + "datetime": date_int, + "direction": "BUY", + "code": code, + "shares": shares, + "price": price, + "cost": cost, + } + ) cash = total_value - sum(new_holdings.get(c, 0) * prices.get(c, 0) for c in new_holdings) holdings.clear() @@ -178,13 +214,31 @@ class RebalanceEngine: max_drawdown = float(np.min(drawdown)) daily_ret = np.diff(total) / total[:-1] daily_ret = daily_ret[~np.isnan(daily_ret)] - sharpe = float(np.mean(daily_ret) / np.std(daily_ret) * np.sqrt(252)) if len(daily_ret) > 1 and np.std(daily_ret) > 0 else 0.0 - return {"total_return": total_return, "annual_return": annual_return, "max_drawdown": max_drawdown, "sharpe": sharpe, "total_trades": len(equity_curve)} + sharpe = ( + float(np.mean(daily_ret) / np.std(daily_ret) * np.sqrt(252)) + if len(daily_ret) > 1 and np.std(daily_ret) > 0 + else 0.0 + ) + return { + "total_return": total_return, + "annual_return": annual_return, + "max_drawdown": max_drawdown, + "sharpe": sharpe, + "total_trades": len(equity_curve), + } def _empty_result(self) -> RebalanceResult: return RebalanceResult( - rebalance_dates=[], states=[], - trades=pd.DataFrame(columns=["datetime", "direction", "code", "shares", "price", "cost"]), + rebalance_dates=[], + states=[], + trades=pd.DataFrame( + columns=["datetime", "direction", "code", "shares", "price", "cost"] + ), equity_curve=pd.DataFrame(columns=["datetime", "total", "cash", "position_value"]), - performance={"total_return": 0.0, "annual_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0}, + performance={ + "total_return": 0.0, + "annual_return": 0.0, + "max_drawdown": 0.0, + "sharpe": 0.0, + }, ) diff --git a/src/easy_tdx/portfolio/risk.py b/src/easy_tdx/portfolio/risk.py index 45ea35d..1bcb5ba 100644 --- a/src/easy_tdx/portfolio/risk.py +++ b/src/easy_tdx/portfolio/risk.py @@ -1,4 +1,5 @@ """简化风险模型。""" + from __future__ import annotations import numpy as np @@ -48,4 +49,8 @@ class RiskModel: risk_contrib = np.abs(w * marginal) total_rc = risk_contrib.sum() max_rc = float(risk_contrib.max() / total_rc) if total_rc > 0 else 0.0 - return {"total_volatility": total_vol, "max_risk_contribution": max_rc, "n_positions": len(codes)} + return { + "total_volatility": total_vol, + "max_risk_contribution": max_rc, + "n_positions": len(codes), + } diff --git a/src/easy_tdx/portfolio/types.py b/src/easy_tdx/portfolio/types.py index 2fe94f1..7c4bfa1 100644 --- a/src/easy_tdx/portfolio/types.py +++ b/src/easy_tdx/portfolio/types.py @@ -1,4 +1,5 @@ """组合管理数据结构。""" + from __future__ import annotations from dataclasses import dataclass