Programming

1. repports

"""
Marts -> figures -> one self-contained HTML file.

Two deliberate choices:

* The report writes its own commentary. A chart with no sentence next to it
  makes the reader do the analyst's job; the sentences here are generated from
  the same marts as the chart, so they can never drift from it.
* Everything renders from marts only. Re-running the report never re-reads
  8M rows, which is what makes it safe to regenerate on demand.
"""
from __future__ import annotations

import datetime as dt
import html
import json
from pathlib import Path

import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio

from . import config as C
from . import marts as M
from .i18n import t, period_label

TOTAL = M.TOTAL


# --------------------------------------------------------------------------
# Chart chrome
# --------------------------------------------------------------------------
def make_template(brand: dict) -> go.layout.Template:
    return go.layout.Template(layout=go.Layout(
        font=dict(family=brand["font_body"], size=13, color=brand["ink"]),
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        colorway=[brand["gold"], brand["ink"], brand["gold_deep"], brand["muted"],
                  brand["good"], brand["bad"], "#6B7A8F", "#A88C4A"],
        margin=dict(l=56, r=24, t=44, b=44),
        hoverlabel=dict(font=dict(family=brand["font_body"], size=12),
                        bgcolor=brand["ink"], font_color="#FFF"),
        xaxis=dict(showgrid=False, linecolor=brand["rule"], ticks="outside",
                   tickcolor=brand["rule"], tickfont=dict(size=11)),
        yaxis=dict(gridcolor=brand["rule"], zerolinecolor=brand["rule"],
                   tickfont=dict(size=11)),
        legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0,
                    font=dict(size=11)),
        title=dict(font=dict(family=brand["font_display"], size=15)),
    ))


def _div(fig: go.Figure, first: bool = False) -> str:
    return pio.to_html(fig, include_plotlyjs="cdn" if first else False,
                       full_html=False, config={"displayModeBar": False,
                                                "responsive": True})


def _money(v: float, units: str = "mlrd") -> float:
    return v / (C.MLRD if units == "mlrd" else C.MLN)


def _tot(df: pd.DataFrame) -> pd.DataFrame:
    return df[(df["dim_name"] == TOTAL)].sort_values("period")


def _dim(df: pd.DataFrame, dim: str) -> pd.DataFrame:
    return df[df["dim_name"] == dim].sort_values("period")


# --------------------------------------------------------------------------
# Figures
# --------------------------------------------------------------------------
def fig_portfolio(pf: pd.DataFrame, spec) -> go.Figure:
    d = _tot(pf)
    x = [period_label(p, spec.lang) for p in d["period"]]
    f = go.Figure()
    f.add_bar(x=x, y=_money(d["exposure"], spec.units), name=t("exposure", spec.lang),
              marker_color=spec.brand["gold"])
    f.add_scatter(x=x, y=d["npl_ratio"] * 100, name=t("npl_ratio", spec.lang),
                  yaxis="y2", mode="lines+markers", line=dict(color=spec.brand["bad"], width=2.5))
    f.add_scatter(x=x, y=d["watch_ratio"] * 100, name=t("watch_ratio", spec.lang),
                  yaxis="y2", mode="lines", line=dict(color=spec.brand["ink"], width=1.5, dash="dot"))
    f.update_layout(
        yaxis=dict(title=t(f"unit_{spec.units}", spec.lang)),
        yaxis2=dict(overlaying="y", side="right", title="%", showgrid=False),
        bargap=0.35, height=360)
    return f


def fig_leading_indicator(pf: pd.DataFrame, spec) -> go.Figure:
    """Watch-list share pushed forward 3 months against realised NPL."""
    d = _tot(pf).reset_index(drop=True)
    x = [period_label(p, spec.lang) for p in d["period"]]
    f = go.Figure()
    f.add_scatter(x=x, y=d["npl_ratio"] * 100, name=t("npl_ratio", spec.lang),
                  mode="lines", line=dict(color=spec.brand["bad"], width=2.5))
    f.add_scatter(x=x, y=d["watch_ratio"].shift(3) * 100,
                  name=t("watch_ratio", spec.lang) + " (t-3)",
                  mode="lines", line=dict(color=spec.brand["gold_deep"], width=2, dash="dash"))
    f.update_layout(yaxis=dict(title="%"), height=320)
    return f


