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,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
分析引擎基类
|
|
3
|
+
|
|
4
|
+
定义分析引擎的标准接口。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Analyzer(ABC):
|
|
14
|
+
"""
|
|
15
|
+
分析引擎基类
|
|
16
|
+
|
|
17
|
+
所有分析引擎必须继承此类。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def analyze(
|
|
22
|
+
self,
|
|
23
|
+
data: pd.DataFrame,
|
|
24
|
+
**kwargs,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
执行分析
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: 输入数据
|
|
31
|
+
**kwargs: 额外参数
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
分析结果字典
|
|
35
|
+
"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_metrics(self) -> list:
|
|
40
|
+
"""
|
|
41
|
+
获取可计算的指标列表
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
指标名称列表
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
数据管理器
|
|
3
|
+
|
|
4
|
+
统一管理数据源,提供数据访问接口。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import date
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from fund_cli.config import get_config
|
|
13
|
+
from fund_cli.data.adapters.akshare_adapter import AKShareAdapter
|
|
14
|
+
from fund_cli.data.base import DataSourceAdapter, DataSourceError
|
|
15
|
+
from fund_cli.data.cache import DataCache
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataManager:
|
|
19
|
+
"""
|
|
20
|
+
数据管理器
|
|
21
|
+
|
|
22
|
+
统一管理多个数据源,提供:
|
|
23
|
+
- 自动数据源选择
|
|
24
|
+
- 数据缓存
|
|
25
|
+
- 统一的数据访问接口
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
cache: DataCache | None = None,
|
|
31
|
+
primary_source: str = "akshare",
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
初始化数据管理器
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
cache: 缓存管理器
|
|
38
|
+
primary_source: 主数据源名称
|
|
39
|
+
"""
|
|
40
|
+
self.config = get_config()
|
|
41
|
+
self._cache = cache or DataCache(
|
|
42
|
+
cache_dir=self.config.data.cache_dir,
|
|
43
|
+
default_ttl=self.config.data.cache_ttl,
|
|
44
|
+
)
|
|
45
|
+
self._primary_source = primary_source
|
|
46
|
+
self._adapters: dict[str, DataSourceAdapter] = {}
|
|
47
|
+
|
|
48
|
+
# 初始化数据源
|
|
49
|
+
self._init_adapters()
|
|
50
|
+
|
|
51
|
+
def _init_adapters(self) -> None:
|
|
52
|
+
"""初始化数据源适配器"""
|
|
53
|
+
# AKShare(默认数据源)
|
|
54
|
+
if self.config.data.akshare_enabled:
|
|
55
|
+
self._adapters["akshare"] = AKShareAdapter(cache=self._cache)
|
|
56
|
+
|
|
57
|
+
def get_adapter(self, source: str | None = None) -> DataSourceAdapter:
|
|
58
|
+
"""
|
|
59
|
+
获取数据源适配器
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
source: 数据源名称,默认使用主数据源
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
数据源适配器实例
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
DataSourceError: 数据源不可用
|
|
69
|
+
"""
|
|
70
|
+
source_name = source or self._primary_source
|
|
71
|
+
|
|
72
|
+
if source_name not in self._adapters:
|
|
73
|
+
raise DataSourceError(f"数据源 {source_name} 未配置或不可用")
|
|
74
|
+
|
|
75
|
+
adapter = self._adapters[source_name]
|
|
76
|
+
|
|
77
|
+
if not adapter.is_available():
|
|
78
|
+
raise DataSourceError(f"数据源 {source_name} 不可用")
|
|
79
|
+
|
|
80
|
+
return adapter
|
|
81
|
+
|
|
82
|
+
# ========== 基金数据接口 ==========
|
|
83
|
+
|
|
84
|
+
def get_fund_info(self, fund_code: str) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
获取基金基础信息
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
fund_code: 基金代码
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
基金信息字典
|
|
93
|
+
"""
|
|
94
|
+
return self.get_adapter().get_fund_info(fund_code)
|
|
95
|
+
|
|
96
|
+
def get_fund_nav(
|
|
97
|
+
self,
|
|
98
|
+
fund_code: str,
|
|
99
|
+
start_date: date | None = None,
|
|
100
|
+
end_date: date | None = None,
|
|
101
|
+
) -> pd.DataFrame:
|
|
102
|
+
"""
|
|
103
|
+
获取基金净值数据
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
fund_code: 基金代码
|
|
107
|
+
start_date: 开始日期
|
|
108
|
+
end_date: 结束日期
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
净值数据 DataFrame
|
|
112
|
+
"""
|
|
113
|
+
return self.get_adapter().get_fund_nav(fund_code, start_date, end_date)
|
|
114
|
+
|
|
115
|
+
def search_funds(
|
|
116
|
+
self,
|
|
117
|
+
fund_type: str | None = None,
|
|
118
|
+
company: str | None = None,
|
|
119
|
+
min_scale: float | None = None,
|
|
120
|
+
max_scale: float | None = None,
|
|
121
|
+
keyword: str | None = None,
|
|
122
|
+
limit: int = 100,
|
|
123
|
+
) -> pd.DataFrame:
|
|
124
|
+
"""
|
|
125
|
+
搜索基金
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
fund_type: 基金类型
|
|
129
|
+
company: 基金公司
|
|
130
|
+
min_scale: 最小规模
|
|
131
|
+
max_scale: 最大规模
|
|
132
|
+
keyword: 关键词
|
|
133
|
+
limit: 返回数量限制
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
基金列表 DataFrame
|
|
137
|
+
"""
|
|
138
|
+
return self.get_adapter().search_funds(
|
|
139
|
+
fund_type=fund_type,
|
|
140
|
+
company=company,
|
|
141
|
+
min_scale=min_scale,
|
|
142
|
+
max_scale=max_scale,
|
|
143
|
+
keyword=keyword,
|
|
144
|
+
limit=limit,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def get_fund_list(self, fund_type: str | None = None) -> pd.DataFrame:
|
|
148
|
+
"""
|
|
149
|
+
获取基金列表
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
fund_type: 基金类型筛选
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
基金列表 DataFrame
|
|
156
|
+
"""
|
|
157
|
+
return self.get_adapter().get_fund_list(fund_type)
|
|
158
|
+
|
|
159
|
+
def get_benchmark_nav(
|
|
160
|
+
self,
|
|
161
|
+
benchmark_code: str,
|
|
162
|
+
start_date: date | None = None,
|
|
163
|
+
end_date: date | None = None,
|
|
164
|
+
) -> pd.DataFrame:
|
|
165
|
+
"""
|
|
166
|
+
获取基准指数数据
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
benchmark_code: 基准指数代码
|
|
170
|
+
start_date: 开始日期
|
|
171
|
+
end_date: 结束日期
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
基准数据 DataFrame
|
|
175
|
+
"""
|
|
176
|
+
return self.get_adapter().get_benchmark_nav(benchmark_code, start_date, end_date)
|
|
177
|
+
|
|
178
|
+
def get_fund_holdings(
|
|
179
|
+
self,
|
|
180
|
+
fund_code: str,
|
|
181
|
+
report_date: date | None = None,
|
|
182
|
+
) -> pd.DataFrame:
|
|
183
|
+
"""获取基金持仓数据"""
|
|
184
|
+
return self.get_adapter().get_fund_holdings(fund_code, report_date)
|
|
185
|
+
|
|
186
|
+
def get_fund_manager(self, fund_code: str) -> dict[str, Any]:
|
|
187
|
+
"""获取基金经理信息"""
|
|
188
|
+
return self.get_adapter().get_fund_manager(fund_code)
|
|
189
|
+
|
|
190
|
+
def get_fund_fee(self, fund_code: str) -> dict[str, Any]:
|
|
191
|
+
"""获取基金费率信息"""
|
|
192
|
+
return self.get_adapter().get_fund_fee(fund_code)
|
|
193
|
+
|
|
194
|
+
def get_fund_rating(self, fund_code: str) -> int | None:
|
|
195
|
+
"""获取基金评级"""
|
|
196
|
+
return self.get_adapter().get_fund_rating(fund_code)
|
|
197
|
+
|
|
198
|
+
def batch_get_fund_nav(
|
|
199
|
+
self,
|
|
200
|
+
fund_codes: list[str],
|
|
201
|
+
start_date: date | None = None,
|
|
202
|
+
end_date: date | None = None,
|
|
203
|
+
) -> dict[str, pd.DataFrame]:
|
|
204
|
+
"""批量获取基金净值"""
|
|
205
|
+
return self.get_adapter().batch_get_fund_nav(fund_codes, start_date, end_date)
|
|
206
|
+
|
|
207
|
+
# ========== 缓存管理 ==========
|
|
208
|
+
|
|
209
|
+
def clear_cache(self) -> None:
|
|
210
|
+
"""清空缓存"""
|
|
211
|
+
self._cache.clear()
|
|
212
|
+
|
|
213
|
+
def get_cache_stats(self) -> dict:
|
|
214
|
+
"""获取缓存统计"""
|
|
215
|
+
return self._cache.get_stats()
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
sources = list(self._adapters.keys())
|
|
219
|
+
return f"DataManager(sources={sources}, primary={self._primary_source})"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# 全局数据管理器实例
|
|
223
|
+
_data_manager: DataManager | None = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_data_manager() -> DataManager:
|
|
227
|
+
"""获取数据管理器实例(单例)"""
|
|
228
|
+
global _data_manager
|
|
229
|
+
if _data_manager is None:
|
|
230
|
+
_data_manager = DataManager()
|
|
231
|
+
return _data_manager
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""数据质量检查器"""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataQualityChecker:
|
|
10
|
+
"""
|
|
11
|
+
数据质量检查器
|
|
12
|
+
|
|
13
|
+
功能:
|
|
14
|
+
- 数据质量检查 (FUND-DATA-005)
|
|
15
|
+
- 增量更新 (FUND-DATA-006)
|
|
16
|
+
- 批量下载 (FUND-DATA-007)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, data_manager=None):
|
|
20
|
+
from fund_cli.core.data_manager import DataManager
|
|
21
|
+
|
|
22
|
+
self._dm = data_manager or DataManager()
|
|
23
|
+
|
|
24
|
+
def check(self, fund_code: str) -> dict[str, Any]:
|
|
25
|
+
"""执行完整数据质量检查"""
|
|
26
|
+
try:
|
|
27
|
+
nav_df = self._dm.get_fund_nav(fund_code)
|
|
28
|
+
if nav_df.empty:
|
|
29
|
+
return {"fund_code": fund_code, "status": "error", "message": "无数据"}
|
|
30
|
+
completeness = self.check_completeness(nav_df)
|
|
31
|
+
accuracy = self.check_accuracy(nav_df)
|
|
32
|
+
timeliness = self.check_timeliness(nav_df)
|
|
33
|
+
overall = (
|
|
34
|
+
"good"
|
|
35
|
+
if completeness["score"] >= 90 and accuracy["score"] >= 90
|
|
36
|
+
else ("warning" if completeness["score"] >= 70 else "poor")
|
|
37
|
+
)
|
|
38
|
+
return {
|
|
39
|
+
"fund_code": fund_code,
|
|
40
|
+
"overall_status": overall,
|
|
41
|
+
"completeness": completeness,
|
|
42
|
+
"accuracy": accuracy,
|
|
43
|
+
"timeliness": timeliness,
|
|
44
|
+
}
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"fund_code": fund_code, "status": "error", "message": str(e)}
|
|
47
|
+
|
|
48
|
+
def check_completeness(self, nav_data: pd.DataFrame) -> dict[str, Any]:
|
|
49
|
+
"""完整性检查 (FUND-DATA-005)"""
|
|
50
|
+
total_rows = len(nav_data)
|
|
51
|
+
if total_rows == 0:
|
|
52
|
+
return {"score": 0, "total_rows": 0, "missing_values": {}, "date_gaps": 0}
|
|
53
|
+
|
|
54
|
+
missing = {}
|
|
55
|
+
for col in ["unit_nav", "daily_return"]:
|
|
56
|
+
if col in nav_data.columns:
|
|
57
|
+
missing[col] = int(nav_data[col].isna().sum())
|
|
58
|
+
|
|
59
|
+
# 检查日期连续性
|
|
60
|
+
if "nav_date" in nav_data.columns:
|
|
61
|
+
dates = pd.to_datetime(nav_data["nav_date"]).sort_values()
|
|
62
|
+
business_days = pd.bdate_range(dates.min(), dates.max())
|
|
63
|
+
expected_count = len(business_days)
|
|
64
|
+
gaps = expected_count - total_rows
|
|
65
|
+
else:
|
|
66
|
+
gaps = 0
|
|
67
|
+
|
|
68
|
+
score = max(0, 100 - (sum(missing.values()) + gaps) * 2)
|
|
69
|
+
return {
|
|
70
|
+
"score": min(100, score),
|
|
71
|
+
"total_rows": total_rows,
|
|
72
|
+
"missing_values": missing,
|
|
73
|
+
"date_gaps": max(0, gaps),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def check_accuracy(self, nav_data: pd.DataFrame) -> dict[str, Any]:
|
|
77
|
+
"""准确性检查(异常值检测)"""
|
|
78
|
+
if nav_data.empty or "unit_nav" not in nav_data.columns:
|
|
79
|
+
return {"score": 100, "anomalies": []}
|
|
80
|
+
|
|
81
|
+
nav = nav_data["unit_nav"].dropna()
|
|
82
|
+
if len(nav) < 10:
|
|
83
|
+
return {"score": 100, "anomalies": []}
|
|
84
|
+
|
|
85
|
+
q1 = nav.quantile(0.25)
|
|
86
|
+
q3 = nav.quantile(0.75)
|
|
87
|
+
iqr = q3 - q1
|
|
88
|
+
lower = q1 - 3 * iqr
|
|
89
|
+
upper = q3 + 3 * iqr
|
|
90
|
+
|
|
91
|
+
anomalies = nav[(nav < lower) | (nav > upper)]
|
|
92
|
+
score = max(0, 100 - len(anomalies) * 5)
|
|
93
|
+
return {
|
|
94
|
+
"score": min(100, score),
|
|
95
|
+
"anomaly_count": len(anomalies),
|
|
96
|
+
"anomalies": anomalies.head(5).tolist(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def check_timeliness(self, nav_data: pd.DataFrame) -> dict[str, Any]:
|
|
100
|
+
"""时效性检查"""
|
|
101
|
+
if nav_data.empty or "nav_date" not in nav_data.columns:
|
|
102
|
+
return {"last_date": None, "days_since_update": None, "status": "unknown"}
|
|
103
|
+
|
|
104
|
+
last_date = pd.to_datetime(nav_data["nav_date"]).max()
|
|
105
|
+
today = pd.Timestamp.now().normalize()
|
|
106
|
+
days_since = (today - last_date).days
|
|
107
|
+
|
|
108
|
+
if days_since <= 1:
|
|
109
|
+
status = "current"
|
|
110
|
+
elif days_since <= 7:
|
|
111
|
+
status = "recent"
|
|
112
|
+
else:
|
|
113
|
+
status = "outdated"
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"last_date": str(last_date.date()),
|
|
117
|
+
"days_since_update": days_since,
|
|
118
|
+
"status": status,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def incremental_update(self, fund_code: str) -> dict[str, Any]:
|
|
122
|
+
"""增量更新 (FUND-DATA-006)"""
|
|
123
|
+
try:
|
|
124
|
+
# 获取缓存中最新日期
|
|
125
|
+
cache = self._dm._cache
|
|
126
|
+
cached = cache.get(
|
|
127
|
+
f"fund_nav:{fund_code}:19900101:{pd.Timestamp.now().strftime('%Y%m%d')}"
|
|
128
|
+
)
|
|
129
|
+
if cached is not None and not cached.empty and "nav_date" in cached.columns:
|
|
130
|
+
last_date = pd.to_datetime(cached["nav_date"]).max().date()
|
|
131
|
+
start = last_date + timedelta(days=1)
|
|
132
|
+
else:
|
|
133
|
+
start = None
|
|
134
|
+
|
|
135
|
+
new_data = self._dm.get_fund_nav(fund_code, start_date=start)
|
|
136
|
+
return {
|
|
137
|
+
"fund_code": fund_code,
|
|
138
|
+
"new_records": len(new_data),
|
|
139
|
+
"start_date": str(start) if start else "all",
|
|
140
|
+
"status": "success",
|
|
141
|
+
}
|
|
142
|
+
except Exception as e:
|
|
143
|
+
return {"fund_code": fund_code, "status": "error", "message": str(e)}
|
|
144
|
+
|
|
145
|
+
def batch_download(self, fund_codes: list[str]) -> dict[str, Any]:
|
|
146
|
+
"""批量下载 (FUND-DATA-007)"""
|
|
147
|
+
results = {}
|
|
148
|
+
success = 0
|
|
149
|
+
for code in fund_codes:
|
|
150
|
+
try:
|
|
151
|
+
nav = self._dm.get_fund_nav(code)
|
|
152
|
+
results[code] = {"status": "success", "records": len(nav)}
|
|
153
|
+
success += 1
|
|
154
|
+
except Exception as e:
|
|
155
|
+
results[code] = {"status": "error", "message": str(e)}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"total": len(fund_codes),
|
|
159
|
+
"success": success,
|
|
160
|
+
"failed": len(fund_codes) - success,
|
|
161
|
+
"details": results,
|
|
162
|
+
}
|
fund_cli/core/monitor.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
基金监控管理器
|
|
3
|
+
|
|
4
|
+
提供基金池管理和净值监控功能。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FundMonitor:
|
|
13
|
+
"""
|
|
14
|
+
基金监控管理器
|
|
15
|
+
|
|
16
|
+
功能:
|
|
17
|
+
- 基金池管理(增删查) (FUND-MONITOR-001)
|
|
18
|
+
- 净值监控和预警 (FUND-MONITOR-002)
|
|
19
|
+
- 数据持久化(JSON文件)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config_dir: str = "~/.fund_cli"):
|
|
23
|
+
"""
|
|
24
|
+
初始化监控管理器
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config_dir: 配置目录
|
|
28
|
+
"""
|
|
29
|
+
self._config_dir = Path(config_dir).expanduser()
|
|
30
|
+
self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._watchlist_path = self._config_dir / "watchlist.json"
|
|
32
|
+
self._rules_path = self._config_dir / "monitor_rules.json"
|
|
33
|
+
self._pools: dict[str, list[dict]] = {}
|
|
34
|
+
self._rules: list[dict] = []
|
|
35
|
+
self._load()
|
|
36
|
+
|
|
37
|
+
def _load(self) -> None:
|
|
38
|
+
"""从文件加载数据"""
|
|
39
|
+
if self._watchlist_path.exists():
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(self._watchlist_path.read_text(encoding="utf-8"))
|
|
42
|
+
self._pools = data.get("pools", {})
|
|
43
|
+
except (json.JSONDecodeError, KeyError):
|
|
44
|
+
self._pools = {}
|
|
45
|
+
else:
|
|
46
|
+
self._pools = {}
|
|
47
|
+
|
|
48
|
+
if self._rules_path.exists():
|
|
49
|
+
try:
|
|
50
|
+
self._rules = json.loads(self._rules_path.read_text(encoding="utf-8"))
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
self._rules = []
|
|
53
|
+
else:
|
|
54
|
+
self._rules = []
|
|
55
|
+
|
|
56
|
+
def _save(self) -> None:
|
|
57
|
+
"""保存数据到文件"""
|
|
58
|
+
self._watchlist_path.write_text(
|
|
59
|
+
json.dumps({"pools": self._pools}, ensure_ascii=False, indent=2),
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
)
|
|
62
|
+
self._rules_path.write_text(
|
|
63
|
+
json.dumps(self._rules, ensure_ascii=False, indent=2),
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ========== 基金池管理 (FUND-MONITOR-001) ==========
|
|
68
|
+
|
|
69
|
+
def add_to_pool(self, fund_code: str, group: str = "default") -> None:
|
|
70
|
+
"""
|
|
71
|
+
添加基金到监控池
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
fund_code: 基金代码
|
|
75
|
+
group: 分组名称
|
|
76
|
+
"""
|
|
77
|
+
if group not in self._pools:
|
|
78
|
+
self._pools[group] = []
|
|
79
|
+
|
|
80
|
+
existing = [f["code"] for f in self._pools[group]]
|
|
81
|
+
if fund_code not in existing:
|
|
82
|
+
self._pools[group].append(
|
|
83
|
+
{
|
|
84
|
+
"code": fund_code,
|
|
85
|
+
"added_at": datetime.now().isoformat(),
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
self._save()
|
|
89
|
+
|
|
90
|
+
def remove_from_pool(self, fund_code: str, group: str | None = None) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
从监控池移除基金
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
fund_code: 基金代码
|
|
96
|
+
group: 分组名称,None表示从所有分组移除
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
是否移除成功
|
|
100
|
+
"""
|
|
101
|
+
removed = False
|
|
102
|
+
if group:
|
|
103
|
+
if group in self._pools:
|
|
104
|
+
before = len(self._pools[group])
|
|
105
|
+
self._pools[group] = [f for f in self._pools[group] if f["code"] != fund_code]
|
|
106
|
+
removed = len(self._pools[group]) < before
|
|
107
|
+
else:
|
|
108
|
+
for g in self._pools:
|
|
109
|
+
before = len(self._pools[g])
|
|
110
|
+
self._pools[g] = [f for f in self._pools[g] if f["code"] != fund_code]
|
|
111
|
+
if len(self._pools[g]) < before:
|
|
112
|
+
removed = True
|
|
113
|
+
|
|
114
|
+
if removed:
|
|
115
|
+
self._save()
|
|
116
|
+
return removed
|
|
117
|
+
|
|
118
|
+
def list_pool(self, group: str | None = None) -> list[dict]:
|
|
119
|
+
"""
|
|
120
|
+
列出监控池中的基金
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
group: 分组名称,None表示列出所有
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
基金列表
|
|
127
|
+
"""
|
|
128
|
+
if group:
|
|
129
|
+
return self._pools.get(group, [])
|
|
130
|
+
result = []
|
|
131
|
+
for g, funds in self._pools.items():
|
|
132
|
+
for f in funds:
|
|
133
|
+
result.append({**f, "group": g})
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def create_pool(self, name: str) -> bool:
|
|
137
|
+
"""创建新的基金池分组"""
|
|
138
|
+
if name not in self._pools:
|
|
139
|
+
self._pools[name] = []
|
|
140
|
+
self._save()
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def delete_pool(self, name: str) -> bool:
|
|
145
|
+
"""删除基金池分组"""
|
|
146
|
+
if name in self._pools and name != "default":
|
|
147
|
+
del self._pools[name]
|
|
148
|
+
self._save()
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# ========== 净值监控 (FUND-MONITOR-002) ==========
|
|
153
|
+
|
|
154
|
+
def add_rule(
|
|
155
|
+
self, fund_code: str, rule_type: str = "nav_change", threshold: float = -2.0
|
|
156
|
+
) -> None:
|
|
157
|
+
"""添加监控规则"""
|
|
158
|
+
self._rules.append(
|
|
159
|
+
{
|
|
160
|
+
"fund_code": fund_code,
|
|
161
|
+
"rule_type": rule_type,
|
|
162
|
+
"threshold": threshold,
|
|
163
|
+
"enabled": True,
|
|
164
|
+
"created_at": datetime.now().isoformat(),
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
self._save()
|
|
168
|
+
|
|
169
|
+
def get_rules(self, fund_code: str | None = None) -> list[dict]:
|
|
170
|
+
"""获取监控规则"""
|
|
171
|
+
if fund_code:
|
|
172
|
+
return [
|
|
173
|
+
r for r in self._rules if r["fund_code"] == fund_code and r.get("enabled", True)
|
|
174
|
+
]
|
|
175
|
+
return [r for r in self._rules if r.get("enabled", True)]
|
|
176
|
+
|
|
177
|
+
def check_nav_changes(self, fund_codes: list[str], threshold: float = -2.0) -> list[dict]:
|
|
178
|
+
"""
|
|
179
|
+
检查净值变动
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
fund_codes: 基金代码列表
|
|
183
|
+
threshold: 预警阈值(默认-2%)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
触发预警的基金列表
|
|
187
|
+
"""
|
|
188
|
+
alerts = []
|
|
189
|
+
try:
|
|
190
|
+
from fund_cli.core.data_manager import DataManager
|
|
191
|
+
|
|
192
|
+
dm = DataManager()
|
|
193
|
+
for code in fund_codes:
|
|
194
|
+
try:
|
|
195
|
+
nav_df = dm.get_fund_nav(code)
|
|
196
|
+
if nav_df.empty or "daily_return" not in nav_df.columns:
|
|
197
|
+
continue
|
|
198
|
+
latest = nav_df.iloc[-1]["daily_return"]
|
|
199
|
+
if isinstance(latest, str):
|
|
200
|
+
latest = float(latest.replace("%", ""))
|
|
201
|
+
if latest <= threshold:
|
|
202
|
+
alerts.append(
|
|
203
|
+
{
|
|
204
|
+
"fund_code": code,
|
|
205
|
+
"daily_return": latest,
|
|
206
|
+
"threshold": threshold,
|
|
207
|
+
"alert_type": "nav_change",
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
except Exception:
|
|
211
|
+
continue
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
return alerts
|
|
215
|
+
|
|
216
|
+
def get_all_fund_codes(self) -> list[str]:
|
|
217
|
+
"""获取所有监控基金代码"""
|
|
218
|
+
codes = set()
|
|
219
|
+
for funds in self._pools.values():
|
|
220
|
+
for f in funds:
|
|
221
|
+
codes.add(f["code"])
|
|
222
|
+
return sorted(codes)
|
|
223
|
+
|
|
224
|
+
def get_pool_names(self) -> list[str]:
|
|
225
|
+
"""获取所有基金池名称"""
|
|
226
|
+
return list(self._pools.keys())
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
total = sum(len(funds) for funds in self._pools.values())
|
|
230
|
+
return f"FundMonitor(pools={len(self._pools)}, funds={total})"
|