fund-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fund_cli/__init__.py +13 -0
- fund_cli/__main__.py +10 -0
- fund_cli/ai/__init__.py +21 -0
- fund_cli/ai/analyzer.py +360 -0
- fund_cli/ai/prompts.py +244 -0
- fund_cli/ai/providers.py +286 -0
- fund_cli/analysis/__init__.py +17 -0
- fund_cli/analysis/attribution.py +161 -0
- fund_cli/analysis/backtest.py +75 -0
- fund_cli/analysis/holding.py +217 -0
- fund_cli/analysis/manager.py +133 -0
- fund_cli/analysis/performance.py +440 -0
- fund_cli/analysis/portfolio.py +152 -0
- fund_cli/analysis/risk.py +300 -0
- fund_cli/cli.py +98 -0
- fund_cli/commands/__init__.py +9 -0
- fund_cli/commands/ai_cmd.py +464 -0
- fund_cli/commands/analyze_cmd.py +418 -0
- fund_cli/commands/compare_cmd.py +264 -0
- fund_cli/commands/config_cmd.py +97 -0
- fund_cli/commands/data_cmd.py +106 -0
- fund_cli/commands/filter_cmd.py +286 -0
- fund_cli/commands/holding_cmd.py +140 -0
- fund_cli/commands/interactive_cmd.py +84 -0
- fund_cli/commands/main.py +17 -0
- fund_cli/commands/manager_cmd.py +74 -0
- fund_cli/commands/monitor_cmd.py +113 -0
- fund_cli/commands/optimize_cmd.py +192 -0
- fund_cli/config.py +163 -0
- fund_cli/core/__init__.py +8 -0
- fund_cli/core/analyzer.py +46 -0
- fund_cli/core/data_manager.py +231 -0
- fund_cli/core/data_quality.py +162 -0
- fund_cli/core/monitor.py +230 -0
- fund_cli/core/optimizer.py +50 -0
- fund_cli/core/optimizers/__init__.py +13 -0
- fund_cli/core/optimizers/efficient_frontier.py +91 -0
- fund_cli/core/optimizers/max_sharpe.py +54 -0
- fund_cli/core/optimizers/mean_variance.py +84 -0
- fund_cli/core/optimizers/risk_parity.py +60 -0
- fund_cli/core/reporter.py +67 -0
- fund_cli/core/reporters/__init__.py +6 -0
- fund_cli/core/reporters/html_reporter.py +62 -0
- fund_cli/core/reporters/markdown_reporter.py +40 -0
- fund_cli/core/screener.py +142 -0
- fund_cli/data/__init__.py +6 -0
- fund_cli/data/adapters/__init__.py +7 -0
- fund_cli/data/adapters/akshare_adapter.py +442 -0
- fund_cli/data/adapters/tushare_adapter.py +254 -0
- fund_cli/data/adapters/wind_adapter.py +78 -0
- fund_cli/data/base.py +209 -0
- fund_cli/data/cache.py +192 -0
- fund_cli/data/models.py +248 -0
- fund_cli/utils/__init__.py +6 -0
- fund_cli/utils/decorators.py +88 -0
- fund_cli/utils/helpers.py +127 -0
- fund_cli/utils/validators.py +77 -0
- fund_cli/views/__init__.py +6 -0
- fund_cli/views/charts.py +120 -0
- fund_cli/views/reports.py +82 -0
- fund_cli/views/tables.py +124 -0
- fund_cli-2.0.0.dist-info/METADATA +183 -0
- fund_cli-2.0.0.dist-info/RECORD +66 -0
- fund_cli-2.0.0.dist-info/WHEEL +4 -0
- fund_cli-2.0.0.dist-info/entry_points.txt +3 -0
- fund_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
业绩分析引擎
|
|
3
|
+
|
|
4
|
+
基于 QuantStats 实现专业的业绩分析功能。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from fund_cli.config import get_config
|
|
13
|
+
from fund_cli.core.analyzer import Analyzer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PerformanceAnalyzer(Analyzer):
|
|
17
|
+
"""
|
|
18
|
+
业绩分析引擎
|
|
19
|
+
|
|
20
|
+
使用 QuantStats 库计算专业业绩指标,包括:
|
|
21
|
+
- 收益指标:总收益、年化收益、累计收益
|
|
22
|
+
- 风险指标:波动率、VaR、CVaR
|
|
23
|
+
- 风险调整收益:夏普比率、索提诺比率、卡玛比率
|
|
24
|
+
- 相对指标:Alpha、Beta、信息比率、跟踪误差
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, risk_free_rate: float | None = None):
|
|
28
|
+
"""
|
|
29
|
+
初始化业绩分析引擎
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
risk_free_rate: 无风险利率,默认从配置读取
|
|
33
|
+
"""
|
|
34
|
+
config = get_config()
|
|
35
|
+
self.risk_free_rate = risk_free_rate or config.analysis.risk_free_rate
|
|
36
|
+
self._qs = None
|
|
37
|
+
|
|
38
|
+
def _get_quantstats(self):
|
|
39
|
+
"""延迟加载 QuantStats"""
|
|
40
|
+
if self._qs is None:
|
|
41
|
+
try:
|
|
42
|
+
import quantstats as qs
|
|
43
|
+
|
|
44
|
+
self._qs = qs
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise ImportError("QuantStats 未安装,请运行: pip install quantstats") from e
|
|
47
|
+
return self._qs
|
|
48
|
+
|
|
49
|
+
def analyze(
|
|
50
|
+
self,
|
|
51
|
+
returns: pd.Series,
|
|
52
|
+
benchmark: pd.Series | None = None,
|
|
53
|
+
**kwargs,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
执行业绩分析
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
returns: 收益率序列(日频)
|
|
60
|
+
benchmark: 基准收益率序列(可选)
|
|
61
|
+
**kwargs: 额外参数
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
分析结果字典
|
|
65
|
+
"""
|
|
66
|
+
qs = self._get_quantstats()
|
|
67
|
+
|
|
68
|
+
# 确保输入为 Series
|
|
69
|
+
if isinstance(returns, pd.DataFrame):
|
|
70
|
+
returns = returns.iloc[:, 0]
|
|
71
|
+
|
|
72
|
+
# 清理数据
|
|
73
|
+
returns = returns.dropna()
|
|
74
|
+
|
|
75
|
+
# 基础收益指标
|
|
76
|
+
metrics = {
|
|
77
|
+
# 收益指标
|
|
78
|
+
"total_return": self._safe_calc(qs.stats.comp, returns) * 100,
|
|
79
|
+
"cagr": self._safe_calc(qs.stats.cagr, returns) * 100,
|
|
80
|
+
"mean_return": returns.mean() * 252 * 100,
|
|
81
|
+
# 风险指标
|
|
82
|
+
"volatility": self._safe_calc(qs.stats.volatility, returns) * 100,
|
|
83
|
+
"max_drawdown": self._safe_calc(qs.stats.max_drawdown, returns) * 100,
|
|
84
|
+
"var_95": self._safe_calc(qs.stats.var, returns) * 100,
|
|
85
|
+
"cvar_95": self._safe_calc(qs.stats.cvar, returns) * 100,
|
|
86
|
+
# 风险调整收益
|
|
87
|
+
"sharpe": self._safe_calc(qs.stats.sharpe, returns, rf=self.risk_free_rate),
|
|
88
|
+
"sortino": self._safe_calc(qs.stats.sortino, returns),
|
|
89
|
+
"calmar": self._safe_calc(qs.stats.calmar, returns),
|
|
90
|
+
# 其他指标
|
|
91
|
+
"skew": self._safe_calc(qs.stats.skew, returns),
|
|
92
|
+
"kurtosis": self._safe_calc(qs.stats.kurtosis, returns),
|
|
93
|
+
"best_day": returns.max() * 100 if not returns.empty else 0,
|
|
94
|
+
"worst_day": returns.min() * 100 if not returns.empty else 0,
|
|
95
|
+
"avg_win": self._safe_calc(qs.stats.avg_win, returns) * 100,
|
|
96
|
+
"avg_loss": self._safe_calc(qs.stats.avg_loss, returns) * 100,
|
|
97
|
+
"win_rate": self._safe_calc(qs.stats.win_rate, returns) * 100,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# 相对指标(如果有基准)
|
|
101
|
+
if benchmark is not None:
|
|
102
|
+
if isinstance(benchmark, pd.DataFrame):
|
|
103
|
+
benchmark = benchmark.iloc[:, 0]
|
|
104
|
+
benchmark = benchmark.dropna()
|
|
105
|
+
|
|
106
|
+
# 对齐日期
|
|
107
|
+
common_dates = returns.index.intersection(benchmark.index)
|
|
108
|
+
if len(common_dates) > 0:
|
|
109
|
+
returns_aligned = returns.loc[common_dates]
|
|
110
|
+
benchmark_aligned = benchmark.loc[common_dates]
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
greeks = qs.stats.greeks(returns_aligned, benchmark_aligned)
|
|
114
|
+
if greeks is not None:
|
|
115
|
+
metrics["alpha"] = float(greeks.iloc[0])
|
|
116
|
+
metrics["beta"] = float(greeks.iloc[1])
|
|
117
|
+
else:
|
|
118
|
+
metrics["alpha"] = None
|
|
119
|
+
metrics["beta"] = None
|
|
120
|
+
except Exception:
|
|
121
|
+
metrics["alpha"] = None
|
|
122
|
+
metrics["beta"] = None
|
|
123
|
+
|
|
124
|
+
# tracking_error / information_ratio / r_squared 在部分版本不可用
|
|
125
|
+
# 使用 hasattr 检查,因为属性访问时会抛出 AttributeError
|
|
126
|
+
if hasattr(qs.stats, "tracking_error"):
|
|
127
|
+
metrics["tracking_error"] = self._safe_calc(
|
|
128
|
+
qs.stats.tracking_error, returns_aligned, benchmark_aligned
|
|
129
|
+
)
|
|
130
|
+
if metrics["tracking_error"] is not None and not (
|
|
131
|
+
isinstance(metrics["tracking_error"], float)
|
|
132
|
+
and metrics["tracking_error"] != metrics["tracking_error"]
|
|
133
|
+
):
|
|
134
|
+
metrics["tracking_error"] = metrics["tracking_error"] * 100
|
|
135
|
+
else:
|
|
136
|
+
metrics["tracking_error"] = None
|
|
137
|
+
else:
|
|
138
|
+
metrics["tracking_error"] = None
|
|
139
|
+
|
|
140
|
+
if metrics["tracking_error"] is None:
|
|
141
|
+
# 手动计算 tracking_error
|
|
142
|
+
excess = returns_aligned - benchmark_aligned
|
|
143
|
+
metrics["tracking_error"] = float(excess.std() * np.sqrt(252)) * 100
|
|
144
|
+
|
|
145
|
+
if hasattr(qs.stats, "information_ratio"):
|
|
146
|
+
metrics["information_ratio"] = self._safe_calc(
|
|
147
|
+
qs.stats.information_ratio, returns_aligned, benchmark_aligned
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
metrics["information_ratio"] = None
|
|
151
|
+
|
|
152
|
+
if metrics["information_ratio"] is None or (
|
|
153
|
+
isinstance(metrics["information_ratio"], float)
|
|
154
|
+
and metrics["information_ratio"] != metrics["information_ratio"]
|
|
155
|
+
):
|
|
156
|
+
# 手动计算 information_ratio
|
|
157
|
+
excess = returns_aligned - benchmark_aligned
|
|
158
|
+
te = excess.std() * np.sqrt(252)
|
|
159
|
+
metrics["information_ratio"] = (
|
|
160
|
+
float(excess.mean() * 252 / te) if te > 0 else None
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if hasattr(qs.stats, "r_squared"):
|
|
164
|
+
metrics["r_squared"] = self._safe_calc(
|
|
165
|
+
qs.stats.r_squared, returns_aligned, benchmark_aligned
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
metrics["r_squared"] = None
|
|
169
|
+
|
|
170
|
+
if metrics["r_squared"] is None or (
|
|
171
|
+
isinstance(metrics["r_squared"], float)
|
|
172
|
+
and metrics["r_squared"] != metrics["r_squared"]
|
|
173
|
+
):
|
|
174
|
+
metrics["r_squared"] = float(returns_aligned.corr(benchmark_aligned) ** 2)
|
|
175
|
+
|
|
176
|
+
return metrics
|
|
177
|
+
|
|
178
|
+
def _safe_calc(self, func, *args, **kwargs) -> Any:
|
|
179
|
+
"""安全计算,捕获异常"""
|
|
180
|
+
try:
|
|
181
|
+
result = func(*args, **kwargs)
|
|
182
|
+
if result is None:
|
|
183
|
+
return float("nan")
|
|
184
|
+
return result
|
|
185
|
+
except Exception:
|
|
186
|
+
return float("nan")
|
|
187
|
+
|
|
188
|
+
def get_metrics(self) -> list[str]:
|
|
189
|
+
"""
|
|
190
|
+
获取可计算的指标列表
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
指标名称列表
|
|
194
|
+
"""
|
|
195
|
+
return [
|
|
196
|
+
"total_return",
|
|
197
|
+
"cagr",
|
|
198
|
+
"volatility",
|
|
199
|
+
"max_drawdown",
|
|
200
|
+
"sharpe",
|
|
201
|
+
"sortino",
|
|
202
|
+
"calmar",
|
|
203
|
+
"var_95",
|
|
204
|
+
"cvar_95",
|
|
205
|
+
"alpha",
|
|
206
|
+
"beta",
|
|
207
|
+
"tracking_error",
|
|
208
|
+
"information_ratio",
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
def calculate_returns(
|
|
212
|
+
self,
|
|
213
|
+
nav_data: pd.DataFrame,
|
|
214
|
+
nav_column: str = "unit_nav",
|
|
215
|
+
) -> pd.Series:
|
|
216
|
+
"""
|
|
217
|
+
从净值数据计算收益率
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
nav_data: 净值数据 DataFrame
|
|
221
|
+
nav_column: 净值列名
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
日收益率序列
|
|
225
|
+
"""
|
|
226
|
+
nav = nav_data.set_index("nav_date")[nav_column]
|
|
227
|
+
returns = nav.pct_change().dropna()
|
|
228
|
+
returns.name = "daily_return"
|
|
229
|
+
return returns
|
|
230
|
+
|
|
231
|
+
def calculate_cumulative_return(
|
|
232
|
+
self,
|
|
233
|
+
returns: pd.Series,
|
|
234
|
+
) -> pd.Series:
|
|
235
|
+
"""
|
|
236
|
+
计算累计收益率
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
returns: 日收益率序列
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
累计收益率序列
|
|
243
|
+
"""
|
|
244
|
+
return (1 + returns).cumprod() - 1
|
|
245
|
+
|
|
246
|
+
def calculate_drawdown(
|
|
247
|
+
self,
|
|
248
|
+
returns: pd.Series,
|
|
249
|
+
) -> pd.Series:
|
|
250
|
+
"""
|
|
251
|
+
计算回撤序列
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
returns: 日收益率序列
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
回撤序列
|
|
258
|
+
"""
|
|
259
|
+
wealth = (1 + returns).cumprod()
|
|
260
|
+
rolling_max = wealth.cummax()
|
|
261
|
+
drawdown = (wealth - rolling_max) / rolling_max
|
|
262
|
+
return drawdown
|
|
263
|
+
|
|
264
|
+
def rolling_performance(self, returns: pd.Series, window: int = 60) -> pd.DataFrame:
|
|
265
|
+
"""
|
|
266
|
+
滚动业绩分析 (FUND-ANALYZE-006)
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
returns: 日收益率序列
|
|
270
|
+
window: 滚动窗口(交易日)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
滚动指标 DataFrame,包含 rolling_return, rolling_sharpe, rolling_volatility, rolling_max_drawdown
|
|
274
|
+
"""
|
|
275
|
+
if len(returns) < window:
|
|
276
|
+
return pd.DataFrame()
|
|
277
|
+
|
|
278
|
+
rolling_ret = returns.rolling(window=window).apply(lambda x: (1 + x).prod() - 1) * 100
|
|
279
|
+
rolling_vol = returns.rolling(window=window).std() * np.sqrt(252) * 100
|
|
280
|
+
rolling_sharpe = (
|
|
281
|
+
(rolling_ret / rolling_vol) if rolling_vol.notna().any() else pd.Series(dtype=float)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _rolling_mdd(x):
|
|
285
|
+
if len(x) == 0:
|
|
286
|
+
return 0
|
|
287
|
+
cumprod = (1 + x).cumprod()
|
|
288
|
+
cummax = cumprod.cummax()
|
|
289
|
+
return ((cummax - cumprod) / cummax).min() * 100
|
|
290
|
+
|
|
291
|
+
rolling_mdd = returns.rolling(window=window).apply(_rolling_mdd)
|
|
292
|
+
|
|
293
|
+
return pd.DataFrame(
|
|
294
|
+
{
|
|
295
|
+
"rolling_return": rolling_ret,
|
|
296
|
+
"rolling_volatility": rolling_vol,
|
|
297
|
+
"rolling_sharpe": rolling_sharpe,
|
|
298
|
+
"rolling_max_drawdown": rolling_mdd,
|
|
299
|
+
}
|
|
300
|
+
).dropna()
|
|
301
|
+
|
|
302
|
+
def monthly_return_distribution(self, returns: pd.Series) -> dict[str, Any]:
|
|
303
|
+
"""
|
|
304
|
+
月度收益分布 (FUND-ANALYZE-008)
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
returns: 日收益率序列
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
月度分布统计字典
|
|
311
|
+
"""
|
|
312
|
+
if returns.empty:
|
|
313
|
+
return {
|
|
314
|
+
"monthly_returns": [],
|
|
315
|
+
"positive_months": 0,
|
|
316
|
+
"negative_months": 0,
|
|
317
|
+
"avg_monthly_return": 0,
|
|
318
|
+
"max_month": 0,
|
|
319
|
+
"min_month": 0,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
monthly = returns.resample("ME").apply(lambda x: (1 + x).prod() - 1) * 100
|
|
323
|
+
monthly = monthly.dropna()
|
|
324
|
+
|
|
325
|
+
positive = (monthly > 0).sum()
|
|
326
|
+
negative = (monthly < 0).sum()
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"total_months": len(monthly),
|
|
330
|
+
"positive_months": int(positive),
|
|
331
|
+
"negative_months": int(negative),
|
|
332
|
+
"win_rate": round(positive / len(monthly) * 100, 1) if len(monthly) > 0 else 0,
|
|
333
|
+
"avg_monthly_return": round(monthly.mean(), 4),
|
|
334
|
+
"std_monthly_return": round(monthly.std(), 4),
|
|
335
|
+
"max_month": round(monthly.max(), 4),
|
|
336
|
+
"min_month": round(monthly.min(), 4),
|
|
337
|
+
"monthly_returns": monthly.to_dict(),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
def scenario_analysis(
|
|
341
|
+
self, returns: pd.Series, scenarios: dict[str, Any] | None = None
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
"""
|
|
344
|
+
情景分析 (FUND-ANALYZE-009)
|
|
345
|
+
|
|
346
|
+
预定义情景:牛市(+20%年化)、熊市(-20%年化)、震荡市(0%年化)
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
returns: 日收益率序列
|
|
350
|
+
scenarios: 自定义情景 {名称: 年化收益率}
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
情景分析结果
|
|
354
|
+
"""
|
|
355
|
+
if scenarios is None:
|
|
356
|
+
scenarios = {
|
|
357
|
+
"牛市": 0.20,
|
|
358
|
+
"温和牛市": 0.10,
|
|
359
|
+
"震荡市": 0.0,
|
|
360
|
+
"温和熊市": -0.10,
|
|
361
|
+
"熊市": -0.20,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
results = {}
|
|
365
|
+
for name, annual_return in scenarios.items():
|
|
366
|
+
daily_return = (1 + annual_return) ** (1 / 252) - 1
|
|
367
|
+
n_days = len(returns)
|
|
368
|
+
simulated = np.random.normal(daily_return, returns.std(), n_days)
|
|
369
|
+
total = (1 + pd.Series(simulated)).prod() - 1
|
|
370
|
+
vol = pd.Series(simulated).std() * np.sqrt(252)
|
|
371
|
+
results[name] = {
|
|
372
|
+
"annual_return": round(annual_return * 100, 2),
|
|
373
|
+
"simulated_total_return": round(total * 100, 2),
|
|
374
|
+
"simulated_volatility": round(vol * 100, 2),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return results
|
|
378
|
+
|
|
379
|
+
def performance_persistence(
|
|
380
|
+
self, returns: pd.Series, periods_per_year: int = 12
|
|
381
|
+
) -> dict[str, Any]:
|
|
382
|
+
"""
|
|
383
|
+
业绩持续性分析 (FUND-ANALYZE-010)
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
returns: 日收益率序列
|
|
387
|
+
periods_per_year: 每年周期数
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
持续性分析结果
|
|
391
|
+
"""
|
|
392
|
+
if len(returns) < periods_per_year * 2:
|
|
393
|
+
return {"persistence_score": 0, "message": "数据不足"}
|
|
394
|
+
|
|
395
|
+
monthly = returns.resample("ME").apply(lambda x: (1 + x).prod() - 1)
|
|
396
|
+
monthly = monthly.dropna()
|
|
397
|
+
|
|
398
|
+
if len(monthly) < 6:
|
|
399
|
+
return {"persistence_score": 0, "message": "月度数据不足"}
|
|
400
|
+
|
|
401
|
+
# 计算排名相关性(相邻周期排名的相关系数)
|
|
402
|
+
half = len(monthly) // 2
|
|
403
|
+
first_half = monthly.iloc[:half]
|
|
404
|
+
second_half = monthly.iloc[half : 2 * half]
|
|
405
|
+
|
|
406
|
+
if len(first_half) > 1 and len(second_half) > 1:
|
|
407
|
+
rank_corr = first_half.rank().corr(second_half.rank())
|
|
408
|
+
else:
|
|
409
|
+
rank_corr = 0
|
|
410
|
+
|
|
411
|
+
# 胜率
|
|
412
|
+
win_rate = (monthly > 0).sum() / len(monthly) * 100
|
|
413
|
+
|
|
414
|
+
# 连续正/负月数
|
|
415
|
+
max_positive_streak = 0
|
|
416
|
+
max_negative_streak = 0
|
|
417
|
+
current_streak = 0
|
|
418
|
+
current_sign = 1
|
|
419
|
+
for val in monthly:
|
|
420
|
+
sign = 1 if val > 0 else -1
|
|
421
|
+
if sign == current_sign:
|
|
422
|
+
current_streak += 1
|
|
423
|
+
else:
|
|
424
|
+
current_streak = 1
|
|
425
|
+
current_sign = sign
|
|
426
|
+
if sign == 1:
|
|
427
|
+
max_positive_streak = max(max_positive_streak, current_streak)
|
|
428
|
+
else:
|
|
429
|
+
max_negative_streak = max(max_negative_streak, current_streak)
|
|
430
|
+
|
|
431
|
+
persistence_score = max(0, min(100, (rank_corr + 1) * 50))
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
"persistence_score": round(persistence_score, 1),
|
|
435
|
+
"rank_correlation": round(rank_corr, 4),
|
|
436
|
+
"monthly_win_rate": round(win_rate, 1),
|
|
437
|
+
"max_positive_streak": max_positive_streak,
|
|
438
|
+
"max_negative_streak": max_negative_streak,
|
|
439
|
+
"total_months": len(monthly),
|
|
440
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
投资组合分析引擎
|
|
3
|
+
|
|
4
|
+
实现组合层面的分析功能,包括组合收益、风险分散度等。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from fund_cli.core.analyzer import Analyzer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PortfolioAnalyzer(Analyzer):
|
|
16
|
+
"""
|
|
17
|
+
投资组合分析引擎
|
|
18
|
+
|
|
19
|
+
支持:
|
|
20
|
+
- 组合权重分析
|
|
21
|
+
- 组合风险分散度
|
|
22
|
+
- 资产相关性分析
|
|
23
|
+
- 组合收益贡献分析
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def analyze(
|
|
27
|
+
self,
|
|
28
|
+
data: pd.DataFrame,
|
|
29
|
+
weights: dict[str, float] | None = None,
|
|
30
|
+
**kwargs,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""
|
|
33
|
+
执行组合分析
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
data: 多资产收益率 DataFrame,每列一个资产
|
|
37
|
+
weights: 各资产权重字典
|
|
38
|
+
**kwargs: 额外参数
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
组合分析结果字典
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(data, pd.Series):
|
|
44
|
+
data = data.to_frame("asset")
|
|
45
|
+
|
|
46
|
+
if weights is None:
|
|
47
|
+
# 等权配置
|
|
48
|
+
n = data.shape[1]
|
|
49
|
+
weights = dict.fromkeys(data.columns, 1.0 / n)
|
|
50
|
+
|
|
51
|
+
result = {
|
|
52
|
+
"asset_count": len(weights),
|
|
53
|
+
"weights": weights,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# 组合收益率
|
|
57
|
+
portfolio_returns = self._calculate_portfolio_returns(data, weights)
|
|
58
|
+
result["portfolio_return"] = float(portfolio_returns.mean() * 252 * 100)
|
|
59
|
+
result["portfolio_volatility"] = float(portfolio_returns.std() * np.sqrt(252) * 100)
|
|
60
|
+
result["portfolio_sharpe"] = float(
|
|
61
|
+
portfolio_returns.mean() / portfolio_returns.std() * np.sqrt(252)
|
|
62
|
+
if portfolio_returns.std() > 0
|
|
63
|
+
else 0
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 相关性分析
|
|
67
|
+
result["correlation_matrix"] = data.corr().to_dict()
|
|
68
|
+
avg_correlation = self._average_correlation(data.corr())
|
|
69
|
+
result["average_correlation"] = float(avg_correlation)
|
|
70
|
+
|
|
71
|
+
# 风险分散度(DR)
|
|
72
|
+
result["diversification_ratio"] = float(self._diversification_ratio(data, weights))
|
|
73
|
+
|
|
74
|
+
# 各资产贡献
|
|
75
|
+
result["contribution"] = self._return_contribution(data, weights)
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def _calculate_portfolio_returns(
|
|
80
|
+
self,
|
|
81
|
+
returns: pd.DataFrame,
|
|
82
|
+
weights: dict[str, float],
|
|
83
|
+
) -> pd.Series:
|
|
84
|
+
"""计算组合收益率序列"""
|
|
85
|
+
valid_cols = [c for c in weights.keys() if c in returns.columns]
|
|
86
|
+
w = np.array([weights[c] for c in valid_cols])
|
|
87
|
+
return returns[valid_cols].dot(w)
|
|
88
|
+
|
|
89
|
+
def _average_correlation(self, corr_matrix: pd.DataFrame) -> float:
|
|
90
|
+
"""计算平均相关系数"""
|
|
91
|
+
n = len(corr_matrix)
|
|
92
|
+
if n <= 1:
|
|
93
|
+
return 1.0
|
|
94
|
+
|
|
95
|
+
# 取上三角(不含对角线)
|
|
96
|
+
mask = np.triu(np.ones((n, n), dtype=bool), k=1)
|
|
97
|
+
values = corr_matrix.values[mask]
|
|
98
|
+
|
|
99
|
+
return float(np.mean(values)) if len(values) > 0 else 1.0
|
|
100
|
+
|
|
101
|
+
def _diversification_ratio(
|
|
102
|
+
self,
|
|
103
|
+
returns: pd.DataFrame,
|
|
104
|
+
weights: dict[str, float],
|
|
105
|
+
) -> float:
|
|
106
|
+
"""
|
|
107
|
+
计算风险分散度(Diversification Ratio)
|
|
108
|
+
|
|
109
|
+
DR = 加权平均波动率 / 组合波动率
|
|
110
|
+
DR > 1 表示组合具有分散化效果
|
|
111
|
+
"""
|
|
112
|
+
valid_cols = [c for c in weights.keys() if c in returns.columns]
|
|
113
|
+
if not valid_cols:
|
|
114
|
+
return 1.0
|
|
115
|
+
|
|
116
|
+
w = np.array([weights[c] for c in valid_cols])
|
|
117
|
+
vols = returns[valid_cols].std() * np.sqrt(252)
|
|
118
|
+
|
|
119
|
+
weighted_vol = float(np.dot(w, vols))
|
|
120
|
+
portfolio_vol = float(returns[valid_cols].dot(w).std() * np.sqrt(252))
|
|
121
|
+
|
|
122
|
+
if portfolio_vol == 0:
|
|
123
|
+
return 1.0
|
|
124
|
+
|
|
125
|
+
return weighted_vol / portfolio_vol
|
|
126
|
+
|
|
127
|
+
def _return_contribution(
|
|
128
|
+
self,
|
|
129
|
+
returns: pd.DataFrame,
|
|
130
|
+
weights: dict[str, float],
|
|
131
|
+
) -> dict[str, float]:
|
|
132
|
+
"""计算各资产收益贡献"""
|
|
133
|
+
contribution = {}
|
|
134
|
+
for asset, weight in weights.items():
|
|
135
|
+
if asset in returns.columns:
|
|
136
|
+
annual_return = returns[asset].mean() * 252 * 100
|
|
137
|
+
contribution[asset] = {
|
|
138
|
+
"weight": weight,
|
|
139
|
+
"return": float(annual_return),
|
|
140
|
+
"contribution": float(weight * annual_return),
|
|
141
|
+
}
|
|
142
|
+
return contribution
|
|
143
|
+
|
|
144
|
+
def get_metrics(self) -> list[str]:
|
|
145
|
+
"""获取可计算的指标列表"""
|
|
146
|
+
return [
|
|
147
|
+
"portfolio_return",
|
|
148
|
+
"portfolio_volatility",
|
|
149
|
+
"portfolio_sharpe",
|
|
150
|
+
"average_correlation",
|
|
151
|
+
"diversification_ratio",
|
|
152
|
+
]
|