def fig_origination(orig: pd.DataFrame, spec) -> go.Figure:
    d = _dim(orig, "department")
    piv = d.pivot_table(index="period", columns="dim_value", values="amount", aggfunc="sum").fillna(0)
    x = [period_label(p, spec.lang) for p in piv.index]
    f = go.Figure()
    for i, col in enumerate(piv.columns):
        f.add_bar(x=x, y=_money(piv[col], spec.units), name=str(col))
    tot = _tot(orig).set_index("period")
    f.add_scatter(x=x, y=tot.reindex(piv.index)["n_new_clients"], yaxis="y2",
                  name=t("n_new_clients", spec.lang), mode="lines",
                  line=dict(color=spec.brand["ink"], width=2))
    f.update_layout(barmode="stack", height=360,
                    yaxis=dict(title=t(f"unit_{spec.units}", spec.lang)),
                    yaxis2=dict(overlaying="y", side="right", showgrid=False))
    return f


def fig_vintage_curves(vint: pd.DataFrame, spec, thr: int = 90, max_curves: int = 14) -> go.Figure:
    d = vint[(vint["dim_name"] == TOTAL) & vint["is_reliable"]]
    vintages = sorted(d["vintage"].unique())[-max_curves:]
    f = go.Figure()
    n = max(len(vintages) - 1, 1)
    for i, v in enumerate(vintages):
        s = d[d["vintage"] == v].sort_values("mob")
        shade = i / n
        colour = f"rgba({int(11 + 230 * shade)},{int(11 + 180 * shade)},{int(12 + 40 * shade)},0.95)"
        f.add_scatter(x=s["mob"], y=s[f"gl{thr}_ever_rate"] * 100,
                      name=period_label(int(v), spec.lang), mode="lines",
                      line=dict(color=colour, width=2 if i >= len(vintages) - 3 else 1.2))
    f.update_layout(height=380, xaxis=dict(title="MOB"),
                    yaxis=dict(title=f"ever {thr}+ DPD, %"),
                    legend=dict(orientation="v", x=1.01, y=1, font=dict(size=10)))
    return f


def fig_vintage_heat(marks: pd.DataFrame, spec) -> go.Figure:
    d = marks[(marks["dim_name"] == TOTAL) & marks["is_reliable"]]
    piv = d.pivot_table(index="metric", columns="vintage", values="rate", aggfunc="mean")
    order = [f"GL{a}+@{b}MOB" for a, b in C.VINTAGE_MARKS]
    piv = piv.reindex([o for o in order if o in piv.index])
    f = go.Figure(go.Heatmap(
        z=piv.to_numpy() * 100,
        x=[period_label(int(v), spec.lang) for v in piv.columns],
        y=list(piv.index),
        colorscale=[[0, "#FFFFFF"], [0.35, spec.brand["gold"]],
                    [0.7, spec.brand["gold_deep"]], [1, spec.brand["bad"]]],
        texttemplate="%{z:.1f}", textfont=dict(size=10),
        colorbar=dict(title="%", thickness=10)))
    f.update_layout(height=260)
    return f


def fig_transition_matrix(tr: pd.DataFrame, spec) -> go.Figure:
    last = tr["period"].max()
    d = tr[tr["period"] == last]
    order = [b for b in C.BUCKET_ORDER if b != "EXIT"]
    piv = (d.pivot_table(index="from_bucket", columns="to_bucket", values="rate_n", aggfunc="sum")
           .reindex(index=order, columns=order + ["EXIT"]).fillna(0))
    f = go.Figure(go.Heatmap(
        z=piv.to_numpy() * 100,
        x=[t(c, spec.lang) for c in piv.columns],
        y=[t(r, spec.lang) for r in piv.index],
        colorscale=[[0, "#FFFFFF"], [0.5, spec.brand["gold"]], [1, spec.brand["ink"]]],
        texttemplate="%{z:.0f}", textfont=dict(size=10),
        colorbar=dict(title="%", thickness=10)))
    f.update_layout(height=340, xaxis=dict(side="top"),
                    yaxis=dict(autorange="reversed"))
    return f


