Files
sanguo_vnpy_v2/sanguo_factor/attribution.py
T
claude_dev 404330f29a
CI/CD / test (push) Successful in 24s
CI/CD / nas-deploy (push) Successful in 11s
CI/CD / nas-verify (push) Successful in 8s
fix(factor): ridge 对照惩罚尺度不变形——A+λ·diag(A)≡相关形阵+λI(审计F-2) [vps]
旧实现 λI 直接加在被 WLS 权重放大的原始正规阵上: 真实 √市值 量级
(w≈6e4, diag(A)~1e8)下相对收缩 ~1e-9, ridge 解≡OLS 解, 对照通道在
唯一触发场景(VIF>10)下必然 ridge_flip 恒空静默放行——spec §4.7-3
「标准化设计阵固定档」名不副实。

修法: 惩罚随各列方差缩放 A+λ·diag(A)(数学上等价 D^½(R+λI)D^½ 的
标准化解)——权重整体缩放 c 时解不变(尺度不变性), 等权/√市值双通道
同效; 系数统一收缩 1/(1+λ), 截距不罚。当前 quant12 vif≈1.4 不进
触发区, 已产归因件不受影响(通道仅 VIF>10 才跑)。

测试 427→428: ①尺度不变+真实量级生效双性质钉死(旧 no-op 必红);
②flip 测试从 isinstance-only 强化为多路共线构造下非空断言
(src4=三源等权组合: 两两 ρ≈0.58 不并族但对前三源 R²≈1, VIF 全线
34~143; OLS 把 premia 为正的 src1 窗累计拧负, ridge 翻正捕获)。
因子档 §7.5 ridge 语义同步修订(档随码走)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 08:03:16 +08:00

