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,7 @@
|
|
|
1
|
+
"""数据源适配器 - AKShare、Tushare、Wind"""
|
|
2
|
+
|
|
3
|
+
from fund_cli.data.adapters.akshare_adapter import AKShareAdapter
|
|
4
|
+
from fund_cli.data.adapters.tushare_adapter import TushareAdapter
|
|
5
|
+
from fund_cli.data.adapters.wind_adapter import WindAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = ["AKShareAdapter", "TushareAdapter", "WindAdapter"]
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AKShare 数据源适配器
|
|
3
|
+
|
|
4
|
+
基于 AKShare 开源库实现数据获取,支持基金信息、净值、筛选等功能。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from fund_cli.data.base import (
|
|
13
|
+
DataNotFoundError,
|
|
14
|
+
DataSourceAdapter,
|
|
15
|
+
DataSourceError,
|
|
16
|
+
)
|
|
17
|
+
from fund_cli.data.cache import DataCache
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AKShareAdapter(DataSourceAdapter):
|
|
21
|
+
"""
|
|
22
|
+
AKShare 数据源适配器
|
|
23
|
+
|
|
24
|
+
使用 AKShare 开源库获取基金数据,特点:
|
|
25
|
+
- 免费使用,无需Token
|
|
26
|
+
- 数据覆盖全面
|
|
27
|
+
- 支持实时数据
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, cache: DataCache | None = None):
|
|
31
|
+
"""
|
|
32
|
+
初始化 AKShare 适配器
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
cache: 缓存管理器,可选
|
|
36
|
+
"""
|
|
37
|
+
super().__init__("akshare")
|
|
38
|
+
self._cache = cache
|
|
39
|
+
self._ak = None
|
|
40
|
+
|
|
41
|
+
def _get_akshare(self):
|
|
42
|
+
"""延迟加载 AKShare"""
|
|
43
|
+
if self._ak is None:
|
|
44
|
+
try:
|
|
45
|
+
import akshare as ak
|
|
46
|
+
|
|
47
|
+
self._ak = ak
|
|
48
|
+
except ImportError as e:
|
|
49
|
+
raise DataSourceError("AKShare 未安装,请运行: pip install akshare") from e
|
|
50
|
+
return self._ak
|
|
51
|
+
|
|
52
|
+
def is_available(self) -> bool:
|
|
53
|
+
"""检查 AKShare 是否可用"""
|
|
54
|
+
try:
|
|
55
|
+
self._get_akshare()
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def get_fund_info(self, fund_code: str) -> dict[str, Any]:
|
|
61
|
+
"""
|
|
62
|
+
获取基金基础信息
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
fund_code: 基金代码
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
基金信息字典
|
|
69
|
+
"""
|
|
70
|
+
# 检查缓存
|
|
71
|
+
if self._cache:
|
|
72
|
+
cached = self._cache.get_fund_info(fund_code)
|
|
73
|
+
if cached:
|
|
74
|
+
return cached
|
|
75
|
+
|
|
76
|
+
ak = self._get_akshare()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# 获取基金基本信息
|
|
80
|
+
df = ak.fund_individual_basic_info_xq(symbol=fund_code)
|
|
81
|
+
|
|
82
|
+
if df.empty:
|
|
83
|
+
raise DataNotFoundError(f"基金 {fund_code} 不存在")
|
|
84
|
+
|
|
85
|
+
# 解析数据
|
|
86
|
+
info_dict = dict(zip(df["item"], df["value"], strict=False))
|
|
87
|
+
|
|
88
|
+
result = {
|
|
89
|
+
"code": fund_code,
|
|
90
|
+
"name": info_dict.get("基金简称", info_dict.get("基金全称", "")),
|
|
91
|
+
"type": info_dict.get("基金类型", "未知"),
|
|
92
|
+
"establish_date": self._parse_date(info_dict.get("成立日期")),
|
|
93
|
+
"manager": info_dict.get("基金经理", ""),
|
|
94
|
+
"company": info_dict.get("基金管理人", ""),
|
|
95
|
+
"scale": self._parse_scale(info_dict.get("基金规模")),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# 缓存结果
|
|
99
|
+
if self._cache:
|
|
100
|
+
self._cache.set_fund_info(fund_code, result)
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
except DataNotFoundError:
|
|
105
|
+
raise
|
|
106
|
+
except Exception as e:
|
|
107
|
+
raise DataSourceError(f"获取基金信息失败: {e}") from e
|
|
108
|
+
|
|
109
|
+
def get_fund_nav(
|
|
110
|
+
self,
|
|
111
|
+
fund_code: str,
|
|
112
|
+
start_date: date | None = None,
|
|
113
|
+
end_date: date | None = None,
|
|
114
|
+
) -> pd.DataFrame:
|
|
115
|
+
"""
|
|
116
|
+
获取基金净值数据
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
fund_code: 基金代码
|
|
120
|
+
start_date: 开始日期
|
|
121
|
+
end_date: 结束日期
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
净值数据 DataFrame
|
|
125
|
+
"""
|
|
126
|
+
# 格式化日期
|
|
127
|
+
start_str = start_date.strftime("%Y%m%d") if start_date else "19900101"
|
|
128
|
+
end_str = end_date.strftime("%Y%m%d") if end_date else datetime.now().strftime("%Y%m%d")
|
|
129
|
+
|
|
130
|
+
# 检查缓存
|
|
131
|
+
if self._cache:
|
|
132
|
+
cached = self._cache.get_fund_nav(fund_code, start_str, end_str)
|
|
133
|
+
if cached is not None:
|
|
134
|
+
return cached
|
|
135
|
+
|
|
136
|
+
ak = self._get_akshare()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# 获取开放式基金净值数据
|
|
140
|
+
df = ak.fund_open_fund_info_em(fund=fund_code, indicator="单位净值走势")
|
|
141
|
+
|
|
142
|
+
if df.empty:
|
|
143
|
+
raise DataNotFoundError(f"基金 {fund_code} 净值数据不存在")
|
|
144
|
+
|
|
145
|
+
# 标准化列名
|
|
146
|
+
df = df.rename(
|
|
147
|
+
columns={
|
|
148
|
+
"净值日期": "nav_date",
|
|
149
|
+
"单位净值": "unit_nav",
|
|
150
|
+
"日增长率": "daily_return",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# 处理日期
|
|
155
|
+
df["nav_date"] = pd.to_datetime(df["nav_date"])
|
|
156
|
+
|
|
157
|
+
# 筛选日期范围
|
|
158
|
+
if start_date:
|
|
159
|
+
df = df[df["nav_date"] >= pd.Timestamp(start_date)]
|
|
160
|
+
if end_date:
|
|
161
|
+
df = df[df["nav_date"] <= pd.Timestamp(end_date)]
|
|
162
|
+
|
|
163
|
+
# 添加基金代码
|
|
164
|
+
df["fund_code"] = fund_code
|
|
165
|
+
|
|
166
|
+
# 计算累计净值(如果没有)
|
|
167
|
+
if "accumulated_nav" not in df.columns:
|
|
168
|
+
df["accumulated_nav"] = df["unit_nav"]
|
|
169
|
+
|
|
170
|
+
# 选择输出列
|
|
171
|
+
result_df = df[
|
|
172
|
+
["fund_code", "nav_date", "unit_nav", "accumulated_nav", "daily_return"]
|
|
173
|
+
].copy()
|
|
174
|
+
result_df = result_df.sort_values("nav_date").reset_index(drop=True)
|
|
175
|
+
|
|
176
|
+
# 缓存结果
|
|
177
|
+
if self._cache:
|
|
178
|
+
self._cache.set_fund_nav(fund_code, start_str, end_str, result_df)
|
|
179
|
+
|
|
180
|
+
return result_df
|
|
181
|
+
|
|
182
|
+
except DataNotFoundError:
|
|
183
|
+
raise
|
|
184
|
+
except Exception as e:
|
|
185
|
+
raise DataSourceError(f"获取基金净值失败: {e}") from e
|
|
186
|
+
|
|
187
|
+
def search_funds(
|
|
188
|
+
self,
|
|
189
|
+
fund_type: str | None = None,
|
|
190
|
+
company: str | None = None,
|
|
191
|
+
min_scale: float | None = None,
|
|
192
|
+
max_scale: float | None = None,
|
|
193
|
+
keyword: str | None = None,
|
|
194
|
+
limit: int = 100,
|
|
195
|
+
) -> pd.DataFrame:
|
|
196
|
+
"""
|
|
197
|
+
搜索/筛选基金
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
fund_type: 基金类型
|
|
201
|
+
company: 基金公司
|
|
202
|
+
min_scale: 最小规模
|
|
203
|
+
max_scale: 最大规模
|
|
204
|
+
keyword: 关键词
|
|
205
|
+
limit: 返回数量限制
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
基金列表 DataFrame
|
|
209
|
+
"""
|
|
210
|
+
ak = self._get_akshare()
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# 获取全部开放式基金列表
|
|
214
|
+
df = ak.fund_open_fund_daily_em()
|
|
215
|
+
|
|
216
|
+
# 筛选条件
|
|
217
|
+
if fund_type:
|
|
218
|
+
df = df[df["基金类型"].str.contains(fund_type, na=False)]
|
|
219
|
+
|
|
220
|
+
if company:
|
|
221
|
+
df = df[df["基金公司"].str.contains(company, na=False)]
|
|
222
|
+
|
|
223
|
+
if keyword:
|
|
224
|
+
mask = df["基金代码"].str.contains(keyword, na=False) | df["基金简称"].str.contains(
|
|
225
|
+
keyword, na=False
|
|
226
|
+
)
|
|
227
|
+
df = df[mask]
|
|
228
|
+
|
|
229
|
+
# 规模筛选
|
|
230
|
+
if min_scale is not None:
|
|
231
|
+
df = df[df["基金规模"] >= min_scale]
|
|
232
|
+
if max_scale is not None:
|
|
233
|
+
df = df[df["基金规模"] <= max_scale]
|
|
234
|
+
|
|
235
|
+
# 限制返回数量
|
|
236
|
+
df = df.head(limit)
|
|
237
|
+
|
|
238
|
+
# 标准化列名
|
|
239
|
+
df = df.rename(
|
|
240
|
+
columns={
|
|
241
|
+
"基金代码": "code",
|
|
242
|
+
"基金简称": "name",
|
|
243
|
+
"基金类型": "type",
|
|
244
|
+
"基金规模": "scale",
|
|
245
|
+
"基金公司": "company",
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return df.reset_index(drop=True)
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
raise DataSourceError(f"搜索基金失败: {e}") from e
|
|
253
|
+
|
|
254
|
+
def get_fund_list(self, fund_type: str | None = None) -> pd.DataFrame:
|
|
255
|
+
"""
|
|
256
|
+
获取基金列表
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
fund_type: 基金类型筛选
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
基金列表 DataFrame
|
|
263
|
+
"""
|
|
264
|
+
return self.search_funds(fund_type=fund_type)
|
|
265
|
+
|
|
266
|
+
def get_benchmark_nav(
|
|
267
|
+
self,
|
|
268
|
+
benchmark_code: str,
|
|
269
|
+
start_date: date | None = None,
|
|
270
|
+
end_date: date | None = None,
|
|
271
|
+
) -> pd.DataFrame:
|
|
272
|
+
"""
|
|
273
|
+
获取基准指数数据
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
benchmark_code: 基准指数代码
|
|
277
|
+
start_date: 开始日期
|
|
278
|
+
end_date: 结束日期
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
基准数据 DataFrame
|
|
282
|
+
"""
|
|
283
|
+
ak = self._get_akshare()
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
# 获取指数数据
|
|
287
|
+
df = ak.stock_zh_index_daily(symbol=f"sh{benchmark_code}")
|
|
288
|
+
|
|
289
|
+
if df.empty:
|
|
290
|
+
raise DataNotFoundError(f"指数 {benchmark_code} 不存在")
|
|
291
|
+
|
|
292
|
+
# 标准化列名
|
|
293
|
+
df = df.rename(
|
|
294
|
+
columns={
|
|
295
|
+
"date": "nav_date",
|
|
296
|
+
"close": "unit_nav",
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 处理日期
|
|
301
|
+
df["nav_date"] = pd.to_datetime(df["nav_date"])
|
|
302
|
+
|
|
303
|
+
# 筛选日期范围
|
|
304
|
+
if start_date:
|
|
305
|
+
df = df[df["nav_date"] >= pd.Timestamp(start_date)]
|
|
306
|
+
if end_date:
|
|
307
|
+
df = df[df["nav_date"] <= pd.Timestamp(end_date)]
|
|
308
|
+
|
|
309
|
+
# 计算收益率
|
|
310
|
+
df["daily_return"] = df["unit_nav"].pct_change() * 100
|
|
311
|
+
df["fund_code"] = benchmark_code
|
|
312
|
+
df["accumulated_nav"] = df["unit_nav"]
|
|
313
|
+
|
|
314
|
+
result_df = df[
|
|
315
|
+
["fund_code", "nav_date", "unit_nav", "accumulated_nav", "daily_return"]
|
|
316
|
+
].copy()
|
|
317
|
+
return result_df.sort_values("nav_date").reset_index(drop=True)
|
|
318
|
+
|
|
319
|
+
except DataNotFoundError:
|
|
320
|
+
raise
|
|
321
|
+
except Exception as e:
|
|
322
|
+
raise DataSourceError(f"获取基准数据失败: {e}") from e
|
|
323
|
+
|
|
324
|
+
def get_fund_holdings(
|
|
325
|
+
self,
|
|
326
|
+
fund_code: str,
|
|
327
|
+
report_date: date | None = None,
|
|
328
|
+
) -> pd.DataFrame:
|
|
329
|
+
"""获取基金持仓数据"""
|
|
330
|
+
cache_key = f"holdings:{fund_code}:{report_date or 'latest'}"
|
|
331
|
+
if self._cache and self._cache.exists(cache_key):
|
|
332
|
+
return self._cache.get(cache_key)
|
|
333
|
+
|
|
334
|
+
ak = self._get_akshare()
|
|
335
|
+
try:
|
|
336
|
+
df = ak.fund_portfolio_hold_em(symbol=fund_code, date=report_date)
|
|
337
|
+
if df.empty:
|
|
338
|
+
raise DataNotFoundError(f"基金 {fund_code} 持仓数据不存在")
|
|
339
|
+
df = df.rename(
|
|
340
|
+
columns={
|
|
341
|
+
"季度": "report_date",
|
|
342
|
+
"股票代码": "stock_code",
|
|
343
|
+
"股票名称": "stock_name",
|
|
344
|
+
"占净值比例": "weight",
|
|
345
|
+
"持股数": "holdings_count",
|
|
346
|
+
"持仓市值": "market_value",
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
if "fund_code" not in df.columns:
|
|
350
|
+
df["fund_code"] = fund_code
|
|
351
|
+
result = df.sort_values("weight", ascending=False).reset_index(drop=True)
|
|
352
|
+
if self._cache:
|
|
353
|
+
self._cache.set(cache_key, result, ttl=86400)
|
|
354
|
+
return result
|
|
355
|
+
except DataNotFoundError:
|
|
356
|
+
raise
|
|
357
|
+
except Exception as e:
|
|
358
|
+
raise DataSourceError(f"获取持仓数据失败: {e}") from e
|
|
359
|
+
|
|
360
|
+
def get_fund_manager(self, fund_code: str) -> dict[str, Any]:
|
|
361
|
+
"""获取基金经理信息"""
|
|
362
|
+
info = self.get_fund_info(fund_code)
|
|
363
|
+
return {
|
|
364
|
+
"name": info.get("manager", ""),
|
|
365
|
+
"fund_code": fund_code,
|
|
366
|
+
"fund_name": info.get("name", ""),
|
|
367
|
+
"company": info.get("company", ""),
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
def get_fund_fee(self, fund_code: str) -> dict[str, Any]:
|
|
371
|
+
"""获取基金费率信息"""
|
|
372
|
+
ak = self._get_akshare()
|
|
373
|
+
try:
|
|
374
|
+
df = ak.fund_individual_detail_info_xq(symbol=fund_code)
|
|
375
|
+
if df.empty:
|
|
376
|
+
raise DataNotFoundError(f"基金 {fund_code} 费率信息不存在")
|
|
377
|
+
info_dict = dict(zip(df["item"], df["value"], strict=False))
|
|
378
|
+
return {
|
|
379
|
+
"management_fee": info_dict.get("管理费率", ""),
|
|
380
|
+
"custody_fee": info_dict.get("托管费率", ""),
|
|
381
|
+
"purchase_fee": info_dict.get("申购费率", ""),
|
|
382
|
+
"redeem_fee": info_dict.get("赎回费率", ""),
|
|
383
|
+
}
|
|
384
|
+
except DataNotFoundError:
|
|
385
|
+
raise
|
|
386
|
+
except Exception as e:
|
|
387
|
+
raise DataSourceError(f"获取费率信息失败: {e}") from e
|
|
388
|
+
|
|
389
|
+
def get_fund_rating(self, fund_code: str) -> int | None:
|
|
390
|
+
"""获取基金评级"""
|
|
391
|
+
ak = self._get_akshare()
|
|
392
|
+
try:
|
|
393
|
+
df = ak.fund_individual_detail_info_xq(symbol=fund_code)
|
|
394
|
+
if df.empty:
|
|
395
|
+
return None
|
|
396
|
+
info_dict = dict(zip(df["item"], df["value"], strict=False))
|
|
397
|
+
rating_str = info_dict.get("基金评级", "")
|
|
398
|
+
if not rating_str:
|
|
399
|
+
return None
|
|
400
|
+
import re
|
|
401
|
+
|
|
402
|
+
match = re.search(r"(\d+)", str(rating_str))
|
|
403
|
+
return int(match.group(1)) if match else None
|
|
404
|
+
except Exception:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
def batch_get_fund_nav(
|
|
408
|
+
self,
|
|
409
|
+
fund_codes: list[str],
|
|
410
|
+
start_date: date | None = None,
|
|
411
|
+
end_date: date | None = None,
|
|
412
|
+
) -> dict[str, pd.DataFrame]:
|
|
413
|
+
"""批量获取基金净值数据"""
|
|
414
|
+
results = {}
|
|
415
|
+
for code in fund_codes:
|
|
416
|
+
try:
|
|
417
|
+
results[code] = self.get_fund_nav(code, start_date, end_date)
|
|
418
|
+
except Exception:
|
|
419
|
+
results[code] = pd.DataFrame()
|
|
420
|
+
return results
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _parse_date(date_str: str | None) -> date | None:
|
|
424
|
+
"""解析日期字符串"""
|
|
425
|
+
if not date_str:
|
|
426
|
+
return None
|
|
427
|
+
try:
|
|
428
|
+
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
429
|
+
except (ValueError, TypeError):
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _parse_scale(scale_str: str | None) -> float | None:
|
|
434
|
+
"""解析规模字符串"""
|
|
435
|
+
if not scale_str:
|
|
436
|
+
return None
|
|
437
|
+
try:
|
|
438
|
+
# 移除单位并转换为浮点数
|
|
439
|
+
scale_str = str(scale_str).replace("亿份", "").replace("亿元", "").strip()
|
|
440
|
+
return float(scale_str)
|
|
441
|
+
except (ValueError, TypeError):
|
|
442
|
+
return None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tushare 数据源适配器
|
|
3
|
+
|
|
4
|
+
基于 Tushare Pro API 实现数据获取。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from fund_cli.config import get_config
|
|
12
|
+
from fund_cli.data.base import (
|
|
13
|
+
DataNotFoundError,
|
|
14
|
+
DataSourceAdapter,
|
|
15
|
+
DataSourceError,
|
|
16
|
+
)
|
|
17
|
+
from fund_cli.data.cache import DataCache
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TushareAdapter(DataSourceAdapter):
|
|
21
|
+
"""
|
|
22
|
+
Tushare 数据源适配器
|
|
23
|
+
|
|
24
|
+
使用 Tushare Pro API 获取基金数据,特点:
|
|
25
|
+
- 数据质量高
|
|
26
|
+
- 需要注册获取 Token
|
|
27
|
+
- 部分接口需要积分
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, cache: DataCache | None = None):
|
|
31
|
+
"""
|
|
32
|
+
初始化 Tushare 适配器
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
cache: 缓存管理器,可选
|
|
36
|
+
"""
|
|
37
|
+
super().__init__("tushare")
|
|
38
|
+
self._cache = cache
|
|
39
|
+
self._ts = None
|
|
40
|
+
self._token: str | None = None
|
|
41
|
+
|
|
42
|
+
def _get_tushare(self):
|
|
43
|
+
"""延迟加载 Tushare"""
|
|
44
|
+
if self._ts is None:
|
|
45
|
+
try:
|
|
46
|
+
import tushare as ts
|
|
47
|
+
|
|
48
|
+
config = get_config()
|
|
49
|
+
self._token = config.data.tushare_token
|
|
50
|
+
if not self._token:
|
|
51
|
+
raise DataSourceError(
|
|
52
|
+
"Tushare Token 未配置,请在 .env 中设置 FUND_DATA_TUSHARE_TOKEN"
|
|
53
|
+
)
|
|
54
|
+
ts.set_token(self._token)
|
|
55
|
+
self._ts = ts.pro_api()
|
|
56
|
+
except ImportError as e:
|
|
57
|
+
raise DataSourceError("Tushare 未安装,请运行: pip install tushare") from e
|
|
58
|
+
return self._ts
|
|
59
|
+
|
|
60
|
+
def is_available(self) -> bool:
|
|
61
|
+
"""检查 Tushare 是否可用"""
|
|
62
|
+
try:
|
|
63
|
+
config = get_config()
|
|
64
|
+
return bool(config.data.tushare_token)
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def get_fund_info(self, fund_code: str) -> dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
获取基金基础信息
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
fund_code: 基金代码
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
基金信息字典
|
|
77
|
+
"""
|
|
78
|
+
if self._cache:
|
|
79
|
+
cached = self._cache.get_fund_info(fund_code)
|
|
80
|
+
if cached:
|
|
81
|
+
return cached
|
|
82
|
+
|
|
83
|
+
ts = self._get_tushare()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
df = ts.fund_basic(ts_code=f"{fund_code}.OF")
|
|
87
|
+
|
|
88
|
+
if df.empty:
|
|
89
|
+
raise DataNotFoundError(f"基金 {fund_code} 不存在")
|
|
90
|
+
|
|
91
|
+
row = df.iloc[0]
|
|
92
|
+
|
|
93
|
+
result = {
|
|
94
|
+
"code": fund_code,
|
|
95
|
+
"name": row.get("name", ""),
|
|
96
|
+
"type": row.get("fund_type", "未知"),
|
|
97
|
+
"establish_date": row.get("found_date", None),
|
|
98
|
+
"manager": row.get("manager", ""),
|
|
99
|
+
"company": row.get("management", ""),
|
|
100
|
+
"scale": None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if self._cache:
|
|
104
|
+
self._cache.set_fund_info(fund_code, result)
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
except DataNotFoundError:
|
|
109
|
+
raise
|
|
110
|
+
except Exception as e:
|
|
111
|
+
raise DataSourceError(f"获取基金信息失败: {e}") from e
|
|
112
|
+
|
|
113
|
+
def get_fund_nav(
|
|
114
|
+
self,
|
|
115
|
+
fund_code: str,
|
|
116
|
+
start_date: Any | None = None,
|
|
117
|
+
end_date: Any | None = None,
|
|
118
|
+
) -> pd.DataFrame:
|
|
119
|
+
"""
|
|
120
|
+
获取基金净值数据
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
fund_code: 基金代码
|
|
124
|
+
start_date: 开始日期
|
|
125
|
+
end_date: 结束日期
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
净值数据 DataFrame
|
|
129
|
+
"""
|
|
130
|
+
ts = self._get_tushare()
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
df = ts.fund_nav(
|
|
134
|
+
ts_code=f"{fund_code}.OF",
|
|
135
|
+
start_date=start_date.strftime("%Y%m%d") if start_date else None,
|
|
136
|
+
end_date=end_date.strftime("%Y%m%d") if end_date else None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if df.empty:
|
|
140
|
+
raise DataNotFoundError(f"基金 {fund_code} 净值数据不存在")
|
|
141
|
+
|
|
142
|
+
df = df.rename(
|
|
143
|
+
columns={
|
|
144
|
+
"end_date": "nav_date",
|
|
145
|
+
"unit_nav": "unit_nav",
|
|
146
|
+
"accum_nav": "accumulated_nav",
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
df["nav_date"] = pd.to_datetime(df["nav_date"])
|
|
151
|
+
df["fund_code"] = fund_code
|
|
152
|
+
|
|
153
|
+
if "daily_return" not in df.columns:
|
|
154
|
+
df["daily_return"] = df["unit_nav"].pct_change() * 100
|
|
155
|
+
|
|
156
|
+
result_df = df[
|
|
157
|
+
["fund_code", "nav_date", "unit_nav", "accumulated_nav", "daily_return"]
|
|
158
|
+
].copy()
|
|
159
|
+
return result_df.sort_values("nav_date").reset_index(drop=True)
|
|
160
|
+
|
|
161
|
+
except DataNotFoundError:
|
|
162
|
+
raise
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise DataSourceError(f"获取基金净值失败: {e}") from e
|
|
165
|
+
|
|
166
|
+
def search_funds(
|
|
167
|
+
self,
|
|
168
|
+
fund_type: str | None = None,
|
|
169
|
+
company: str | None = None,
|
|
170
|
+
min_scale: float | None = None,
|
|
171
|
+
max_scale: float | None = None,
|
|
172
|
+
keyword: str | None = None,
|
|
173
|
+
limit: int = 100,
|
|
174
|
+
) -> pd.DataFrame:
|
|
175
|
+
"""搜索基金"""
|
|
176
|
+
ts = self._get_tushare()
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
df = ts.fund_basic(market="O")
|
|
180
|
+
|
|
181
|
+
if fund_type:
|
|
182
|
+
df = df[df["fund_type"].str.contains(fund_type, na=False)]
|
|
183
|
+
if company:
|
|
184
|
+
df = df[df["management"].str.contains(company, na=False)]
|
|
185
|
+
if keyword:
|
|
186
|
+
mask = df["ts_code"].str.contains(keyword, na=False) | df["name"].str.contains(
|
|
187
|
+
keyword, na=False
|
|
188
|
+
)
|
|
189
|
+
df = df[mask]
|
|
190
|
+
|
|
191
|
+
df = df.head(limit)
|
|
192
|
+
|
|
193
|
+
df = df.rename(
|
|
194
|
+
columns={
|
|
195
|
+
"ts_code": "code",
|
|
196
|
+
"name": "name",
|
|
197
|
+
"fund_type": "type",
|
|
198
|
+
"management": "company",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return df.reset_index(drop=True)
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
raise DataSourceError(f"搜索基金失败: {e}") from e
|
|
206
|
+
|
|
207
|
+
def get_fund_list(self, fund_type: str | None = None) -> pd.DataFrame:
|
|
208
|
+
"""获取基金列表"""
|
|
209
|
+
return self.search_funds(fund_type=fund_type)
|
|
210
|
+
|
|
211
|
+
def get_benchmark_nav(
|
|
212
|
+
self,
|
|
213
|
+
benchmark_code: str,
|
|
214
|
+
start_date: Any | None = None,
|
|
215
|
+
end_date: Any | None = None,
|
|
216
|
+
) -> pd.DataFrame:
|
|
217
|
+
"""获取基准指数数据"""
|
|
218
|
+
ts = self._get_tushare()
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
ts_code = (
|
|
222
|
+
f"{benchmark_code}.SH" if benchmark_code.startswith("0") else f"{benchmark_code}.SZ"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
df = ts.index_daily(
|
|
226
|
+
ts_code=ts_code,
|
|
227
|
+
start_date=start_date.strftime("%Y%m%d") if start_date else None,
|
|
228
|
+
end_date=end_date.strftime("%Y%m%d") if end_date else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if df.empty:
|
|
232
|
+
raise DataNotFoundError(f"指数 {benchmark_code} 不存在")
|
|
233
|
+
|
|
234
|
+
df = df.rename(
|
|
235
|
+
columns={
|
|
236
|
+
"trade_date": "nav_date",
|
|
237
|
+
"close": "unit_nav",
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
df["nav_date"] = pd.to_datetime(df["nav_date"])
|
|
242
|
+
df["daily_return"] = df["unit_nav"].pct_change() * 100
|
|
243
|
+
df["fund_code"] = benchmark_code
|
|
244
|
+
df["accumulated_nav"] = df["unit_nav"]
|
|
245
|
+
|
|
246
|
+
result_df = df[
|
|
247
|
+
["fund_code", "nav_date", "unit_nav", "accumulated_nav", "daily_return"]
|
|
248
|
+
].copy()
|
|
249
|
+
return result_df.sort_values("nav_date").reset_index(drop=True)
|
|
250
|
+
|
|
251
|
+
except DataNotFoundError:
|
|
252
|
+
raise
|
|
253
|
+
except Exception as e:
|
|
254
|
+
raise DataSourceError(f"获取基准数据失败: {e}") from e
|