def fig_cure(cure: pd.DataFrame, spec) -> go.Figure:
    d = cure[cure["outcome"] == "cured"]
    piv = d.pivot_table(index="period", columns="from_bucket", values="rate_n", aggfunc="sum")
    keep = [b for b in C.BUCKET_ORDER if b in piv.columns and b not in ("CUR", "EXIT")]
    piv = piv[keep]
    x = [period_label(p, spec.lang) for p in piv.index]
    f = go.Figure()
    for col in piv.columns:
        f.add_scatter(x=x, y=piv[col] * 100, name=t(col, spec.lang), mode="lines")
    f.update_layout(height=340, yaxis=dict(title="cure rate, %"))
    return f


def fig_pd(pdm: pd.DataFrame, spec) -> go.Figure:
    d = pdm[(pdm["dim_name"] == "bucket") & pdm["is_reliable"]]
    piv = d.pivot_table(index="period", columns="dim_value", values="pd", aggfunc="mean")
    keep = [b for b in C.BUCKET_ORDER if b in piv.columns and b not in C.DEFAULT_BUCKETS + ("EXIT",)]
    piv = piv[keep]
    x = [period_label(p, spec.lang) for p in piv.index]
    f = go.Figure()
    for col in piv.columns:
        f.add_scatter(x=x, y=piv[col] * 100, name=t(col, spec.lang), mode="lines+markers")
    f.update_layout(height=340, yaxis=dict(title=f"PD {C.PD_HORIZON_MONTHS}m, %"))
    return f


def fig_bridge(br: pd.DataFrame, spec, n_months: int = 1) -> go.Figure:
    d = br.tail(n_months).iloc[-1]
    labels = ["opening", "inflow", "cured", "exited", "residual", "closing"]
    f = go.Figure(go.Waterfall(
        orientation="v",
        measure=["absolute", "relative", "relative", "relative", "relative", "total"],
        x=[t(k, spec.lang) for k in labels],
        y=[_money(d[k], spec.units) for k in labels],
        text=[f"{_money(d[k], spec.units):,.1f}" for k in labels],
        connector=dict(line=dict(color=spec.brand["rule"])),
        increasing=dict(marker=dict(color=spec.brand["bad"])),
        decreasing=dict(marker=dict(color=spec.brand["good"])),
        totals=dict(marker=dict(color=spec.brand["ink"]))))
    f.update_layout(height=340, yaxis=dict(title=t(f"unit_{spec.units}", spec.lang)))
    return f


def fig_bridge_trend(br: pd.DataFrame, spec) -> go.Figure:
    x = [period_label(p, spec.lang) for p in br["period"]]
    f = go.Figure()
    for k, col in (("inflow", spec.brand["bad"]), ("cured", spec.brand["good"]),
                   ("exited", spec.brand["muted"]), ("residual", spec.brand["gold_deep"])):
        f.add_bar(x=x, y=_money(br[k], spec.units), name=t(k, spec.lang), marker_color=col)
    f.add_scatter(x=x, y=_money(br["closing"] - br["opening"], spec.units),
                  name="Δ NPL", mode="lines+markers",
                  line=dict(color=spec.brand["ink"], width=2))
    f.update_layout(barmode="relative", height=340,
                    yaxis=dict(title=t(f"unit_{spec.units}", spec.lang)))
    return f


def fig_burden(cb: pd.DataFrame, spec) -> go.Figure:
    piv = cb.pivot_table(index="period", columns="burden", values="client_share", aggfunc="sum").fillna(0)
    x = [period_label(p, spec.lang) for p in piv.index]
    f = go.Figure()
    for col in piv.columns:
        f.add_scatter(x=x, y=piv[col] * 100, name=str(col), stackgroup="one", mode="none")
    f.update_layout(height=320, yaxis=dict(title="% " + t("n_clients", spec.lang)))
    return f


