Files

657 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""金融产品分析模块 v7.7.0(在 v5.0.0 基础上增强)
相比 v4.x 占位级实现,v5.0.0 提供:
- **真实风险指标**:基于净值时间序列计算年化收益/年化波动/最大回撤/夏普/卡玛/正收益天数
(数据不足 30 点时返回 insufficient_data,不再伪造数值)
- **数据驱动的投资风格分析**:持仓集中度(CR5/CR10) + 行业暴露 + 风格定位
- **数据驱动的组合复刻**:同行业+市值接近的替代标的(fallback 到旧映射并标注来源)
- **数值距离相似推荐**:特征向量欧氏距离替代写死映射
v7.7.0 新增:
- **指标扩展**:Sortino 比率(下行波动口径)/ 胜率 / 单日最佳最差收益;
风险指标内存缓存(同序列重复计算命中缓存,批量分析提速)
- **nav_trend_analysis 趋势分析**:区间收益/回归斜率年化/距峰值回撤/近期波动比,
输出强上行/缓步上行/震荡/下行四档定性标签
向后兼容:所有公开函数签名保持 v4.x(calculate_risk_metrics 增加可选入参
risk_free_rate;旧 dict 入参路径走估算并标注 estimate:true)。
"""
import json
import math
import statistics
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union, Sequence
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
# 无风险利率默认值(年化 2%)
DEFAULT_RISK_FREE = 0.02
# 一年交易日数
TRADING_DAYS = 252
# v7.7 风险指标缓存(同序列重复计算,如批量分析/报告生成)
_METRICS_CACHE: Dict[Tuple, Dict[str, Any]] = {}
_METRICS_CACHE_MAX = 256
# 行业映射(股票代码 → 行业,够用即可;完整映射依赖外部注册表)
_CODE_INDUSTRY_HINT = {
"6": "沪市主板", "0": "深市主板", "3": "创业板", "68": "科创板", "8": "北交所",
"4": "北交所", "9": "B股",
}
# 行业关键词(股票名 → 行业),用于数据驱动替代标的选择
_INDUSTRY_HINTS = [
(["白酒", "酒业", "茅台", "五粮液", "泸州", "古井", "洋河", "酒鬼"], "白酒"),
(["银行"], "银行"),
(["证券", "券商"], "券商"),
(["保险"], "保险"),
(["宁德", "比亚迪", "锂", "电池", "新能源"], "新能源"),
(["地产", "万科", "保利", "金地"], "地产"),
(["医药", "药", "医疗", "生物"], "医药"),
(["科技", "芯片", "半导体", "软件"], "科技"),
(["通信", "移动", "电信"], "通信"),
]
def _industry_of(name: str) -> str:
"""按股票名关键词判断行业(简单词库)。"""
if not name:
return ""
for keywords, tag in _INDUSTRY_HINTS:
if any(k in name for k in keywords):
return tag
return ""
def _normalize_series(nav_data: Union[List[Dict[str, Any]], Dict[str, Any]]) -> List[float]:
"""从各种入参形态提取升序净值序列。
支持:
- [{"date":..., "nav":1.2}, ...]
- [1.1, 1.2, 1.3](纯数值列表)
- {"dates": [...], "navs": [...]}
空值/NaN 过滤;返回浮点列表。
"""
values: List[float] = []
if isinstance(nav_data, list):
for item in nav_data:
if isinstance(item, dict):
v = item.get("nav")
if v is None:
v = item.get("value")
else:
v = item
values.append(_to_float(v))
elif isinstance(nav_data, dict):
navs = nav_data.get("navs") or nav_data.get("values") or []
values = [_to_float(v) for v in navs]
return [v for v in values if v is not None and not math.isnan(v) and v > 0]
def _to_float(v: Any) -> Optional[float]:
try:
if v is None:
return None
return float(v)
except (TypeError, ValueError):
return None
def _pct_changes(values: Sequence[float]) -> List[float]:
"""逐日收益率序列。"""
out = []
for i in range(1, len(values)):
prev = values[i - 1]
if prev > 0:
out.append(values[i] / prev - 1.0)
return out
def _max_drawdown(values: Sequence[float]) -> float:
"""最大回撤(正数,如 0.25 = 回撤 25%)。"""
peak = values[0]
max_dd = 0.0
for v in values:
if v > peak:
peak = v
if peak > 0:
dd = 1.0 - v / peak
if dd > max_dd:
max_dd = dd
return max_dd
def _annualized_return(values: Sequence[float]) -> float:
if len(values) < 2:
return 0.0
start, end = values[0], values[-1]
if start <= 0:
return 0.0
total_return = end / start - 1.0
years = (len(values) - 1) / TRADING_DAYS
if years <= 0:
return total_return
return (1.0 + total_return) ** (1.0 / years) - 1.0
def calculate_risk_metrics(nav_data: Union[List[Dict[str, Any]], Dict[str, Any]],
risk_free_rate: float = DEFAULT_RISK_FREE) -> Dict[str, Any]:
"""计算真实风险指标(v5.0.0)。
Args:
nav_data: 两种入参均支持(向后兼容):
- List[{"date":..., "nav":...}] 净值时间序列(升序)→ 真实指标
- Dict 旧格式 {"1month":2.3,"3month":5.6,"1year":15.7} → 估算,标注 estimate:true
risk_free_rate: 无风险利率(年化,默认 0.02)
Returns:
{annualized_return, annualized_volatility, max_drawdown, sharpe_ratio,
calmar_ratio, sortino_ratio, win_rate, best_day, worst_day,
positive_days, total_days};
数据不足 30 点 → {"insufficient_data": True, "points": n}(不伪造数值)。
"""
# 旧 dict 格式(周期收益率,无时间序列)
if isinstance(nav_data, dict) and not (nav_data.get("navs") or nav_data.get("values")):
return _estimate_metrics_from_periods(nav_data, risk_free_rate)
values = _normalize_series(nav_data)
if len(values) < 30:
return {"insufficient_data": True, "points": len(values)}
# v7.7 缓存(同序列+同无风险利率直接命中)
cache_key = (tuple(values), round(float(risk_free_rate), 6))
cached = _METRICS_CACHE.get(cache_key)
if cached is not None:
return dict(cached)
returns = _pct_changes(values)
if not returns:
return {"insufficient_data": True, "points": len(values)}
ann_ret = _annualized_return(values)
ann_vol = (statistics.pstdev(returns) if len(returns) > 1 else 0.0) * math.sqrt(TRADING_DAYS)
max_dd = _max_drawdown(values)
sharpe = (ann_ret - risk_free_rate) / ann_vol if ann_vol > 1e-9 else 0.0
calmar = ann_ret / max_dd if max_dd > 1e-9 else 0.0
positive_days = sum(1 for r in returns if r > 0)
# v7.7 Sortino:下行偏差口径(只惩罚亏损波动)
downside_sq = [min(r, 0.0) ** 2 for r in returns]
downside_dev = math.sqrt(sum(downside_sq) / len(downside_sq)) * math.sqrt(TRADING_DAYS) \
if downside_sq else 0.0
sortino = (ann_ret - risk_free_rate) / downside_dev if downside_dev > 1e-9 else 0.0
result = {
"annualized_return": round(ann_ret, 6),
"annualized_volatility": round(ann_vol, 6),
"max_drawdown": round(max_dd, 6),
"sharpe_ratio": round(sharpe, 4),
"calmar_ratio": round(calmar, 4),
"sortino_ratio": round(sortino, 4),
"win_rate": round(positive_days / len(returns), 4),
"best_day": round(max(returns), 6),
"worst_day": round(min(returns), 6),
"positive_days": positive_days,
"total_days": len(returns),
"points": len(values),
}
if len(_METRICS_CACHE) >= _METRICS_CACHE_MAX:
_METRICS_CACHE.pop(next(iter(_METRICS_CACHE))) # FIFO 淘汰最旧
_METRICS_CACHE[cache_key] = dict(result)
return result
def _estimate_metrics_from_periods(nav_data: Dict[str, Any],
risk_free_rate: float = DEFAULT_RISK_FREE) -> Dict[str, Any]:
"""旧格式估算(仅 1year/3year 等周期收益率,无序列)— 标注 estimate:true。"""
one_year = _to_float(nav_data.get("1year"))
three_year = _to_float(nav_data.get("3year"))
max_dd = _to_float(nav_data.get("max_drawdown"))
out: Dict[str, Any] = {"estimate": True}
if one_year is not None:
out["annual_return"] = one_year / 100.0
if three_year is not None:
try:
out["annual_return_3y"] = (1 + three_year / 100.0) ** (1 / 3) - 1
except (ValueError, TypeError):
pass
if max_dd is not None and max_dd != 0:
out["calmar_ratio"] = abs(out.get("annual_return", 0.0) / (max_dd / 100.0))
return out
def nav_trend_analysis(nav_data: Union[List[Dict[str, Any]], Dict[str, Any]],
windows: Sequence[int] = (20, 60)) -> Dict[str, Any]:
"""v7.7 净值趋势分析(纯函数,可离线单测)。
Returns:
points / return_{w}d(区间收益)/ daily_slope(回归斜率,相对口径)/
annualized_slope / off_peak(距峰值回撤)/ at_high(是否高位附近)/
vol_ratio_recent20(近 20 日波动/全期波动)/ trend(定性标签);
数据不足 10 点 → {"insufficient_data": True, "points": n}。
"""
values = _normalize_series(nav_data)
if len(values) < 10:
return {"insufficient_data": True, "points": len(values)}
out: Dict[str, Any] = {"points": len(values)}
for w in windows:
if len(values) > w:
seg = values[-(w + 1):]
out[f"return_{w}d"] = round(seg[-1] / seg[0] - 1.0, 6)
# 线性回归斜率(相对口径,免量纲)
n = len(values)
mean_x = (n - 1) / 2.0
mean_y = sum(values) / n
den = sum((i - mean_x) ** 2 for i in range(n))
slope = sum((i - mean_x) * (values[i] - mean_y) for i in range(n)) / den if den else 0.0
daily_slope = slope / mean_y if mean_y > 0 else 0.0
out["daily_slope"] = round(daily_slope, 8)
out["annualized_slope"] = round(daily_slope * TRADING_DAYS, 6)
peak = max(values)
out["off_peak"] = round(1.0 - values[-1] / peak, 6)
out["at_high"] = values[-1] >= peak * 0.999
rets = _pct_changes(values)
if len(rets) >= 40:
all_vol = statistics.pstdev(rets)
out["vol_ratio_recent20"] = round(statistics.pstdev(rets[-20:]) / all_vol, 3) \
if all_vol > 1e-12 else None
ann_slope = out["annualized_slope"]
if ann_slope > 0.10 and out["off_peak"] < 0.05:
out["trend"] = "强上行"
elif ann_slope > 0.02:
out["trend"] = "缓步上行"
elif ann_slope >= -0.02:
out["trend"] = "震荡"
else:
out["trend"] = "下行"
return out
# ============================================================
# 2. 投资风格分析(数据驱动)
# ============================================================
def analyze_investment_style(holdings: Dict[str, Any],
nav_data: Union[List[Dict[str, Any]], Dict[str, float]] = None) -> str:
"""分析投资风格,返回风格标签字符串(向后兼容返回 str)。
增强点(v5.0.0):
- 持仓集中度:CR5 / CR10(按权重降序)
- 市值暴露:基于股票代码(沪主板/深主板/创业板/科创板/北交所)
- 风格定位:基于 1year 收益或净值序列斜率
"""
labels = []
stocks = holdings.get("stocks") or []
if stocks:
# 集中度
weights = sorted((_to_float(s.get("weight")) or 0.0 for s in stocks), reverse=True)
cr5 = sum(weights[:5])
if stocks and cr5 >= 50:
labels.append("集中")
else:
labels.append("分散")
# 板块暴露
boards = {"沪主板": 0, "深主板": 0, "创业板": 0, "科创板": 0, "北交所": 0}
for s in stocks:
code = str(s.get("code", ""))
if code.startswith("68"):
boards["科创板"] += 1
elif code.startswith(("30", "300")):
boards["创业板"] += 1
elif code.startswith(("4", "8", "920")):
boards["北交所"] += 1
elif code.startswith("6"):
boards["沪主板"] += 1
elif code.startswith(("0", "2", "3")):
boards["深主板"] += 1
main = max(boards.items(), key=lambda kv: kv[1])
if main[1] > 0:
labels.append(main[0])
# 风格定位:基于收益
if nav_data is not None:
if isinstance(nav_data, list):
values = _normalize_series(nav_data)
if len(values) >= 5:
momentum = values[-1] / values[0] - 1.0
if momentum > 0.30:
labels.append("积极成长")
elif momentum > 0.10:
labels.append("成长")
elif momentum > 0.0:
labels.append("均衡")
else:
labels.append("稳健")
else:
one_year = _to_float(nav_data.get("1year"))
if one_year is not None:
if one_year > 30:
labels.append("积极成长")
elif one_year > 15:
labels.append("成长")
elif one_year > 5:
labels.append("均衡")
else:
labels.append("稳健")
return "-".join(labels) if labels else "均衡型"
# ============================================================
# 3. 相似产品推荐(数值距离)
# ============================================================
def _product_feature_vector(product_info: Dict[str, Any]) -> List[float]:
"""提取产品特征向量(风格分/收益/风险),用于数值距离。"""
hist = product_info.get("historical_nav") or {}
if isinstance(hist, list):
values = _normalize_series(hist)
metrics = calculate_risk_metrics(values)
if metrics.get("insufficient_data"):
return [0.0, 0.0, 0.0]
return [
metrics.get("annualized_return", 0.0) * 100, # 收益
metrics.get("annualized_volatility", 0.0) * 100, # 波动
metrics.get("max_drawdown", 0.0) * 100, # 回撤
]
# 旧 dict 格式
return [
_to_float(hist.get("1year")) or 0.0,
_to_float(hist.get("6month")) or 0.0,
_to_float(hist.get("3month")) or 0.0,
]
def _euclidean(a: Sequence[float], b: Sequence[float]) -> float:
if not a or not b:
return float("inf")
n = min(len(a), len(b))
return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(n)))
def suggest_similar_products(product_info: Dict[str, Any], limit: int = 3,
metric: str = "euclidean") -> List[Dict[str, Any]]:
"""推荐相似产品 — 基于特征向量数值距离(v5.0.0 替代写死映射)。
候选来自本地基金数据库 data/fund_managers_distilled.json(如存在)。
"""
db_path = SKILL_DATA_DIR / "fund_managers_distilled.json"
if not db_path.exists():
return [{"note": "本地数据库不可用,请先更新基金数据"}]
target_vec = _product_feature_vector(product_info)
target_type = product_info.get("product_type", "")
target_style = product_info.get("investment_style", "")
try:
with open(db_path, "r", encoding="utf-8") as f:
fund_db = json.load(f)
fund_data = fund_db.get("managers", []) if isinstance(fund_db, dict) else fund_db
except Exception as e:
return [{"error": f"数据库查询失败: {e}"}]
scored = []
for item in fund_data[:2000]:
vec = [
_to_float(item.get("annual_return")) or 0.0,
_to_float(item.get("volatility")) or 0.0,
_to_float(item.get("max_drawdown")) or 0.0,
]
dist = _euclidean(target_vec, vec)
bonus = 0.0
if target_type and target_type in str(item.get("fund_type", "")):
bonus -= 5.0
if target_style and target_style in str(item.get("investment_style", "")):
bonus -= 3.0
scored.append((dist + bonus, item))
scored.sort(key=lambda x: x[0])
out = []
for _, item in scored[:limit]:
out.append({
"name": item.get("name", ""),
"fund_name": item.get("current_fund_name", ""),
"company": item.get("company_name", ""),
"style": item.get("investment_style", ""),
"distance": round(_, 4),
"metric": metric,
})
return out
# ============================================================
# 4. 组合复刻(数据驱动替代标的)
# ============================================================
# 旧硬编码替代映射(v5.0.0 起仅作 fallback,命中时标注 mapping_source=fallback)
_LEGACY_ALTERNATIVES = {
"贵州茅台": ["五粮液", "泸州老窖", "洋河股份"],
"宁德时代": ["比亚迪", "国轩高科", "亿纬锂能"],
"五粮液": ["贵州茅台", "泸州老窖", "古井贡酒"],
"比亚迪": ["宁德时代", "理想汽车", "小鹏汽车"],
"招商银行": ["宁波银行", "平安银行", "兴业银行"],
"中国平安": ["中国人寿", "新华保险", "太平洋保险"],
"腾讯控股": ["阿里巴巴", "美团", "京东"],
"阿里巴巴": ["腾讯控股", "拼多多", "京东"],
}
def suggest_alternatives(stock_name: str) -> List[str]:
"""推荐股票替代品(fallback 映射,v5.0.0 保留作降级)。"""
return _LEGACY_ALTERNATIVES.get(stock_name, ["同行业龙头股"])
def generate_portfolio_replication(product_info: Dict[str, Any],
candidates: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""生成投资组合复刻建议。
v5.0.0 增强:
- allocation.alternative 优先数据驱动(同行业 + 市值接近),标注 mapping_source
- 候选缺失时回退旧映射(mapping_source=fallback)
"""
replication = {
"target_product": product_info.get("product_name", "未知"),
"target_code": product_info.get("product_code", ""),
"target_style": "",
"allocation": [],
"similar_products": [],
"total_suggestion": "",
"mapping_source": "data-driven",
}
holdings = product_info.get("holdings", {})
nav_data = product_info.get("historical_nav", {})
style = analyze_investment_style(holdings, nav_data)
replication["target_style"] = style
stocks = holdings.get("stocks") or []
if stocks:
for i, stock in enumerate(stocks[:10], 1):
alt = suggest_alternatives(stock.get("name", ""))
src = "fallback"
if candidates:
# 数据驱动:同行业 + 权重接近的候选
alt = _data_driven_alternatives(stock, candidates, limit=2)
src = "data-driven"
replication["allocation"].append({
"rank": i,
"stock_code": stock.get("code", ""),
"stock_name": stock.get("name", ""),
"weight": stock.get("weight", 0),
"alternative": alt,
"mapping_source": src,
})
replication["similar_products"] = suggest_similar_products(product_info)
replication["total_suggestion"] = (
f"基于{replication['target_product']}的分析:\n"
f"- 投资风格:{style}\n"
f"- 建议持有周期:3-5 年\n"
f"- 适合投资者类型:{'积极型' if '成长' in style or '集中' in style else '稳健型'}\n"
f"- 配置建议:可将总资产的20-30%配置于同类风格产品"
)
return replication
def _data_driven_alternatives(stock: Dict[str, Any], candidates: List[Dict[str, Any]],
limit: int = 2) -> List[str]:
"""数据驱动替代:同行业(词库优先)+ 同板块 + 权重接近的候选股票名。"""
code = str(stock.get("code", ""))
board = _CODE_INDUSTRY_HINT.get(code[:1], "") or _CODE_INDUSTRY_HINT.get(code[:2], "")
stock_name = stock.get("name", "")
industry = _industry_of(stock_name)
weight = _to_float(stock.get("weight")) or 0.0
scored = []
for c in candidates:
c_code = str(c.get("code", ""))
c_board = _CODE_INDUSTRY_HINT.get(c_code[:1], "") or _CODE_INDUSTRY_HINT.get(c_code[:2], "")
c_name = c.get("name", "")
if not c_name or c_name == stock_name:
continue
score = 0.0
# 行业匹配权重最高
if industry and _industry_of(c_name) == industry:
score += 2.0
if board and c_board == board:
score += 0.5
c_weight = _to_float(c.get("weight")) or 0.0
score += 0.3 * (1.0 - min(abs(c_weight - weight) / 10.0, 1.0)) # 权重接近加分
scored.append((score, c_name))
scored.sort(key=lambda x: -x[0])
out = [n for _, n in scored[:limit] if _ > 0]
return out if out else suggest_alternatives(stock_name)
# ============================================================
# 5. 综合分析报告(保持 analyze_product 输出格式)
# ============================================================
def analyze_product(product_info: Dict[str, Any]) -> str:
"""综合分析产品并生成报告(v5.0.0 用真实风险指标替换占位估算)。"""
name = product_info.get("product_name", "未知")
code = product_info.get("product_code", "")
product_type = product_info.get("product_type", "")
company = product_info.get("company", "")
manager = product_info.get("manager", "")
risk = product_info.get("risk_level", "")
nav = product_info.get("nav", {})
historical = product_info.get("historical_nav", {})
holdings = product_info.get("holdings", {})
risk_metrics = product_info.get("risk_metrics", {})
style = analyze_investment_style(holdings, historical)
metrics_calc = calculate_risk_metrics(historical)
trend_calc = nav_trend_analysis(historical) \
if isinstance(historical, list) else None
report = f"""
【产品分析报告】
■ 基本信息
名称:{name}
代码:{code}
类型:{product_type}
公司:{company}
经理:{manager}
风险等级:{risk}
■ 收益表现
{"-"*40}
"""
if nav:
report += f"最新净值:{nav.get('current', 'N/A')}\n"
report += f"日涨跌幅:{nav.get('daily_change', 'N/A')}%\n"
if isinstance(historical, dict):
report += "\n历史收益:\n"
for period, value in historical.items():
if period in ("1month", "3month", "6month", "1year", "3year"):
report += f" 近{period}:{value}%\n"
report += f"""
■ 风险指标(v5.0.0 真实计算)
{"-"*40}
"""
for k, v in risk_metrics.items():
report += f"{k}:{v}\n"
if metrics_calc.get("insufficient_data"):
report += f"净值数据不足({metrics_calc.get('points', 0)} 点),无法计算真实风险指标\n"
else:
labels = {
"annualized_return": "年化收益", "annualized_volatility": "年化波动",
"max_drawdown": "最大回撤", "sharpe_ratio": "夏普比率",
"calmar_ratio": "卡玛比率", "sortino_ratio": "索提诺比率",
"win_rate": "胜率", "best_day": "单日最佳", "worst_day": "单日最差",
"positive_days": "正收益天数",
}
for k, v in metrics_calc.items():
if k in labels:
report += f"{labels[k]}:{v:.4f}\n" if isinstance(v, float) else f"{labels[k]}:{v}\n"
if trend_calc and not trend_calc.get("insufficient_data"):
report += (f"\n趋势(v7.7):{trend_calc.get('trend', '')},"
f"距峰值 {trend_calc.get('off_peak', 0) * 100:.2f}%,"
f"年化斜率 {trend_calc.get('annualized_slope', 0) * 100:.1f}%\n")
report += f"""
■ 持仓分析
{"-"*40}
"""
if holdings.get("stocks"):
report += "前十大重仓股:\n"
for i, stock in enumerate(holdings["stocks"][:10], 1):
report += f" {i}. {stock.get('name', '')}({stock.get('code', '')}) - {stock.get('weight', 0)}%\n"
if holdings.get("top_industry"):
report += f"\n重点行业:{holdings['top_industry']}\n"
report += f"""
■ 风格定位
{"-"*40}
{style}
■ 综合建议
{"-"*40}
"""
if "成长" in style:
report += "该产品为成长风格,适合积极型投资者,建议定投介入,持有周期3年以上。\n"
elif "均衡" in style:
report += "该产品为均衡风格,适合稳健型投资者,可作为资产配置的一部分。\n"
elif "稳健" in style:
report += "该产品为稳健风格,适合保守型投资者,可用于资产保值。\n"
else:
report += "建议根据自身风险偏好适量配置。\n"
return report
if __name__ == "__main__":
# 测试
test_product = {
"product_name": "华夏成长混合",
"product_code": "000001",
"product_type": "混合型-灵活配置",
"company": "华夏基金",
"manager": "张明",
"risk_level": "中高风险",
"nav": {"current": 3.4567, "daily_change": 1.23},
"historical_nav": {"1month": 2.34, "3month": 5.89, "6month": 8.45, "1year": 15.67},
"holdings": {
"stocks": [
{"code": "600519", "name": "贵州茅台", "weight": 5.2},
{"code": "000858", "name": "五粮液", "weight": 4.8},
{"code": "300750", "name": "宁德时代", "weight": 4.5}
],
"top_industry": "食品饮料"
}
}
# 真实风险指标(合成 120 天序列)
synthetic = [{"date": f"2026-01-{i:02d}", "nav": 1.0 * (1 + 0.001 * i + (0.002 if i % 3 == 0 else 0))}
for i in range(1, 121)]
print("真实风险指标(120点):")
print(json.dumps(calculate_risk_metrics(synthetic), ensure_ascii=False, indent=2))
print("\n旧格式估算:")
print(json.dumps(calculate_risk_metrics({"1year": 15.67}), ensure_ascii=False, indent=2))
print("\n" + "=" * 50)
print(analyze_product(test_product))