skills/portfolio-analyzer/scripts/portfolio_analyzer.py

714 lines
24 KiB
Python
Raw Normal View History

"""
投资组合分析引擎 数据加载 + 风险指标 + 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()