def fig_insurance(pf: pd.DataFrame, spec) -> go.Figure:
    d = _tot(pf)
    x = [period_label(p, spec.lang) for p in d["period"]]
    f = go.Figure()
    f.add_scatter(x=x, y=d["npl_ratio_gross_of_insurance"] * 100,
                  name=t("npl_ratio", spec.lang) + " gross", mode="lines",
                  line=dict(color=spec.brand["bad"], width=2, dash="dash"))
    f.add_scatter(x=x, y=d["npl_ratio"] * 100, name=t("npl_ratio", spec.lang),
                  mode="lines", line=dict(color=spec.brand["ink"], width=2.5),
                  fill="tonexty", fillcolor="rgba(242,194,48,0.30)")
    f.update_layout(height=320, yaxis=dict(title="%"))
    return f


def fig_branch_rank(pf: pd.DataFrame, spec, top: int = 18) -> go.Figure:
    last = pf["period"].max()
    d = _dim(pf, "filial")
    cur = d[d["period"] == last].set_index("dim_value")
    prev_p = sorted(d["period"].unique())[-13] if d["period"].nunique() > 13 else d["period"].min()
    prev = d[d["period"] == prev_p].set_index("dim_value")
    cur = cur.assign(delta_pp=(cur["npl_ratio"] - prev["npl_ratio"].reindex(cur.index)) * 100)
    cur = cur.sort_values("npl_ratio", ascending=True).tail(top)
    colours = [spec.brand["bad"] if v > 0 else spec.brand["good"] for v in cur["delta_pp"].fillna(0)]
    f = go.Figure()
    f.add_bar(x=cur["npl_ratio"] * 100, y=cur.index, orientation="h",
              marker_color=spec.brand["gold"], name=t("npl_ratio", spec.lang))
    f.add_scatter(x=cur["npl_ratio"] * 100, y=cur.index, mode="markers",
                  marker=dict(color=colours, size=9, symbol="diamond"),
                  name="Δ 12m, pp",
                  hovertemplate="Δ %{customdata:+.2f} pp<extra></extra>",
                  customdata=cur["delta_pp"])
    f.update_layout(height=max(320, 22 * len(cur)), xaxis=dict(title="NPL 90+, %"))
    return f


def fig_segment_matrix(pf: pd.DataFrame, spec, dim: str = "passport", top: int = 12) -> go.Figure:
    """Size versus quality: where the money is against where the risk is."""
    last = pf["period"].max()
    d = _dim(pf, dim)
    cur = d[d["period"] == last].nlargest(top, "exposure")
    f = go.Figure(go.Scatter(
        x=_money(cur["exposure"], spec.units), y=cur["npl_ratio"] * 100,
        mode="markers+text", text=cur["dim_value"], textposition="top center",
        textfont=dict(size=10),
        marker=dict(size=np.sqrt(cur["n_loans"].clip(lower=1)) * 1.6,
                    color=spec.brand["gold"], line=dict(color=spec.brand["ink"], width=1))))
    f.add_hline(y=float(_tot(pf).iloc[-1]["npl_ratio"] * 100),
                line=dict(color=spec.brand["ink"], width=1, dash="dot"))
    f.update_layout(height=400, xaxis=dict(title=t(f"unit_{spec.units}", spec.lang)),
                    yaxis=dict(title="NPL 90+, %"))
    return f


# --------------------------------------------------------------------------
# Commentary generated from the same marts
# --------------------------------------------------------------------------
def _pp(v):
    return f"{v:+.2f} п.п." if not pd.isna(v) else "н/д"


