Programming
1. config
"""
Single source of truth for every business rule in the report.
Nothing downstream is allowed to hardcode a threshold. If a stakeholder asks
"why is NPL 7.2% here and 7.4% in the risk report?" the answer must be findable
in this one file.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
# --------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------
# Set LOANBOOK_DATA to move the warehouse (a mounted volume on the server, a
# fast local disk on your machine). It must be set BEFORE this module is first
# imported — every path below is derived from it at import time, so reassigning
# config.DATA afterwards would leave the others pointing at the old root.
ROOT = Path(__file__).resolve().parent.parent
DATA = Path(os.environ.get("LOANBOOK_DATA") or ROOT / "data").expanduser().resolve()
RAW_DIR = DATA / "raw" # what the core system dumps
LAKE_DIR = DATA / "lake" # snapshots, partitioned by report_date
PANEL_PATH = DATA / "panel" / "loan_month_panel.parquet"
MART_DIR = DATA / "marts" # small aggregates, the report reads only these
REPORT_DIR = DATA / "reports"
FX_PATH = DATA / "reference" / "fx_rates.csv"
for _p in (RAW_DIR, LAKE_DIR, PANEL_PATH.parent, MART_DIR, REPORT_DIR, FX_PATH.parent):
_p.mkdir(parents=True, exist_ok=True)
# --------------------------------------------------------------------------
# Risk definitions
# --------------------------------------------------------------------------
NPL_CATEGORIES = (3, 4, 5) # kategoriya_npl_90 values that mean "defaulted"
PERFORMING_CATEGORIES = (0, 1, 2)
# Watch list: not NPL by category, but already materially overdue.
# This is the leading indicator: what lands in NPL 1-2 quarters from now.
WATCH_DPD_MIN = 31
WATCH_DPD_MAX = 90
# Ordered delinquency states. Order matters: it defines "improved" vs "worsened"
# in the transition matrix.
DPD_BUCKETS: tuple[tuple[int, int, str], ...] = (
(-10**9, 0, "CUR"),
(1, 30, "B01_30"),
(31, 60, "B31_60"),
(61, 90, "B61_90"),
(91, 180, "B91_180"),
(181, 360, "B181_360"),
(361, 10**9, "B360P"),
)
BUCKET_ORDER = [b[2] for b in DPD_BUCKETS] + ["EXIT"]
DEFAULT_BUCKETS = ("B91_180", "B181_360", "B360P")
# --------------------------------------------------------------------------
# Vintage / cohort analysis
# --------------------------------------------------------------------------
# (dpd_threshold, months_on_book). "GL30+@3MOB" == (30, 3).
VINTAGE_MARKS: tuple[tuple[int, int], ...] = ((30, 3), (30, 6), (90, 9), (90, 12))
# Cohorts smaller than this are statistically meaningless — suppressed, not shown.
MIN_COHORT_SIZE = 30
# Forward window for the empirical (observed) probability of default.
PD_HORIZON_MONTHS = 12
# --------------------------------------------------------------------------
# Currency
# --------------------------------------------------------------------------
BASE_CURRENCY = 0 # 0 == UZS in the source system
FX_FALLBACK = {0: 1.0, 840: 12_600.0, 978: 13_700.0}
# Constant-rate mode strips FX revaluation out of portfolio growth.
# Always report both: nominal is what accounting sees, constant is what the
# business actually did.
FX_CONSTANT_DATE = "2026-08-01"
MLN = 1_000_000.0
MLRD = 1_000_000_000.0
# --------------------------------------------------------------------------
# Dimensions the whole report can be sliced by
# --------------------------------------------------------------------------
DIMENSIONS: tuple[str, ...] = (
"department",
"filial",
"passport",
"avto",
"valuta",
"sud",
)
# --------------------------------------------------------------------------
# Source schema
# --------------------------------------------------------------------------
DATE_COLS = ("dogovor_sana", "vidacha_sana", "report_date")
ID_COLS = ("contragent", "anketa")
AMOUNT_COLS = (
"brutto_95_summa", "brutto_summa",
"protsent_16309_summa", "protsent_16377_summa", "protsent_16379_summa",
"spisat_95413_summa", "spisat_91501_summa", "reserve_summa",
"vidacha_tekushiy_summa", "pogashen_tekushiy_summa",
"straxovka_summa", "straxovka_brutto_95_summa", "all_time_given_amount",
)
CAT_COLS = (
"filial", "tobo", "department", "department_number", "passport",
"contragent_name", "sud", "avto", "valuta",
"kategoriya_origin", "kategoriya_npl_90", "kategoriya_straxovka",
)
EXPOSURE_COL = "brutto_95_summa" # the one number that means "portfolio"
@dataclass(frozen=True)
class ReportSpec:
"""Everything a report run needs to know. Serialise it next to the output."""
title_key: str = "report_title"
lang: str = "ru"
fx_mode: str = "nominal" # "nominal" | "constant"
units: str = "mlrd" # display unit for money
period_from: str | None = None
period_to: str | None = None
dimensions: tuple[str, ...] = DIMENSIONS
brand: dict = field(default_factory=lambda: {
"ink": "#0B0B0C",
"ink_soft": "#2A2A2E",
"gold": "#F2C230",
"gold_deep": "#C9971A",
"paper": "#FFFFFF",
"rule": "#E4E1D8",
"muted": "#8A867C",
"bad": "#B3341F",
"good": "#2F6B4F",
"font_display": "Cambria, 'Times New Roman', Georgia, serif",
"font_body": "Cambria, Georgia, serif",
"font_mono": "'Cascadia Mono', Consolas, 'SF Mono', monospace",
})
PO
powerty
Author
· Staff
Aug. 26, 2026
Aug. 26, 2026
2
Views
0
Likes
3m
Read