mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
feat(portfolio): add optimizer, risk model, rebalance engine, CLI pfactor command, bump v1.13.0
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
"""组合管理数据结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
Reference in New Issue
Block a user