def commentary(marts: dict, spec) -> dict[str, list[str]]:
    ru = spec.lang == "ru"
    pf = _tot(marts["portfolio_monthly"])
    cur, prev = pf.iloc[-1], pf.iloc[-2]
    yoy = pf.iloc[-13] if len(pf) >= 13 else pf.iloc[0]
    out: dict[str, list[str]] = {}

    growth = cur["exposure"] / yoy["exposure"] - 1
    out["overview"] = [
        (f"Портфель на {period_label(int(cur['period']), spec.lang)} — "
         f"{_money(cur['exposure'], spec.units):,.1f} {t('unit_' + spec.units, spec.lang)}, "
         f"{growth:+.1%} за 12 месяцев." if ru else
         f"Portfolio at {period_label(int(cur['period']), 'en')}: "
         f"{_money(cur['exposure'], spec.units):,.1f}, {growth:+.1%} YoY."),
        (f"NPL 90+ — {cur['npl_ratio']:.2%}, изменение за месяц "
         f"{_pp((cur['npl_ratio'] - prev['npl_ratio']) * 100)}, "
         f"за год {_pp((cur['npl_ratio'] - yoy['npl_ratio']) * 100)}" if ru else
         f"NPL 90+ at {cur['npl_ratio']:.2%}, {(cur['npl_ratio']-prev['npl_ratio'])*100:+.2f}pp MoM."),
        (f"Зона риска 31–90 дней — {cur['watch_ratio']:.2%} портфеля: это тот объём, "
         f"который формирует NPL следующих двух кварталов." if ru else
         f"Watch list (31–90 DPD) at {cur['watch_ratio']:.2%} — next two quarters' NPL feedstock."),
    ]

    # Which branches drive the NPL move — the question always asked second.
    fl = _dim(marts["portfolio_monthly"], "filial")
    a = fl[fl["period"] == cur["period"]].set_index("dim_value")["npl_exposure"]
    b = fl[fl["period"] == prev["period"]].set_index("dim_value")["npl_exposure"]
    delta = (a - b.reindex(a.index)).dropna().sort_values(ascending=False)
    total_up = delta[delta > 0].sum()
    if total_up > 0 and len(delta):
        top3 = delta.head(3)
        share = top3.sum() / total_up
        out["overview"].append(
            (f"{share:.0%} прироста NPL за месяц дали три филиала: "
             + ", ".join(f"{i} ({_money(v, spec.units):,.1f})" for i, v in top3.items()) + "." if ru else
             f"{share:.0%} of the monthly NPL increase came from three branches: "
             + ", ".join(f"{i}" for i in top3.index) + "."))

    # Vintage direction.
    vm = marts["vintage_marks"]
    g = vm[(vm["dim_name"] == TOTAL) & (vm["metric"] == "GL90+@12MOB") & vm["is_reliable"]]
    if len(g) >= 4:
        g = g.sort_values("vintage")
        recent, older = g["rate"].iloc[-3:].mean(), g["rate"].iloc[:3].mean()
        direction = ("улучшается" if recent < older else "ухудшается") if ru else \
                    ("improving" if recent < older else "deteriorating")
        out["vintage"] = [
            (f"Качество выдач {direction}: GL90+@12MOB по последним когортам "
             f"{recent:.2%} против {older:.2%} по ранним." if ru else
             f"Origination quality is {direction}: GL90+@12MOB {recent:.2%} recent vs {older:.2%} early.")]

    # Bridge.
    br = marts["npl_bridge"]
    if len(br):
        last = br.iloc[-1]
        out["bridge"] = [
            (f"За месяц в дефолт ушло {_money(last['inflow'], spec.units):,.1f} "
             f"{t('unit_' + spec.units, spec.lang)}, восстановлено "
             f"{_money(-last['cured'], spec.units):,.1f}, выбыло с баланса "
             f"{_money(-last['exited'], spec.units):,.1f}." if ru else
             f"Monthly inflow {_money(last['inflow'], spec.units):,.1f}, "
             f"cured {_money(-last['cured'], spec.units):,.1f}."),
            (f"Соотношение восстановления к притоку — "
             f"{(-last['cured'] / last['inflow']):.0%}." if ru and last["inflow"] else
             ""),
        ]
        out["bridge"] = [s for s in out["bridge"] if s]

    # Insurance.
    relief = cur.get("insurance_npl_relief_pp", np.nan)
    if not pd.isna(relief) and relief > 0.01:
        out["insurance"] = [
            (f"Без страховых выплат NPL 90+ составил бы "
             f"{cur['npl_ratio_gross_of_insurance']:.2%} вместо {cur['npl_ratio']:.2%}: "
             f"страховое покрытие удерживает {relief:.2f} п.п. портфеля." if ru else
             f"Without insurance NPL would be {cur['npl_ratio_gross_of_insurance']:.2%} "
             f"vs {cur['npl_ratio']:.2%} — {relief:.2f}pp of relief.")]

    # Cure.
    cr = marts["cure_rates"]
    late = cr[(cr["period"] == cr["period"].max()) & (cr["outcome"] == "cured")]
    early = late[late["from_bucket"] == "B01_30"]["rate_n"].sum()
    deep = late[late["from_bucket"].isin(C.DEFAULT_BUCKETS)]["rate_n"].sum()
    if early or deep:
        out["cure"] = [
            (f"Из корзины 1–30 дней возвращается в норму {early:.0%} кредитов за месяц, "
             f"из корзин 90+ — {deep:.1%}. Разрыв показывает, где ещё работает сбор, "
             f"а где решает уже только суд и залог." if ru else
             f"{early:.0%} of 1–30 DPD loans cure within a month versus {deep:.1%} of 90+.")]
    return out


