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 " "}</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)} ·
{html.escape(t('generated', lang))}: {stamp} · 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
PO
powerty
Author
· Staff
Aug. 26, 2026
Aug. 26, 2026
2
Views
0
Likes
12m
Read