Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:
- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
- docx family: kept docx (official, full toolchain) + docx-cn
(GB/T 9704 Chinese official-document constants),
dropped docx_writer (no scripts, name collided with docx)
- humanizer family: kept humanizer-zh (6 zh reference docs),
dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
live credentials
Result: 25 skills, 572 files, ~7.5 MB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
714 lines
24 KiB
Python
714 lines
24 KiB
Python
"""
|
||
投资组合分析引擎 — 数据加载 + 风险指标 + 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()
|