Mt5pro-bot2/python/backtest/report.py

75 lines
5.7 KiB
Python

2026-10-08 09:07:44 +04:00
import html
def _svg(vals, color, fill=False, w=900, h=240, label=""):
if len(vals) < 2:
return ""
step = max(1, len(vals) // 800)
v = vals[::step]
lo, hi = min(v), max(v)
rng = (hi - lo) or 1
pts = [(i / (len(v) - 1) * w, h - 18 - (x - lo) / rng * (h - 36)) for i, x in enumerate(v)]
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
area = f'<polygon points="0,{h-18} {d} {w},{h-18}" fill="{color}" opacity="0.15"/>' if fill else ""
return (f'<svg viewBox="0 0 {w} {h}" width="100%" style="display:block">{area}'
f'<polyline points="{d}" fill="none" stroke="{color}" stroke-width="2"/>'
f'<text x="6" y="14" font-size="12" fill="currentColor">{label} max {hi:,.0f}</text>'
f'<text x="6" y="{h-4}" font-size="12" fill="currentColor">min {lo:,.0f}</text></svg>')
def _card(t, v, cls=""):
return f'<div class="card {cls}"><div class="t">{t}</div><div class="v">{v}</div></div>'
def render_report(path, title, m, equity, trades, wf=None, mc=None, synthetic=False):
pf = "∞" if m["profit_factor"] == float("inf") else f"{m['profit_factor']:.2f}"
good = "ok" if m["net"] >= 0 else "bad"
cards = "".join([
_card("Résultat net", f"{m['net']:+,.0f} $", good), _card("Rendement", f"{m['return_pct']:+.1f} %", good),
_card("Trades", m["trades"]), _card("Winrate", f"{m['win_rate']:.0f} %"),
_card("Profit factor", pf), _card("Drawdown max", f"{m['max_dd_pct']:.1f} %", "bad" if m["max_dd_pct"] > 10 else ""),
_card("Sharpe", f"{m['sharpe']:.2f}"), _card("Espérance / trade", f"{m['expectancy']:+.1f} $"),
_card("R moyen", f"{m['avg_r']:+.2f}"), _card("Série de pertes max", m["max_loss_streak"]),
])
peak = equity.cummax()
dd = ((peak - equity) / peak * 100).tolist()
banner = ('<div class="warn">⚠️ DONNÉES SYNTHÉTIQUES : ces résultats n\'ont aucune valeur de performance réelle. '
'Ils servent uniquement à vérifier le fonctionnement du moteur.</div>') if synthetic else ""
wf_html = ""
if wf and wf["windows"]:
rows = "".join(
f"<tr><td>{w['k']}</td><td>{w['test_from']} → {w['test_to']}</td><td>{html.escape(str(w['params']))}</td>"
f"<td>{w['is_return']:+.1f}%</td><td class='{'ok' if w['oos_return']>=0 else 'bad'}'>{w['oos_return']:+.1f}%</td>"
f"<td>{w['oos_dd']:.1f}%</td><td>{w['oos_trades']}</td></tr>" for w in wf["windows"])
wfe = f"{wf['wfe']:.0f} %" if wf["wfe"] is not None else "n/a"
wf_html = (f"<h2>Walk-forward (hors échantillon)</h2><p>Efficacité walk-forward : <b>{wfe}</b> "
"(au-dessus de ~50 % = stratégie plutôt robuste ; proche de 0 ou négatif = surajustement probable)</p>"
"<div class='sc'><table><tr><th>#</th><th>Période test</th><th>Paramètres choisis</th><th>IS</th><th>OOS</th><th>DD OOS</th><th>Trades</th></tr>"
f"{rows}</table></div>")
mc_html = ""
if mc:
mc_html = (f"<h2>Monte Carlo ({mc['runs']} simulations)</h2><div class='grid'>"
+ _card("Capital final (pire 5 %)", f"{mc['final_p5']:,.0f} $") + _card("Capital final (médian)", f"{mc['final_p50']:,.0f} $")
+ _card("Capital final (meilleur 5 %)", f"{mc['final_p95']:,.0f} $") + _card("DD médian", f"{mc['dd_p50']:.1f} %")
+ _card("DD dans 95 % des cas ≤", f"{mc['dd_p95']:.1f} %") + _card("Proba. d'être en perte", f"{mc['prob_loss']:.0f} %")
+ _card("Proba. DD ≥ 10 % (limite prop)", f"{mc['prob_dd10']:.0f} %") + _card(f"Proba. DD ≥ {mc['ruin_dd_pct']:.0f} %", f"{mc['prob_ruin']:.0f} %")
+ "</div>")
last = trades.tail(40)
trows = "".join(
f"<tr><td>{str(r.entry_time)[:16]}</td><td>{r.side}</td><td>{r.lots:.2f}</td><td>{r.entry:.5g}</td><td>{r.exit:.5g}</td>"
f"<td class='{'ok' if r.pnl>=0 else 'bad'}'>{r.pnl:+.1f}</td><td>{r.reason}</td></tr>" for r in last.itertuples())
doc = f"""<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><style>
:root{{--bg:#fff;--fg:#14181f;--card:#f3f5f8;--ok:#0a8f4d;--bad:#d1342f;--ac:#2563eb}}
@media(prefers-color-scheme:dark){{:root{{--bg:#0f1319;--fg:#e6e9ee;--card:#1b222c;--ok:#34d399;--bad:#f87171;--ac:#60a5fa}}}}
body{{background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif;margin:0;padding:16px;max-width:980px;margin:auto}}
h1{{font-size:20px}}h2{{font-size:17px;margin-top:28px}}.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}}
.card{{background:var(--card);border-radius:10px;padding:10px}}.t{{font-size:12px;opacity:.7}}.v{{font-size:18px;font-weight:600}}
.ok .v,td.ok{{color:var(--ok)}}.bad .v,td.bad{{color:var(--bad)}}.warn{{background:#fff3cd;color:#664d03;padding:10px;border-radius:8px;margin:10px 0}}
.sc{{overflow-x:auto}}table{{border-collapse:collapse;width:100%;font-size:13px}}td,th{{padding:5px 8px;border-bottom:1px solid var(--card);text-align:left;white-space:nowrap}}
.risk{{font-size:12px;opacity:.7;margin-top:30px}}</style></head><body>
<h1>{html.escape(title)}</h1>{banner}<div class="grid">{cards}</div>
<h2>Courbe d'equity</h2>{_svg(equity.tolist(), "var(--ac)", True, label="Equity")}
<h2>Drawdown (%)</h2>{_svg([-x for x in dd], "var(--bad)", True, label="DD")}
{wf_html}{mc_html}
<h2>40 derniers trades</h2><div class="sc"><table><tr><th>Entrée</th><th>Sens</th><th>Lots</th><th>Prix in</th><th>Prix out</th><th>PnL $</th><th>Sortie</th></tr>{trows}</table></div>
<p class="risk">Les performances passées ne garantissent pas les résultats futurs. Le trading comporte un risque de perte en capital.</p>
</body></html>"""
open(path, "w", encoding="utf-8").write(doc)