"""Tears 分层序列序列化(方案A tears 端点数据源). create_full_tear_sheet 的 matplotlib 图,本质是"已算好的序列被画成图"—— 本模块把同一批 alphalens performance 序列(日度IC / 分组日度收益 / 因子秩自相关) 直接序列化成前端 ECharts 可渲染的 dict,替代 iframe 内嵌浅色 HTML 报告 (用户 2026-08-29 拍板方案A:原生重构)。 """ import pandas as pd # 与 analyzer.py 同款幂等补丁:alphalens-reloaded 的 demean_forward_returns 用 # groupby.transform(lambda) 在 pandas2 崩,独立 import 本模块(单测路径)时 # analyzer 可能未加载 → 补丁不在 → mean_return_by_quantile 崩。重复 patch 无害。 try: import alphalens.utils as _al_utils def _demean_forward_returns_pandas2(factor_data, grouper=None): factor_data = factor_data.copy() if not grouper: grouper = factor_data.index.get_level_values("date") cols = _al_utils.get_forward_returns_columns(factor_data.columns) means = factor_data.groupby(grouper)[cols].transform("mean") factor_data[cols] = factor_data[cols] - means return factor_data _al_utils.demean_forward_returns = _demean_forward_returns_pandas2 except ImportError: pass # 同款幂等补丁:alphalens quantize_factor 的 pd.qcut(x, q) 不传 duplicates # (默认 'raise')——秩类因子(cs_rank/ts_rank 族,如 alpha16)截面值高度并列, # 分位边界必重合 → ValueError: Bin edges must be unique → 被 no_raise 吞成 # 全 NaN → binning 丢 100% → merged 空 → IC 全空 + tears 崩(NAS 2026-08-30 # 实锤:alpha16 303/303 日期全抛,中位日 25 值仅 6 唯一)。改默认 duplicates='drop' # 后并列值并入少数分位(quantile_keys 变短,前端已按实际 keys 动态渲染)。 _orig_qcut = pd.qcut def _qcut_dup_drop(*args, **kwargs): kwargs.setdefault("duplicates", "drop") return _orig_qcut(*args, **kwargs) if not getattr(pd.qcut, "_sanguo_dup_drop", False): _qcut_dup_drop._sanguo_dup_drop = True pd.qcut = _qcut_dup_drop _TRADING_DAYS = 252 def _nav(daily: pd.Series) -> pd.Series: """日度收益(可含重叠窗口)→ 累计净值,首值 1;NaN 日视为空仓(0 收益).""" return (1.0 + daily.fillna(0.0)).cumprod() def _max_drawdown(nav: pd.Series) -> float: """净值序列最大回撤(≤0).""" v = (nav / nav.cummax() - 1.0).min() return float(v) if pd.notna(v) else 0.0 def _annualized(daily: pd.Series, period: int) -> float: """日均 × 252 / period:线性去重叠年化,1/5/10D 三周期可比(1D≈eval 链路口径).""" s = daily.dropna() if s.empty: return 0.0 return float(s.mean() * _TRADING_DAYS / period) def _monthly_ic(ic: pd.Series) -> list[dict]: """日度 IC 按月聚合(月度柱/热力图数据),口径同 sanguo_factor.metrics.monthly_ic.""" s = ic.dropna() if s.empty: return [] g = s.groupby(s.index.to_period("M")).mean() return [{"month": t.strftime("%Y-%m"), "ic": round(float(v), 6)} for t, v in g.items()] def build_tears_data(merged_data, periods: tuple = (1, 5, 10)) -> dict: """alphalens factor_data → tears 序列 dict. 每序列自带日期轴(ic_dates/nav_dates);IC/分组收益均调 alphalens 原函数, 与 tearsheet 同源。quantile_nav 给全 10 组,前端按需画 Q1/Q5/Q10。 """ from alphalens.performance import ( factor_information_coefficient, factor_rank_autocorrelation, mean_return_by_quantile, ) ic_df = factor_information_coefficient(merged_data) qr_by_date, _ = mean_return_by_quantile(merged_data, by_date=True, demeaned=True) # 因子秩自相关(1D,指标条一项):失败不致命 → None try: ac = factor_rank_autocorrelation(merged_data, period=1).dropna() factor_autocorr = round(float(ac.mean()), 4) if len(ac) else None except Exception: factor_autocorr = None out: dict = {"factor_autocorr": factor_autocorr, "periods": {}} for p in periods: # 列名兼容:alphalens-reloaded 生成 "1D",老版纯数字 "1"(同 analyzer 口径) col = next((c for c in ic_df.columns if c in (f"{p}D", str(p))), None) if col is None: continue ic = ic_df[col].dropna() qd = qr_by_date[col].unstack("factor_quantile") # date × quantile qkeys = sorted(int(q) for q in qd.columns) quantile_nav: dict = {} quantile_annual: dict = {} for q in qkeys: daily_q = qd[q] quantile_nav[str(q)] = [round(float(v), 6) for v in _nav(daily_q)] quantile_annual[str(q)] = round(_annualized(daily_q, p), 4) # 多空 = 最高分位 − 最低分位(每日,demeaned 超额口径同 tearsheet)。 # 离散因子 duplicates='drop' 后极端形态可能仅 1 组:多空退化为零序列。 if len(qkeys) >= 2: ls_daily = qd[qkeys[-1]] - qd[qkeys[0]] elif qkeys: ls_daily = qd[qkeys[0]] * 0.0 else: ls_daily = pd.Series(dtype=float) ls_nav = _nav(ls_daily) n = len(ic) m = float(ic.mean()) if n else 0.0 sd = float(ic.std()) if n > 1 else 0.0 out["periods"][f"{p}D"] = { "count": int(n), "ic_mean": round(m, 6) if n else None, "ic_std": round(sd, 6) if n > 1 else None, "icir": round(m / sd, 4) if sd > 0 else None, "t_stat": round(m / (sd / n ** 0.5), 4) if sd > 0 and n > 1 else None, "win_rate": round(float((ic > 0).mean()), 4) if n else None, "ic_dates": [d.strftime("%Y-%m-%d") for d in ic.index], "ic_values": [round(float(v), 6) for v in ic], "monthly_ic": _monthly_ic(ic), "quantile_keys": [str(q) for q in qkeys], "nav_dates": [d.strftime("%Y-%m-%d") for d in qd.index], "quantile_nav": quantile_nav, "quantile_annual": quantile_annual, "ls_nav": [round(float(v), 6) for v in ls_nav], "ls_annual": round(_annualized(ls_daily, p), 4), "ls_max_dd": round(_max_drawdown(ls_nav), 4), } return out