2329 lines
79 KiB
Python
2329 lines
79 KiB
Python
"""
|
||
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(t−1) 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(t−1) − Σ 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(t−1)</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(t−1))',
|
||
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}} ${{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 |