Project_Carmignac/src/repair_challenge/helpers.py

2329 lines
79 KiB
Python
Raw 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.

"""
Helper methods that are used in the repair challenge
"""
import json
import pandas as pd
import numpy as np
import s3fs
import os
def load_data_diagnostics():
fs = s3fs.S3FileSystem(
client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
key=os.environ["AWS_ACCESS_KEY_ID"],
secret=os.environ["AWS_SECRET_ACCESS_KEY"],
token=os.environ["AWS_SESSION_TOKEN"],
)
with fs.open("projet-bdc-data//carmignac/Flows ENSAE V2 -20251105.csv", "rb") as f:
flows = pd.read_csv(f, sep=";")
with fs.open("projet-bdc-data//carmignac/AUM ENSAE V2 -20251105.csv", "rb") as f:
aum = pd.read_csv(f, sep=";")
aum["Centralisation Date"] = pd.to_datetime(aum["Centralisation Date"])
flows["Centralisation Date"] = pd.to_datetime(flows["Centralisation Date"])
return aum, flows
def load_data_repair():
fs = s3fs.S3FileSystem(
client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
key=os.environ["AWS_ACCESS_KEY_ID"],
secret=os.environ["AWS_SECRET_ACCESS_KEY"],
token=os.environ["AWS_SESSION_TOKEN"],
)
with fs.open("projet-bdc-data//carmignac/Flows ENSAE V2 -20251105.csv", "rb") as f:
flows = pd.read_csv(f, sep=";")
with fs.open("projet-bdc-data//carmignac/AUM ENSAE V2 -20251105.csv", "rb") as f:
aum = pd.read_csv(f, sep=";")
aum["Centralisation Date"] = pd.to_datetime(aum["Centralisation Date"])
flows["Centralisation Date"] = pd.to_datetime(flows["Centralisation Date"])
# Noms courts
aum = aum.rename(
columns={
"Registrar Account - ID": "reg_id",
"Product - Isin": "isin",
"Centralisation Date": "date",
"Quantity - AUM": "qty_aum",
"Value - AUM €": "val_eur",
"Registrar Account - Region": "region",
}
)
flows = flows.rename(
columns={
"Registrar Account - ID": "reg_id",
"Product - Isin": "isin",
"Centralisation Date": "date",
"Quantity - NetFlows": "qty_net",
"Value € - NetFlows": "val_net_eur",
}
)
aum["reg_id"] = aum["reg_id"].astype(str)
flows["reg_id"] = flows["reg_id"].astype(str)
return aum, flows
def load_inputs_branch(mapping_path, surgery_path):
fs = s3fs.S3FileSystem(
client_kwargs={"endpoint_url": "https://" + "minio-simple.lab.groupe-genes.fr"},
key=os.environ["AWS_ACCESS_KEY_ID"],
secret=os.environ["AWS_SECRET_ACCESS_KEY"],
token=os.environ["AWS_SESSION_TOKEN"],
)
with fs.open(
"s3://projet-bdc-data/carmignac/AUM ENSAE V2 -20251105.csv", "rb"
) as f:
aum = pd.read_csv(f, sep=";")
mapping = pd.read_csv(mapping_path, parse_dates=["date"])
surgery = (
pd.read_csv(surgery_path, parse_dates=["date"])
if surgery_path
else pd.DataFrame()
)
# Normalise ID columns to string
aum["Registrar Account - ID"] = aum["Registrar Account - ID"].astype(str)
mapping["reg_orig"] = mapping["reg_orig"].astype(str)
mapping["reg_used"] = mapping["reg_used"].astype(str)
if not surgery.empty:
surgery["reg_orig"] = surgery["reg_orig"].astype(str)
surgery["reg_from"] = surgery["reg_from"].astype(str)
surgery["reg_to"] = surgery["reg_to"].astype(str)
return aum, mapping, surgery
# ─────────────────────────────────────────────────────────────
# BUILD HTML REPORT
# ─────────────────────────────────────────────────────────────
def build_html_diagnostics(df_broken, df_all, df_agg, df_err_isin, df_err_agg, alpha):
# ── JS-ready data ────────────────────────────────────────────
# Timeline: n_broken and total_missing per month
tl = (
df_all[df_all["broken"]]
.groupby("date")
.agg(
n_broken=("isin", "count"),
total_missing=("missing_flow", lambda x: x.abs().sum()),
n_lag=("is_lag", "sum"),
)
.reindex(df_all["date"].sort_values().unique())
.fillna(0)
)
tl.index = pd.to_datetime(tl.index)
dates_str = json.dumps([d.strftime("%Y-%m-%d") for d in tl.index])
def jf(arr, dec=4):
return json.dumps(
[round(float(v), dec) if not np.isnan(v) else None for v in arr]
)
ISIN_COLORS = [
"#2563eb",
"#16a34a",
"#dc2626",
"#d97706",
"#7c3aed",
"#0891b2",
"#db2777",
"#65a30d",
"#ea580c",
"#6366f1",
]
n_broken_js = jf(tl["n_broken"].values, 0)
total_miss_js = jf(tl["total_missing"].values)
n_lag_js = jf(tl["n_lag"].values, 0)
# Aggregate (cross-ISIN) JS data
agg_dates_str = json.dumps(
[d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_agg["date"])]
)
agg_delta_js = jf(df_agg["delta_aum"].values)
agg_flow_js = jf(df_agg["flow_total"].values)
agg_missing_js = jf(df_agg["missing_flow"].values)
agg_pct_js = jf((df_agg["missing_pct"] * 100).values)
# Aggregate KPIs
n_agg_broken = int(df_agg["broken"].sum())
n_agg_lag = int(df_agg["is_lag"].sum())
n_agg_genuine = n_agg_broken - n_agg_lag
max_agg_pct = float(df_agg["missing_pct"].max() * 100) if len(df_agg) else 0
# Aggregate detail table rows
agg_rows = []
for _, r in df_agg[df_agg["broken"]].iterrows():
lb = '<span class="lag-badge">lag</span>' if r["is_lag"] else ""
pc = "pct-high" if r["missing_pct"] > 0.1 else "pct-med"
ds = (
r["date"].strftime("%Y-%m-%d")
if hasattr(r["date"], "strftime")
else str(r["date"])[:10]
)
mc = "miss-neg" if r["missing_flow"] < 0 else "miss-pos"
agg_rows.append(
f"<tr><td>{ds}</td>"
f'<td class="mono right">{r["q_total_prev"]:,.1f}</td>'
f'<td class="mono right">{r["q_total_curr"]:,.1f}</td>'
f'<td class="mono right">{r["flow_total"]:,.1f}</td>'
f'<td class="mono right {mc}">{r["missing_flow"]:+,.1f}</td>'
f'<td class="mono right {pc}">{r["missing_pct"] * 100:.2f}%</td>'
f"<td>{lb}</td></tr>"
)
agg_detail_rows = (
"".join(agg_rows)
if agg_rows
else (
'<tr><td colspan="7" style="padding:24px;text-align:center;'
'color:var(--success);font-family:var(--mono)">✓ No broken months at aggregate level</td></tr>'
)
)
# ── Error account JS data ────────────────────────────────────
err_dates_str = json.dumps(
[d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_err_agg["date"])]
)
err_agg_stock_js = jf(df_err_agg["stock_error_agg"].values)
err_agg_res_js = jf(df_err_agg["residual_agg"].values)
err_agg_pct_js = jf(df_err_agg["stock_error_agg_pct"].values)
# Top 5 ISINs by max absolute stock error
top_err_isins = (
df_err_isin.groupby("isin")["stock_error"]
.apply(lambda x: x.abs().max())
.nlargest(5)
.index.tolist()
)
all_err_dates = sorted(df_err_isin["date"].unique())
err_isin_datasets = []
for idx, isin in enumerate(top_err_isins):
sub = (
df_err_isin[df_err_isin["isin"] == isin]
.set_index("date")["stock_error"]
.reindex(all_err_dates)
)
err_isin_datasets.append(
{
"label": isin,
"data": [
round(float(v), 3) if not pd.isna(v) else None for v in sub.values
],
"borderColor": ISIN_COLORS[idx % len(ISIN_COLORS)],
"backgroundColor": ISIN_COLORS[idx % len(ISIN_COLORS)] + "22",
"borderWidth": 1.5,
"pointRadius": 0,
"tension": 0.3,
"fill": False,
}
)
err_isin_ts_json = json.dumps(err_isin_datasets)
err_isin_dates_str = json.dumps(
[
d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
for d in all_err_dates
]
)
# Error account KPIs
max_agg_stock_err = float(df_err_agg["stock_error_agg"].abs().max())
max_agg_stock_pct = float(df_err_agg["stock_error_agg_pct"].max())
# Stationarity proxy: std / mean_abs (lower = more stationary)
agg_std = float(df_err_agg["stock_error_agg"].std())
agg_mean = float(df_err_agg["stock_error_agg"].abs().mean())
stationarity = round(agg_std / max(agg_mean, 1e-9), 3)
# Error account ISIN detail table (worst months per ISIN)
err_worst = (
df_err_isin.assign(abs_stock=df_err_isin["stock_error"].abs())
.sort_values("abs_stock", ascending=False)
.head(200)
)
err_isin_rows = []
for _, r in err_worst.iterrows():
ds = (
r["date"].strftime("%Y-%m-%d")
if hasattr(r["date"], "strftime")
else str(r["date"])[:10]
)
sc = "miss-neg" if r["stock_error"] < 0 else "miss-pos"
rc = "miss-neg" if r["residual"] < 0 else "miss-pos"
pch = (
"pct-high"
if r["stock_error_pct"] > 5
else ("pct-med" if r["stock_error_pct"] > 1 else "")
)
err_isin_rows.append(
f"<tr><td>{ds}</td>"
f'<td class="mono">{r["isin"]}</td>'
f'<td class="mono right {rc}">{r["residual"]:+,.2f}</td>'
f'<td class="mono right {sc}">{r["stock_error"]:+,.2f}</td>'
f'<td class="mono right {pch}">{r["stock_error_pct"]:.3f}%</td></tr>'
)
err_isin_detail = (
"".join(err_isin_rows)
if err_isin_rows
else (
'<tr><td colspan="5" style="padding:24px;text-align:center;'
'color:var(--success);font-family:var(--mono)">✓ Error account is flat (no residuals)</td></tr>'
)
)
# Per-ISIN summary
isin_sum = (
df_broken.groupby("isin")
.agg(
n_months=("date", "count"),
avg_pct=("missing_pct", "mean"),
total_abs=("missing_flow", lambda x: x.abs().sum()),
)
.sort_values("total_abs", ascending=False)
)
# Per-ISIN missing_pct timeseries for the top 5 ISINs
top_isins = isin_sum.head(5).index.tolist()
all_dates = sorted(df_all["date"].unique())
isin_ts_datasets = []
for idx, isin in enumerate(top_isins):
sub = (
df_all[df_all["isin"] == isin]
.set_index("date")["missing_pct"]
.reindex(all_dates)
.fillna(0)
)
isin_ts_datasets.append(
{
"label": isin,
"data": [round(float(v) * 100, 3) for v in sub.values],
"borderColor": ISIN_COLORS[idx % len(ISIN_COLORS)],
"backgroundColor": ISIN_COLORS[idx % len(ISIN_COLORS)] + "22",
"borderWidth": 2,
"pointRadius": 0,
"tension": 0.3,
"fill": False,
}
)
isin_ts_json = json.dumps(isin_ts_datasets)
all_dates_str = json.dumps(
[
d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
for d in all_dates
]
)
# Detail table rows
detail_rows = ""
for _, r in df_broken.head(200).iterrows():
lag_badge = '<span class="lag-badge">lag</span>' if r["is_lag"] else ""
pct_class = "pct-high" if r["missing_pct"] > 0.1 else "pct-med"
detail_rows += f"""
<tr>
<td>{r["date"].strftime("%Y-%m-%d") if hasattr(r["date"], "strftime") else str(r["date"])[:10]}</td>
<td class="mono">{r["isin"]}</td>
<td class="mono right">{r["q_agg_prev"]:,.1f}</td>
<td class="mono right">{r["q_agg_curr"]:,.1f}</td>
<td class="mono right">{r["flow_agg"]:,.1f}</td>
<td class="mono right {"miss-neg" if r["missing_flow"] < 0 else "miss-pos"}">{r["missing_flow"]:+,.1f}</td>
<td class="mono right {pct_class}">{r["missing_pct"] * 100:.2f}%</td>
<td>{lag_badge}</td>
</tr>"""
# ISIN summary table
isin_rows = ""
for isin, row in isin_sum.iterrows():
isin_rows += f"""
<tr>
<td class="mono">{isin}</td>
<td class="mono right">{int(row["n_months"])}</td>
<td class="mono right">{row["avg_pct"] * 100:.2f}%</td>
<td class="mono right">{row["total_abs"]:,.1f}</td>
</tr>"""
# KPIs
total = len(df_all)
n_broken_kpi = len(df_broken)
n_lag_kpi = int(df_broken["is_lag"].sum())
n_genuine = n_broken_kpi - n_lag_kpi
max_pct = df_broken["missing_pct"].max() * 100 if len(df_broken) else 0
n_isins = df_broken["isin"].nunique()
no_broken_msg = ""
if n_broken_kpi == 0:
no_broken_msg = '<div class="no-broken">✓ No broken months detected at this threshold.</div>'
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Carmignac — Broken Months Diagnostics</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600;700&display=swap');
:root {{
--bg: #0d0f12; --surface: #151820; --border: #252a35;
--accent: #3b82f6; --warn: #f59e0b; --danger: #ef4444;
--success: #10b981; --text: #e2e8f0; --muted: #64748b;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: var(--sans); background: var(--bg); color: var(--text);
padding: 0 0 60px; }}
.header {{ background: linear-gradient(135deg,#0d1117,#111827,#1a0a0a);
border-bottom: 1px solid var(--border); padding: 40px 48px 36px; }}
.header-eyebrow {{ font-family: var(--mono); font-size: 11px; letter-spacing:.15em;
color: var(--danger); text-transform: uppercase; margin-bottom:10px; }}
.header h1 {{ font-size: 2rem; font-weight: 700; letter-spacing:-.02em; margin-bottom:8px; }}
.header-sub {{ font-size:.85rem; color: var(--muted); font-family: var(--mono); }}
.kpi-strip {{ display: grid; grid-template-columns: repeat(auto-fit,minmax(160px,1fr));
gap: 1px; background: var(--border); border-bottom: 1px solid var(--border); }}
.kpi {{ background: var(--surface); padding: 22px 28px;
display: flex; flex-direction: column; gap: 4px; }}
.kpi-label {{ font-size:.7rem; letter-spacing:.1em; text-transform:uppercase;
color: var(--muted); font-family: var(--mono); }}
.kpi-value {{ font-size:1.6rem; font-weight:700; font-family: var(--mono); line-height:1; }}
.kpi-value.danger {{ color: var(--danger); }}
.kpi-value.warn {{ color: var(--warn); }}
.kpi-value.success {{ color: var(--success); }}
.kpi-sub {{ font-size:.7rem; color: var(--muted); font-family: var(--mono); }}
.main {{ max-width:1400px; margin:0 auto; padding:36px 48px;
display:flex; flex-direction:column; gap:32px; }}
.card {{ background: var(--surface); border: 1px solid var(--border);
border-radius:8px; overflow:hidden; }}
.card-header {{ padding:18px 24px 14px; border-bottom:1px solid var(--border);
display:flex; align-items:baseline; gap:12px; }}
.card-title {{ font-size:.8rem; font-weight:600; letter-spacing:.1em;
text-transform:uppercase; color: var(--muted); font-family: var(--mono); }}
.card-desc {{ font-size:.78rem; color: #475569; }}
.card-body {{ padding:24px; }}
.chart-wrap {{ position:relative; height:260px; }}
.chart-wrap-tall {{ position:relative; height:320px; }}
.grid-2 {{ display:grid; grid-template-columns:1fr 1fr; gap:24px; }}
@media(max-width:900px) {{ .grid-2 {{ grid-template-columns:1fr; }}
.main {{ padding:24px 20px; }} }}
.section-label {{ font-family: var(--mono); font-size:.68rem; letter-spacing:.15em;
text-transform:uppercase; color: var(--muted);
padding-left:10px; border-left:3px solid var(--danger);
margin-bottom:-8px; }}
table {{ width:100%; border-collapse:collapse; font-size:.82rem; }}
th {{ font-family: var(--mono); font-size:.68rem; letter-spacing:.08em;
text-transform:uppercase; color: var(--muted); padding:10px 14px;
text-align:left; border-bottom:1px solid var(--border); background:#0f1218; }}
td {{ padding:10px 14px; border-bottom:1px solid #1a1f2a; vertical-align:middle; }}
tr:last-child td {{ border-bottom:none; }}
tr:hover td {{ background:#181e2b; }}
.mono {{ font-family: var(--mono); font-size:.78rem; }}
.right {{ text-align:right; }}
.miss-pos {{ color: var(--warn); }}
.miss-neg {{ color: var(--danger); }}
.pct-high {{ color: var(--danger); font-weight:600; }}
.pct-med {{ color: var(--warn); }}
.lag-badge {{ font-family: var(--mono); font-size:.65rem; padding:2px 6px;
background:#f59e0b22; border:1px solid #f59e0b66; border-radius:3px;
color: var(--warn); }}
.no-broken {{ padding:40px; text-align:center; color: var(--success);
font-family: var(--mono); font-size:.9rem; }}
.footer {{ text-align:center; font-family: var(--mono); font-size:.68rem;
color:#334155; margin-top:16px; letter-spacing:.05em; }}
.alpha-note {{ font-family: var(--mono); font-size:.75rem; color: var(--muted);
padding:10px 24px 0; }}
</style>
</head>
<body>
<div class="header">
<div class="header-eyebrow">Carmignac × ENSAE · Data Challenge 2025</div>
<h1>Broken Months Diagnostics</h1>
<div class="header-sub">
Aggregate stock-flow equation check · ISIN level · threshold α = {alpha:.1%}<br>
<span style='font-size:.78rem'>Missing % = |missing flow| / max(|ΔAUM|, |recorded flow|, 1 share) — capped at movement size, not stock level</span>
</div>
</div>
<div class="kpi-strip">
<div class="kpi">
<span class="kpi-label">(ISIN, month) pairs</span>
<span class="kpi-value">{total:,}</span>
<span class="kpi-sub">examined</span>
</div>
<div class="kpi">
<span class="kpi-label">Broken months</span>
<span class="kpi-value {"danger" if n_broken_kpi > 0 else "success"}">{
n_broken_kpi:,}</span>
<span class="kpi-sub">{n_broken_kpi / total * 100:.1f}% of pairs</span>
</div>
<div class="kpi">
<span class="kpi-label">Likely lags</span>
<span class="kpi-value warn">{n_lag_kpi}</span>
<span class="kpi-sub">resolved by ±{3}d window</span>
</div>
<div class="kpi">
<span class="kpi-label">Genuine gaps</span>
<span class="kpi-value {"danger" if n_genuine > 0 else "success"}">{
n_genuine
}</span>
<span class="kpi-sub">unresolved by lag fix</span>
</div>
<div class="kpi">
<span class="kpi-label">ISINs affected</span>
<span class="kpi-value">{n_isins}</span>
<span class="kpi-sub">distinct ISINs</span>
</div>
<div class="kpi">
<span class="kpi-label">Max missing %</span>
<span class="kpi-value {"danger" if max_pct > 10 else "warn"}">{max_pct:.1f}%</span>
<span class="kpi-sub">worst single (isin, month)</span>
</div>
</div>
<div class="main">
<div class="section-label">00 · Error account — cumulative residuals</div>
<div class="card">
<div class="card-header">
<span class="card-title">Aggregate error account stock over time</span>
<span class="card-desc">
Stock_error(t_ref) = 0 by definition. At each prior month, the stock absorbs the residual
[ΔQ_total F_total]. A stationary signal near zero = clean data.
A drifting signal = structural data quality problem.
</span>
</div>
<div class="card-body" style="padding-bottom:8px">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);margin-bottom:20px">
<div style="background:var(--surface);padding:16px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max |stock error|</div>
<div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:var(--danger)">{
max_agg_stock_err:,.1f} shares</div>
</div>
<div style="background:var(--surface);padding:16px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max % of total AUM</div>
<div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:{
"var(--danger)" if max_agg_stock_pct > 5 else "var(--warn)"
}">{max_agg_stock_pct:.3f}%</div>
</div>
<div style="background:var(--surface);padding:16px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Stationarity (σ/μ)</div>
<div style="font-family:var(--mono);font-size:1.4rem;font-weight:700;color:{
"var(--success)" if stationarity < 1 else "var(--warn)"
}">{stationarity:.3f}</div>
<div style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">lower = more stationary</div>
</div>
</div>
<div class="chart-wrap-tall"><canvas id="chartErrAggStock"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Monthly aggregate residual</span>
<span class="card-desc">ΔQ_total F_total per month (should be near zero)</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartErrAggRes"></canvas></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Error stock — top 5 ISINs</span>
<span class="card-desc">Cumulative error stock per ISIN (most affected)</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartErrIsinTs"></canvas></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Error account detail — worst (ISIN, month) pairs</span>
<span class="card-desc">Sorted by absolute cumulative error stock. stock_error_pct = |stock| / max(ISIN AUM)</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead><tr>
<th>Date</th><th>ISIN</th>
<th class="right">Monthly residual</th>
<th class="right">Cumulative stock</th>
<th class="right">% of max AUM</th>
</tr></thead>
<tbody>{err_isin_detail}</tbody>
</table>
</div>
</div>
<div class="section-label">01 · Aggregate view — all ISINs combined</div>
<div class="card">
<div class="card-header">
<span class="card-title">Stock-flow equation — total portfolio</span>
<span class="card-desc">
Σ Q(t) Σ Q(t1) vs Σ F(t) across all ISINs and accounts.
Detects months where the global portfolio is incoherent, independent of ISIN-level breakdown.
</span>
</div>
<div class="card-body">
<div class="chart-wrap-tall"><canvas id="chartAggOverlay"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Aggregate missing flow over time</span>
<span class="card-desc">Σ Q(t) Σ Q(t1) Σ F(t) — should be near zero every month</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartAggMissing"></canvas></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Aggregate missing % of movement</span>
<span class="card-desc">|missing| / max(|ΔAUM|, |flow|) — months above α flagged in red</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartAggPct"></canvas></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Aggregate broken months — detail</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead><tr>
<th>Date</th>
<th class="right">Σ Q(t1)</th><th class="right">Σ Q(t)</th>
<th class="right">Σ Flow</th><th class="right">Missing</th>
<th class="right">Missing %</th><th></th>
</tr></thead>
<tbody>{agg_detail_rows}</tbody>
</table>
</div>
</div>
<div class="section-label">01 · Timeline — per ISIN</div>
<div class="card">
<div class="card-header">
<span class="card-title">Broken (isin, month) pairs per month</span>
<span class="card-desc">Stacked: genuine gaps (red) vs likely accounting lags (amber)</span>
</div>
<div class="card-body">
<div class="chart-wrap-tall"><canvas id="chartTimeline"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Total absolute missing flow per month</span>
<span class="card-desc">Sum of |missing flow| across all broken ISINs</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartMissing"></canvas></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Missing % — top 5 ISINs over time</span>
<span class="card-desc">|missing flow| / max(|ΔAUM|, |recorded flow|) per ISIN — capped at movement size</span>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartIsinTs"></canvas></div>
</div>
</div>
</div>
<div class="section-label">02 · By ISIN</div>
<div class="card">
<div class="card-header">
<span class="card-title">ISIN summary — most affected</span>
</div>
<div class="card-body" style="padding:0">
{
'<div class="no-broken">No broken months detected.</div>'
if n_broken_kpi == 0
else f'''
<table>
<thead><tr>
<th>ISIN</th><th>Broken months</th>
<th>Avg missing %</th><th>Total |missing| (shares)</th>
</tr></thead>
<tbody>{isin_rows}</tbody>
</table>'''
}
</div>
</div>
<div class="section-label">03 · Detail log</div>
<div class="card">
<div class="card-header">
<span class="card-title">All broken (isin, month) pairs</span>
<span class="card-desc">
<span class="lag-badge">lag</span> = likely resolved by extending flow window ±3 days
</span>
</div>
<div class="alpha-note">Threshold α = {alpha:.1%} · showing up to 200 rows</div>
<div class="card-body" style="padding:0">
{
'<div class="no-broken">✓ No broken months detected at this threshold.</div>'
if n_broken_kpi == 0
else f'''
<table>
<thead><tr>
<th>Date</th><th>ISIN</th>
<th class="right">Q(t-1)</th><th class="right">Q(t)</th>
<th class="right">Net flow</th><th class="right">Missing</th>
<th class="right">Missing % of movement</th><th></th>
</tr></thead>
<tbody>{detail_rows}</tbody>
</table>'''
}
</div>
</div>
</div>
<div class="footer">Generated by carmignac_diagnostics.py · Carmignac × ENSAE Data Challenge 2025</div>
<script>
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = '#1e2535';
Chart.defaults.font.family = "'IBM Plex Mono', monospace";
Chart.defaults.font.size = 11;
const DATES = {dates_str};
const N_BROKEN = {n_broken_js};
const N_LAG = {n_lag_js};
const TOT_MISS = {total_miss_js};
const ISIN_TS = {isin_ts_json};
const ALL_DATES = {all_dates_str};
function tip() {{
return {{
backgroundColor:'#0d1117', borderColor:'#252a35', borderWidth:1,
titleFont:{{family:"'IBM Plex Mono'"}}, bodyFont:{{family:"'IBM Plex Mono'"}}, padding:10
}};
}}
function xAxis() {{
return {{ type:'category', ticks:{{maxTicksLimit:10,maxRotation:0}},
grid:{{color:'#1a2030'}} }};
}}
function yAxis(label) {{
return {{ grid:{{color:'#1a2030'}},
title:{{display:!!label,text:label,color:'#475569'}} }};
}}
// n_genuine per month = N_BROKEN - N_LAG
const N_GENUINE = N_BROKEN.map((b,i) => b - (N_LAG[i]||0));
new Chart(document.getElementById('chartTimeline'), {{
type:'bar',
data:{{
labels: DATES,
datasets:[
{{ label:'Genuine gaps', data:N_GENUINE,
backgroundColor:'#ef444488', borderColor:'#ef4444', borderWidth:1, borderRadius:2 }},
{{ label:'Likely lags', data:N_LAG,
backgroundColor:'#f59e0b88', borderColor:'#f59e0b', borderWidth:1, borderRadius:2 }},
]
}},
options:{{
responsive:true, maintainAspectRatio:false,
interaction:{{mode:'index',intersect:false}},
plugins:{{
legend:{{position:'top',labels:{{boxWidth:12,padding:16}}}},
tooltip:tip()
}},
scales:{{ x:xAxis(), y:{{...yAxis('# (isin, month) pairs'), stacked:true}} }},
}}
}});
new Chart(document.getElementById('chartMissing'), {{
type:'bar',
data:{{
labels: DATES,
datasets:[{{ label:'|Missing flow| (shares)', data:TOT_MISS,
backgroundColor:'#dc262688', borderColor:'#dc2626',
borderWidth:1, borderRadius:2 }}]
}},
options:{{
responsive:true, maintainAspectRatio:false,
plugins:{{legend:{{display:false}}, tooltip:tip()}},
scales:{{ x:xAxis(), y:yAxis('Shares') }}
}}
}});
new Chart(document.getElementById('chartIsinTs'), {{
type:'line',
data:{{ labels:ALL_DATES, datasets:ISIN_TS }},
options:{{
responsive:true, maintainAspectRatio:false,
interaction:{{mode:'index',intersect:false}},
plugins:{{
legend:{{position:'right',labels:{{boxWidth:10,padding:8,font:{{size:10}}}}}},
tooltip:tip()
}},
scales:{{ x:xAxis(), y:yAxis('Missing (%)') }}
}}
}});
// ── Error account charts ─────────────────────────────────────
const ERR_DATES = {err_dates_str};
const ERR_AGG_STOCK = {err_agg_stock_js};
const ERR_AGG_RES = {err_agg_res_js};
const ERR_AGG_PCT = {err_agg_pct_js};
const ERR_ISIN_TS = {err_isin_ts_json};
const ERR_ISIN_DATES= {err_isin_dates_str};
// Aggregate error stock — line with zero reference
new Chart(document.getElementById('chartErrAggStock'), {{
type: 'line',
data: {{
labels: ERR_DATES,
datasets: [
{{ label: 'Aggregate error stock (shares)',
data: ERR_AGG_STOCK,
borderColor: '#ef4444', backgroundColor: '#ef444418',
borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true }},
]
}},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{mode:'index', intersect:false}},
plugins: {{ legend:{{display:false}}, tooltip: tip() }},
scales: {{
x: xAxis(),
y: {{
...yAxis('Shares'),
grid: {{
color: ctx => ctx.tick.value === 0 ? '#ffffff55' : '#1a2030',
lineWidth: ctx => ctx.tick.value === 0 ? 1.5 : 1,
}}
}}
}}
}}
}});
// Monthly residual bar
new Chart(document.getElementById('chartErrAggRes'), {{
type: 'bar',
data: {{
labels: ERR_DATES,
datasets: [{{ label: 'Monthly residual (shares)', data: ERR_AGG_RES,
backgroundColor: ERR_AGG_RES.map(v => v < 0 ? '#ef444488' : '#f59e0b88'),
borderColor: ERR_AGG_RES.map(v => v < 0 ? '#ef4444' : '#f59e0b'),
borderWidth: 1, borderRadius: 2 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{legend:{{display:false}}, tooltip: tip()}},
scales: {{ x: xAxis(), y: yAxis('Shares') }}
}}
}});
// Per-ISIN error stock timeseries
new Chart(document.getElementById('chartErrIsinTs'), {{
type: 'line',
data: {{ labels: ERR_ISIN_DATES, datasets: ERR_ISIN_TS }},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{mode:'index', intersect:false}},
plugins: {{
legend:{{position:'right',labels:{{boxWidth:10,padding:8,font:{{size:10}}}}}},
tooltip: tip()
}},
scales: {{ x: xAxis(), y: yAxis('Error stock (shares)') }}
}}
}});
// ── Aggregate charts ─────────────────────────────────────────
const AGG_DATES = {agg_dates_str};
const AGG_DELTA = {agg_delta_js};
const AGG_FLOW = {agg_flow_js};
const AGG_MISSING = {agg_missing_js};
const AGG_PCT = {agg_pct_js};
const ALPHA = {alpha};
// Color each bar: red if broken, amber if lag, else subtle blue
const aggPctColors = AGG_PCT.map(v =>
Math.abs(v) > ALPHA * 100 ? '#ef444488' : '#3b82f622'
);
const aggPctBorders = AGG_PCT.map(v =>
Math.abs(v) > ALPHA * 100 ? '#ef4444' : '#3b82f655'
);
// Overlay: ΔAUM vs total flow
new Chart(document.getElementById('chartAggOverlay'), {{
type: 'line',
data: {{
labels: AGG_DATES,
datasets: [
{{ label: 'ΔAUM (Σ Q(t) Σ Q(t1))',
data: AGG_DELTA, borderColor: '#3b82f6', backgroundColor: '#3b82f622',
borderWidth: 2, pointRadius: 0, tension: 0.3, fill: false }},
{{ label: 'Σ Net flows recorded',
data: AGG_FLOW, borderColor: '#10b981', backgroundColor: '#10b98122',
borderWidth: 2, pointRadius: 0, tension: 0.3, fill: false }},
]
}},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{mode:'index', intersect:false}},
plugins: {{
legend: {{position:'top', labels:{{boxWidth:12, padding:16}}}},
tooltip: tip()
}},
scales: {{ x: xAxis(), y: yAxis('Shares') }}
}}
}});
// Missing flow bar
new Chart(document.getElementById('chartAggMissing'), {{
type: 'bar',
data: {{
labels: AGG_DATES,
datasets: [{{ label: 'Missing flow (shares)', data: AGG_MISSING,
backgroundColor: AGG_MISSING.map(v => v < 0 ? '#ef444488' : '#f59e0b88'),
borderColor: AGG_MISSING.map(v => v < 0 ? '#ef4444' : '#f59e0b'),
borderWidth: 1, borderRadius: 2 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{legend:{{display:false}}, tooltip: tip()}},
scales: {{ x: xAxis(), y: yAxis('Shares') }}
}}
}});
// Missing % bar, coloured by threshold
new Chart(document.getElementById('chartAggPct'), {{
type: 'bar',
data: {{
labels: AGG_DATES,
datasets: [{{ label: 'Missing % of movement', data: AGG_PCT,
backgroundColor: aggPctColors, borderColor: aggPctBorders,
borderWidth: 1, borderRadius: 2 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{
legend: {{display:false}},
tooltip: tip(),
annotation: {{}} // threshold line handled via color
}},
scales: {{ x: xAxis(), y: {{...yAxis('Missing (%)'), min: 0}} }}
}}
}});
</script>
</body>
</html>"""
return html
def build_html_repair(analytics, surgery, scores, mapping, df_err_isin=None, df_err_agg=None):
tl = analytics["timeline"]
ss = analytics["surgery_stats"]
piv = analytics["pivot"]
ch = analytics["churn"]
dates_str = analytics["dates"]
# ── helpers to serialise for JS ─────────────────────────────
def jf(arr, decimals=6):
return json.dumps(
[round(float(v), decimals) if not np.isnan(v) else None for v in arr]
)
def js(arr):
return json.dumps(list(arr))
# ── colour palette ───────────────────────────────────────────
REG_COLORS = [
"#2563eb",
"#16a34a",
"#dc2626",
"#d97706",
"#7c3aed",
"#0891b2",
"#db2777",
"#65a30d",
"#ea580c",
"#6366f1",
"#059669",
"#b45309",
"#9333ea",
"#0284c7",
"#e11d48",
]
# ── 4.1 Surgery sparkline data ──────────────────────────────
surg_dates = [d.strftime("%Y-%m-%d") for d in ss.index]
n_surg = jf(ss["n_surgeries"].values, 0)
total_gain = jf(ss["total_gain"].values)
avg_gain = jf(ss["avg_gain"].values)
avg_jaccard = jf(ss["avg_jaccard"].values)
# ── 4.2 Individual trajectories ────────────────────────────
reg_ids = list(piv.columns)
traj_datasets = []
# Surgery lookup: reg_orig -> list of {date, from, to, composite}
surg_by_reg = {}
for _, row in surgery.iterrows():
surg_by_reg.setdefault(row["reg_orig"], []).append(
{
"date": row["date"].strftime("%Y-%m-%d"),
"reg_from": str(row["reg_from"]),
"reg_to": str(row["reg_to"]),
"composite": round(float(row["jaccard_composite"]), 4),
"gain": round(float(row["gain_vs_no_surgery"]), 6),
}
)
for idx, rid in enumerate(reg_ids):
remapped = rid in analytics["ever_remapped"]
traj_datasets.append(
{
"label": rid,
"data": [
round(float(v), 6) if not np.isnan(v) else None
for v in piv[rid].values
],
"borderColor": REG_COLORS[idx % len(REG_COLORS)],
"backgroundColor": REG_COLORS[idx % len(REG_COLORS)] + "22",
"borderWidth": 2,
"borderDash": [6, 3] if remapped else [],
"pointRadius": 0,
"tension": 0.3,
"fill": False,
"remapped": remapped,
"surgeries": surg_by_reg.get(rid, []),
}
)
traj_json = json.dumps(traj_datasets)
# ── 4.2b Error account data (optional) ────────────────────
has_error = df_err_isin is not None and df_err_agg is not None
if has_error:
err_dates = [d.strftime("%Y-%m-%d") for d in pd.to_datetime(df_err_agg["date"])]
err_agg_stock = [
round(float(v), 3) if not pd.isna(v) else None
for v in df_err_agg["stock_error_agg"].values
]
err_agg_res = [
round(float(v), 3) if not pd.isna(v) else None
for v in df_err_agg["residual_agg"].values
]
err_agg_pct = [
round(float(v), 4) if not pd.isna(v) else None
for v in df_err_agg["stock_error_agg_pct"].values
]
# Top 5 ISINs by max |stock error|
top_err = (
df_err_isin.groupby("isin")["stock_error"]
.apply(lambda x: x.abs().max())
.nlargest(5)
.index.tolist()
)
all_err_dates = sorted(df_err_isin["date"].unique())
ERR_COLORS = ["#ef4444", "#f59e0b", "#8b5cf6", "#06b6d4", "#10b981"]
err_isin_ds = []
for idx, isin in enumerate(top_err):
sub = (
df_err_isin[df_err_isin["isin"] == isin]
.set_index("date")["stock_error"]
.reindex(all_err_dates)
)
err_isin_ds.append(
{
"label": isin,
"data": [
round(float(v), 3) if not pd.isna(v) else None
for v in sub.values
],
"borderColor": ERR_COLORS[idx % len(ERR_COLORS)],
"backgroundColor": ERR_COLORS[idx % len(ERR_COLORS)] + "22",
"borderWidth": 1.5,
"pointRadius": 0,
"tension": 0.3,
"fill": False,
}
)
max_err_stock = float(df_err_agg["stock_error_agg"].abs().max())
max_err_pct = float(df_err_agg["stock_error_agg_pct"].max())
agg_std = float(df_err_agg["stock_error_agg"].std())
agg_mean = float(df_err_agg["stock_error_agg"].abs().mean())
stationarity = round(agg_std / max(agg_mean, 1e-9), 3)
err_dates_js = json.dumps(err_dates)
err_agg_stock_js = json.dumps(err_agg_stock)
err_agg_res_js = json.dumps(err_agg_res)
err_agg_pct_js = json.dumps(err_agg_pct)
err_isin_ds_js = json.dumps(err_isin_ds)
err_isin_dates_js = json.dumps(
[
d.strftime("%Y-%m-%d") if hasattr(d, "strftime") else str(d)[:10]
for d in all_err_dates
]
)
# ISIN detail table (top 100 worst)
err_rows = []
for _, r in (
df_err_isin.assign(abs_s=df_err_isin["stock_error"].abs())
.sort_values("abs_s", ascending=False)
.head(100)
.iterrows()
):
ds = (
r["date"].strftime("%Y-%m-%d")
if hasattr(r["date"], "strftime")
else str(r["date"])[:10]
)
sc = "color:var(--danger)" if r["stock_error"] < 0 else "color:var(--warn)"
rc = "color:var(--danger)" if r["residual"] < 0 else "color:var(--warn)"
pch = (
"color:var(--danger);font-weight:600"
if r["stock_error_pct"] > 5
else ("color:var(--warn)" if r["stock_error_pct"] > 1 else "")
)
err_rows.append(
f"<tr><td>{ds}</td>"
f'<td style="font-family:var(--mono)">{r["isin"]}</td>'
f'<td style="text-align:right;font-family:var(--mono);{rc}">{r["residual"]:+,.2f}</td>'
f'<td style="text-align:right;font-family:var(--mono);{sc}">{r["stock_error"]:+,.2f}</td>'
f'<td style="text-align:right;font-family:var(--mono);{pch}">{r["stock_error_pct"]:.3f}%</td>'
f"</tr>"
)
err_isin_detail = (
"".join(err_rows)
if err_rows
else (
'<tr><td colspan="5" style="padding:24px;text-align:center;color:var(--accent2)'
';font-family:var(--mono)">✓ Error account is flat</td></tr>'
)
)
# HTML block for error account section
err_section_html = f"""
<div class="section-label">06 · Error Account</div>
<div class="card">
<div class="card-header">
<span class="card-title">Aggregate error account stock</span>
<span class="card-desc">
Stock_error(t_ref) = 0. The stock absorbs unreconciled residuals going backwards.
A flat signal near zero = clean data. A drift = structural gap.
</span>
</div>
<div class="card-body" style="padding-bottom:8px">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);margin-bottom:20px">
<div style="background:var(--surface);padding:14px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max |error stock|</div>
<div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:var(--danger)">{max_err_stock:,.1f} shares</div>
</div>
<div style="background:var(--surface);padding:14px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Max % of total AUM</div>
<div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:{"var(--danger)" if max_err_pct > 5 else "var(--warn)"}">{max_err_pct:.3f}%</div>
</div>
<div style="background:var(--surface);padding:14px 20px">
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Stationarity σ/μ</div>
<div style="font-family:var(--mono);font-size:1.35rem;font-weight:700;color:{"var(--accent2)" if stationarity < 1 else "var(--warn)"}">{stationarity:.3f}</div>
<div style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">lower = more stationary</div>
</div>
</div>
<div class="chart-wrap-tall"><canvas id="chartErrStock"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Monthly aggregate residual</span>
<span class="card-desc">ΔQ_total F_total per month</span>
</div>
<div class="card-body"><div class="chart-wrap"><canvas id="chartErrRes"></canvas></div></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Error stock — top 5 ISINs</span>
<span class="card-desc">Cumulative error stock per ISIN</span>
</div>
<div class="card-body"><div class="chart-wrap"><canvas id="chartErrIsin"></canvas></div></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Error account detail — worst (ISIN, month) pairs</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead><tr>
<th>Date</th><th>ISIN</th>
<th style="text-align:right">Monthly residual</th>
<th style="text-align:right">Cumul. stock</th>
<th style="text-align:right">% of max AUM</th>
</tr></thead>
<tbody>{err_isin_detail}</tbody>
</table>
</div>
</div>"""
# JS block for error account charts
err_js_block = f"""
// ── 8. Error account charts ──────────────────────────────────
const ERR_DATES = {err_dates_js};
const ERR_AGG_STOCK = {err_agg_stock_js};
const ERR_AGG_RES = {err_agg_res_js};
const ERR_ISIN_TS = {err_isin_ds_js};
const ERR_ISIN_DATES = {err_isin_dates_js};
new Chart(document.getElementById('chartErrStock'), {{
type: 'line',
data: {{ labels: ERR_DATES, datasets: [{{
label: 'Aggregate error stock', data: ERR_AGG_STOCK,
borderColor: '#ef4444', backgroundColor: '#ef444415',
borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true
}}] }},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{mode:'index', intersect:false}},
plugins: {{ legend: {{display:false}}, tooltip: tooltip() }},
scales: {{ x: timeAxis(), y: {{
...yAxis('Shares'),
grid: {{ color: ctx => ctx.tick.value === 0 ? '#ffffff55' : '#1a2030',
lineWidth: ctx => ctx.tick.value === 0 ? 1.5 : 1 }}
}} }}
}}
}});
new Chart(document.getElementById('chartErrRes'), {{
type: 'bar',
data: {{ labels: ERR_DATES, datasets: [{{
label: 'Monthly residual', data: ERR_AGG_RES,
backgroundColor: ERR_AGG_RES.map(v => v != null && v < 0 ? '#ef444488' : '#f59e0b88'),
borderColor: ERR_AGG_RES.map(v => v != null && v < 0 ? '#ef4444' : '#f59e0b'),
borderWidth: 1, borderRadius: 2
}}] }},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{display:false}}, tooltip: tooltip() }},
scales: {{ x: timeAxis(), y: yAxis('Shares') }}
}}
}});
new Chart(document.getElementById('chartErrIsin'), {{
type: 'line',
data: {{ labels: ERR_ISIN_DATES, datasets: ERR_ISIN_TS }},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{mode:'index', intersect:false}},
plugins: {{
legend: {{position:'right', labels:{{boxWidth:10, padding:8, font:{{size:10}}}}}},
tooltip: tooltip()
}},
scales: {{ x: timeAxis(), y: yAxis('Error stock (shares)') }}
}}
}});"""
else:
err_section_html = ""
err_js_block = ""
# ── 4.3 Surgery detail table rows ──────────────────────────
sd = analytics["surgery_detail"].sort_values("date")
surg_rows_html = ""
if len(sd) == 0:
surg_rows_html = "<tr><td colspan='9' style='text-align:center;color:#888'>No surgeries performed</td></tr>"
else:
for _, r in sd.iterrows():
gain_class = "gain-high" if r["gain_vs_no_surgery"] > 0.05 else "gain-low"
lb = int(r.get("lookback_months", 1))
lb_cell = (
f'<span style="font-family:var(--mono);font-size:.65rem;padding:1px 5px;'
f"border-radius:3px;background:#7c3aed22;border:1px solid #7c3aed55;"
f'color:#a78bfa">{lb}m</span>'
if lb > 1
else ""
)
surg_rows_html += f"""
<tr>
<td>{r["date"].date()}</td>
<td><span class="reg-badge">{r["reg_orig"]}</span></td>
<td class="code-cell">{r["reg_from"]}</td>
<td>→</td>
<td class="code-cell">{r["reg_to"]}</td>
<td>{r["jaccard_composite"]:.4f}</td>
<td class="{gain_class}">+{r["gain_vs_no_surgery"]:.6f}</td>
<td>{r["gain_pct_of_score"]:.1f}%</td>
<td>{lb_cell}</td>
</tr>"""
# ── 4.4 Top accounts table ──────────────────────────────────
last_date = piv.index.max()
top_accounts = piv.loc[last_date].dropna().sort_values(ascending=False)
top_rows_html = ""
for rank, (rid, sc) in enumerate(top_accounts.items(), 1):
remapped = "" if rid in analytics["ever_remapped"] else ""
bar_w = int(sc / top_accounts.max() * 100)
color = REG_COLORS[(rank - 1) % len(REG_COLORS)]
top_rows_html += f"""
<tr>
<td class="rank">#{rank}</td>
<td><span class="reg-badge" style="background:{color}22;border-color:{color}">{rid}</span></td>
<td class="score-val">{sc:.6f}</td>
<td class="bar-cell">
<div class="score-bar" style="width:{bar_w}%;background:{color}"></div>
</td>
<td class="center">{remapped}</td>
</tr>"""
# ─────────────────────────────────────────────────────────────
# HTML TEMPLATE
# ─────────────────────────────────────────────────────────────
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Carmignac Pipeline — Analysis Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600;700&display=swap');
:root {{
--bg: #0d0f12;
--surface: #151820;
--border: #252a35;
--accent: #3b82f6;
--accent2: #10b981;
--warn: #f59e0b;
--danger: #ef4444;
--text: #e2e8f0;
--muted: #64748b;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: var(--sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 0 0 60px;
}}
/* ── Header ── */
.header {{
background: linear-gradient(135deg, #0d1117 0%, #111827 50%, #0d1f3c 100%);
border-bottom: 1px solid var(--border);
padding: 40px 48px 36px;
position: relative;
overflow: hidden;
}}
.header::before {{
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 70% 80% at 80% 50%, #1e40af18, transparent);
pointer-events: none;
}}
.header-eyebrow {{
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 10px;
}}
.header h1 {{
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 8px;
}}
.header-sub {{
font-size: 0.85rem;
color: var(--muted);
font-family: var(--mono);
}}
/* ── KPI strip ── */
.kpi-strip {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1px;
background: var(--border);
border-bottom: 1px solid var(--border);
}}
.kpi {{
background: var(--surface);
padding: 22px 28px;
display: flex;
flex-direction: column;
gap: 4px;
}}
.kpi-label {{
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
font-family: var(--mono);
}}
.kpi-value {{
font-size: 1.6rem;
font-weight: 700;
font-family: var(--mono);
color: var(--text);
line-height: 1;
}}
.kpi-value.accent {{ color: var(--accent); }}
.kpi-value.success {{ color: var(--accent2); }}
.kpi-value.warn {{ color: var(--warn); }}
.kpi-sub {{
font-size: 0.7rem;
color: var(--muted);
font-family: var(--mono);
}}
/* ── Main layout ── */
.main {{
max-width: 1400px;
margin: 0 auto;
padding: 36px 48px;
display: flex;
flex-direction: column;
gap: 32px;
}}
/* ── Cards ── */
.card {{
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}}
.card-header {{
padding: 18px 24px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 12px;
}}
.card-title {{
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
font-family: var(--mono);
}}
.card-desc {{
font-size: 0.78rem;
color: #475569;
}}
.card-body {{
padding: 24px;
}}
.chart-wrap {{
position: relative;
height: 280px;
}}
.chart-wrap-tall {{
position: relative;
height: 340px;
}}
/* ── Two-column grid ── */
.grid-2 {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}}
@media (max-width: 900px) {{
.grid-2 {{ grid-template-columns: 1fr; }}
.main {{ padding: 24px 20px; }}
}}
/* ── Section label ── */
.section-label {{
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
padding: 0 4px;
border-left: 3px solid var(--accent);
padding-left: 10px;
margin-bottom: -8px;
}}
/* ── Tables ── */
table {{
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}}
th {{
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
background: #0f1218;
}}
td {{
padding: 10px 14px;
border-bottom: 1px solid #1a1f2a;
vertical-align: middle;
}}
tr:last-child td {{ border-bottom: none; }}
tr:hover td {{ background: #181e2b; }}
.rank {{ color: var(--muted); font-family: var(--mono); font-size: 0.75rem; }}
.score-val {{ font-family: var(--mono); color: var(--accent2); }}
.code-cell {{ font-family: var(--mono); font-size: 0.78rem; color: #94a3b8; }}
.center {{ text-align: center; color: var(--accent2); }}
.gain-high {{ font-family: var(--mono); color: var(--accent2); font-weight: 600; }}
.gain-low {{ font-family: var(--mono); color: var(--warn); }}
.bar-cell {{ width: 120px; }}
.score-bar {{
height: 6px;
border-radius: 3px;
min-width: 2px;
transition: width 0.3s;
}}
.reg-badge {{
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
background: #1e2a3a;
border: 1px solid #2d3f54;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--accent);
white-space: nowrap;
}}
/* ── Legend patch ── */
.legend-patch {{
display: inline-block;
width: 12px; height: 12px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}}
/* ── No-surgery notice ── */
.no-surg {{
padding: 32px;
text-align: center;
color: var(--muted);
font-family: var(--mono);
font-size: 0.82rem;
}}
/* ── Trajectory explorer ── */
.badge-surgery {{
font-family: var(--mono);
font-size: 0.68rem;
color: var(--warn);
background: #f59e0b18;
border: 1px solid #f59e0b44;
border-radius: 3px;
padding: 1px 6px;
margin-left: 4px;
}}
.traj-selector {{
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: #0f1218;
}}
.traj-btn {{
font-family: var(--mono);
font-size: 0.72rem;
padding: 5px 12px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
}}
.traj-btn:hover {{ border-color: var(--acc, var(--accent)); color: var(--text); }}
.traj-btn.active {{
background: color-mix(in srgb, var(--acc, var(--accent)) 15%, transparent);
border-color: var(--acc, var(--accent));
color: var(--acc, var(--accent));
}}
.traj-btn-badge {{
font-size: 0.6rem;
background: #f59e0b33;
color: var(--warn);
border-radius: 3px;
padding: 0 4px;
margin-left: 4px;
}}
.traj-focus-wrap {{ padding: 16px 24px 8px; }}
.traj-account-meta {{
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
min-height: 28px;
}}
.meta-id {{
font-family: var(--mono);
font-size: 0.85rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 4px;
border: 1.5px solid;
}}
.meta-surgs {{ display: flex; flex-wrap: wrap; gap: 6px; }}
.surg-chip {{
font-family: var(--mono);
font-size: 0.7rem;
color: var(--warn);
background: #f59e0b18;
border: 1px solid #f59e0b44;
border-radius: 4px;
padding: 2px 8px;
}}
.spark-section-label {{
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
padding: 14px 24px 6px;
border-top: 1px solid var(--border);
margin-top: 8px;
}}
.spark-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1px;
background: var(--border);
border-top: 1px solid var(--border);
}}
.spark-cell {{
background: var(--surface);
padding: 10px 12px 8px;
cursor: pointer;
transition: background 0.12s;
}}
.spark-cell:hover {{ background: #1a2030; }}
.spark-cell.active {{ background: #131b2a; outline: 1px solid #3b82f644; }}
.spark-label {{
font-family: var(--mono);
font-size: 0.68rem;
color: var(--muted);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.spark-badge {{
font-size: 0.58rem;
color: var(--warn);
background: #f59e0b22;
border-radius: 2px;
padding: 0 3px;
}}
/* ── Footer ── */
.footer {{
text-align: center;
font-family: var(--mono);
font-size: 0.68rem;
color: #334155;
margin-top: 16px;
letter-spacing: 0.05em;
}}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════ HEADER -->
<div class="header">
<div class="header-eyebrow">Carmignac × ENSAE · Data Challenge 2025</div>
<h1>Pipeline Results — Analysis Report</h1>
<div class="header-sub">Registrar ID repair · Score propagation · Surgery audit</div>
</div>
<!-- ═══════════════════════════════════════════ KPI STRIP -->
<div class="kpi-strip">
<div class="kpi">
<span class="kpi-label">Σ score at t_ref</span>
<span class="kpi-value success">{tl["sum_post"].iloc[-1]:.4f}</span>
<span class="kpi-sub">post-surgery</span>
</div>
<div class="kpi">
<span class="kpi-label">Σ score at t_min</span>
<span class="kpi-value accent">{tl["sum_post"].iloc[0]:.4f}</span>
<span class="kpi-sub">post-surgery</span>
</div>
<div class="kpi">
<span class="kpi-label">Max recovery</span>
<span class="kpi-value warn">{tl["recovery_pct"].max():.1f}%</span>
<span class="kpi-sub">score rescued by surgery</span>
</div>
<div class="kpi">
<span class="kpi-label">Total surgeries</span>
<span class="kpi-value">{len(surgery)}</span>
<span class="kpi-sub">operations performed</span>
</div>
<div class="kpi">
<span class="kpi-label">Reg IDs universe</span>
<span class="kpi-value">{piv.shape[1]}</span>
<span class="kpi-sub">at reference date</span>
</div>
<div class="kpi">
<span class="kpi-label">Ever remapped</span>
<span class="kpi-value warn">{len(analytics["ever_remapped"])}</span>
<span class="kpi-sub">reg IDs w/ code change</span>
</div>
</div>
<!-- ═══════════════════════════════════════════ MAIN -->
<div class="main">
<div class="section-label">01 · Score Integrity Over Time</div>
<!-- Chart 1: Σ score with vs without surgery -->
<div class="card">
<div class="card-header">
<span class="card-title">Sum of scores — pre vs post surgery</span>
<span class="card-desc">
Post-surgery (solid) shows the corrected score after code repairs.
Pre-surgery (dashed) is the counterfactual without any remapping.
Gap = score rescued.
</span>
</div>
<div class="card-body">
<div class="chart-wrap-tall">
<canvas id="chartSigma"></canvas>
</div>
</div>
</div>
<!-- Chart 2: Score drop (pre) -->
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Score recovered by surgery</span>
<span class="card-desc">Difference post pre at each date</span>
</div>
<div class="card-body">
<div class="chart-wrap">
<canvas id="chartRecovery"></canvas>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Portfolio concentration (entropy)</span>
<span class="card-desc">Shannon entropy of score distribution — higher = more spread</span>
</div>
<div class="card-body">
<div class="chart-wrap">
<canvas id="chartEntropy"></canvas>
</div>
</div>
</div>
</div>
<div class="section-label">02 · Individual Score Trajectories</div>
<div class="card">
<div class="card-header">
<span class="card-title">Score explorer — per Registrar Account</span>
<span class="card-desc">
Click an account to inspect its full history.
<span class="badge-surgery">◆ remapped</span> = surgery was applied.
</span>
</div>
<div class="card-body" style="padding:0">
<!-- Selector pill bar -->
<div class="traj-selector" id="trajSelector"></div>
<!-- Focused chart + metadata -->
<div class="traj-focus-wrap">
<div class="traj-account-meta" id="trajMeta"></div>
<div style="position:relative;height:300px">
<canvas id="chartTrajFocus"></canvas>
</div>
</div>
<!-- Sparkline overview grid -->
<div class="spark-section-label">All accounts — overview</div>
<div class="spark-grid" id="sparkGrid"></div>
</div>
</div>
<div class="section-label">03 · Surgery Operations</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<span class="card-title">Surgeries per time step</span>
<span class="card-desc">Number of code remappings performed at each month</span>
</div>
<div class="card-body">
<div class="chart-wrap">
<canvas id="chartNSurg"></canvas>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Score gain per surgery</span>
<span class="card-desc">Average gain in Σ score from surgery at each month</span>
</div>
<div class="card-body">
<div class="chart-wrap">
<canvas id="chartGain"></canvas>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Jaccard similarity of surgery matches</span>
<span class="card-desc">
Composite Jaccard score of the matched code pair — closer to 1.0 = stronger portfolio overlap.
Low values may indicate uncertain matches.
</span>
</div>
<div class="card-body">
<div class="chart-wrap">
<canvas id="chartJaccard"></canvas>
</div>
</div>
</div>
<div class="section-label">04 · Surgery Detail Log</div>
<div class="card">
<div class="card-header">
<span class="card-title">All surgery operations</span>
</div>
<div class="card-body" style="padding:0">
{
'<div class="no-surg">No surgeries were performed on this dataset.</div>'
if len(surgery) == 0
else f'''
<table>
<thead>
<tr>
<th>Date</th>
<th>Reg orig</th>
<th>Code from</th>
<th></th>
<th>Code to</th>
<th>Jaccard</th>
<th>Score gain</th>
<th>% of score</th>
<th>Lookback</th>
</tr>
</thead>
<tbody>{surg_rows_html}</tbody>
</table>'''
}
</div>
</div>
<div class="section-label">05 · Score Ranking at t_ref</div>
<div class="card">
<div class="card-header">
<span class="card-title">Accounts ranked by weight at reference date</span>
<span class="card-desc">✓ in last column = account was remapped at some point in history</span>
</div>
<div class="card-body" style="padding:0">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Registrar ID</th>
<th>Score (weight)</th>
<th style="width:140px">Relative size</th>
<th>Remapped</th>
</tr>
</thead>
<tbody>{top_rows_html}</tbody>
</table>
</div>
</div>
{err_section_html}
</div><!-- /main -->
<div class="footer">Generated by carmignac_analysis.py · Carmignac × ENSAE Data Challenge 2025</div>
<!-- ═══════════════════════════════════════════ CHARTS JS -->
<script>
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = '#1e2535';
Chart.defaults.font.family = "'IBM Plex Mono', monospace";
Chart.defaults.font.size = 11;
const DATES = {js(dates_str)};
const SUM_POST = {jf(tl["sum_post"].values)};
const SUM_PRE = {jf(tl["sum_pre"].values)};
const RECOVERY = {jf(tl["recovery_pct"].values, 4)};
const ENTROPY = {jf(tl["entropy"].values, 4)};
const SURG_DATES = {js(surg_dates)};
const N_SURG = {n_surg};
const TOTAL_GAIN = {total_gain};
const AVG_GAIN = {avg_gain};
const AVG_JACCARD = {avg_jaccard};
const TRAJ = {traj_json};
// ── Shared options helpers ────────────────────────────────────
function timeAxis(label) {{
return {{
type: 'category',
ticks: {{ maxTicksLimit: 10, maxRotation: 0 }},
grid: {{ color: '#1a2030' }},
title: {{ display: !!label, text: label, color: '#475569' }},
}};
}}
function yAxis(label, opts={{}}) {{
return {{
grid: {{ color: '#1a2030' }},
title: {{ display: !!label, text: label, color: '#475569' }},
...opts,
}};
}}
function tooltip() {{
return {{
backgroundColor: '#0d1117',
borderColor: '#252a35',
borderWidth: 1,
titleFont: {{ family: "'IBM Plex Mono'" }},
bodyFont: {{ family: "'IBM Plex Mono'" }},
padding: 10,
}};
}}
// ── 1. Sigma pre/post ─────────────────────────────────────────
new Chart(document.getElementById('chartSigma'), {{
type: 'line',
data: {{
labels: DATES,
datasets: [
{{
label: 'Σ score (post-surgery)',
data: SUM_POST,
borderColor: '#10b981',
backgroundColor: '#10b98115',
borderWidth: 2.5,
pointRadius: 0,
fill: false,
tension: 0.2,
}},
{{
label: 'Σ score (pre-surgery / counterfactual)',
data: SUM_PRE,
borderColor: '#ef4444',
borderDash: [6, 4],
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.2,
backgroundColor: 'transparent',
}},
],
}},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{ mode: 'index', intersect: false }},
plugins: {{
legend: {{ position: 'top', labels: {{ boxWidth: 12, padding: 16 }} }},
tooltip: tooltip(),
}},
scales: {{
x: timeAxis(),
y: yAxis('Σ scores', {{ min: 0, max: 1.05, ticks: {{ stepSize: 0.1 }} }}),
}},
}},
}});
// ── 2. Recovery ───────────────────────────────────────────────
new Chart(document.getElementById('chartRecovery'), {{
type: 'bar',
data: {{
labels: DATES,
datasets: [{{
label: 'Score recovered (%)',
data: RECOVERY,
backgroundColor: '#3b82f6aa',
borderColor: '#3b82f6',
borderWidth: 1,
borderRadius: 2,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
scales: {{
x: timeAxis(),
y: yAxis('Recovery (% of Σ)', {{ min: 0 }}),
}},
}},
}});
// ── 3. Entropy ────────────────────────────────────────────────
new Chart(document.getElementById('chartEntropy'), {{
type: 'line',
data: {{
labels: DATES,
datasets: [{{
label: 'Shannon entropy',
data: ENTROPY,
borderColor: '#d97706',
backgroundColor: '#d9770622',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.3,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
scales: {{ x: timeAxis(), y: yAxis('Entropy (nats)') }},
}},
}});
// ── 4. Trajectory explorer ───────────────────────────────────
(function() {{
let focusChart = null;
let sparkCharts = [];
let activeIdx = 0;
// Surgery annotation plugin (vertical line + label)
const surgeryPlugin = {{
id: 'surgeryLines',
afterDraw(chart) {{
const surgeries = chart._surgeries || [];
if (!surgeries.length) return;
const {{ctx, chartArea, scales}} = chart;
surgeries.forEach(op => {{
const xIdx = DATES.indexOf(op.date);
if (xIdx < 0) return;
const x = scales.x.getPixelForValue(xIdx);
ctx.save();
ctx.strokeStyle = '#f59e0bcc';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(x, chartArea.top);
ctx.lineTo(x, chartArea.bottom);
ctx.stroke();
// Label
ctx.font = "10px 'IBM Plex Mono'";
ctx.fillStyle = '#f59e0b';
ctx.textAlign = 'center';
ctx.fillText('', x, chartArea.top + 10);
ctx.restore();
}});
}}
}};
function buildFocusChart(idx) {{
const d = TRAJ[idx];
if (focusChart) focusChart.destroy();
const ctx = document.getElementById('chartTrajFocus').getContext('2d');
focusChart = new Chart(ctx, {{
type: 'line',
data: {{
labels: DATES,
datasets: [{{
label: d.label,
data: d.data,
borderColor: d.borderColor,
backgroundColor: d.borderColor + '18',
borderWidth: 2.5,
borderDash: d.borderDash,
pointRadius: d.data.map((_, i) => {{
return d.surgeries.some(s => DATES[i] === s.date) ? 6 : 0;
}}),
pointBackgroundColor: '#f59e0b',
pointBorderColor: '#f59e0b',
tension: 0.3,
fill: true,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{ mode: 'index', intersect: false }},
plugins: {{
legend: {{ display: false }},
tooltip: {{
...tooltip(),
callbacks: {{
afterBody(items) {{
const i = items[0].dataIndex;
const s = d.surgeries.find(x => DATES[i] === x.date);
if (!s) return [];
return [
'',
'✦ Surgery applied',
'From : ' + s.reg_from,
'To : ' + s.reg_to,
'Jaccard: ' + s.composite,
'Gain: +' + s.gain,
];
}}
}}
}},
}},
scales: {{
x: timeAxis(),
y: yAxis('Score (weight)', {{ min: 0 }}),
}},
}},
plugins: [surgeryPlugin],
}});
focusChart._surgeries = d.surgeries;
// Update meta panel
const meta = document.getElementById('trajMeta');
const surgHTML = d.surgeries.length
? d.surgeries.map(s =>
`<span class="surg-chip">✦ ${{s.date}} &nbsp;${{s.reg_from}} → ${{s.reg_to}}</span>`
).join('')
: '<span style="color:#475569;font-size:0.75rem">No surgery applied</span>';
meta.innerHTML = `
<span class="meta-id" style="border-color:${{d.borderColor}};color:${{d.borderColor}}">${{d.label}}</span>
<div class="meta-surgs">${{surgHTML}}</div>
`;
}}
function buildSparkGrid() {{
const grid = document.getElementById('sparkGrid');
grid.innerHTML = '';
TRAJ.forEach((d, idx) => {{
const wrap = document.createElement('div');
wrap.className = 'spark-cell' + (idx === activeIdx ? ' active' : '');
wrap.dataset.idx = idx;
const label = document.createElement('div');
label.className = 'spark-label';
label.innerHTML = d.label + (d.remapped
? ' <span class="spark-badge">R</span>' : '');
const canvasWrap = document.createElement('div');
canvasWrap.style.cssText = 'position:relative;height:52px';
const cv = document.createElement('canvas');
canvasWrap.appendChild(cv);
wrap.appendChild(label);
wrap.appendChild(canvasWrap);
grid.appendChild(wrap);
const sc = new Chart(cv, {{
type: 'line',
data: {{
labels: DATES,
datasets: [{{
data: d.data,
borderColor: d.borderColor,
borderWidth: 1.5,
pointRadius: 0,
tension: 0.3,
fill: false,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: {{ legend: {{ display: false }}, tooltip: {{ enabled: false }} }},
scales: {{
x: {{ display: false }},
y: {{ display: false, min: 0 }},
}},
}},
}});
sparkCharts.push(sc);
wrap.addEventListener('click', () => {{
document.querySelectorAll('.spark-cell').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.traj-btn').forEach(b => b.classList.remove('active'));
wrap.classList.add('active');
document.querySelector(`.traj-btn[data-idx="${{idx}}"]`).classList.add('active');
activeIdx = idx;
buildFocusChart(idx);
}});
}});
}}
function buildSelector() {{
const sel = document.getElementById('trajSelector');
sel.innerHTML = '';
TRAJ.forEach((d, idx) => {{
const btn = document.createElement('button');
btn.className = 'traj-btn' + (idx === 0 ? ' active' : '');
btn.dataset.idx = idx;
btn.style.setProperty('--acc', d.borderColor);
btn.innerHTML = d.label + (d.remapped ? ' <span class="traj-btn-badge">R</span>' : '');
btn.addEventListener('click', () => {{
document.querySelectorAll('.traj-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.spark-cell').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
const cell = document.querySelector(`.spark-cell[data-idx="${{idx}}"]`);
if (cell) cell.classList.add('active');
activeIdx = idx;
buildFocusChart(idx);
}});
sel.appendChild(btn);
}});
}}
buildSelector();
buildFocusChart(0);
buildSparkGrid();
}})();
// ── 5. N surgeries ────────────────────────────────────────────
new Chart(document.getElementById('chartNSurg'), {{
type: 'bar',
data: {{
labels: SURG_DATES,
datasets: [{{
label: 'Surgeries',
data: N_SURG,
backgroundColor: '#7c3aed99',
borderColor: '#7c3aed',
borderWidth: 1,
borderRadius: 3,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
scales: {{
x: timeAxis('Month'),
y: yAxis('# operations', {{ min: 0, ticks: {{ stepSize: 1 }} }}),
}},
}},
}});
// ── 6. Avg gain ───────────────────────────────────────────────
new Chart(document.getElementById('chartGain'), {{
type: 'bar',
data: {{
labels: SURG_DATES,
datasets: [
{{
label: 'Total gain',
data: TOTAL_GAIN,
backgroundColor: '#10b98199',
borderColor: '#10b981',
borderWidth: 1,
borderRadius: 3,
}},
{{
label: 'Avg gain / surgery',
data: AVG_GAIN,
backgroundColor: '#06b6d455',
borderColor: '#06b6d4',
borderWidth: 1,
borderRadius: 3,
}},
],
}},
options: {{
responsive: true, maintainAspectRatio: false,
interaction: {{ mode: 'index', intersect: false }},
plugins: {{ legend: {{ position: 'top', labels: {{ boxWidth: 10 }} }}, tooltip: tooltip() }},
scales: {{ x: timeAxis('Month'), y: yAxis('Score gain') }},
}},
}});
// ── 7. Jaccard ────────────────────────────────────────────────
new Chart(document.getElementById('chartJaccard'), {{
type: 'bar',
data: {{
labels: SURG_DATES,
datasets: [{{
label: 'Avg Jaccard composite',
data: AVG_JACCARD,
backgroundColor: '#f59e0b88',
borderColor: '#f59e0b',
borderWidth: 1,
borderRadius: 3,
}}],
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }}, tooltip: tooltip() }},
scales: {{
x: timeAxis('Month'),
y: yAxis('Jaccard composite', {{ min: 0, max: 1.05 }}),
}},
}},
}});
{err_js_block}
</script>
</body>
</html>"""
return html