skills/portfolio-analyzer/scripts/portfolio_analyzer.py
wangyitong a65adcc2e5 Initial commit: merged, deduplicated, and vetted skill collection
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>
2026-07-13 14:47:12 +08:00

714 lines
24 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.

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