feat(portfolio): add optimizer, risk model, rebalance engine, CLI pfactor command, bump v1.13.0

This commit is contained in:
GitHub
2026-06-12 20:25:50 +08:00
parent e6a69d51e4
commit b7b72b10d1
8 changed files with 168 additions and 19 deletions
+1 -1
View File
@@ -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"
+2
View File
@@ -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)
+58
View File
@@ -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,
)
)
+25
View File
@@ -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",
]
+4
View File
@@ -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()
+71 -17
View File
@@ -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,
},
)
+6 -1
View File
@@ -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),
}
+1
View File
@@ -1,4 +1,5 @@
"""组合管理数据结构。"""
from __future__ import annotations
from dataclasses import dataclass