fix(factor): tears iframe 渲染——patch GridFigure.close 收集 figure 生成 HTML

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 os.path.exists 失败
返回 404,iframe 加载空白(回退到 SPA 首页标题)。

monkey-patch GridFigure.close 收集 figure 而非销毁(同 empyrical/demean
适配思路,不改 alphalens 源码),跑完把所有 figure savefig 成 base64 嵌入
真 HTML 文件,report_paths 指向该 html。

浏览器端到端验证(VPS factor_2d01a718):report endpoint 404→200,iframe
渲染 4 张 tearsheet 图(returns/information/turnover/quantile table,2.4MB),
naturalWidth>0 确认非 broken,console 无报错。
This commit is contained in:
2026-07-17 14:38:03 +08:00
parent 3b116b63f9
commit 65021bd32c
+59 -9
View File
@@ -290,19 +290,69 @@ def run_factor_analysis(
}
# Generate tears sheet(独立 trytears 失败只标注,不覆盖上面的 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 收集 figurebase64 嵌入真 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 + 仍存活的 figureplot_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'<img src="data:image/png;base64,{_b64}"/>')
plt.close(_fig)
_html = (
f'<html><head><meta charset="utf-8"><title>{factor_name} tears</title>'
'<style>body{font-family:-apple-system,Segoe UI,sans-serif;margin:0;'
'padding:12px;background:#fff;}h2{margin:0 0 12px;font-size:18px;}'
'img{display:block;max-width:100%;margin:0 0 14px;border:1px solid #eee;}'
'</style></head><body>'
f'<h2>{factor_name} — Alphalens Tearsheet</h2>'
+ "".join(_img_tags)
+ "</body></html>"
)
factor_report_path = os.path.join(output_dir, f"{factor_name}_tears.html")
plt.savefig(factor_report_path.replace(".html", ".png")) # Save as PNG
report_paths[factor_name] = factor_report_path.replace(".png", ".html")
with open(factor_report_path, "w", encoding="utf-8") as _fp:
_fp.write(_html)
report_paths[factor_name] = factor_report_path
ic_summary[factor_name]["report"] = factor_report_path
except Exception as tears_e:
ic_summary[factor_name]["tears_error"] = f"{type(tears_e).__name__}: {tears_e}"