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.
Files changed (66) hide show
  1. fund_cli/__init__.py +13 -0
  2. fund_cli/__main__.py +10 -0
  3. fund_cli/ai/__init__.py +21 -0
  4. fund_cli/ai/analyzer.py +360 -0
  5. fund_cli/ai/prompts.py +244 -0
  6. fund_cli/ai/providers.py +286 -0
  7. fund_cli/analysis/__init__.py +17 -0
  8. fund_cli/analysis/attribution.py +161 -0
  9. fund_cli/analysis/backtest.py +75 -0
  10. fund_cli/analysis/holding.py +217 -0
  11. fund_cli/analysis/manager.py +133 -0
  12. fund_cli/analysis/performance.py +440 -0
  13. fund_cli/analysis/portfolio.py +152 -0
  14. fund_cli/analysis/risk.py +300 -0
  15. fund_cli/cli.py +98 -0
  16. fund_cli/commands/__init__.py +9 -0
  17. fund_cli/commands/ai_cmd.py +464 -0
  18. fund_cli/commands/analyze_cmd.py +418 -0
  19. fund_cli/commands/compare_cmd.py +264 -0
  20. fund_cli/commands/config_cmd.py +97 -0
  21. fund_cli/commands/data_cmd.py +106 -0
  22. fund_cli/commands/filter_cmd.py +286 -0
  23. fund_cli/commands/holding_cmd.py +140 -0
  24. fund_cli/commands/interactive_cmd.py +84 -0
  25. fund_cli/commands/main.py +17 -0
  26. fund_cli/commands/manager_cmd.py +74 -0
  27. fund_cli/commands/monitor_cmd.py +113 -0
  28. fund_cli/commands/optimize_cmd.py +192 -0
  29. fund_cli/config.py +163 -0
  30. fund_cli/core/__init__.py +8 -0
  31. fund_cli/core/analyzer.py +46 -0
  32. fund_cli/core/data_manager.py +231 -0
  33. fund_cli/core/data_quality.py +162 -0
  34. fund_cli/core/monitor.py +230 -0
  35. fund_cli/core/optimizer.py +50 -0
  36. fund_cli/core/optimizers/__init__.py +13 -0
  37. fund_cli/core/optimizers/efficient_frontier.py +91 -0
  38. fund_cli/core/optimizers/max_sharpe.py +54 -0
  39. fund_cli/core/optimizers/mean_variance.py +84 -0
  40. fund_cli/core/optimizers/risk_parity.py +60 -0
  41. fund_cli/core/reporter.py +67 -0
  42. fund_cli/core/reporters/__init__.py +6 -0
  43. fund_cli/core/reporters/html_reporter.py +62 -0
  44. fund_cli/core/reporters/markdown_reporter.py +40 -0
  45. fund_cli/core/screener.py +142 -0
  46. fund_cli/data/__init__.py +6 -0
  47. fund_cli/data/adapters/__init__.py +7 -0
  48. fund_cli/data/adapters/akshare_adapter.py +442 -0
  49. fund_cli/data/adapters/tushare_adapter.py +254 -0
  50. fund_cli/data/adapters/wind_adapter.py +78 -0
  51. fund_cli/data/base.py +209 -0
  52. fund_cli/data/cache.py +192 -0
  53. fund_cli/data/models.py +248 -0
  54. fund_cli/utils/__init__.py +6 -0
  55. fund_cli/utils/decorators.py +88 -0
  56. fund_cli/utils/helpers.py +127 -0
  57. fund_cli/utils/validators.py +77 -0
  58. fund_cli/views/__init__.py +6 -0
  59. fund_cli/views/charts.py +120 -0
  60. fund_cli/views/reports.py +82 -0
  61. fund_cli/views/tables.py +124 -0
  62. fund_cli-2.0.0.dist-info/METADATA +183 -0
  63. fund_cli-2.0.0.dist-info/RECORD +66 -0
  64. fund_cli-2.0.0.dist-info/WHEEL +4 -0
  65. fund_cli-2.0.0.dist-info/entry_points.txt +3 -0
  66. 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