""" 投资组合分析引擎 — 数据加载 + 风险指标 + Brinson 归因。 用法: import sys, os sys.path.insert(0, os.path.expanduser("~/.claude/skills/portfolio-analyzer/scripts")) from portfolio_analyzer import ( load_portfolio_data, fetch_benchmark, fetch_shenwan_returns, compute_risk_metrics, compute_brinson, ) 依赖: - pandas, numpy, openpyxl - windpy-sdk/scripts/wind_client.py (fetch_benchmark, fetch_shenwan_returns) """ from __future__ import annotations import os import sys from pathlib import Path import numpy as np import pandas as pd # --------------------------------------------------------------------------- # Wind 客户端(延迟导入,仅在 fetch 函数中使用) # --------------------------------------------------------------------------- _WIND_CLIENT_DIR = str( Path(__file__).resolve().parent.parent.parent / "windpy-sdk" / "scripts" ) def _get_wind_client(): """延迟导入 wind_client,避免非 Windows 环境报错。""" if _WIND_CLIENT_DIR not in sys.path: sys.path.insert(0, _WIND_CLIENT_DIR) import wind_client return wind_client # --------------------------------------------------------------------------- # 申万一级行业代码 # --------------------------------------------------------------------------- SHENWAN_L1_CODES = [ "801010.SI", "801020.SI", "801030.SI", "801040.SI", "801050.SI", "801080.SI", "801880.SI", "801110.SI", "801120.SI", "801130.SI", "801140.SI", "801150.SI", "801160.SI", "801170.SI", "801180.SI", "801200.SI", "801210.SI", "801780.SI", "801790.SI", "801720.SI", "801710.SI", "801730.SI", "801740.SI", "801750.SI", "801760.SI", "801770.SI", "801950.SI", "801960.SI", "801970.SI", "801890.SI", "801980.SI", ] SHENWAN_L1_NAMES = { "801010.SI": "农林牧渔", "801020.SI": "采掘", "801030.SI": "化工", "801040.SI": "钢铁", "801050.SI": "有色金属", "801080.SI": "电子", "801880.SI": "汽车", "801110.SI": "家用电器", "801120.SI": "食品饮料", "801130.SI": "纺织服饰", "801140.SI": "轻工制造", "801150.SI": "医药生物", "801160.SI": "公用事业", "801170.SI": "交通运输", "801180.SI": "房地产", "801200.SI": "商贸零售", "801210.SI": "社会服务", "801780.SI": "银行", "801790.SI": "非银金融", "801720.SI": "建筑装饰", "801710.SI": "建筑材料", "801730.SI": "电力设备", "801740.SI": "国防军工", "801750.SI": "计算机", "801760.SI": "传媒", "801770.SI": "通信", "801950.SI": "煤炭", "801960.SI": "石油石化", "801970.SI": "环保", "801890.SI": "机械设备", "801980.SI": "美容护理", } # 行业名称别名 → 标准名 _INDUSTRY_ALIASES = { "农业": "农林牧渔", "有色": "有色金属", "家电": "家用电器", "食品": "食品饮料", "医药": "医药生物", "生物医药": "医药生物", "地产": "房地产", "商贸": "商贸零售", "零售": "商贸零售", "军工": "国防军工", "非银": "非银金融", "电新": "电力设备", "新能源": "电力设备", "建材": "建筑材料", "建筑": "建筑装饰", } # 反向映射: 标准名 → Wind 代码 _NAME_TO_CODE = {v: k for k, v in SHENWAN_L1_NAMES.items()} def match_industry(name: str) -> str | None: """将行业名称模糊匹配到 Wind 申万一级行业指数代码。""" name = name.strip() # 精确匹配 if name in _NAME_TO_CODE: return _NAME_TO_CODE[name] # 别名匹配 if name in _INDUSTRY_ALIASES: return _NAME_TO_CODE.get(_INDUSTRY_ALIASES[name]) # 包含匹配 for standard_name, code in _NAME_TO_CODE.items(): if standard_name in name or name in standard_name: return code return None # =================================================================== # 1. load_portfolio_data — 解析 Excel 中的 5 张表 # =================================================================== # 列名模糊匹配模式 _COL_PATTERNS = { # 时序分析 "date": ["日期", "交易日期", "Date", "TradeDate", "trade_date"], "portfolio_code": ["组合代码", "组合编码", "PortCode", "产品代码", "port_code"], "portfolio_name": ["组合名称", "组合简称", "PortName", "产品名称", "port_name"], "nav": ["单位净值", "净值", "NAV", "UnitNAV", "unit_nav"], "acc_nav": ["累计净值", "AccNAV", "累计单位净值", "acc_nav"], "daily_return": ["日收益率", "DailyReturn", "日回报", "daily_return"], "acc_return": ["累计收益率", "AccReturn", "累计回报", "acc_return"], # 交易明细 "sec_code": ["证券代码", "股票代码", "SecCode", "sec_code", "wind_code"], "sec_name": ["证券名称", "股票名称", "SecName", "sec_name"], "direction": ["买卖方向", "交易方向", "Direction", "direction", "side"], "volume": ["成交数量", "交易数量", "Volume", "volume"], "price": ["成交价格", "交易价格", "Price", "price"], "amount": ["成交金额", "交易金额", "Amount", "amount"], "commission": ["手续费", "佣金", "Commission", "commission"], # 收益贡献 "industry": ["行业", "行业名称", "申万行业", "Industry", "industry", "所属行业"], "market_value": ["持仓市值", "市值", "MarketValue", "market_value"], "weight": ["权重", "持仓权重", "Weight", "weight"], "sec_return": ["收益率", "个股收益率", "SecReturn", "sec_return"], "return_contrib": ["收益贡献", "收益率贡献", "绝对收益率贡献", "ReturnContrib", "return_contrib"], # 业绩归因 "asset_class": ["资产大类", "大类资产", "AssetClass", "asset_class"], "asset_subclass": ["资产类别", "子类", "AssetSubclass", "asset_subclass"], "port_weight": ["组合权重", "实际权重", "PortWeight", "port_weight", "行业权重"], "bench_weight": ["基准权重", "BenchWeight", "bench_weight"], "port_return": ["组合收益", "实际收益", "PortReturn", "port_return", "行业收益"], "bench_return": ["基准收益", "BenchReturn", "bench_return"], "allocation": ["配置效应", "Allocation", "allocation"], "selection": ["选股效应", "Selection", "selection"], "interaction": ["交互效应", "Interaction", "interaction"], "total_attribution": ["总归因", "超额收益", "TotalAttribution", "total_attribution"], } def _match_columns(df: pd.DataFrame) -> dict[str, str]: """将 DataFrame 列名映射到标准化列名。返回 {标准名: 原始列名}。""" mapping = {} cols = list(df.columns) for std_name, patterns in _COL_PATTERNS.items(): for pat in patterns: for col in cols: if col.strip() == pat: mapping[std_name] = col break if std_name in mapping: break return mapping def _rename_columns(df: pd.DataFrame, mapping: dict[str, str]) -> pd.DataFrame: """用标准化列名重命名 DataFrame。""" reverse = {v: k for k, v in mapping.items()} return df.rename(columns=reverse) def _identify_sheet(df: pd.DataFrame) -> str: """根据特征列识别表类型。""" cols = {c.strip() for c in df.columns} nav_kw = {"单位净值", "累计净值", "NAV", "净值", "UnitNAV", "unit_nav"} trade_kw = {"成交金额", "交易金额", "买卖方向", "交易方向", "Amount", "Direction"} contrib_kw = {"收益贡献", "收益率贡献", "绝对收益率贡献", "ReturnContrib", "return_contrib"} asset_kw = {"资产大类", "大类资产", "AssetClass", "asset_class"} industry_kw = {"行业", "行业名称", "申万行业", "Industry", "industry", "所属行业"} brinson_kw = {"配置效应", "选股效应", "Allocation", "Selection", "allocation", "selection"} if cols & nav_kw: return "nav_timeseries" if cols & trade_kw: return "trade_detail" if cols & contrib_kw: return "return_contribution" if cols & asset_kw: return "asset_attribution" if (cols & industry_kw) and (cols & brinson_kw): return "industry_attribution" return "unknown" def _clean_numeric(s: pd.Series) -> pd.Series: """清洗数值列:去除百分号、逗号、空格。""" if s.dtype == object: s = s.str.replace("%", "", regex=False) s = s.str.replace(",", "", regex=False) s = s.str.strip() return pd.to_numeric(s, errors="coerce") def _detect_percentage(s: pd.Series) -> bool: """检测列值是否为百分比形式(值域 ±100 而非 ±1)。""" valid = s.dropna() if len(valid) == 0: return False return valid.abs().max() > 1.0 and valid.abs().max() <= 100.0 _DATE_FORMATS = ["%Y-%m-%d", "%Y/%m/%d", "%Y%m%d", "%Y-%m-%d %H:%M:%S"] def _parse_dates(s: pd.Series) -> pd.Series: """尝试多种格式解析日期列。""" for fmt in _DATE_FORMATS: try: return pd.to_datetime(s, format=fmt) except (ValueError, TypeError): continue return pd.to_datetime(s, errors="coerce") def _process_nav_sheet(df: pd.DataFrame, mapping: dict[str, str]) -> pd.DataFrame: """处理时序分析表:标准化列名、日期解析、pivot 为宽表。""" df = _rename_columns(df, mapping) if "date" in df.columns: df["date"] = _parse_dates(df["date"]) if "nav" in df.columns: df["nav"] = _clean_numeric(df["nav"]) # 如果有组合代码列,pivot 为宽表 if "portfolio_code" in df.columns and "nav" in df.columns: nav_wide = df.pivot_table(index="date", columns="portfolio_code", values="nav") nav_wide = nav_wide.sort_index().ffill() return nav_wide return df def _process_trade_sheet(df: pd.DataFrame, mapping: dict[str, str]) -> pd.DataFrame: """处理交易明细表:标准化、统一买卖方向。""" df = _rename_columns(df, mapping) if "date" in df.columns: df["date"] = _parse_dates(df["date"]) for col in ["volume", "price", "amount", "commission"]: if col in df.columns: df[col] = _clean_numeric(df[col]) # 统一买卖方向 if "direction" in df.columns: df["direction"] = df["direction"].str.strip().replace( {"B": "买入", "S": "卖出", "买": "买入", "卖": "卖出"} ) return df def _process_contrib_sheet(df: pd.DataFrame, mapping: dict[str, str]) -> pd.DataFrame: """处理收益贡献表:标准化、按贡献绝对值排序。""" df = _rename_columns(df, mapping) for col in ["market_value", "weight", "sec_return", "return_contrib"]: if col in df.columns: df[col] = _clean_numeric(df[col]) if col in ("sec_return", "return_contrib", "weight") and _detect_percentage(df[col]): df[col] = df[col] / 100 if "return_contrib" in df.columns: df = df.sort_values("return_contrib", key=abs, ascending=False) return df def _process_attribution_sheet(df: pd.DataFrame, mapping: dict[str, str]) -> pd.DataFrame: """处理归因表(资产大类/行业):标准化数值列。""" df = _rename_columns(df, mapping) numeric_cols = [ "port_weight", "bench_weight", "port_return", "bench_return", "allocation", "selection", "interaction", "total_attribution", ] for col in numeric_cols: if col in df.columns: df[col] = _clean_numeric(df[col]) if _detect_percentage(df[col]): df[col] = df[col] / 100 return df def load_portfolio_data(filepath: str) -> dict[str, pd.DataFrame]: """ 加载投资组合 Excel 文件,自动识别 5 张表并解析。 Parameters ---------- filepath : str Excel 文件路径 Returns ------- dict 键为表类型,值为解析后的 DataFrame: - "nav_timeseries": 净值宽表(列=组合,index=日期) - "trade_detail": 交易明细 - "return_contribution": 收益贡献(按绝对值排序) - "asset_attribution": 资产大类归因 - "industry_attribution": 行业归因 """ xls = pd.ExcelFile(filepath) result = {} for sheet_name in xls.sheet_names: df = pd.read_excel(xls, sheet_name=sheet_name) if df.empty: continue sheet_type = _identify_sheet(df) if sheet_type == "unknown": continue mapping = _match_columns(df) if sheet_type == "nav_timeseries": result[sheet_type] = _process_nav_sheet(df, mapping) elif sheet_type == "trade_detail": result[sheet_type] = _process_trade_sheet(df, mapping) elif sheet_type == "return_contribution": result[sheet_type] = _process_contrib_sheet(df, mapping) elif sheet_type == "asset_attribution": result[sheet_type] = _process_attribution_sheet(df, mapping) elif sheet_type == "industry_attribution": result[sheet_type] = _process_attribution_sheet(df, mapping) return result # =================================================================== # 2. fetch_benchmark — 获取基准净值数据 # =================================================================== def fetch_benchmark( code: str, start: str, end: str, ) -> pd.DataFrame: """ 通过 Wind 获取基准指数净值(收盘价)。 Parameters ---------- code : str 基准指数代码,如 "000300.SH"(沪深 300) start : str 起始日期,如 "2025-01-01" end : str 结束日期,如 "2026-02-27" Returns ------- pd.DataFrame columns: ["close"],index: DatetimeIndex(name="date") """ wc = _get_wind_client() df = wc.wsd(code, "close", start, end) return df # =================================================================== # 3. fetch_shenwan_returns — 获取 31 行业收益率 # =================================================================== def fetch_shenwan_returns( start: str, end: str, ) -> pd.DataFrame: """ 通过 Wind 获取 31 个申万一级行业指数的区间涨跌幅。 Parameters ---------- start : str 起始日期 end : str 结束日期 Returns ------- pd.DataFrame index: 行业名称,columns: ["code", "pct_chg"] """ wc = _get_wind_client() codes = ",".join(SHENWAN_L1_CODES) df = wc.wss(codes, "pct_chg_per", f"startDate={start};endDate={end}") # 添加行业名称 df["industry"] = df.index.map(SHENWAN_L1_NAMES) df = df.rename(columns={"PCT_CHG_PER": "pct_chg"}) if "pct_chg" in df.columns: df["pct_chg"] = df["pct_chg"] / 100 # Wind 返回百分比数值 df["code"] = df.index df = df.set_index("industry") return df[["code", "pct_chg"]] # =================================================================== # 3b. fetch_benchmark_industry_weights — 基准指数的行业权重 # =================================================================== def fetch_benchmark_industry_weights( index_code: str, date: str, ) -> pd.Series: """ 获取基准指数在指定日期的申万一级行业权重分布。 步骤: 1. wset("indexconstituent") 拉取指数成分股及权重 2. wss() 查每只成分股的申万一级行业 3. 按行业聚合权重 Parameters ---------- index_code : str 指数代码,如 "000300.SH" date : str 日期,如 "20260227" 或 "2026-02-27" Returns ------- pd.Series index=行业名称, values=权重(0~1 之间),合计为 1 """ wc = _get_wind_client() date_str = date.replace("-", "") # 1. 获取指数成分股及权重 constituents = wc.wset( "indexconstituent", f"date={date_str};windcode={index_code}", ) # wset 返回列名通常为: wind_code, sec_name, i_weight 等 # 列名可能为大写或小写,做容错处理 cols_lower = {c.lower(): c for c in constituents.columns} code_col = cols_lower.get("wind_code", cols_lower.get("windcode")) weight_col = cols_lower.get("i_weight", cols_lower.get("weight")) if code_col is None or weight_col is None: raise ValueError( f"无法识别成分股列名,实际列: {list(constituents.columns)}" ) stocks = constituents[code_col].tolist() weights = pd.to_numeric(constituents[weight_col], errors="coerce").values if not stocks: raise ValueError(f"指数 {index_code} 在 {date} 无成分股数据") # 2. 查每只成分股的申万一级行业 # 分批查询(wss 一次最多约 100 只) batch_size = 80 industry_map = {} for i in range(0, len(stocks), batch_size): batch = stocks[i : i + batch_size] codes_str = ",".join(batch) ind_df = wc.wss( codes_str, "industry_sw", f"tradeDate={date_str};industryType=1", ) for code in batch: if code in ind_df.index: val = ind_df.loc[code, "INDUSTRY_SW"] industry_map[code] = val if pd.notna(val) else "其他" else: industry_map[code] = "其他" # 3. 按行业聚合权重 records = [] for code, w in zip(stocks, weights): ind_name = industry_map.get(code, "其他") records.append({"industry": ind_name, "weight": w}) agg = pd.DataFrame(records).groupby("industry")["weight"].sum() # 归一化为 0~1 total = agg.sum() if total > 0: agg = agg / total return agg.sort_values(ascending=False) # =================================================================== # 4. compute_risk_metrics — 计算风险指标 # =================================================================== def annualized_return(nav: pd.Series) -> float: """年化收益率。nav: 日频净值序列。""" total_return = nav.iloc[-1] / nav.iloc[0] n_days = len(nav) - 1 if n_days <= 0: return 0.0 return float(total_return ** (252 / n_days) - 1) def annualized_volatility(nav: pd.Series) -> float: """年化波动率(基于对数收益率)。""" log_returns = np.log(nav / nav.shift(1)).dropna() if len(log_returns) == 0: return 0.0 return float(log_returns.std() * np.sqrt(252)) def max_drawdown(nav: pd.Series) -> float: """最大回撤(返回正数,如 0.15 表示 15%)。""" running_max = nav.cummax() drawdown = (running_max - nav) / running_max return float(drawdown.max()) def sharpe_ratio(nav: pd.Series, rf: float = 0.015) -> float: """夏普比率。rf: 年化无风险利率,默认 1.5%。""" ann_ret = annualized_return(nav) ann_vol = annualized_volatility(nav) if ann_vol == 0: return 0.0 return float((ann_ret - rf) / ann_vol) def information_ratio(nav_p: pd.Series, nav_b: pd.Series) -> float: """信息比率。nav_p: 组合净值, nav_b: 基准净值(需日期对齐)。""" ret_p = np.log(nav_p / nav_p.shift(1)).dropna() ret_b = np.log(nav_b / nav_b.shift(1)).dropna() common = ret_p.index.intersection(ret_b.index) if len(common) == 0: return 0.0 excess = ret_p.loc[common] - ret_b.loc[common] te = excess.std() * np.sqrt(252) if te == 0: return 0.0 ann_excess = annualized_return(nav_p) - annualized_return(nav_b) return float(ann_excess / te) def calmar_ratio(nav: pd.Series) -> float: """卡尔玛比率 = 年化收益 / 最大回撤。""" mdd = max_drawdown(nav) if mdd == 0: return 0.0 return float(annualized_return(nav) / mdd) def sortino_ratio(nav: pd.Series, rf: float = 0.015) -> float: """索提诺比率。""" log_returns = np.log(nav / nav.shift(1)).dropna() daily_rf = rf / 252 downside = log_returns[log_returns < daily_rf] - daily_rf if len(downside) == 0: return 0.0 downside_vol = downside.std() * np.sqrt(252) if downside_vol == 0: return 0.0 return float((annualized_return(nav) - rf) / downside_vol) def win_rate(nav: pd.Series) -> float: """胜率:正收益天数占比。""" daily_returns = nav.pct_change().dropna() if len(daily_returns) == 0: return 0.0 return float((daily_returns > 0).sum() / len(daily_returns)) def compute_risk_metrics( nav: pd.Series, benchmark: pd.Series | None = None, rf: float = 0.015, ) -> dict[str, float]: """ 计算全套风险指标。 Parameters ---------- nav : pd.Series 组合日频净值序列(index 为日期) benchmark : pd.Series, optional 基准日频净值序列(用于计算信息比率) rf : float 年化无风险利率,默认 1.5% Returns ------- dict 包含 8 个指标的字典 """ metrics = { "年化收益率": annualized_return(nav), "年化波动率": annualized_volatility(nav), "最大回撤": max_drawdown(nav), "夏普比率": sharpe_ratio(nav, rf), "卡尔玛比率": calmar_ratio(nav), "索提诺比率": sortino_ratio(nav, rf), "胜率": win_rate(nav), } if benchmark is not None: metrics["信息比率"] = information_ratio(nav, benchmark) else: metrics["信息比率"] = None return metrics # =================================================================== # 5. compute_brinson — Brinson 三因素归因 # =================================================================== def compute_brinson( port_weights: pd.Series, bench_weights: pd.Series, port_returns: pd.Series, bench_returns: pd.Series, ) -> pd.DataFrame: """ Brinson 三因素归因(配置 + 选股 + 交互)。 Parameters ---------- port_weights : pd.Series 组合行业权重,index=行业名 bench_weights : pd.Series 基准行业权重 port_returns : pd.Series 组合行业收益率 bench_returns : pd.Series 基准行业收益率 Returns ------- pd.DataFrame columns: ["配置效应", "选股效应", "交互效应", "总归因"] index: 行业名称 """ # 对齐行业(取并集,缺失补 0) all_industries = ( port_weights.index .union(bench_weights.index) .union(port_returns.index) .union(bench_returns.index) ) pw = port_weights.reindex(all_industries, fill_value=0) bw = bench_weights.reindex(all_industries, fill_value=0) pr = port_returns.reindex(all_industries, fill_value=0) br = bench_returns.reindex(all_industries, fill_value=0) bench_total = (bw * br).sum() allocation = (pw - bw) * (br - bench_total) selection_ = bw * (pr - br) interaction_ = (pw - bw) * (pr - br) total = allocation + selection_ + interaction_ result = pd.DataFrame({ "配置效应": allocation, "选股效应": selection_, "交互效应": interaction_, "总归因": total, }) return result.sort_values("总归因", ascending=False) # =================================================================== # 辅助分析函数 # =================================================================== def align_nav(nav1: pd.Series, nav2: pd.Series) -> tuple[pd.Series, pd.Series]: """按日期取交集并对齐两个净值序列。""" common = nav1.index.intersection(nav2.index) return nav1.loc[common], nav2.loc[common] def rolling_volatility(nav: pd.Series, window: int = 20) -> pd.Series: """滚动年化波动率。""" log_ret = np.log(nav / nav.shift(1)) return log_ret.rolling(window).std() * np.sqrt(252) def drawdown_series(nav: pd.Series) -> pd.Series: """每日回撤幅度(正数表示回撤)。""" running_max = nav.cummax() return (running_max - nav) / running_max def top_drawdowns(nav: pd.Series, n: int = 5) -> pd.DataFrame: """提取前 N 次最大回撤的起止日期和幅度。""" dd = drawdown_series(nav) results = [] remaining = dd.copy() for _ in range(n): if remaining.max() == 0: break end_idx = remaining.idxmax() before = remaining.loc[:end_idx] start_candidates = before[before == 0] start_idx = start_candidates.index[-1] if len(start_candidates) > 0 else before.index[0] results.append({ "起始日期": start_idx, "最低点日期": end_idx, "回撤幅度": remaining[end_idx], }) remaining.loc[start_idx:end_idx] = 0 return pd.DataFrame(results) def cumulative_return(nav: pd.Series) -> pd.Series: """累计收益率序列。""" return nav / nav.iloc[0] - 1 def daily_returns(nav: pd.Series) -> pd.Series: """日收益率序列。""" return nav.pct_change().dropna()