63a829cc46
主要内容: - 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)
327 lines
14 KiB
Python
327 lines
14 KiB
Python
"""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())
|