# --------------------------------------------------------------------------
# HTML assembly
# --------------------------------------------------------------------------
CSS = """
:root{--ink:%(ink)s;--ink-soft:%(ink_soft)s;--gold:%(gold)s;--gold-deep:%(gold_deep)s;
--paper:%(paper)s;--rule:%(rule)s;--muted:%(muted)s;--bad:%(bad)s;--good:%(good)s;}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);
font-family:%(font_body)s;font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:1120px;margin:0 auto;padding:0 28px}
.cover{background:var(--ink);color:#fff;padding:64px 0 52px;margin-bottom:8px}
.cover .eyebrow{font-family:%(font_mono)s;font-size:11px;letter-spacing:.22em;
text-transform:uppercase;color:var(--gold)}
.cover h1{font-family:%(font_display)s;font-weight:400;font-size:44px;line-height:1.1;
margin:14px 0 10px;max-width:20ch}
.cover .meta{color:#B9B5AB;font-size:13px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
gap:1px;background:#26262A;margin-top:34px;border-top:2px solid var(--gold)}
.kpi{background:var(--ink);padding:18px 16px}
.kpi .k{font-family:%(font_mono)s;font-size:10px;letter-spacing:.14em;
text-transform:uppercase;color:#8E8A80}
.kpi .v{font-family:%(font_display)s;font-size:27px;margin-top:6px}
.kpi .d{font-size:12px;margin-top:2px}
.up{color:#E6A6A6}.down{color:#9FD3B6}
section{padding:44px 0 8px;border-top:1px solid var(--rule)}
section:first-of-type{border-top:none}
h2{font-family:%(font_display)s;font-weight:400;font-size:27px;margin:0 0 4px;
display:flex;align-items:baseline;gap:14px}
h2 .num{font-family:%(font_mono)s;font-size:12px;color:var(--gold-deep);letter-spacing:.1em}
h3{font-family:%(font_display)s;font-weight:400;font-size:17px;margin:26px 0 6px}
.lede{color:var(--ink-soft);max-width:78ch;margin:10px 0 20px}
.lede li{margin-bottom:5px}
.chart{margin:8px 0 6px}
.note{border-left:2px solid var(--gold);padding:9px 0 9px 14px;margin:16px 0;
font-size:13.5px;color:var(--ink-soft);background:#FCFAF4}
.note b{font-family:%(font_mono)s;font-size:10px;letter-spacing:.12em;
text-transform:uppercase;color:var(--gold-deep);display:block;margin-bottom:3px}
table{border-collapse:collapse;width:100%%;font-size:13px;margin:12px 0}
th,td{padding:7px 10px;text-align:right;border-bottom:1px solid var(--rule)}
th{font-family:%(font_mono)s;font-size:10px;letter-spacing:.1em;text-transform:uppercase;
color:var(--muted);text-align:right;border-bottom:1px solid var(--ink)}
td:first-child,th:first-child{text-align:left}
tr:hover td{background:#FCFAF4}
.status-FAIL{color:var(--bad);font-weight:600}
.status-WARN{color:var(--gold-deep)}
.status-pass{color:var(--good)}
footer{background:var(--ink);color:#8E8A80;padding:30px 0;margin-top:52px;font-size:12px}
@media print{.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}
section{break-inside:avoid}}
@media (max-width:720px){.cover h1{font-size:31px}.wrap{padding:0 16px}}
"""


