Files
mymt5opp/scripts/build_optuna_dashboard.py
gavindiaz 63a829cc46 phase 7-8 完成 + warmup 修复 + 产物结构化重组
主要内容:
- Phase 8 PROMOTE: finalist #1 (trial #324) registry 条目,自动生成
- Optuna objective warmup bug 修复 (shared/optimizer/objective.py)
- studies/ 目录按用途重组为 optuna/ + finalists/ + features/ 三层
- reports/ 加入 Optuna 中文 dashboard (5 主图 + 18 slice + 15 contour)
- 新增 PROJECT_GUIDE.md 项目说明文档
- 新增 build_registry_entry.py / build_optuna_dashboard.py / build_feature_datasets.py
- .gitignore: 允许提交 studies/*.db (Optuna DB) 和 reports/*.html (MT5 + dashboard)
2026-06-27 00:28:07 +08:00

327 lines
14 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.
"""Build a single-file interactive Optuna dashboard (中文 HTML).
Loads the persisted Optuna study from ``studies/optuna/gold_scalper_pro_is2025.db`` and
writes ``reports/optuna_dashboard_<study>.html`` containing 7 plotly charts
bundled into one page (each ``fig.to_html(full_html=False, include_plotlyjs='cdn')``
fragments + minimal CSS). Every chart's title and axis labels are localized
to 简体中文 so the report reads natively.
Charts:
1. 优化历史 (plot_optimization_history)
2. 参数重要性 (plot_param_importances)
3. 平行坐标图 (plot_parallel_coordinate)
4. 参数切片图 (plot_slice)
5. 等高线图 (plot_contour)
6. 经验分布函数 (plot_edf)
7. 时间线 (plot_timeline)
Usage:
python scripts/build_optuna_dashboard.py
"""
from __future__ import annotations
import sys
from pathlib import Path
from html import escape
PROJECT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT))
import optuna
STUDY_NAME = "gold_scalper_pro_is2025"
STUDY_DB = PROJECT / "studies" / "optuna" / "gold_scalper_pro_is2025.db"
OUT_HTML = PROJECT / "reports" / f"optuna_dashboard_{STUDY_NAME}.html"
# Chart-level metadata: (call name, 中文标题, 中文 X 轴, 中文 Y 轴)
# Y axis label None means "leave Optuna default" (some plots set their own).
# Note: plot_slice and plot_contour are rendered separately as per-param
# grids below the main dashboard — those two are too dense (19 params) to
# be readable as a single chart.
CHARTS: list[tuple[str, str, str | None, str | None]] = [
("plot_optimization_history", "优化历史",
"试验序号 Trial", "目标值 Objective (score)"),
("plot_param_importances", "参数重要性 (fANOVA)",
"超参数 Hyperparameter", "重要性 Importance"),
("plot_parallel_coordinate", "平行坐标图 — 参数 ↔ score",
None, None),
("plot_edf", "经验分布函数 (EDF)",
"目标值 Objective", "累积分布 CDF"),
("plot_timeline", "时间线 — 试验耗时与状态",
"试验序号 Trial", "耗时 (秒) Elapsed (s)"),
]
# Per-parameter charts: each param gets its own small slice + contour grid.
# Rendered as separate <section> blocks below the main dashboard.
PER_PARAM_CHARTS = [
"InpFastEmaPeriod", "InpSlowEmaPeriod", "InpRsiPeriod",
"InpRsiBuyLevel", "InpRsiSellLevel", "InpPullbackAtrMult",
"InpAtrPeriod", "InpMaxSpreadAtrPct",
"InpRiskPercent", "InpAtrSLMult", "InpAtrTPMult",
"InpBreakEvenPoints", "InpBreakEvenLock",
"InpTrailStartPoints", "InpTrailStepPoints",
"InpMaxTradesPerDay", "InpDailyLossLimit", "InpMinSecondsBetween",
]
def localize(fig, title_zh: str, x_zh: str | None, y_zh: str | None):
"""Localize a plotly Figure's title + axis labels to 简体中文."""
fig.update_layout(title=title_zh)
if x_zh is not None:
fig.update_xaxes(title_text=x_zh)
if y_zh is not None:
fig.update_yaxes(title_text=y_zh)
# Translate the legend "Objective" → "目标值" where it shows up.
if fig.layout.legend and fig.layout.legend.title:
leg = fig.layout.legend.title.text
if leg and "Objective" in leg:
fig.update_layout(legend_title_text="图例")
# Apply a Chinese-readable base font + light theme.
fig.update_layout(
font=dict(family="Microsoft YaHei, Arial, sans-serif", size=12, color="#222"),
template="plotly_white",
)
return fig
def render_chart(fn_name: str, study: optuna.Study, include_plotly: bool) -> str:
"""Call optuna.visualization.<fn>(study), localize, return HTML fragment.
plotly.js is loaded ONCE via CDN <script> in ``<head>`` (see
build_index_html). Every chart fragment therefore passes
include_plotlyjs=False — no per-chart JS bundle, no async race, no 5 MB
of inline JS blocking the parser before any chart can render.
"""
fn = getattr(optuna.visualization, fn_name, None)
if fn is None:
return f'<div class="chart-error">⚠ 函数 <code>{escape(fn_name)}</code> 不存在</div>'
try:
fig = fn(study)
except Exception as e: # some plots fail on trivial studies
return (f'<div class="chart-error">⚠ <code>{escape(fn_name)}</code> 生成失败:'
f'{escape(str(e))}</div>')
title_zh = next(t for fn_, t, *_ in CHARTS if fn_ == fn_name)
x_zh = next(x for fn_, _, x, *_ in CHARTS if fn_ == fn_name)
y_zh = next(y for fn_, _, _, y in CHARTS if fn_ == fn_name)
fig = localize(fig, title_zh, x_zh, y_zh)
fig.update_layout(height=520, width=1100)
return fig.to_html(
full_html=False,
include_plotlyjs=False,
div_id=f"chart-{fn_name}",
)
def render_slice_grid(study: optuna.Study, params: list[str]) -> list[tuple[str, str]]:
"""Render one slice chart PER parameter — readable single-column subplots.
plot_slice(study) defaults to cramming all params into one 5400px-wide
figure where axis labels overlap. Splitting per-param gives each a
1100×520 card where labels are readable.
"""
fragments: list[tuple[str, str]] = []
for p in params:
try:
fig = optuna.visualization.plot_slice(study, params=[p])
except Exception as e:
frag = (f'<div class="chart-error">⚠ slice[{escape(p)}] 生成失败:'
f'{escape(str(e))}</div>')
fragments.append((f"切片 — {p}", frag))
continue
fig = localize(fig, f"参数切片 — {p}", p, "目标值 Objective (score)")
fig.update_layout(height=420, width=900, margin=dict(l=60, r=40, t=60, b=60))
frag = fig.to_html(full_html=False, include_plotlyjs=False,
div_id=f"slice-{p}")
fragments.append((f"切片 — {p}", frag))
return fragments
def render_contour_grid(study: optuna.Study, params: list[str]) -> list[tuple[str, str]]:
"""Render contour charts for the most important param pairs.
Full N×N contour is unreadable (19² = 361 subplots). Instead, take the
top-K most important params (by fANOVA) and render only those pairs —
a K×K grid that's actually readable.
"""
try:
importances = optuna.importance.get_param_importances(study)
# Get top-K by importance; only params that exist in our list.
top_params = [p for p, _ in sorted(importances.items(),
key=lambda x: x[1], reverse=True)
if p in params][:6]
except Exception:
top_params = params[:6] # fallback: first 6
fragments: list[tuple[str, str]] = []
# Render each pair (i<j) as its own contour chart.
for i, p1 in enumerate(top_params):
for p2 in top_params[i + 1:]:
try:
fig = optuna.visualization.plot_contour(study, params=[p1, p2])
except Exception as e:
frag = (f'<div class="chart-error">⚠ contour[{escape(p1)}×{escape(p2)}] '
f'生成失败:{escape(str(e))}</div>')
fragments.append((f"等高线 — {p1} × {p2}", frag))
continue
fig = localize(fig, f"等高线 — {p1} × {p2}", p1, p2)
fig.update_layout(height=520, width=700,
margin=dict(l=70, r=70, t=60, b=70))
frag = fig.to_html(full_html=False, include_plotlyjs=False,
div_id=f"contour-{p1}-{p2}")
fragments.append((f"等高线 — {p1} × {p2}", frag))
return fragments
def build_index_html(
study: optuna.Study,
main_fragments: list[tuple[str, str]],
slice_fragments: list[tuple[str, str]],
contour_fragments: list[tuple[str, str]],
) -> str:
"""Assemble chart fragments into a single styled dashboard HTML."""
n_trials = len(study.trials)
completed = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
pruned = len([t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED])
failed = len([t for t in study.trials if t.state == optuna.trial.TrialState.FAIL])
best = study.best_trial if study.best_trial is not None else None
best_str = (
f"trial #{best.number}, score={best.value:.4f}"
if best is not None else "—"
)
def cards(frs):
return "\n".join(
f'<section class="chart"><h2>{escape(title)}</h2>{frag}</section>'
for title, frag in frs
)
main_cards = cards(main_fragments)
slice_cards = cards(slice_fragments)
contour_cards = cards(contour_fragments)
slice_section = (
f'<h2 class="section-title">参数切片图(单参数影响)</h2>'
f'<p class="section-desc">每参数独立小图,避免 19 参数挤在 5400px 宽的复合图里导致标签重叠。</p>'
f'{slice_cards}'
if slice_fragments else ""
)
contour_section = (
f'<h2 class="section-title">等高线图(参数两两交互)</h2>'
f'<p class="section-desc">按 fANOVA 重要性 Top-6 参数两两配对,避免 19²=361 子图密集到无法读。</p>'
f'{contour_cards}'
if contour_fragments else ""
)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Optuna Dashboard — {escape(STUDY_NAME)}</title>
<!-- plotly.js loaded via CDN, SYNCHRONOUSLY in <head> (no async/defer).
Browser blocks parsing until this <script> finishes, so by the time
the body's chart <script>Plotly.newPlot(...)</script> tags execute,
window.Plotly is defined. -->
<script src="https://cdn.plot.ly/plotly-3.6.0.min.js"></script>
<style>
:root {{
--bg:#f5f5f7; --fg:#222; --card:#fff; --border:#ddd;
--accent:#2563eb; --muted:#666;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; padding: 2rem; background: var(--bg); color: var(--fg);
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif; line-height: 1.6;
}}
header {{ margin-bottom: 2rem; border-bottom: 2px solid var(--accent); padding-bottom: 1rem; }}
h1 {{ margin: 0 0 .25rem; font-size: 1.75rem; }}
h2 {{ margin: 0 0 .75rem; font-size: 1.25rem; color: var(--accent); }}
.meta {{ display: flex; gap: 1.5rem; flex-wrap: wrap; color: var(--muted); font-size: .9rem; }}
.meta b {{ color: var(--fg); }}
.grid {{ display: flex; flex-direction: column; gap: 2rem; }}
.chart {{
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.04);
overflow-x: auto;
}}
.chart-error {{ color: #b00; padding: 1rem; background: #fff0f0; border-radius: 6px; }}
.section-title {{
margin: 3rem 0 0.5rem; padding-top: 1.5rem; border-top: 2px dashed var(--accent);
font-size: 1.4rem; color: var(--accent);
}}
.section-desc {{ margin: 0 0 1.5rem; color: var(--muted); font-size: .9rem; }}
footer {{ margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border);
color: var(--muted); font-size: .85rem; }}
</style>
</head>
<body>
<header>
<h1>Optuna 优化仪表盘</h1>
<div class="meta">
<span>研究名称:<b>{escape(STUDY_NAME)}</b></span>
<span>试验总数:<b>{n_trials}</b></span>
<span>完成:<b>{completed}</b></span>
<span>剪枝:<b>{pruned}</b></span>
<span>失败:<b>{failed}</b></span>
<span>最佳:<b>{best_str}</b></span>
</div>
</header>
<main class="grid">
{main_cards}
{slice_section}
{contour_section}
</main>
<footer>
生成时间:2026-06-26 · 来源:<code>{escape(str(STUDY_DB.relative_to(PROJECT)))}</code>
· 框架:<a href="https://optuna.org">Optuna</a> + <a href="https://plotly.com/python">Plotly</a>
</footer>
</body>
</html>
"""
def main() -> int:
if not STUDY_DB.exists():
print(f"study DB not found: {STUDY_DB}")
return 1
print(f"loading study: {STUDY_NAME}{STUDY_DB.relative_to(PROJECT)}")
study = optuna.load_study(
study_name=STUDY_NAME,
storage=f"sqlite:///{STUDY_DB}",
)
best_val = study.best_trial.value if study.best_trial is not None else None
print(f" trials: {len(study.trials)} best: "
f"{best_val:.4f}" if best_val is not None else " trials: (no best yet)")
# Main dashboard charts (single-figure plots that render fine at 1100×520).
main_fragments: list[tuple[str, str]] = []
for i, (fn_name, title_zh, *_) in enumerate(CHARTS):
print(f" · {fn_name} ({title_zh}) …", end=" ", flush=True)
frag = render_chart(fn_name, study, include_plotly=(i == 0))
main_fragments.append((title_zh, frag))
print("OK" if "chart-error" not in frag else "FAILED")
# Per-parameter slice charts — readable single-column subplots instead
# of plot_slice's 5400px-wide composite that crammed all 19 params.
print(f"\n building per-param slice charts ({len(PER_PARAM_CHARTS)} params)…")
slice_fragments = render_slice_grid(study, PER_PARAM_CHARTS)
n_ok = sum(1 for _, f in slice_fragments if "chart-error" not in f)
print(f" slice: {n_ok}/{len(slice_fragments)} OK")
# Per-pair contour charts — only top-6 important params (15 pairs)
# instead of plot_contour's 19² = 361 unreadable subplots.
print(f" building per-pair contour charts (top-6 important params)…")
contour_fragments = render_contour_grid(study, PER_PARAM_CHARTS)
n_ok = sum(1 for _, f in contour_fragments if "chart-error" not in f)
print(f" contour: {n_ok}/{len(contour_fragments)} OK")
OUT_HTML.parent.mkdir(parents=True, exist_ok=True)
html = build_index_html(study, main_fragments, slice_fragments, contour_fragments)
OUT_HTML.write_text(html, encoding="utf-8")
print(f"\nwritten: {OUT_HTML.relative_to(PROJECT)} ({len(html):,} bytes)")
print(f"open: {OUT_HTML.as_uri()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())