# -*- 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))