mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +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]
|
[project]
|
||||||
name = "easy-tdx"
|
name = "easy-tdx"
|
||||||
version = "1.12.0"
|
version = "1.13.0"
|
||||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .cmd_capital import capital_flow
|
|||||||
from .cmd_chanlun import chanlun
|
from .cmd_chanlun import chanlun
|
||||||
from .cmd_ex import ex
|
from .cmd_ex import ex
|
||||||
from .cmd_factor import factor
|
from .cmd_factor import factor
|
||||||
|
from .cmd_pfactor import pfactor
|
||||||
from .cmd_finance import f10, fund_flow
|
from .cmd_finance import f10, fund_flow
|
||||||
from .cmd_indicator import indicator, indicator_list
|
from .cmd_indicator import indicator, indicator_list
|
||||||
from .cmd_info import server_info, symbol_info
|
from .cmd_info import server_info, symbol_info
|
||||||
@@ -83,6 +84,7 @@ cli.add_command(indicator_list)
|
|||||||
cli.add_command(offline)
|
cli.add_command(offline)
|
||||||
cli.add_command(chanlun)
|
cli.add_command(chanlun)
|
||||||
cli.add_command(factor)
|
cli.add_command(factor)
|
||||||
|
cli.add_command(pfactor)
|
||||||
cli.add_command(backtest)
|
cli.add_command(backtest)
|
||||||
cli.add_command(portfolio)
|
cli.add_command(portfolio)
|
||||||
cli.add_command(run_all)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -26,9 +27,11 @@ _OPTIMIZER_REGISTRY: dict[str, type[WeightOptimizer]] = {}
|
|||||||
|
|
||||||
def register_optimizer(name: str) -> type[WeightOptimizer]:
|
def register_optimizer(name: str) -> type[WeightOptimizer]:
|
||||||
"""注册优化器。"""
|
"""注册优化器。"""
|
||||||
|
|
||||||
def wrapper(cls: type[WeightOptimizer]) -> type[WeightOptimizer]:
|
def wrapper(cls: type[WeightOptimizer]) -> type[WeightOptimizer]:
|
||||||
_OPTIMIZER_REGISTRY[name] = cls
|
_OPTIMIZER_REGISTRY[name] = cls
|
||||||
return cls
|
return cls
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ class MeanVarianceOptimizer(WeightOptimizer):
|
|||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
try:
|
try:
|
||||||
from scipy.optimize import minimize # noqa: F401
|
from scipy.optimize import minimize # noqa: F401
|
||||||
|
|
||||||
return self._optimize_with_scipy(factor_scores, n_stocks)
|
return self._optimize_with_scipy(factor_scores, n_stocks)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
fallback = EqualWeightOptimizer()
|
fallback = EqualWeightOptimizer()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""多期调仓回测引擎。"""
|
"""多期调仓回测引擎。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -111,21 +112,38 @@ class RebalanceEngine:
|
|||||||
if holdings[c] > 0 and c in prices:
|
if holdings[c] > 0 and c in prices:
|
||||||
weights[c] = holdings[c] * prices[c] / total_value
|
weights[c] = holdings[c] * prices[c] / total_value
|
||||||
|
|
||||||
states.append(PortfolioState(
|
states.append(
|
||||||
date=date_int, weights=weights, holdings=dict(holdings),
|
PortfolioState(
|
||||||
cash=cash, total_value=total_value,
|
date=date_int,
|
||||||
positions_count=len([s for s in holdings.values() if s > 0]),
|
weights=weights,
|
||||||
))
|
holdings=dict(holdings),
|
||||||
equity_records.append({"datetime": date_int, "total": total_value, "cash": cash, "position_value": position_value})
|
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)
|
equity_curve = pd.DataFrame(equity_records)
|
||||||
trades_df = pd.DataFrame(trades_list) if trades_list else pd.DataFrame(
|
trades_df = (
|
||||||
columns=["datetime", "direction", "code", "shares", "price", "cost"]
|
pd.DataFrame(trades_list)
|
||||||
|
if trades_list
|
||||||
|
else pd.DataFrame(columns=["datetime", "direction", "code", "shares", "price", "cost"])
|
||||||
)
|
)
|
||||||
performance = self._compute_performance(equity_curve)
|
performance = self._compute_performance(equity_curve)
|
||||||
return RebalanceResult(
|
return RebalanceResult(
|
||||||
rebalance_dates=rebalance_dates, states=states, trades=trades_df,
|
rebalance_dates=rebalance_dates,
|
||||||
equity_curve=equity_curve, performance=performance,
|
states=states,
|
||||||
|
trades=trades_df,
|
||||||
|
equity_curve=equity_curve,
|
||||||
|
performance=performance,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _rebalance(
|
def _rebalance(
|
||||||
@@ -145,7 +163,16 @@ class RebalanceEngine:
|
|||||||
sell_value = holdings[code] * price
|
sell_value = holdings[code] * price
|
||||||
cost = sell_value * (self._commission + self._slippage)
|
cost = sell_value * (self._commission + self._slippage)
|
||||||
cash += sell_value - cost
|
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]
|
del holdings[code]
|
||||||
|
|
||||||
new_holdings: dict[str, float] = {}
|
new_holdings: dict[str, float] = {}
|
||||||
@@ -159,7 +186,16 @@ class RebalanceEngine:
|
|||||||
new_holdings[code] = shares
|
new_holdings[code] = shares
|
||||||
trade_value = shares * price
|
trade_value = shares * price
|
||||||
cost = trade_value * (self._commission + self._slippage)
|
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)
|
cash = total_value - sum(new_holdings.get(c, 0) * prices.get(c, 0) for c in new_holdings)
|
||||||
holdings.clear()
|
holdings.clear()
|
||||||
@@ -178,13 +214,31 @@ class RebalanceEngine:
|
|||||||
max_drawdown = float(np.min(drawdown))
|
max_drawdown = float(np.min(drawdown))
|
||||||
daily_ret = np.diff(total) / total[:-1]
|
daily_ret = np.diff(total) / total[:-1]
|
||||||
daily_ret = daily_ret[~np.isnan(daily_ret)]
|
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
|
sharpe = (
|
||||||
return {"total_return": total_return, "annual_return": annual_return, "max_drawdown": max_drawdown, "sharpe": sharpe, "total_trades": len(equity_curve)}
|
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:
|
def _empty_result(self) -> RebalanceResult:
|
||||||
return RebalanceResult(
|
return RebalanceResult(
|
||||||
rebalance_dates=[], states=[],
|
rebalance_dates=[],
|
||||||
trades=pd.DataFrame(columns=["datetime", "direction", "code", "shares", "price", "cost"]),
|
states=[],
|
||||||
|
trades=pd.DataFrame(
|
||||||
|
columns=["datetime", "direction", "code", "shares", "price", "cost"]
|
||||||
|
),
|
||||||
equity_curve=pd.DataFrame(columns=["datetime", "total", "cash", "position_value"]),
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -48,4 +49,8 @@ class RiskModel:
|
|||||||
risk_contrib = np.abs(w * marginal)
|
risk_contrib = np.abs(w * marginal)
|
||||||
total_rc = risk_contrib.sum()
|
total_rc = risk_contrib.sum()
|
||||||
max_rc = float(risk_contrib.max() / total_rc) if total_rc > 0 else 0.0
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|||||||
Reference in New Issue
Block a user