def _kpi(label, value, delta=None, delta_good_down=True):
    cls = ""
    if delta is not None and not pd.isna(delta):
        worse = delta > 0 if delta_good_down else delta < 0
        cls = "up" if worse else "down"
        delta = f"{delta:+.2f} п.п."
    return (f'<div class="kpi"><div class="k">{html.escape(label)}</div>'
            f'<div class="v">{value}</div>'
            f'<div class="d {cls}">{delta or "&nbsp;"}</div></div>')


def _note(title, body):
    return f'<div class="note"><b>{html.escape(title)}</b>{body}</div>'


def _section(n, key, lang, lede_items, charts, note=None):
    lede = ""
    if lede_items:
        lede = "<ul class='lede'>" + "".join(f"<li>{s}</li>" for s in lede_items) + "</ul>"
    body = "".join(f'<div class="chart">{c}</div>' for c in charts)
    return (f'<section><div class="wrap"><h2><span class="num">{n:02d}</span>'
            f'{html.escape(t(key, lang))}</h2>{lede}{body}{note or ""}</div></section>')


def build_report(marts: dict, checks: pd.DataFrame, spec) -> Path:
    pio.templates["kb"] = make_template(spec.brand)
    pio.templates.default = "kb"
    lang = spec.lang
    com = commentary(marts, spec)
    pf_t = _tot(marts["portfolio_monthly"])
    cur, prev = pf_t.iloc[-1], pf_t.iloc[-2]
    last_period = int(cur["period"])

    kpis = "".join([
        _kpi(t("exposure", lang) + f", {t('unit_' + spec.units, lang)}",
             f"{_money(cur['exposure'], spec.units):,.0f}",
             None),
        _kpi(t("npl_ratio", lang), f"{cur['npl_ratio']:.2%}",
             (cur["npl_ratio"] - prev["npl_ratio"]) * 100),
        _kpi(t("watch_ratio", lang), f"{cur['watch_ratio']:.2%}",
             (cur["watch_ratio"] - prev["watch_ratio"]) * 100),
        _kpi(t("coverage_ratio", lang), f"{cur['coverage_ratio']:.0%}",
             (cur["coverage_ratio"] - prev["coverage_ratio"]) * 100, delta_good_down=False),
        _kpi(t("n_clients", lang), f"{int(cur['n_clients']):,}".replace(",", " ")),
        _kpi(t("loans_per_client", lang), f"{cur['loans_per_client']:.2f}"),
    ])

    first = True

    def D(fig):
        nonlocal first
        s = _div(fig, first)
        first = False
        return s

    secs = []
    secs.append(_section(1, "sec_overview", lang, com.get("overview"), [
        D(fig_portfolio(marts["portfolio_monthly"], spec)),
        D(fig_leading_indicator(marts["portfolio_monthly"], spec)),
    ], _note(t("methodology", lang),
             "Зона риска 31–90 дней сдвинута на 3 месяца вперёд и наложена на факт NPL: "
             "расхождение между линиями показывает, насколько сбор перехватывает поток "
             "до перехода в дефолт." if lang == "ru" else
             "Watch list lagged three months against realised NPL.")))

    secs.append(_section(2, "sec_origination", lang, None, [
        D(fig_origination(marts["origination_monthly"], spec)),
        D(fig_burden(marts["client_burden"], spec)),
    ], _note(t("caveat", lang),
             "«Новый клиент» определяется как первое появление контрагента в данных. "
             "В первом месяце истории все клиенты выглядят новыми — этот месяц исключён "
             "из выводов." if lang == "ru" else
             "'New client' = first appearance in the data; the first month is truncated.")))

    secs.append(_section(3, "sec_quality", lang, None, [
        D(fig_branch_rank(marts["portfolio_monthly"], spec)),
        D(fig_segment_matrix(marts["portfolio_monthly"], spec, "passport")),
    ]))

    secs.append(_section(4, "sec_vintage", lang, com.get("vintage"), [
        D(fig_vintage_curves(marts["vintages"], spec, 90)),
        D(fig_vintage_heat(marts["vintage_marks"], spec)),
    ], _note(t("methodology", lang),
             "GLx+@yMOB — доля когорты выдачи, которая <i>хотя бы раз</i> достигла x дней "
             "просрочки к y-му месяцу жизни. Когорты меньше "
             f"{C.MIN_COHORT_SIZE} кредитов скрыты как статистически незначимые." if lang == "ru" else
             "GLx+@yMOB = share of the origination cohort ever x days past due by month y.")))

    secs.append(_section(5, "sec_migration", lang, com.get("cure"), [
        D(fig_transition_matrix(marts["transitions"], spec)),
        D(fig_cure(marts["cure_rates"], spec)),
    ], _note(t("methodology", lang),
             "«Выбыл» — отдельное состояние, а не восстановление: кредит ушёл с баланса "
             "(погашен, списан или продан). Смешивать его с cure нельзя." if lang == "ru" else
             "'Left the book' is a separate state from cure.")))

    secs.append(_section(6, "sec_pd", lang, None, [
        D(fig_pd(marts["pd_observed"], spec)),
    ], _note(t("methodology", lang),
             f"PD — фактическая частота перехода в дефолт в течение {C.PD_HORIZON_MONTHS} "
             "месяцев, а не модельная оценка. Наблюдения без полного горизонта исключены, "
             "иначе последние месяцы выглядели бы безопаснее, чем они есть." if lang == "ru" else
             f"Observed {C.PD_HORIZON_MONTHS}-month default frequency, right-censored.")))

    secs.append(_section(7, "sec_bridge", lang, com.get("bridge"), [
        D(fig_bridge(marts["npl_bridge"], spec)),
        D(fig_bridge_trend(marts["npl_bridge"], spec)),
    ]))

    if "insurance" in com or marts["portfolio_monthly"]["insurance_npl_relief_pp"].abs().max() > 0:
        secs.append(_section(8, "sec_insurance", lang, com.get("insurance"), [
            D(fig_insurance(marts["portfolio_monthly"], spec)),
        ]))

    # Methodology and quality
    rows = "".join(
        f"<tr><td>{html.escape(r.check)}</td>"
        f"<td class='status-{r.status}'>{r.status}</td>"
        f"<td style='text-align:left'>{html.escape(str(r.detail))}</td></tr>"
        for r in checks.itertuples())
    qa = (f"<table><thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>"
          f"<tbody>{rows}</tbody></table>")
    secs.append(_section(9, "sec_method", lang, None, [qa]))

    body = "".join(secs)
    css = CSS % spec.brand
    stamp = dt.datetime.now().strftime("%d.%m.%Y %H:%M")
    title = t("report_title", lang)

    html_doc = f"""<!doctype html>
<html lang="{lang}"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)} — {period_label(last_period, lang)}</title>
<style>{css}</style></head><body>
<div class="cover"><div class="wrap">
<div class="eyebrow">{html.escape(t('sec_overview', lang))}</div>
<h1>{html.escape(title)}</h1>
<div class="meta">{html.escape(t('as_of', lang))}: {period_label(last_period, lang)} &nbsp;·&nbsp;
{html.escape(t('generated', lang))}: {stamp} &nbsp;·&nbsp; FX: {spec.fx_mode}</div>
<div class="kpis">{kpis}</div>
</div></div>
{body}
<footer><div class="wrap">{html.escape(title)} · {period_label(last_period, lang)} ·
generated by lending_analytics</div></footer>
</body></html>"""

    C.REPORT_DIR.mkdir(parents=True, exist_ok=True)
    out = C.REPORT_DIR / f"report_{last_period}_{lang}.html"
    out.write_text(html_doc, encoding="utf-8")
    (C.REPORT_DIR / f"report_{last_period}_{lang}.spec.json").write_text(
        json.dumps({"period": last_period, "lang": lang, "fx_mode": spec.fx_mode,
                    "generated": stamp}, ensure_ascii=False, indent=2), encoding="utf-8")
    return out
Helpful? Dislike 0 Log in to react