diff --git a/sanguo_factor/analyzer.py b/sanguo_factor/analyzer.py
index 8781d02..7f2320d 100644
--- a/sanguo_factor/analyzer.py
+++ b/sanguo_factor/analyzer.py
@@ -290,19 +290,69 @@ def run_factor_analysis(
}
# Generate tears sheet(独立 try:tears 失败只标注,不覆盖上面的 IC 成功)
- from io import StringIO
+ #
+ # alphalens-reloaded 的 create_full_tear_sheet 内部,每个 tear sheet
+ # 末尾调 GridFigure.close() → plt.close(fig)。Agg 后端下 figure 被销毁,
+ # 之后 plt.savefig 只能拿到最后一个空占位 figure(实证 2.3KB 空 PNG),
+ # 且原代码 savefig 存 .png 却把 report_paths 记成 .html → report endpoint
+ # 404。monkey-patch GridFigure.close 收集 figure,base64 嵌入真 HTML 文件。
+ # 同 empyrical np.NINF / demean_forward_returns 适配思路,不改 alphalens 源码。
+ from io import StringIO, BytesIO
+ import base64
+ import alphalens.tears as _al_tears
old_stdout = sys.stdout
- sys.stdout = StringIO() # Capture stdout to avoid display issues
+ sys.stdout = StringIO() # Capture stdout (alphalens 打印统计表)
try:
- create_full_tear_sheet(
- merged_data,
- long_short=True,
- group_neutral=False,
- by_group=False
+ _collected_figs: list = []
+ _orig_gridfig_close = _al_tears.GridFigure.close
+ def _collect_gridfig_close(self, _sink=_collected_figs):
+ fig = getattr(self, "fig", None)
+ if fig is not None:
+ _sink.append(fig)
+ self.gs = None # 不调 plt.close,保留 figure
+ _al_tears.GridFigure.close = _collect_gridfig_close
+ try:
+ plt.close("all")
+ create_full_tear_sheet(
+ merged_data,
+ long_short=True,
+ group_neutral=False,
+ by_group=False,
+ )
+ finally:
+ _al_tears.GridFigure.close = _orig_gridfig_close
+
+ # 合并 collected + 仍存活的 figure(plot_quantile_statistics_table
+ # 的表格图不经过 GridFigure,需从 fignums 补)
+ _all_figs = list(_collected_figs)
+ for _num in plt.get_fignums():
+ _f = plt.figure(_num)
+ if _f not in _all_figs:
+ _all_figs.append(_f)
+
+ _img_tags = []
+ for _fig in _all_figs:
+ _buf = BytesIO()
+ _fig.savefig(_buf, format="png", dpi=72, bbox_inches="tight")
+ _buf.seek(0)
+ _b64 = base64.b64encode(_buf.read()).decode("ascii")
+ _img_tags.append(f'')
+ plt.close(_fig)
+
+ _html = (
+ f'