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,50 @@
|
|
|
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 Optimizer(ABC):
|
|
14
|
+
"""
|
|
15
|
+
优化引擎基类
|
|
16
|
+
|
|
17
|
+
所有优化引擎必须继承此类。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def optimize(
|
|
22
|
+
self,
|
|
23
|
+
returns: pd.DataFrame,
|
|
24
|
+
**kwargs,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
执行组合优化
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
returns: 多资产收益率 DataFrame,每列一个资产
|
|
31
|
+
**kwargs: 额外参数(如目标收益率、最大回撤限制等)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
优化结果字典,包含:
|
|
35
|
+
- weights: 各资产权重字典
|
|
36
|
+
- expected_return: 预期收益率
|
|
37
|
+
- expected_volatility: 预期波动率
|
|
38
|
+
- sharpe_ratio: 夏普比率
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def get_methods(self) -> list[str]:
|
|
44
|
+
"""
|
|
45
|
+
获取支持的优化方法列表
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
方法名称列表
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""组合优化引擎"""
|
|
2
|
+
|
|
3
|
+
from fund_cli.core.optimizers.efficient_frontier import EfficientFrontierCalculator
|
|
4
|
+
from fund_cli.core.optimizers.max_sharpe import MaxSharpeOptimizer
|
|
5
|
+
from fund_cli.core.optimizers.mean_variance import MeanVarianceOptimizer
|
|
6
|
+
from fund_cli.core.optimizers.risk_parity import RiskParityOptimizer
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MeanVarianceOptimizer",
|
|
10
|
+
"MaxSharpeOptimizer",
|
|
11
|
+
"RiskParityOptimizer",
|
|
12
|
+
"EfficientFrontierCalculator",
|
|
13
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""有效前沿计算器"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EfficientFrontierCalculator:
|
|
10
|
+
"""有效前沿计算器 - PORTFOLIO-OPT-006"""
|
|
11
|
+
|
|
12
|
+
def calculate(
|
|
13
|
+
self,
|
|
14
|
+
returns: pd.DataFrame,
|
|
15
|
+
n_points: int = 50,
|
|
16
|
+
risk_free_rate: float = 0.03,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""
|
|
19
|
+
计算有效前沿上的点集
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
returns: 收益率 DataFrame
|
|
23
|
+
n_points: 前沿点数
|
|
24
|
+
risk_free_rate: 无风险利率
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
包含 frontier_returns, frontier_volatilities, frontier_sharpes 的字典
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
from pypfopt import EfficientFrontier, expected_returns, risk_models
|
|
31
|
+
|
|
32
|
+
mu = expected_returns.mean_historical_return(returns)
|
|
33
|
+
S = risk_models.sample_cov(returns)
|
|
34
|
+
|
|
35
|
+
min_ret = mu.min()
|
|
36
|
+
max_ret = mu.max()
|
|
37
|
+
target_returns = np.linspace(min_ret, max_ret, n_points)
|
|
38
|
+
|
|
39
|
+
frontier_volatilities = []
|
|
40
|
+
frontier_returns = []
|
|
41
|
+
frontier_sharpes = []
|
|
42
|
+
|
|
43
|
+
for target in target_returns:
|
|
44
|
+
try:
|
|
45
|
+
ef = EfficientFrontier(mu, S)
|
|
46
|
+
ef.efficient_return(target)
|
|
47
|
+
ret, vol, _ = ef.portfolio_performance(
|
|
48
|
+
verbose=False, risk_free_rate=risk_free_rate
|
|
49
|
+
)
|
|
50
|
+
frontier_returns.append(ret)
|
|
51
|
+
frontier_volatilities.append(vol)
|
|
52
|
+
frontier_sharpes.append((ret - risk_free_rate) / vol if vol > 0 else 0)
|
|
53
|
+
except Exception:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
if len(frontier_returns) == 0:
|
|
57
|
+
return self._fallback_calculate(returns, n_points, risk_free_rate)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"frontier_returns": [round(r, 6) for r in frontier_returns],
|
|
61
|
+
"frontier_volatilities": [round(v, 6) for v in frontier_volatilities],
|
|
62
|
+
"frontier_sharpes": [round(s, 4) for s in frontier_sharpes],
|
|
63
|
+
"n_points": len(frontier_returns),
|
|
64
|
+
}
|
|
65
|
+
except Exception:
|
|
66
|
+
return self._fallback_calculate(returns, n_points, risk_free_rate)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _fallback_calculate(
|
|
70
|
+
returns: pd.DataFrame, n_points: int, risk_free_rate: float
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""回退实现"""
|
|
73
|
+
n = returns.shape[1]
|
|
74
|
+
vols = []
|
|
75
|
+
rets = []
|
|
76
|
+
for i in range(n_points):
|
|
77
|
+
np.random.seed(i)
|
|
78
|
+
w = np.random.dirichlet(np.ones(n))
|
|
79
|
+
port_ret = (returns.mean() * 252 * w).sum()
|
|
80
|
+
port_vol = np.sqrt(w @ returns.cov().values @ w * 252)
|
|
81
|
+
vols.append(port_vol)
|
|
82
|
+
rets.append(port_ret)
|
|
83
|
+
return {
|
|
84
|
+
"frontier_returns": [round(r, 6) for r in rets],
|
|
85
|
+
"frontier_volatilities": [round(v, 6) for v in vols],
|
|
86
|
+
"frontier_sharpes": [
|
|
87
|
+
round((r - risk_free_rate) / v, 4) if v > 0 else 0
|
|
88
|
+
for r, v in zip(rets, vols, strict=False)
|
|
89
|
+
],
|
|
90
|
+
"n_points": n,
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""最大夏普比率优化器"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from fund_cli.core.optimizer import Optimizer
|
|
8
|
+
from fund_cli.core.optimizers.mean_variance import MeanVarianceOptimizer
|
|
9
|
+
from fund_cli.data.models import OptimizationConstraint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MaxSharpeOptimizer(Optimizer):
|
|
13
|
+
"""最大夏普比率优化器 - PORTFOLIO-OPT-002"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, risk_free_rate: float = 0.03):
|
|
16
|
+
self.risk_free_rate = risk_free_rate
|
|
17
|
+
|
|
18
|
+
def optimize(
|
|
19
|
+
self,
|
|
20
|
+
returns: pd.DataFrame,
|
|
21
|
+
constraints: OptimizationConstraint | None = None,
|
|
22
|
+
**kwargs: Any,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""执行最大夏普比率优化"""
|
|
25
|
+
try:
|
|
26
|
+
from pypfopt import EfficientFrontier, expected_returns, risk_models
|
|
27
|
+
except ImportError:
|
|
28
|
+
return MeanVarianceOptimizer._fallback_optimize(returns, constraints)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
mu = expected_returns.mean_historical_return(returns)
|
|
32
|
+
S = risk_models.sample_cov(returns)
|
|
33
|
+
|
|
34
|
+
min_w = constraints.min_weight if constraints else 0.0
|
|
35
|
+
max_w = constraints.max_weight if constraints else 1.0
|
|
36
|
+
|
|
37
|
+
ef = EfficientFrontier(mu, S, weight_bounds=(min_w, max_w))
|
|
38
|
+
ef.max_sharpe(risk_free_rate=self.risk_free_rate)
|
|
39
|
+
|
|
40
|
+
weights = ef.clean_weights()
|
|
41
|
+
perf = ef.portfolio_performance(verbose=False, risk_free_rate=self.risk_free_rate)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"weights": {k: round(v, 6) for k, v in weights.items()},
|
|
45
|
+
"expected_return": round(perf[0], 6),
|
|
46
|
+
"volatility": round(perf[1], 6),
|
|
47
|
+
"sharpe_ratio": round(perf[2], 4),
|
|
48
|
+
"method": "max_sharpe",
|
|
49
|
+
}
|
|
50
|
+
except Exception:
|
|
51
|
+
return MeanVarianceOptimizer._fallback_optimize(returns, constraints)
|
|
52
|
+
|
|
53
|
+
def get_methods(self) -> list[str]:
|
|
54
|
+
return ["max_sharpe"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""均值-方差优化器(Markowitz模型)"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from fund_cli.core.optimizer import Optimizer
|
|
9
|
+
from fund_cli.data.models import OptimizationConstraint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MeanVarianceOptimizer(Optimizer):
|
|
13
|
+
"""均值-方差优化器 - PORTFOLIO-OPT-001"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, risk_free_rate: float = 0.03):
|
|
16
|
+
self.risk_free_rate = risk_free_rate
|
|
17
|
+
|
|
18
|
+
def optimize(
|
|
19
|
+
self,
|
|
20
|
+
returns: pd.DataFrame,
|
|
21
|
+
constraints: OptimizationConstraint | None = None,
|
|
22
|
+
**kwargs: Any,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
执行均值-方差优化
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
returns: 收益率 DataFrame,每列一只基金
|
|
29
|
+
constraints: 优化约束条件
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
优化结果字典,包含 weights, expected_return, volatility, sharpe_ratio
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from pypfopt import EfficientFrontier, expected_returns, risk_models
|
|
36
|
+
except ImportError:
|
|
37
|
+
return self._fallback_optimize(returns, constraints)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
mu = expected_returns.mean_historical_return(returns)
|
|
41
|
+
S = risk_models.sample_cov(returns)
|
|
42
|
+
|
|
43
|
+
min_w = constraints.min_weight if constraints else 0.0
|
|
44
|
+
max_w = constraints.max_weight if constraints else 1.0
|
|
45
|
+
|
|
46
|
+
ef = EfficientFrontier(mu, S, weight_bounds=(min_w, max_w))
|
|
47
|
+
|
|
48
|
+
if constraints and constraints.target_return is not None:
|
|
49
|
+
ef.efficient_return(constraints.target_return)
|
|
50
|
+
else:
|
|
51
|
+
ef.max_sharpe(risk_free_rate=self.risk_free_rate)
|
|
52
|
+
|
|
53
|
+
weights = ef.clean_weights()
|
|
54
|
+
perf = ef.portfolio_performance(verbose=False, risk_free_rate=self.risk_free_rate)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"weights": {k: round(v, 6) for k, v in weights.items()},
|
|
58
|
+
"expected_return": round(perf[0], 6),
|
|
59
|
+
"volatility": round(perf[1], 6),
|
|
60
|
+
"sharpe_ratio": round(perf[2], 4),
|
|
61
|
+
"method": "mean_variance",
|
|
62
|
+
}
|
|
63
|
+
except Exception:
|
|
64
|
+
return self._fallback_optimize(returns, constraints)
|
|
65
|
+
|
|
66
|
+
def get_methods(self) -> list[str]:
|
|
67
|
+
return ["mean_variance"]
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _fallback_optimize(
|
|
71
|
+
returns: pd.DataFrame, constraints: OptimizationConstraint | None = None
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""PyPortfolioOpt不可用时的回退实现"""
|
|
74
|
+
n = returns.shape[1]
|
|
75
|
+
equal_weights = {col: round(1.0 / n, 6) for col in returns.columns}
|
|
76
|
+
port_return = (returns.mean() * 252).mean()
|
|
77
|
+
port_vol = returns.std().mean() * np.sqrt(252)
|
|
78
|
+
return {
|
|
79
|
+
"weights": equal_weights,
|
|
80
|
+
"expected_return": round(port_return, 6),
|
|
81
|
+
"volatility": round(port_vol, 6),
|
|
82
|
+
"sharpe_ratio": round((port_return - 0.03) / port_vol, 4) if port_vol > 0 else 0.0,
|
|
83
|
+
"method": "equal_weight_fallback",
|
|
84
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""风险平价优化器"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from fund_cli.core.optimizer import Optimizer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RiskParityOptimizer(Optimizer):
|
|
12
|
+
"""风险平价优化器 - PORTFOLIO-OPT-003"""
|
|
13
|
+
|
|
14
|
+
def optimize(
|
|
15
|
+
self,
|
|
16
|
+
returns: pd.DataFrame,
|
|
17
|
+
**kwargs: Any,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""执行风险平价优化"""
|
|
20
|
+
try:
|
|
21
|
+
from pypfopt import HRPOpt
|
|
22
|
+
except ImportError:
|
|
23
|
+
return self._fallback_risk_parity(returns)
|
|
24
|
+
|
|
25
|
+
hrp = HRPOpt(returns)
|
|
26
|
+
weights = hrp.optimize()
|
|
27
|
+
|
|
28
|
+
port_returns = (returns * pd.Series(weights)).sum(axis=1)
|
|
29
|
+
expected_return = port_returns.mean() * 252
|
|
30
|
+
volatility = port_returns.std() * np.sqrt(252)
|
|
31
|
+
sharpe = (expected_return - 0.03) / volatility if volatility > 0 else 0.0
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"weights": {k: round(v, 6) for k, v in weights.items()},
|
|
35
|
+
"expected_return": round(expected_return, 6),
|
|
36
|
+
"volatility": round(volatility, 6),
|
|
37
|
+
"sharpe_ratio": round(sharpe, 4),
|
|
38
|
+
"method": "risk_parity",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def get_methods(self) -> list[str]:
|
|
42
|
+
return ["risk_parity"]
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _fallback_risk_parity(returns: pd.DataFrame) -> dict[str, Any]:
|
|
46
|
+
"""回退实现:基于波动率倒数加权"""
|
|
47
|
+
vols = returns.std()
|
|
48
|
+
inv_vols = 1.0 / vols
|
|
49
|
+
weights = inv_vols / inv_vols.sum()
|
|
50
|
+
port_returns = (returns * weights).sum(axis=1)
|
|
51
|
+
expected_return = port_returns.mean() * 252
|
|
52
|
+
volatility = port_returns.std() * np.sqrt(252)
|
|
53
|
+
sharpe = (expected_return - 0.03) / volatility if volatility > 0 else 0.0
|
|
54
|
+
return {
|
|
55
|
+
"weights": {k: round(v, 6) for k, v in weights.items()},
|
|
56
|
+
"expected_return": round(expected_return, 6),
|
|
57
|
+
"volatility": round(volatility, 6),
|
|
58
|
+
"sharpe_ratio": round(sharpe, 4),
|
|
59
|
+
"method": "inverse_volatility_fallback",
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 Reporter(ABC):
|
|
14
|
+
"""
|
|
15
|
+
报告生成器基类
|
|
16
|
+
|
|
17
|
+
所有报告生成器必须继承此类。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def generate(
|
|
22
|
+
self,
|
|
23
|
+
fund_code: str,
|
|
24
|
+
metrics: dict[str, Any],
|
|
25
|
+
nav_data: pd.DataFrame | None = None,
|
|
26
|
+
benchmark_data: pd.DataFrame | None = None,
|
|
27
|
+
**kwargs,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""
|
|
30
|
+
生成报告
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
fund_code: 基金代码
|
|
34
|
+
metrics: 分析指标字典
|
|
35
|
+
nav_data: 净值数据
|
|
36
|
+
benchmark_data: 基准数据
|
|
37
|
+
**kwargs: 额外参数
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
报告内容字符串
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def save(
|
|
46
|
+
self,
|
|
47
|
+
content: str,
|
|
48
|
+
output_path: str,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
保存报告到文件
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
content: 报告内容
|
|
55
|
+
output_path: 输出文件路径
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def get_formats(self) -> list:
|
|
61
|
+
"""
|
|
62
|
+
获取支持的报告格式
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
格式列表(如 ['html', 'markdown', 'pdf'])
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""HTML报告生成器"""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fund_cli.core.reporter import Reporter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HtmlReporter(Reporter):
|
|
10
|
+
"""HTML报告生成器 (FUND-ANALYZE-011)"""
|
|
11
|
+
|
|
12
|
+
def generate(self, fund_code: str, metrics: dict[str, Any], **kwargs) -> str:
|
|
13
|
+
kwargs.get("nav_data")
|
|
14
|
+
html = f"""<!DOCTYPE html>
|
|
15
|
+
<html lang="zh-CN">
|
|
16
|
+
<head><meta charset="UTF-8"><title>{fund_code} 分析报告</title>
|
|
17
|
+
<style>
|
|
18
|
+
body {{ font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; color: #333; }}
|
|
19
|
+
h1 {{ color: #1a5276; border-bottom: 2px solid #2980b9; padding-bottom: 10px; }}
|
|
20
|
+
h2 {{ color: #2c3e50; margin-top: 30px; }}
|
|
21
|
+
table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
|
|
22
|
+
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
|
|
23
|
+
th {{ background: #2980b9; color: white; }}
|
|
24
|
+
tr:nth-child(even) {{ background: #f2f2f2; }}
|
|
25
|
+
.positive {{ color: #27ae60; }}
|
|
26
|
+
.negative {{ color: #e74c3c; }}
|
|
27
|
+
.footer {{ margin-top: 30px; font-size: 12px; color: #999; border-top: 1px solid #eee; padding-top: 10px; }}
|
|
28
|
+
</style></head><body>
|
|
29
|
+
<h1>{fund_code} 基金分析报告</h1>
|
|
30
|
+
<p>报告日期: {date.today().strftime('%Y-%m-%d')}</p>
|
|
31
|
+
<h2>核心指标</h2>
|
|
32
|
+
<table><tr><th>指标</th><th>值</th></tr>"""
|
|
33
|
+
|
|
34
|
+
key_metrics = [
|
|
35
|
+
("总收益率", metrics.get("total_return", "N/A")),
|
|
36
|
+
("年化收益率", metrics.get("annualized_return", "N/A")),
|
|
37
|
+
("波动率", metrics.get("volatility", "N/A")),
|
|
38
|
+
("夏普比率", metrics.get("sharpe_ratio", "N/A")),
|
|
39
|
+
("最大回撤", metrics.get("max_drawdown", "N/A")),
|
|
40
|
+
("索提诺比率", metrics.get("sortino_ratio", "N/A")),
|
|
41
|
+
("Alpha", metrics.get("alpha", "N/A")),
|
|
42
|
+
("Beta", metrics.get("beta", "N/A")),
|
|
43
|
+
]
|
|
44
|
+
for name, value in key_metrics:
|
|
45
|
+
if isinstance(value, float):
|
|
46
|
+
cls = "positive" if value > 0 else "negative" if value < 0 else ""
|
|
47
|
+
html += f"<tr><td>{name}</td><td class='{cls}'>{value:.4f}</td></tr>"
|
|
48
|
+
else:
|
|
49
|
+
html += f"<tr><td>{name}</td><td>{value}</td></tr>"
|
|
50
|
+
|
|
51
|
+
html += "</table>"
|
|
52
|
+
html += "<div class='footer'>本报告由 Fund CLI 自动生成,仅供参考,不构成投资建议。</div>"
|
|
53
|
+
html += "</body></html>"
|
|
54
|
+
return html
|
|
55
|
+
|
|
56
|
+
def save(self, content: str, output_path: str) -> None:
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
Path(output_path).write_text(content, encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
def get_formats(self) -> list[str]:
|
|
62
|
+
return ["html"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Markdown报告生成器"""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fund_cli.core.reporter import Reporter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarkdownReporter(Reporter):
|
|
10
|
+
"""Markdown报告生成器"""
|
|
11
|
+
|
|
12
|
+
def generate(self, fund_code: str, metrics: dict[str, Any], **kwargs) -> str:
|
|
13
|
+
md = f"# {fund_code} 基金分析报告\n\n"
|
|
14
|
+
md += f"报告日期: {date.today().strftime('%Y-%m-%d')}\n\n"
|
|
15
|
+
md += "## 核心指标\n\n"
|
|
16
|
+
md += "| 指标 | 值 |\n|------|-----|\n"
|
|
17
|
+
|
|
18
|
+
key_metrics = [
|
|
19
|
+
("总收益率", metrics.get("total_return")),
|
|
20
|
+
("年化收益率", metrics.get("annualized_return")),
|
|
21
|
+
("波动率", metrics.get("volatility")),
|
|
22
|
+
("夏普比率", metrics.get("sharpe_ratio")),
|
|
23
|
+
("最大回撤", metrics.get("max_drawdown")),
|
|
24
|
+
]
|
|
25
|
+
for name, value in key_metrics:
|
|
26
|
+
if isinstance(value, float):
|
|
27
|
+
md += f"| {name} | {value:.4f} |\n"
|
|
28
|
+
else:
|
|
29
|
+
md += f"| {name} | {value or 'N/A'} |\n"
|
|
30
|
+
|
|
31
|
+
md += "\n---\n*本报告由 Fund CLI 自动生成,仅供参考。*\n"
|
|
32
|
+
return md
|
|
33
|
+
|
|
34
|
+
def save(self, content: str, output_path: str) -> None:
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
Path(output_path).write_text(content, encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
def get_formats(self) -> list[str]:
|
|
40
|
+
return ["markdown"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""基金筛选引擎"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from fund_cli.data.models import FundFilter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FundScreener:
|
|
13
|
+
"""
|
|
14
|
+
基金筛选引擎
|
|
15
|
+
|
|
16
|
+
功能:
|
|
17
|
+
- 费率筛选 (FUND-FILTER-005)
|
|
18
|
+
- 经理筛选 (FUND-FILTER-006)
|
|
19
|
+
- 评级筛选 (FUND-FILTER-007)
|
|
20
|
+
- 高级表达式筛选 (FUND-FILTER-012)
|
|
21
|
+
- 模板保存/加载 (FUND-FILTER-011)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# 允许的表达式函数白名单
|
|
25
|
+
_SAFE_FUNCTIONS = {"sum", "mean", "min", "max", "abs", "len"}
|
|
26
|
+
|
|
27
|
+
def __init__(self, data_manager=None):
|
|
28
|
+
from fund_cli.core.data_manager import DataManager
|
|
29
|
+
|
|
30
|
+
self._dm = data_manager or DataManager()
|
|
31
|
+
self._template_dir = Path("~/.fund_cli/templates").expanduser()
|
|
32
|
+
self._template_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
def screen(self, filter_obj: FundFilter) -> pd.DataFrame:
|
|
35
|
+
"""执行通用筛选"""
|
|
36
|
+
df = self._dm.search_funds(
|
|
37
|
+
fund_type=filter_obj.fund_type.value if filter_obj.fund_type else None,
|
|
38
|
+
company=filter_obj.company,
|
|
39
|
+
min_scale=filter_obj.min_scale,
|
|
40
|
+
max_scale=filter_obj.max_scale,
|
|
41
|
+
keyword=filter_obj.keyword,
|
|
42
|
+
limit=filter_obj.limit,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if df.empty:
|
|
46
|
+
return df
|
|
47
|
+
|
|
48
|
+
# 业绩筛选
|
|
49
|
+
if filter_obj.min_return_1y is not None and "return_1y" in df.columns:
|
|
50
|
+
df = df[df["return_1y"] >= filter_obj.min_return_1y]
|
|
51
|
+
if filter_obj.max_return_1y is not None and "return_1y" in df.columns:
|
|
52
|
+
df = df[df["return_1y"] <= filter_obj.max_return_1y]
|
|
53
|
+
|
|
54
|
+
# 风险筛选
|
|
55
|
+
if filter_obj.max_drawdown is not None and "max_drawdown" in df.columns:
|
|
56
|
+
df = df[df["max_drawdown"] >= filter_obj.max_drawdown]
|
|
57
|
+
if filter_obj.min_sharpe is not None and "sharpe_ratio" in df.columns:
|
|
58
|
+
df = df[df["sharpe_ratio"] >= filter_obj.min_sharpe]
|
|
59
|
+
|
|
60
|
+
# V1.0 新增筛选
|
|
61
|
+
if filter_obj.fee_rate_max is not None and "fee_rate" in df.columns:
|
|
62
|
+
df = df[df["fee_rate"] <= filter_obj.fee_rate_max]
|
|
63
|
+
if filter_obj.manager_name and "manager" in df.columns:
|
|
64
|
+
df = df[df["manager"].str.contains(filter_obj.manager_name, na=False)]
|
|
65
|
+
if filter_obj.min_rating is not None and "rating" in df.columns:
|
|
66
|
+
df = df[df["rating"] >= filter_obj.min_rating]
|
|
67
|
+
|
|
68
|
+
# 排序
|
|
69
|
+
if filter_obj.sort_by and filter_obj.sort_by in df.columns:
|
|
70
|
+
ascending = filter_obj.sort_order == "asc"
|
|
71
|
+
df = df.sort_values(filter_obj.sort_by, ascending=ascending)
|
|
72
|
+
|
|
73
|
+
return df.reset_index(drop=True)
|
|
74
|
+
|
|
75
|
+
def screen_by_fee(self, max_fee_rate: float, fund_type: str | None = None) -> pd.DataFrame:
|
|
76
|
+
"""费率筛选 (FUND-FILTER-005)"""
|
|
77
|
+
f = FundFilter(fee_rate_max=max_fee_rate)
|
|
78
|
+
if fund_type:
|
|
79
|
+
from fund_cli.data.models import FundType
|
|
80
|
+
|
|
81
|
+
f.fund_type = FundType(fund_type)
|
|
82
|
+
return self.screen(f)
|
|
83
|
+
|
|
84
|
+
def screen_by_manager(self, manager_name: str) -> pd.DataFrame:
|
|
85
|
+
"""经理筛选 (FUND-FILTER-006)"""
|
|
86
|
+
f = FundFilter(manager_name=manager_name)
|
|
87
|
+
return self.screen(f)
|
|
88
|
+
|
|
89
|
+
def screen_by_rating(self, min_rating: int) -> pd.DataFrame:
|
|
90
|
+
"""评级筛选 (FUND-FILTER-007)"""
|
|
91
|
+
f = FundFilter(min_rating=min_rating)
|
|
92
|
+
return self.screen(f)
|
|
93
|
+
|
|
94
|
+
def evaluate_expression(self, df: pd.DataFrame, expression: str) -> pd.DataFrame:
|
|
95
|
+
"""
|
|
96
|
+
高级表达式筛选 (FUND-FILTER-012)
|
|
97
|
+
|
|
98
|
+
使用 pandas query 安全执行表达式。
|
|
99
|
+
支持的运算符: >, <, =, >=, <=, ==, !=
|
|
100
|
+
支持的逻辑: AND, OR, and, or, &, |
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
# 安全检查:禁止危险操作
|
|
104
|
+
dangerous = re.findall(
|
|
105
|
+
r"(?:__|import|exec|eval|open|os\.|sys\.)", expression, re.IGNORECASE
|
|
106
|
+
)
|
|
107
|
+
if dangerous:
|
|
108
|
+
raise ValueError(f"表达式包含不允许的操作: {dangerous}")
|
|
109
|
+
|
|
110
|
+
result = df.query(expression)
|
|
111
|
+
return result.reset_index(drop=True)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise ValueError(f"表达式解析失败: {e}") from e
|
|
114
|
+
|
|
115
|
+
def save_template(self, name: str, filter_obj: FundFilter) -> None:
|
|
116
|
+
"""保存筛选模板 (FUND-FILTER-011)"""
|
|
117
|
+
path = self._template_dir / f"{name}.json"
|
|
118
|
+
data = filter_obj.model_dump(mode="json")
|
|
119
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
def load_template(self, name: str) -> FundFilter:
|
|
122
|
+
"""加载筛选模板"""
|
|
123
|
+
path = self._template_dir / f"{name}.json"
|
|
124
|
+
if not path.exists():
|
|
125
|
+
raise FileNotFoundError(f"模板 {name} 不存在")
|
|
126
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
127
|
+
return FundFilter(**data)
|
|
128
|
+
|
|
129
|
+
def list_templates(self) -> list[str]:
|
|
130
|
+
"""列出所有筛选模板"""
|
|
131
|
+
templates = []
|
|
132
|
+
for f in self._template_dir.glob("*.json"):
|
|
133
|
+
templates.append(f.stem)
|
|
134
|
+
return sorted(templates)
|
|
135
|
+
|
|
136
|
+
def delete_template(self, name: str) -> bool:
|
|
137
|
+
"""删除筛选模板"""
|
|
138
|
+
path = self._template_dir / f"{name}.json"
|
|
139
|
+
if path.exists():
|
|
140
|
+
path.unlink()
|
|
141
|
+
return True
|
|
142
|
+
return False
|