567 lines
24 KiB
Python
Raw 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.
"""因子贡献分解(P3 第三刀, spec §4.7 五条, 2026-09-25 拍板).
日度 Fama-MacBeth: 个股日收益 ~ 标准化因子暴露 + 控制集(市场截距 +
log 市值 + 行业哑变量)逐日截面 WLS 回归 → 因子日收益序列 f_k(t);
组合贡献 = 市场 + Σ_k 暴露·f_k + 行业 + 特质, 恒等式可加和验证
(特质单列——占比高=解释力弱, 结论降权)。
共线 = 决议 K 0.7 族压缩为默认回归元(quant12 纯量价 12 源零 size/行业,
不加控制集则小票潮汐系统性泼给 vol/量能代表源); VIF 披露; 代表后仍
VIF>10 触发 ridge 对照(固定 λ=1.0 标准化阵, 月度结论翻转才报警)。
不借 PCA/lasso。加权 = sqrt(市值) WLS 默认 + 等权敏感性固定双跑。
持仓 = 截面确定性函数重建(模型组合: TopN 等权硬切; ST/涨跌停/次新等
执行级过滤不重建——模型→实盘差距归偏差日报半边, 归因两半天然对齐)。
跑位 = CLI 独立, 与月度批评首班零耦合。
"""
from __future__ import annotations
import sqlite3
import numpy as np
import pandas as pd
def load_sections(sections_dir: str, names: list[str]) -> dict[str, pd.DataFrame]:
"""装载截面宽表(parquet: index=date × columns=vt_symbol). 缺文件 fail-loud."""
import os
out: dict[str, pd.DataFrame] = {}
for n in names:
p = os.path.join(sections_dir, f"{n}.parquet")
if not os.path.exists(p):
raise FileNotFoundError(f"截面缺失: {p}(先跑源级导出批)")
df = pd.read_parquet(p)
df.index = pd.to_datetime(df.index)
out[n] = df
return out
def load_closes(vnpy_db: str, symbols: list[str],
start: str, end: str) -> pd.DataFrame:
"""日线收盘 pivot 宽表(index='YYYY-MM-DD' str). 逐符号索引查, 秒级."""
from sanguo_data.datareader import guess_exchange
rows: list[tuple[str, str, float]] = []
conn = sqlite3.connect(vnpy_db, timeout=60)
conn.execute("PRAGMA busy_timeout=30000")
try:
for vt in symbols:
sym = vt.split(".", 1)[0]
ex = guess_exchange(sym).value
cur = conn.execute(
"SELECT datetime, close_price FROM dbbardata "
"WHERE symbol=? AND exchange=? AND interval='d' "
"AND datetime>=? AND datetime<=? ORDER BY datetime",
(sym, ex, start, f"{end} 23:59:59"))
for dt, close in cur.fetchall():
rows.append((str(dt)[:10], vt, float(close)))
finally:
conn.close()
df = pd.DataFrame(rows, columns=["date", "vt_symbol", "close"])
if df.empty:
return pd.DataFrame()
return df.pivot(index="date", columns="vt_symbol", values="close").sort_index()
def forward_returns(closes: pd.DataFrame) -> pd.DataFrame:
"""实现日收益: index t 的值 = t-1 收盘→t 收盘(pct_change)."""
return closes.pct_change()
def zscore_cross(df: pd.DataFrame) -> pd.DataFrame:
"""逐日截面 z 分数(均值 0 方差 1; 单股日 std=0 → NaN).
inf 暴露=缺失(首跑实证: alpha83 部分日含 ±inf, pandas std 不跳 inf
→ 整行 z 陪葬 NaN)——先替换再标准化, 其余股正常.
"""
df = df.replace([np.inf, -np.inf], np.nan)
return df.sub(df.mean(axis=1), axis=0).div(df.std(axis=1), axis=0)
def _mean_daily_rank_corr(exposures: dict[str, pd.DataFrame]) -> pd.DataFrame:
"""日均 Spearman: 逐日截面秩相关再对日取均值(精确式, 非全池近似)."""
names = list(exposures)
acc = pd.DataFrame(0.0, index=names, columns=names)
cnt = pd.DataFrame(0, index=names, columns=names)
for d in exposures[names[0]].index:
cols = {}
for n in names:
s = exposures[n].loc[d].dropna()
if len(s) >= 3:
cols[n] = s.rank()
if len(cols) < 2:
continue
c = pd.DataFrame(cols).corr() # 秩的 Pearson = Spearman
acc = acc.add(c.fillna(0.0), fill_value=0.0)
cnt = cnt.add(c.notna().astype(int), fill_value=0)
return acc / cnt.replace(0, 1)
def family_representatives(exposures: dict[str, pd.DataFrame],
threshold: float = 0.7) -> dict[str, list[str]]:
"""|日均秩相关|>=threshold 连通分量分族; 代表=族内中位点(
与成员平均|corr|最大, 平手按名序). 返回 {代表名: 成员名表}."""
corr = _mean_daily_rank_corr(exposures)
names = sorted(exposures)
parent = {n: n for n in names}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for i, a in enumerate(names):
for b in names[i + 1:]:
if abs(corr.loc[a, b]) >= threshold:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
groups: dict[str, list[str]] = {}
for n in names:
groups.setdefault(find(n), []).append(n)
out: dict[str, list[str]] = {}
for members in groups.values():
ms = sorted(members)
if len(ms) == 1:
out[ms[0]] = ms
continue
best, best_score = ms[0], -1.0
for m in ms:
score = sum(abs(corr.loc[m, o]) for o in ms if o != m)
if score > best_score + 1e-12:
best, best_score = m, score
out[best] = ms
return out
def vif(zdf: pd.DataFrame) -> dict[str, float]:
"""标准化设计阵 VIF = 相关阵逆对角线(奇异→inf)."""
X = zdf.dropna().to_numpy()
if X.ndim != 2 or X.shape[0] <= X.shape[1]:
return {c: float("inf") for c in zdf.columns}
corr = np.corrcoef(X, rowvar=False)
try:
inv = np.linalg.inv(corr)
except np.linalg.LinAlgError:
return {c: float("inf") for c in zdf.columns}
return {c: float(inv[i, i]) for i, c in enumerate(zdf.columns)}
RIDGE_LAMBDA = 1.0 # ridge 对照固定档(相关形设计阵 +λI, 尺度不变; spec
# §4.7-3 不调参. 09-27 审计 F-2: 旧实现 λI 直接加在
# 被 WLS 权重放大的原始正规阵上, √市值 量级(对角
# ~1e8)下相对收缩 ~1e-9=no-op, 对照通道恒空)
VIF_TRIGGER = 10.0 # 代表后仍 >10 → ridge 对照(spec §4.7-3)
def solve_day(X: np.ndarray, y: np.ndarray, w: np.ndarray,
ridge: float = 0.0) -> np.ndarray:
"""单日截面 WLS(X 第 0 列=截距全 1; ridge>0 截距不罚).
sqrt(市值) 权重由调用方传入(等权敏感性=传全 1, 固定双跑无旋钮).
ridge 惩罚随各列方差缩放: A+λ·diag(A) ≡ D^½(R+λI)D^½(R=相关形阵)——
权重整体缩放 c 时解不变(尺度不变), 系数统一收缩 1/(1+λ); 任意权重
量级(等权/√市值)下对照通道同等生效.
"""
sw = np.sqrt(np.maximum(w, 0.0))
Xw, yw = X * sw[:, None], y * sw
A, b = Xw.T @ Xw, Xw.T @ yw
if ridge > 0.0:
pen = np.diag(A) * ridge
pen[0] = 0.0 # 截距不罚
A = A + np.diag(pen)
return np.linalg.lstsq(A, b, rcond=None)[0]
def _tradable(vt: str) -> bool:
"""主板+创业板(剔科创 68/北交 4,8/920).
与 sanguo_portfolio/strategies/factor_topn._filter_kcbj_keep_chinext
同规则的 6 行双胞胎——factor 域不反向依赖 portfolio, 改规则双改.
"""
code = vt.split(".", 1)[0]
if not code:
return False
return not (code[0] in ("4", "8") or code[:2] in ("68", "92"))
def holdings_schedule(section: pd.DataFrame, top_n: int = 200,
reb_days: int = 63) -> list[tuple[pd.Timestamp, list[str]]]:
"""模型组合持仓日程: 每 reb_days 个交易日按截面值重选 TopN(硬切
band=1.0; 值大=好, 同 factor_topn 方向约定). 执行级过滤(ST/涨跌停/
次新)不重建——模型→实盘差距归偏差日报半边."""
out: list[tuple[pd.Timestamp, list[str]]] = []
held: list[str] | None = None
for i, d in enumerate(section.index):
if i % reb_days == 0 or held is None:
row = section.loc[d].dropna()
row = row[[c for c in row.index if _tradable(c)]]
held = list(row.sort_values(ascending=False).index[:top_n])
if held:
out.append((d, list(held)))
return out
def portfolio_returns(schedule: list[tuple[pd.Timestamp, list[str]]],
rets: pd.DataFrame) -> tuple[pd.Series, pd.DataFrame]:
"""组合日收益+逐日权重(实现日口径). 同持期间权重随前日收益漂移
(买入持有); 停牌 NaN 收益按 0(价格冻结, 口径披露)."""
r_p = pd.Series(0.0, index=[d for d, _ in schedule])
whist = pd.DataFrame(0.0, index=r_p.index, columns=rets.columns)
held: list[str] | None = None
w = pd.Series(dtype=float)
prev_ret: pd.Series | None = None
for d, h in schedule:
if h != held:
held = h
w = pd.Series(0.0, index=rets.columns)
w[h] = 1.0 / len(h)
elif prev_ret is not None:
growth = w * (1.0 + prev_ret.reindex(w.index).fillna(0.0))
w = growth / growth.sum()
r_p.loc[d] = float((w * rets.loc[d].reindex(w.index).fillna(0.0)).sum())
whist.loc[d] = w
prev_ret = rets.loc[d]
return r_p, whist
# ---------------- 归因主循环(P3 §4.7-1/2/3) ----------------
SIZE_LEAK_THRESHOLD = 0.02 # |size 桶窗口 cum| > 2% = 泄漏报警(拍板值)
MIN_FLIP_MAGNITUDE = 0.001 # 月度 |贡献| 低于此不判翻转(噪声地板)
def size_leak(res: dict) -> tuple[bool, float]:
"""sanity 锚点: 组合层已 size 中性化 ⇒ size 桶累计贡献应≈0."""
cum = float((1.0 + res["contributions"]["size"]).prod() - 1.0)
return abs(cum) > SIZE_LEAK_THRESHOLD, cum
_SUFFIX_MAP = {".SH": ".SSE", ".SZ": ".SZSE", ".BJ": ".BSE"}
def _norm_symbol(s: str) -> str:
"""通用 .SH/.SZ/.BJ 形(SW 等官方交付) → vnpy vt_symbol 形."""
for suf, vt in _SUFFIX_MAP.items():
if s.endswith(suf):
return s[: -len(suf)] + vt
return s
def _industry_dummies(industry_map: pd.DataFrame) -> tuple[dict, list]:
"""行业码映射 + 哑变量码表(去最大桶为基线; 未知 0 单独一列).
表 symbol 先经 _norm_symbol 归一(data 交付 .SH/.SZ 通用形态).
"""
ind_code = { _norm_symbol(s): int(c) for s, c in
industry_map.set_index("symbol")["industry_code"].items() }
codes = sorted(set(ind_code.values()))
baseline = (max(codes, key=lambda c: sum(1 for v in ind_code.values()
if v == c)) if codes else None)
return ind_code, [c for c in codes if c != baseline]
def _day_design(z_row: dict, zs_row: pd.Series, f_ret_next: pd.DataFrame,
t, ind_code: dict, dummy_codes: list):
"""单日设计阵装配(主循环与 ridge 通道共用).
z_row/zs_row = 当日已切片的暴露行(调用方负责逐日切片一次).
返回 (X, y, rows_sym) 或 None(样本不足 max(3×列数, 50))."""
rows_x, rows_y, rows_sym = [], [], []
base = f_ret_next.loc[t].dropna()
for s in base.index:
xs = [z_row[k].get(s, np.nan) for k in z_row]
xs.append(zs_row.get(s, np.nan))
if not all(np.isfinite(v) for v in xs):
continue
rows_x.append([1.0] + xs + [1.0 if ind_code[s] == c else 0.0
for c in dummy_codes])
rows_y.append(float(base[s]))
rows_sym.append(s)
n_cols = 1 + len(z_row) + 1 + len(dummy_codes)
if len(rows_x) < max(3 * n_cols, 50):
return None
return np.array(rows_x), np.array(rows_y), rows_sym
def attribute(section: pd.DataFrame, exposures: dict[str, pd.DataFrame],
size_log: pd.DataFrame, industry_map: pd.DataFrame,
rets: pd.DataFrame, top_n: int = 200, reb_days: int = 63,
weighting: str = "sqrt_mktcap") -> dict:
"""归因主循环: 逐日 FM 回归 → 因子日收益 → 组合贡献分解.
时序: t 日暴露解释 t+1 实现收益; 贡献落 t+1(权重=当日开盘权重,
暴露=t 日值); 逐股分解 r_i=拟合+残差精确 ⇒ 恒等式对任意持仓成立;
持仓中暴露 NaN 的个股拟合记 0(收益全入特质, 占比披露).
contributions 不含 ind_ 明细列(聚合进 industry 桶, 消费端 schema
干净); 明细保留在 factor_returns.
"""
fams = family_representatives(exposures)
reps = list(fams)
z = {k: zscore_cross(v) for k, v in exposures.items()}
z_size = zscore_cross(size_log)
dates = [d for d in section.index if d in rets.index]
f_ret_next = rets.shift(-1)
# 行业表契约(data 09-26): 表内只含 SW 已映射股(.SH/.SZ 通用形态, 入口
# 归一到 vt 形), join 不中 → 0 码独立桶(显式哑变量列; 0 桶最大时自适
# 应成基线)——不静默丢样本
universe = sorted(set(rets.columns) | set(section.columns))
known = industry_map.set_index("symbol")["industry_code"]
known.index = [_norm_symbol(s) for s in known.index]
industry_map = pd.DataFrame({
"symbol": universe,
"industry_code": known.reindex(universe).fillna(0).astype(int).to_numpy(),
})
ind_code, dummy_codes = _industry_dummies(industry_map)
sched = holdings_schedule(section, top_n=top_n, reb_days=reb_days)
_, whist = portfolio_returns(sched, rets)
whist = whist.reindex(dates)
k_rep = len(reps)
fr_cols = ["market"] + reps + ["size"] + [f"ind_{c}" for c in dummy_codes]
contributions = pd.DataFrame(
0.0, index=dates, columns=["market"] + reps + ["size", "industry", "specific"])
factor_returns = pd.DataFrame(np.nan, index=dates, columns=fr_cols)
port_ret = pd.Series(0.0, index=dates)
expo_sum: dict[str, list[float]] = {k: [] for k in list(reps) + ["size"]}
r2s: list[float] = []
nan_hold_counts = hold_counts = 0
unknown = mapped = 0
mkt_raw = np.exp(size_log) # sqrt(市值) WLS 权的市值
for i, t in enumerate(dates[:-1]): # 最后一日无 t+1 收益
t1 = dates[i + 1]
w_day = whist.loc[t1]
held = [s for s in w_day.index[w_day > 0]]
if not held:
continue
hold_counts += len(held)
z_row = {k: z[k].loc[t] for k in reps}
zs_row = z_size.loc[t]
nan_hold_counts += sum(
1 for s in held
if not all(np.isfinite(z_row[k].get(s, np.nan)) for k in reps)
or not np.isfinite(zs_row.get(s, np.nan)))
got = _day_design(z_row, zs_row, f_ret_next, t, ind_code, dummy_codes)
if got is None:
continue
X, y, rows_sym = got
unknown += sum(1 for s in rows_sym if ind_code[s] == 0)
mapped += len(rows_sym)
if weighting == "sqrt_mktcap":
w = np.sqrt(mkt_raw.loc[t].reindex(rows_sym).fillna(0.0)
.clip(lower=1e-8).to_numpy())
else:
w = np.ones(len(rows_sym))
beta = solve_day(X, y, w)
sw = np.sqrt(w)
resid = y - X @ beta
ss_res = float(np.sum(sw ** 2 * resid ** 2))
ybar = float(np.average(y, weights=w))
ss_tot = float(np.sum(sw ** 2 * (y - ybar) ** 2))
r2s.append(1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan)
factor_returns.loc[t] = beta
# 贡献(实现日 t1): E_k = Σ w_i(t1)·x_i(t); ind_ 聚合进 industry 桶
w_held = w_day.reindex(rows_sym).fillna(0.0)
expo = (X * w_held.to_numpy()[:, None]).sum(axis=0)
contrib_vals = expo * beta
row = {"market": float(contrib_vals[0])}
for j, k in enumerate(reps):
row[k] = float(contrib_vals[1 + j])
expo_sum[k].append(float(expo[1 + j]))
row["size"] = float(contrib_vals[1 + k_rep])
expo_sum["size"].append(float(expo[1 + k_rep]))
row["industry"] = float(contrib_vals[1 + k_rep + 1:].sum())
# specific: 持仓全体 r_i − 拟合_i(样本外股拟合=0 → 全入特质);
# NaN 收益=价格冻结口径 0(显式 isfinite, 勿用 or 惯用语——NaN truthy)
fitted = X @ beta
r_t1 = rets.loc[t1]
spec = 0.0
rows_sym_set = set(rows_sym)
for j, s in enumerate(rows_sym):
wgt = float(w_day.get(s, 0.0))
if wgt > 0:
rv = r_t1.get(s, np.nan)
spec += wgt * ((0.0 if not np.isfinite(rv) else float(rv))
- fitted[j])
for s in held:
if s not in rows_sym_set:
rv = r_t1.get(s, np.nan)
spec += float(w_day[s]) * (0.0 if not np.isfinite(rv)
else float(rv))
row["specific"] = spec
contributions.loc[t1] = row
port_ret.loc[t1] = float((w_day * r_t1.reindex(w_day.index)
.fillna(0.0)).sum())
# VIF(逐源 z 分数 stack 须 inner 对齐——各源 NaN 域不同, 错位=静默垃圾)
stacked = pd.concat({k: z[k].stack() for k in reps}, axis=1, join="inner")
v = vif(stacked)
# ridge 对照: 仅当代表 VIF 超阈(spec §4.7-3), 窗口累计符号翻转才报
ridge_flip: list[str] = []
if any(vv > VIF_TRIGGER for vv in v.values()):
ols_m = contributions[reps].groupby(
contributions.index.to_period("M")).sum()
ridge_m = _ridge_monthly(z, reps, z_size, f_ret_next, dates,
ind_code, dummy_codes, mkt_raw, weighting)
for k in reps:
a, b = float(ols_m[k].sum()), float(ridge_m[k].sum())
if (abs(a) > MIN_FLIP_MAGNITUDE and abs(b) > MIN_FLIP_MAGNITUDE
and np.sign(a) != np.sign(b)):
ridge_flip.append(k)
return {
"factor_returns": factor_returns, "contributions": contributions,
"portfolio_ret": port_ret, "families": fams,
"exposure_avg": {k: float(np.nanmean(vv)) for k, vv in expo_sum.items()},
"r2_mean": float(np.nanmean(r2s)) if r2s else float("nan"),
"vif": v, "ridge_flip": ridge_flip,
"nan_exposure_holding_share": nan_hold_counts / max(hold_counts, 1),
"unknown_industry_share": unknown / max(mapped, 1),
}
def _ridge_monthly(z, reps, z_size, f_ret_next, dates, ind_code,
dummy_codes, mkt_raw, weighting) -> pd.DataFrame:
"""ridge 对照通道(λ=RIDGE_LAMBDA 固定): 复用 _day_design, 只换估计."""
daily = pd.DataFrame(0.0, index=dates[:-1], columns=reps)
for t in dates[:-1]:
z_row = {k: z[k].loc[t] for k in reps}
got = _day_design(z_row, z_size.loc[t], f_ret_next, t,
ind_code, dummy_codes)
if got is None:
continue
X, y, rows_sym = got
if weighting == "sqrt_mktcap":
w = np.sqrt(mkt_raw.loc[t].reindex(rows_sym).fillna(0.0)
.clip(lower=1e-8).to_numpy())
else:
w = np.ones(len(rows_sym))
beta = solve_day(X, y, w, ridge=RIDGE_LAMBDA)
daily.loc[t] = beta[1:1 + len(reps)]
return daily.groupby(daily.index.to_period("M")).sum()
# ---------------- 月度汇总 / 报告 / CLI(P3 §4.7-4/5) ----------------
def monthly_rows(res: dict, families: dict[str, list[str]]) -> list[dict]:
"""月度行(schema verbatim): 代表行(factor=代表名)→market→size→
industry→specific(三桶殿后). cum_ret=Π(1+日贡献)−1 全窗累计."""
contrib = res["contributions"]
order = [k for k in contrib.columns
if k not in ("market", "size", "industry", "specific")] \
+ ["market", "size", "industry", "specific"]
months = contrib.index.to_period("M")
rows: list[dict] = []
for k in order:
cum = float((1.0 + contrib[k].fillna(0.0)).prod() - 1.0)
for m, v in contrib[k].groupby(months).sum().items():
rows.append({"factor": k, "month": str(m),
"contribution_ret": float(v), "cum_ret": cum,
"exposure_avg": float(res["exposure_avg"].get(k, 0.0))})
return rows
def rolling_ir(series: pd.Series, months: int) -> float | None:
"""滚动窗年化 IR(mean/std×√252), 样本不足或 std=0 → None."""
s = series.dropna()
if len(s) < months * 15: # 月≈21 交易日, 15=下限容忍
return None
win = s.iloc[-months * 21:]
sd = float(win.std())
if not np.isfinite(sd) or sd == 0.0:
return None
return float(win.mean() / sd * np.sqrt(252.0))
def build_report(res: dict, families: dict[str, list[str]], as_of: str,
start: str, end: str) -> dict:
from datetime import datetime
leak, size_cum = size_leak(res)
return {
"as_of": as_of,
"generated_at": datetime.now().isoformat(timespec="seconds"),
"window": {"start": start, "end": end},
"factorContribution": monthly_rows(res, families),
"families": families,
"rolling": {k: {"ir_12m": rolling_ir(res["contributions"][k], 12),
"ir_24m": rolling_ir(res["contributions"][k], 24)}
for k in res["contributions"].columns},
"diagnostics": {
"r2_mean": res["r2_mean"], "vif": res["vif"],
"vif_max": max(res["vif"].values()) if res["vif"] else None,
"ridge_flip": res["ridge_flip"], "size_leak": leak,
"size_cum": size_cum,
"nan_exposure_holding_share": res["nan_exposure_holding_share"],
"unknown_industry_share": res["unknown_industry_share"],
"weighting": "sqrt_mktcap",
"industry_note": "行业=最新静态成员回溯(迁移缓慢, 业界同款); "
"表未覆盖股=0 码独立桶",
},
}
def main(argv: list[str] | None = None) -> int:
"""CLI: python -m sanguo_factor.attribution --sections-dir D --factor F ...
产物=<out-dir>/attribution_<end>.json(tmp+os.replace 原子写).
"""
import argparse
import json
import os
import tempfile
ap = argparse.ArgumentParser(description="因子贡献分解(日度 Fama-MacBeth)")
ap.add_argument("--sections-dir", required=True)
ap.add_argument("--factor", required=True, help="组合截面因子名(持仓重建用)")
ap.add_argument("--sources", default=None,
help="逗号分隔源截面名(缺省=QUANT_SOURCES)")
ap.add_argument("--size-name", default="size_log")
ap.add_argument("--start", required=True)
ap.add_argument("--end", required=True)
ap.add_argument("--vnpy-db", required=True)
ap.add_argument("--industry-path", required=True)
ap.add_argument("--out-dir", required=True)
ap.add_argument("--top-n", type=int, default=200)
ap.add_argument("--reb-days", type=int, default=63)
ap.add_argument("--weighting", choices=["sqrt_mktcap", "equal"],
default="sqrt_mktcap")
args = ap.parse_args(argv)
from .composite_library import QUANT_SOURCES
sources = (args.sources.split(",") if args.sources
else [n for n, _ in QUANT_SOURCES])
names = sources + [args.size_name, args.factor]
secs = load_sections(args.sections_dir, names)
section = secs[args.factor]
section = section.loc[(section.index >= pd.Timestamp(args.start))
& (section.index <= pd.Timestamp(args.end))]
symbols = sorted(set().union(*[set(df.columns) for df in secs.values()]))
closes = load_closes(args.vnpy_db, symbols, args.start, args.end)
closes.index = pd.to_datetime(closes.index)
rets = forward_returns(closes)
industry_map = pd.read_parquet(args.industry_path)
res = attribute(section, {k: secs[k] for k in sources},
secs[args.size_name], industry_map, rets,
top_n=args.top_n, reb_days=args.reb_days,
weighting=args.weighting)
fams = {k: [s for s in v if s in sources] for k, v in res["families"].items()}
fams = {k: v for k, v in fams.items() if v}
report = build_report(res, fams, args.end, args.start, args.end)
os.makedirs(args.out_dir, exist_ok=True)
final = os.path.join(args.out_dir, f"attribution_{args.end}.json")
fd, tmp = tempfile.mkstemp(suffix=".json", dir=args.out_dir)
os.close(fd)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
os.replace(tmp, final)
print(f"[attribution] {final} rows={len(report['factorContribution'])} "
f"r2={report['diagnostics']['r2_mean']:.3f} "
f"size_leak={report['diagnostics']['size_leak']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())