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",
    })
Helpful? Dislike 0 Log in to react