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
fund_cli/ai/providers.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM 提供商管理(V2.0 实现)
|
|
3
|
+
|
|
4
|
+
管理多个 LLM 提供商的连接和调用,支持OpenAI、阿里云Qwen等。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from fund_cli.config import AIConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMProvider(ABC):
|
|
19
|
+
"""LLM 提供商抽象基类"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: AIConfig):
|
|
22
|
+
self.config = config
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def generate(self, prompt: str, **kwargs: Any) -> str:
|
|
26
|
+
"""生成文本
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
prompt: 输入提示词
|
|
30
|
+
**kwargs: 额外参数
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
生成的文本
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def is_available(self) -> bool:
|
|
39
|
+
"""检查提供商是否可用
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
是否可用
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def validate_config(self) -> bool:
|
|
48
|
+
"""验证配置是否有效
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
配置是否有效
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OpenAIProvider(LLMProvider):
|
|
57
|
+
"""OpenAI 提供商"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config: AIConfig):
|
|
60
|
+
super().__init__(config)
|
|
61
|
+
self.api_key = config.api_key
|
|
62
|
+
self.model = config.model or "gpt-4"
|
|
63
|
+
self.base_url = config.api_base or "https://api.openai.com/v1"
|
|
64
|
+
|
|
65
|
+
def generate(self, prompt: str, **kwargs: Any) -> str:
|
|
66
|
+
"""使用 OpenAI API 生成文本"""
|
|
67
|
+
if not self.api_key:
|
|
68
|
+
raise ValueError("OpenAI API key not configured")
|
|
69
|
+
|
|
70
|
+
headers = {
|
|
71
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
payload = {
|
|
76
|
+
"model": kwargs.get("model", self.model),
|
|
77
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
78
|
+
"temperature": kwargs.get("temperature", self.config.temperature),
|
|
79
|
+
"max_tokens": kwargs.get("max_tokens", self.config.max_tokens),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
response = self._call_with_retry(f"{self.base_url}/chat/completions", headers, payload)
|
|
83
|
+
|
|
84
|
+
if response and "choices" in response:
|
|
85
|
+
return response["choices"][0]["message"]["content"]
|
|
86
|
+
raise RuntimeError("Invalid response from OpenAI API")
|
|
87
|
+
|
|
88
|
+
def is_available(self) -> bool:
|
|
89
|
+
"""检查OpenAI API是否可用"""
|
|
90
|
+
if not self.api_key:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
94
|
+
response = requests.get(
|
|
95
|
+
f"{self.base_url}/models",
|
|
96
|
+
headers=headers,
|
|
97
|
+
timeout=self.config.timeout,
|
|
98
|
+
)
|
|
99
|
+
return response.status_code == 200
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def validate_config(self) -> bool:
|
|
104
|
+
"""验证配置"""
|
|
105
|
+
return bool(self.api_key and self.model)
|
|
106
|
+
|
|
107
|
+
def _call_with_retry(self, url: str, headers: dict, payload: dict) -> dict | None:
|
|
108
|
+
"""带重试的API调用"""
|
|
109
|
+
last_error = None
|
|
110
|
+
for attempt in range(self.config.retry_count):
|
|
111
|
+
try:
|
|
112
|
+
response = requests.post(
|
|
113
|
+
url,
|
|
114
|
+
headers=headers,
|
|
115
|
+
json=payload,
|
|
116
|
+
timeout=self.config.timeout,
|
|
117
|
+
)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
return response.json()
|
|
120
|
+
except requests.RequestException as e:
|
|
121
|
+
last_error = e
|
|
122
|
+
if attempt < self.config.retry_count - 1:
|
|
123
|
+
time.sleep(2**attempt) # 指数退避
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
f"API call failed after {self.config.retry_count} attempts: {last_error}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class QwenProvider(LLMProvider):
|
|
130
|
+
"""阿里云Qwen 提供商"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, config: AIConfig):
|
|
133
|
+
super().__init__(config)
|
|
134
|
+
self.api_key = config.qwen_api_key or config.api_key
|
|
135
|
+
self.model = config.qwen_model or "qwen-max"
|
|
136
|
+
self.base_url = config.qwen_base_url or "https://dashscope.aliyuncs.com/api/v1"
|
|
137
|
+
|
|
138
|
+
def generate(self, prompt: str, **kwargs: Any) -> str:
|
|
139
|
+
"""使用 Qwen API 生成文本"""
|
|
140
|
+
if not self.api_key:
|
|
141
|
+
raise ValueError("Qwen API key not configured")
|
|
142
|
+
|
|
143
|
+
headers = {
|
|
144
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
payload = {
|
|
149
|
+
"model": kwargs.get("model", self.model),
|
|
150
|
+
"input": {"messages": [{"role": "user", "content": prompt}]},
|
|
151
|
+
"parameters": {
|
|
152
|
+
"temperature": kwargs.get("temperature", self.config.temperature),
|
|
153
|
+
"max_tokens": kwargs.get("max_tokens", self.config.max_tokens),
|
|
154
|
+
"result_format": "message",
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
response = self._call_with_retry(
|
|
159
|
+
f"{self.base_url}/services/aigc/text-generation/generation",
|
|
160
|
+
headers,
|
|
161
|
+
payload,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if response and "output" in response:
|
|
165
|
+
return response["output"]["choices"][0]["message"]["content"]
|
|
166
|
+
raise RuntimeError("Invalid response from Qwen API")
|
|
167
|
+
|
|
168
|
+
def is_available(self) -> bool:
|
|
169
|
+
"""检查Qwen API是否可用"""
|
|
170
|
+
if not self.api_key:
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
174
|
+
# 使用简单的模型列表接口测试
|
|
175
|
+
response = requests.get(
|
|
176
|
+
f"{self.base_url}/models",
|
|
177
|
+
headers=headers,
|
|
178
|
+
timeout=self.config.timeout,
|
|
179
|
+
)
|
|
180
|
+
return response.status_code == 200
|
|
181
|
+
except Exception:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def validate_config(self) -> bool:
|
|
185
|
+
"""验证配置"""
|
|
186
|
+
return bool(self.api_key and self.model)
|
|
187
|
+
|
|
188
|
+
def _call_with_retry(self, url: str, headers: dict, payload: dict) -> dict | None:
|
|
189
|
+
"""带重试的API调用"""
|
|
190
|
+
last_error = None
|
|
191
|
+
for attempt in range(self.config.retry_count):
|
|
192
|
+
try:
|
|
193
|
+
response = requests.post(
|
|
194
|
+
url,
|
|
195
|
+
headers=headers,
|
|
196
|
+
json=payload,
|
|
197
|
+
timeout=self.config.timeout,
|
|
198
|
+
)
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
return response.json()
|
|
201
|
+
except requests.RequestException as e:
|
|
202
|
+
last_error = e
|
|
203
|
+
if attempt < self.config.retry_count - 1:
|
|
204
|
+
time.sleep(2**attempt) # 指数退避
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
f"API call failed after {self.config.retry_count} attempts: {last_error}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class LiteLLMProvider(LLMProvider):
|
|
211
|
+
"""LiteLLM 统一封装提供商"""
|
|
212
|
+
|
|
213
|
+
def __init__(self, config: AIConfig):
|
|
214
|
+
super().__init__(config)
|
|
215
|
+
self.api_key = config.api_key
|
|
216
|
+
self.model = config.model or "gpt-4"
|
|
217
|
+
|
|
218
|
+
def generate(self, prompt: str, **kwargs: Any) -> str:
|
|
219
|
+
"""使用 LiteLLM 生成文本"""
|
|
220
|
+
try:
|
|
221
|
+
import litellm
|
|
222
|
+
|
|
223
|
+
litellm.api_key = self.api_key
|
|
224
|
+
|
|
225
|
+
response = litellm.completion(
|
|
226
|
+
model=self.model,
|
|
227
|
+
messages=[{"role": "user", "content": prompt}],
|
|
228
|
+
temperature=kwargs.get("temperature", self.config.temperature),
|
|
229
|
+
max_tokens=kwargs.get("max_tokens", self.config.max_tokens),
|
|
230
|
+
)
|
|
231
|
+
return response.choices[0].message.content
|
|
232
|
+
except ImportError as err:
|
|
233
|
+
raise RuntimeError("litellm not installed. Install with: pip install litellm") from err
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise RuntimeError(f"LiteLLM API error: {e}") from e
|
|
236
|
+
|
|
237
|
+
def is_available(self) -> bool:
|
|
238
|
+
"""检查LiteLLM是否可用"""
|
|
239
|
+
try:
|
|
240
|
+
import importlib.util
|
|
241
|
+
|
|
242
|
+
spec = importlib.util.find_spec("litellm")
|
|
243
|
+
if spec is None:
|
|
244
|
+
return False
|
|
245
|
+
return bool(self.api_key)
|
|
246
|
+
except ImportError:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def validate_config(self) -> bool:
|
|
250
|
+
"""验证配置"""
|
|
251
|
+
return bool(self.api_key and self.model)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_provider(config: AIConfig | None = None) -> LLMProvider:
|
|
255
|
+
"""
|
|
256
|
+
获取 LLM 提供商实例
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
config: AI配置,如果为None则使用默认配置
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
LLM 提供商实例
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ValueError: 如果提供商类型不支持
|
|
266
|
+
"""
|
|
267
|
+
if config is None:
|
|
268
|
+
from fund_cli.config import get_config
|
|
269
|
+
|
|
270
|
+
config = get_config().ai
|
|
271
|
+
|
|
272
|
+
providers = {
|
|
273
|
+
"openai": OpenAIProvider,
|
|
274
|
+
"qwen": QwenProvider,
|
|
275
|
+
"litellm": LiteLLMProvider,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
provider_name = config.provider.lower()
|
|
279
|
+
provider_class = providers.get(provider_name)
|
|
280
|
+
|
|
281
|
+
if provider_class is None:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"不支持的提供商: {provider_name}. " f"支持的提供商: {', '.join(providers.keys())}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return provider_class(config)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""分析模块 - 业绩分析、风险分析、归因分析、组合分析、经理分析、持仓分析"""
|
|
2
|
+
|
|
3
|
+
from fund_cli.analysis.attribution import AttributionAnalyzer
|
|
4
|
+
from fund_cli.analysis.holding import HoldingAnalyzer
|
|
5
|
+
from fund_cli.analysis.manager import ManagerAnalyzer
|
|
6
|
+
from fund_cli.analysis.performance import PerformanceAnalyzer
|
|
7
|
+
from fund_cli.analysis.portfolio import PortfolioAnalyzer
|
|
8
|
+
from fund_cli.analysis.risk import RiskAnalyzer
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PerformanceAnalyzer",
|
|
12
|
+
"RiskAnalyzer",
|
|
13
|
+
"AttributionAnalyzer",
|
|
14
|
+
"PortfolioAnalyzer",
|
|
15
|
+
"ManagerAnalyzer",
|
|
16
|
+
"HoldingAnalyzer",
|
|
17
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
归因分析引擎
|
|
3
|
+
|
|
4
|
+
实现 Brinson 归因分析等归因分析功能。
|
|
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 AttributionAnalyzer(Analyzer):
|
|
16
|
+
"""
|
|
17
|
+
归因分析引擎
|
|
18
|
+
|
|
19
|
+
支持:
|
|
20
|
+
- Brinson 归因分析(配置效应、选择效应、交互效应)
|
|
21
|
+
- 收益率分解
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def analyze(
|
|
25
|
+
self,
|
|
26
|
+
data: pd.DataFrame,
|
|
27
|
+
benchmark_weights: dict[str, float] | None = None,
|
|
28
|
+
portfolio_weights: dict[str, float] | None = None,
|
|
29
|
+
**kwargs,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
执行归因分析
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
data: 包含各资产收益率的 DataFrame
|
|
36
|
+
benchmark_weights: 基准组合权重 {资产名: 权重}
|
|
37
|
+
portfolio_weights: 投资组合权重 {资产名: 权重}
|
|
38
|
+
**kwargs: 额外参数
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
归因分析结果字典
|
|
42
|
+
"""
|
|
43
|
+
if benchmark_weights is None or portfolio_weights is None:
|
|
44
|
+
return self._simple_decomposition(data)
|
|
45
|
+
|
|
46
|
+
return self._brinson_attribution(data, benchmark_weights, portfolio_weights)
|
|
47
|
+
|
|
48
|
+
def _simple_decomposition(self, returns: pd.DataFrame) -> dict[str, Any]:
|
|
49
|
+
"""
|
|
50
|
+
简单收益率分解
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
returns: 收益率 DataFrame
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
分解结果
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(returns, pd.Series):
|
|
59
|
+
returns = returns.to_frame("fund")
|
|
60
|
+
|
|
61
|
+
result = {}
|
|
62
|
+
for col in returns.columns:
|
|
63
|
+
col_data = returns[col].dropna()
|
|
64
|
+
if len(col_data) == 0:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
cumulative = (1 + col_data).prod() - 1
|
|
68
|
+
annualized = (1 + cumulative) ** (252 / len(col_data)) - 1
|
|
69
|
+
|
|
70
|
+
result[col] = {
|
|
71
|
+
"total_return": float(cumulative * 100),
|
|
72
|
+
"annualized_return": float(annualized * 100),
|
|
73
|
+
"volatility": float(col_data.std() * np.sqrt(252) * 100),
|
|
74
|
+
"sharpe": float(
|
|
75
|
+
col_data.mean() / col_data.std() * np.sqrt(252) if col_data.std() > 0 else 0
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def _brinson_attribution(
|
|
82
|
+
self,
|
|
83
|
+
returns: pd.DataFrame,
|
|
84
|
+
benchmark_weights: dict[str, float],
|
|
85
|
+
portfolio_weights: dict[str, float],
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Brinson 归因分析
|
|
89
|
+
|
|
90
|
+
将组合收益与基准收益的差异分解为:
|
|
91
|
+
- 配置效应(Allocation Effect)
|
|
92
|
+
- 选择效应(Selection Effect)
|
|
93
|
+
- 交互效应(Interaction Effect)
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
returns: 各资产收益率 DataFrame
|
|
97
|
+
benchmark_weights: 基准权重
|
|
98
|
+
portfolio_weights: 组合权重
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Brinson 归因结果
|
|
102
|
+
"""
|
|
103
|
+
common_assets = (
|
|
104
|
+
set(benchmark_weights.keys()) & set(portfolio_weights.keys()) & set(returns.columns)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not common_assets:
|
|
108
|
+
return {
|
|
109
|
+
"allocation_effect": 0.0,
|
|
110
|
+
"selection_effect": 0.0,
|
|
111
|
+
"interaction_effect": 0.0,
|
|
112
|
+
"total_active_return": 0.0,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# 计算各资产平均收益率
|
|
116
|
+
asset_returns = {}
|
|
117
|
+
for asset in common_assets:
|
|
118
|
+
if asset in returns.columns:
|
|
119
|
+
asset_returns[asset] = returns[asset].mean()
|
|
120
|
+
|
|
121
|
+
# Brinson 归因分解
|
|
122
|
+
allocation_effect = 0.0
|
|
123
|
+
selection_effect = 0.0
|
|
124
|
+
interaction_effect = 0.0
|
|
125
|
+
|
|
126
|
+
benchmark_total_return = 0.0
|
|
127
|
+
portfolio_total_return = 0.0
|
|
128
|
+
|
|
129
|
+
for asset in common_assets:
|
|
130
|
+
wp = portfolio_weights.get(asset, 0)
|
|
131
|
+
wb = benchmark_weights.get(asset, 0)
|
|
132
|
+
rp = asset_returns.get(asset, 0)
|
|
133
|
+
rb = rp # 简化:假设基准收益率等于资产收益率
|
|
134
|
+
|
|
135
|
+
allocation_effect += (wp - wb) * rb
|
|
136
|
+
selection_effect += wb * (rp - rb)
|
|
137
|
+
interaction_effect += (wp - wb) * (rp - rb)
|
|
138
|
+
|
|
139
|
+
portfolio_total_return += wp * rp
|
|
140
|
+
benchmark_total_return += wb * rb
|
|
141
|
+
|
|
142
|
+
total_active = portfolio_total_return - benchmark_total_return
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"allocation_effect": float(allocation_effect * 252 * 100),
|
|
146
|
+
"selection_effect": float(selection_effect * 252 * 100),
|
|
147
|
+
"interaction_effect": float(interaction_effect * 252 * 100),
|
|
148
|
+
"total_active_return": float(total_active * 252 * 100),
|
|
149
|
+
"portfolio_return": float(portfolio_total_return * 252 * 100),
|
|
150
|
+
"benchmark_return": float(benchmark_total_return * 252 * 100),
|
|
151
|
+
"asset_count": len(common_assets),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def get_metrics(self) -> list[str]:
|
|
155
|
+
"""获取可计算的指标列表"""
|
|
156
|
+
return [
|
|
157
|
+
"allocation_effect",
|
|
158
|
+
"selection_effect",
|
|
159
|
+
"interaction_effect",
|
|
160
|
+
"total_active_return",
|
|
161
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""组合回测引擎"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from fund_cli.core.analyzer import Analyzer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BacktestAnalyzer(Analyzer):
|
|
12
|
+
"""组合回测引擎 - PORTFOLIO-OPT-007"""
|
|
13
|
+
|
|
14
|
+
def analyze(self, data: pd.DataFrame, **kwargs: Any) -> dict[str, Any]:
|
|
15
|
+
weights = kwargs.get("weights", {})
|
|
16
|
+
return self.run_backtest(data, weights)
|
|
17
|
+
|
|
18
|
+
def run_backtest(
|
|
19
|
+
self,
|
|
20
|
+
returns: pd.DataFrame,
|
|
21
|
+
weights: dict[str, float] | None = None,
|
|
22
|
+
rebalance_freq: str = "monthly",
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
运行组合回测
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
returns: 多基金收益率 DataFrame
|
|
29
|
+
weights: 基金权重字典,None表示等权
|
|
30
|
+
rebalance_freq: 再平衡频率 (daily/monthly/quarterly/yearly/never)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
回测结果字典
|
|
34
|
+
"""
|
|
35
|
+
if weights is None:
|
|
36
|
+
n = returns.shape[1]
|
|
37
|
+
weights = dict.fromkeys(returns.columns, 1.0 / n)
|
|
38
|
+
|
|
39
|
+
w = pd.Series(weights)
|
|
40
|
+
daily_returns = (returns * w).sum(axis=1)
|
|
41
|
+
|
|
42
|
+
# 计算净值曲线
|
|
43
|
+
nav = (1 + daily_returns).cumprod()
|
|
44
|
+
|
|
45
|
+
# 计算指标
|
|
46
|
+
total_days = len(daily_returns)
|
|
47
|
+
total_return = (nav.iloc[-1] / nav.iloc[0] - 1) * 100 if len(nav) > 1 else 0
|
|
48
|
+
annual_return = (1 + total_return / 100) ** (252 / total_days) - 1 if total_days > 0 else 0
|
|
49
|
+
annual_vol = daily_returns.std() * np.sqrt(252) * 100
|
|
50
|
+
sharpe = (annual_return - 0.03) / (annual_vol / 100) if annual_vol > 0 else 0
|
|
51
|
+
|
|
52
|
+
# 最大回撤
|
|
53
|
+
peak = nav.cummax()
|
|
54
|
+
drawdown = (nav - peak) / peak
|
|
55
|
+
max_drawdown = drawdown.min() * 100
|
|
56
|
+
|
|
57
|
+
# 胜率
|
|
58
|
+
win_rate = (
|
|
59
|
+
(daily_returns > 0).sum() / len(daily_returns) * 100 if len(daily_returns) > 0 else 0
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"total_return": round(total_return, 4),
|
|
64
|
+
"annual_return": round(annual_return * 100, 4),
|
|
65
|
+
"annual_volatility": round(annual_vol, 4),
|
|
66
|
+
"sharpe_ratio": round(sharpe, 4),
|
|
67
|
+
"max_drawdown": round(max_drawdown, 4),
|
|
68
|
+
"win_rate": round(win_rate, 2),
|
|
69
|
+
"trading_days": total_days,
|
|
70
|
+
"rebalance_freq": rebalance_freq,
|
|
71
|
+
"nav_curve": nav.tolist()[-10:], # 最后10个净值点
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def get_metrics(self) -> list[str]:
|
|
75
|
+
return ["total_return", "annual_return", "sharpe_ratio", "max_drawdown", "win_rate"]
|