/
keorlov
/
collsim
Обзор
Документация
Войти
/
keorlov
/
collsim
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
engine.py
735 строк
31 KB
Konstantin Orlov
wip
14 июл 2026, 13:09
14 июл 2026, 13:09
688f811
Код
Авторство
О чём код?
""" Engine симуляции портфеля розничного кредитования. Оптимизированный под PyPy: только списки, словари, random.random(), никаких pandas/numpy в горячем цикле. """ from __future__ import annotations import random from dataclasses import dataclass from typing import Literal from config_loader import config, Config from models import Client, Loan Product = Literal["potreb", "card", "mortgage", "auto"] Status = Literal["current", "soft", "hard", "closed", "sold"] ClientType = Literal["chronic", "forgetful"] @dataclass(slots=True) class DailyMetrics: """Ежедневные метрики симуляции.""" day: int current_count: int soft_count: int hard_count: int closed_count: int sold_count: int total_balance: float npl_balance: float npl_pct: float past_due_balance: float daily_budget_spent: float recovered_today: float new_clients: int cured_today: int sold_today: int inflow_soft: int outflow_cured: int outflow_hard: int spent_sms: float spent_call: float recovered_sms: float recovered_call: float recovered_waiting: float cost_of_risk: float cession_losses: float interest_income_today: float dpd_1_30_count: int dpd_31_60_count: int dpd_61_89_count: int sms_count: int call_count: int waiting_count: int class SimulationEngine: """ Движок агентного моделирования кредитного портфеля. Горячий цикл оптимизирован под PyPy: только списки, словари, random.random(), встроенная математика. Никаких pandas/numpy в горячем цикле. """ __slots__ = ( "config", "clients", "client_counter", "daily_metrics", "rng", "product_choices", "product_weights", "client_type_choices", "client_type_weights", "product_params", "channel_names", "channel_costs", "channel_p_base", "daily_sms_budget", "daily_call_budget", "max_calls_week", "max_sms_week", "cession_dpd", "cession_rate", "base_hazard", "gini", "optimization_mode", "cure_recovery_rate", "hard_cure_rate", "_delinquent_clients", "_client_events", ) def __init__(self, seed: int | None = None, config_override: Config | None = None): self.config = config_override or config self.clients: list[Client] = [] self.client_counter = 0 self.daily_metrics: list[DailyMetrics] = [] self.rng = random.Random(seed) # Предварительно считаем веса для random.choices self.product_choices = list(self.config.products.keys()) self.product_weights = [self.config.products[p].share for p in self.product_choices] self.client_type_choices = list(self.config.client_types.keys()) self.client_type_weights = [self.config.client_types[ct].share for ct in self.client_type_choices] # Параметры продуктов для быстрого доступа self.product_params = {} for name, p in self.config.products.items(): self.product_params[name] = { "avg_balance": p.avg_balance, "term_days": p.term_days, "hazard_mult": p.hazard_mult, "daily_close_prob": p.daily_close_prob or 0.0, "daily_interest_rate": p.daily_interest_rate, "nim_annual_rate": p.nim_annual_rate, } # Параметры каналов self.channel_names = ["call", "sms", "waiting"] self.channel_costs = {c: self.config.channels[c].cost for c in self.channel_names} self.channel_p_base = {c: self.config.channels[c].p_base for c in self.channel_names} # Бюджеты и лимиты self.daily_sms_budget = self.config.budgets.daily_sms self.daily_call_budget = self.config.budgets.daily_call self.max_calls_week = self.config.legal_limits.max_calls_per_week self.max_sms_week = self.config.legal_limits.max_sms_per_week self.cession_dpd = self.config.hard_collection.cession_dpd self.cession_rate = self.config.hard_collection.cession_rate # Хазард, Джини и recovery self.base_hazard = self.config.hazard.base_daily_rate self.gini = self.config.simulation.gini self.optimization_mode = self.config.simulation.optimization_mode self.cure_recovery_rate = self.config.simulation.cure_recovery_rate self.hard_cure_rate = self.config.simulation.hard_cure_rate self._delinquent_clients: dict[int, dict] = {} self._client_events: dict[int, list[dict]] = {} def _random_client_id(self) -> int: self.client_counter += 1 return self.client_counter def _random_product(self) -> str: return self.rng.choices(self.product_choices, weights=self.product_weights, k=1)[0] def _random_client_type(self) -> str: return self.rng.choices(self.client_type_choices, weights=self.client_type_weights, k=1)[0] def _random_gini_score(self) -> float: return self.rng.random() def _create_loan(self, product: str) -> Loan: params = self.product_params[product] balance = params["avg_balance"] * (0.8 + 0.4 * self.rng.random()) term = params["term_days"] return Loan( product=product, balance=balance, original_balance=balance, original_term_days=term, term_days_left=term, dpd=0, status="current", ) def _create_client(self, client_type: str, product: str) -> Client: client_id = self._random_client_id() loan = self._create_loan(product) return Client( client_id=client_id, client_type=client_type, gini_score=self._random_gini_score(), loan=loan, ) def _initialize_portfolio(self) -> None: """Тёплый старт: создаёт начальный портфель с распределением по статусам.""" soft_share = self.config.initial_state.soft_share hard_share = self.config.initial_state.hard_share hard_dpd_max = self.config.initial_state.hard_dpd_max for _ in range(self.config.portfolio.initial_clients): ctype = self._random_client_type() product = self._random_product() client = self._create_client(ctype, product) loan = client.loan roll = self.rng.random() if roll < hard_share: loan.status = "hard" loan.dpd = self.rng.randint(90, hard_dpd_max) rate = self.product_params[product]["daily_interest_rate"] loan.balance *= (1.0 + rate) ** loan.dpd elif roll < hard_share + soft_share: loan.status = "soft" loan.dpd = self.rng.randint(1, 89) rate = self.product_params[product]["daily_interest_rate"] loan.balance *= (1.0 + rate) ** loan.dpd else: loan.status = "current" loan.dpd = 0 # Для кредитов со сроком: уменьши term_days_left (кредит уже живёт) if loan.original_term_days is not None: max_age = int(loan.original_term_days * 0.8) age = self.rng.randint(0, max_age) loan.term_days_left = max(1, loan.original_term_days - age) if loan.status in ("soft", "hard"): self._record_first_delinquency(client, 0) self.clients.append(client) def _origination(self, day: int) -> int: """Origination: добавляем новых клиентов согласно daily_growth_rate.""" n_new = int(len(self.clients) * self.config.portfolio.daily_growth_rate) n_new += 1 if self.rng.random() < (len(self.clients) * self.config.portfolio.daily_growth_rate) % 1 else 0 for _ in range(n_new): ctype = self._random_client_type() product = self._random_product() self.clients.append(self._create_client(ctype, product)) return n_new def _amortization(self) -> None: """Амортизация: погашение телом кредита для current кредитов со сроком.""" daily_close_prob_card = self.product_params["card"]["daily_close_prob"] for client in self.clients: loan = client.loan if loan.status != "current": continue if loan.original_term_days is not None: # Аннуитетное погашение тела (упрощённо: линейное) principal_payment = loan.original_balance / loan.original_term_days loan.balance -= principal_payment loan.term_days_left -= 1 if loan.term_days_left <= 0 and loan.balance <= 0.01: loan.status = "closed" loan.balance = 0.0 else: # Карты: случайное закрытие if self.rng.random() < daily_close_prob_card: loan.status = "closed" loan.balance = 0.0 def _accrue_interest(self) -> None: """Начисление процентов: для soft и hard (до 90 дней в hard) кредитов каждый день.""" for client in self.clients: loan = client.loan if loan.status == "soft" or (loan.status == "hard" and loan.dpd <= 180): rate = self.product_params[loan.product]["daily_interest_rate"] loan.balance *= (1.0 + rate) def _compute_interest_income(self) -> float: """NIM: дневной доход от current кредитов.""" income = 0.0 for client in self.clients: loan = client.loan if loan.status == "current": nim_rate = self.product_params[loan.product]["nim_annual_rate"] income += loan.balance * (nim_rate / 365.0) return income def _hazard(self, day: int) -> int: """Hazard: переход current -> soft (dpd=0, следующий шаг _dpd_rolling сделает dpd=1).""" inflow = 0 for client in self.clients: loan = client.loan if loan.status != "current": continue p_type = self.config.client_types[client.client_type].hazard_multiplier p_prod = self.product_params[loan.product]["hazard_mult"] relapse_factor = 1.0 + client.cure_count * 0.15 p_hazard = self.base_hazard * p_type * p_prod * relapse_factor if self.rng.random() < p_hazard: loan.status = "soft" loan.dpd = 0 self._record_first_delinquency(client, day) self._log_event(client.client_id, day, "hazard", balance=loan.balance) inflow += 1 return inflow def _dpd_rolling(self, day: int) -> tuple[int, set[int]]: """DPD Rolling: soft -> hard при dpd >= 90. Возвращает (кол-во переходов, ID перешедших).""" just_hardened: set[int] = set() outflow = 0 for client in self.clients: loan = client.loan if loan.status == "soft": loan.dpd += 1 if client.client_id in self._delinquent_clients: entry = self._delinquent_clients[client.client_id] if loan.dpd > entry["max_dpd"]: entry["max_dpd"] = loan.dpd if loan.dpd >= 90: loan.status = "hard" just_hardened.add(client.client_id) self._log_event(client.client_id, day, "soft_to_hard", dpd=loan.dpd, balance=loan.balance) outflow += 1 return outflow, just_hardened def _hard_collection(self, just_hardened: set[int] | None = None, day: int = 0) -> dict: """Hard collection: hard -> sold при dpd >= cession_dpd. Клиенты, перешедшие из soft в этот шаг, не получают +1 DPD.""" if just_hardened is None: just_hardened = set() recovered = 0.0 sold_count = 0 cession_losses = 0.0 for client in self.clients: loan = client.loan if loan.status != "hard": continue if client.client_id not in just_hardened: loan.dpd += 1 # Реструктуризация: шанс возвращения в soft if self.rng.random() < self.hard_cure_rate: loan.status = "soft" loan.dpd = 89 self._log_event(client.client_id, day, "hard_to_soft", dpd=89, balance=loan.balance) continue if loan.dpd >= self.cession_dpd: recovered_amount = loan.balance * self.cession_rate cession_losses += loan.balance * (1.0 - self.cession_rate) recovered += recovered_amount loan.balance = 0.0 loan.status = "sold" self._log_event(client.client_id, day, "sold", dpd=loan.dpd) sold_count += 1 return {"recovered": recovered, "sold_count": sold_count, "cession_losses": cession_losses} def _calculate_p_adj(self, client: Client, channel: str) -> float: """Расчёт скорректированной вероятности p_adj по формуле Gini с DPD Decay.""" p_base = self.channel_p_base[channel] gini_score = client.gini_score dpd_decay = max(0.1, 1.0 - client.loan.dpd / 120.0) p_adj = p_base * (1.0 + self.gini * (gini_score * 2.0 - 1.0)) * dpd_decay return max(0.0, min(0.95, p_adj)) def _record_first_delinquency(self, client: Client, day: int) -> None: if client.client_id not in self._delinquent_clients: self._delinquent_clients[client.client_id] = { "client_id": client.client_id, "client_type": client.client_type, "product": client.loan.product, "original_balance": client.loan.original_balance, "gini_score": client.gini_score, "first_delinquent_day": day, "last_delinquent_day": day, "total_contacts": 0, "max_dpd": client.loan.dpd, "contacts_by_channel": {"sms": 0, "call": 0}, } else: entry = self._delinquent_clients[client.client_id] if client.loan.dpd > entry["max_dpd"]: entry["max_dpd"] = client.loan.dpd if day > entry["last_delinquent_day"]: entry["last_delinquent_day"] = day def _record_contact(self, client: Client, channel: str) -> None: if client.client_id in self._delinquent_clients: entry = self._delinquent_clients[client.client_id] entry["total_contacts"] += 1 if channel in entry["contacts_by_channel"]: entry["contacts_by_channel"][channel] += 1 def _log_event(self, client_id: int, day: int, event_type: str, **kwargs) -> None: if client_id not in self._client_events: self._client_events[client_id] = [] self._client_events[client_id].append({ "day": day, "type": event_type, **kwargs, }) def get_client_events_dict(self) -> dict[int, list[dict]]: return self._client_events def get_delinquent_clients_dict(self) -> list[dict]: client_map = {c.client_id: c for c in self.clients} result = [] for cid, entry in self._delinquent_clients.items(): client = client_map.get(cid) if client: result.append({ **entry, "balance": client.loan.balance, "status": client.loan.status, "dpd": client.loan.dpd, }) else: result.append({ **entry, "balance": 0.0, "status": "closed", "dpd": 0, }) result.sort(key=lambda x: (-x["first_delinquent_day"], -x["max_dpd"])) return result def _cure_client(self, client: Client, day: int = 0) -> float: """Cure: клиент гасит 1-3 пропущенных платежа (для срочных) или % от баланса (для карт), возвращается в current.""" loan = client.loan if loan.original_term_days is not None: monthly_payment = loan.original_balance / (loan.original_term_days / 30.0) n_payments = self.rng.randint(1, 3) cure_amount = min(monthly_payment * n_payments, loan.balance) else: cure_amount = loan.balance * self.cure_recovery_rate loan.balance -= cure_amount loan.dpd = 0 loan.status = "current" client.cure_count += 1 self._log_event(client.client_id, day, "cure", cure_amount=cure_amount, balance=loan.balance) return cure_amount def _collection_strategy(self, day: int) -> dict: """Collection strategy: жадный выбор канала для soft клиентов.""" soft_clients = [c for c in self.clients if c.loan.status == "soft"] client_evs = [] for client in soft_clients: loan = client.loan balance = loan.balance best_ev = -1.0 best_channel = "waiting" for channel in self.channel_names: if channel == "waiting": p_adj = self._calculate_p_adj(client, "waiting") ev = balance * p_adj else: p_adj = self._calculate_p_adj(client, channel) if self.optimization_mode == "uplift": p_wait = self._calculate_p_adj(client, "waiting") ev = balance * (p_adj - p_wait) else: ev = balance * p_adj if ev > best_ev: best_ev = ev best_channel = channel client_evs.append((best_ev, client, best_channel)) client_evs.sort(key=lambda x: x[0], reverse=True) sms_budget = self.daily_sms_budget call_budget = self.daily_call_budget recovered = 0.0 cured = 0 spent = 0.0 spent_sms = 0.0 spent_call = 0.0 recovered_sms = 0.0 recovered_call = 0.0 recovered_waiting = 0.0 sms_count = 0 call_count = 0 waiting_count = 0 for ev, client, channel in client_evs: if channel == "waiting": waiting_count += 1 p_adj = self._calculate_p_adj(client, "waiting") if self.rng.random() < p_adj: cure_amount = self._cure_client(client, day) recovered += cure_amount recovered_waiting += cure_amount cured += 1 continue cost = self.channel_costs[channel] can_afford = (channel == "sms" and sms_budget >= cost) or (channel == "call" and call_budget >= cost) # Fallback: если нет бюджета на выбранный канал — пробуем другой if not can_afford: if channel == "call" and sms_budget >= self.channel_costs["sms"]: channel, cost = "sms", self.channel_costs["sms"] elif channel == "sms" and call_budget >= self.channel_costs["call"]: channel, cost = "call", self.channel_costs["call"] # 230-ФЗ: если лимит исчерпан — пробуем другой канал, иначе waiting if channel == "call" and client.calls_this_week >= self.max_calls_week: if client.sms_this_week < self.max_sms_week and sms_budget >= self.channel_costs["sms"]: channel, cost = "sms", self.channel_costs["sms"] else: channel = "waiting" if channel == "sms" and client.sms_this_week >= self.max_sms_week: if client.calls_this_week < self.max_calls_week and call_budget >= self.channel_costs["call"]: channel, cost = "call", self.channel_costs["call"] else: channel = "waiting" # Нет активного канала — обязательный waiting if channel == "waiting": waiting_count += 1 p_adj = self._calculate_p_adj(client, "waiting") if self.rng.random() < p_adj: cure_amount = self._cure_client(client, day) recovered += cure_amount recovered_waiting += cure_amount cured += 1 continue # Финальная проверка бюджета if (channel == "sms" and sms_budget < cost) or (channel == "call" and call_budget < cost): waiting_count += 1 p_adj = self._calculate_p_adj(client, "waiting") if self.rng.random() < p_adj: cure_amount = self._cure_client(client, day) recovered += cure_amount recovered_waiting += cure_amount cured += 1 continue # Исполнение активного канала if channel == "call": call_budget -= cost client.calls_this_week += 1 spent_call += cost call_count += 1 else: sms_budget -= cost client.sms_this_week += 1 spent_sms += cost sms_count += 1 spent += cost client.last_contact_day = day self._record_contact(client, channel) p_adj = self._calculate_p_adj(client, channel) self._log_event(client.client_id, day, "contact", channel=channel, p_adj=round(p_adj, 4), balance=round(loan.balance, 2)) if self.rng.random() < p_adj: cure_amount = self._cure_client(client, day) recovered += cure_amount cured += 1 if channel == "call": recovered_call += cure_amount else: recovered_sms += cure_amount return { "recovered": recovered, "cured": cured, "spent": spent, "spent_sms": spent_sms, "spent_call": spent_call, "recovered_sms": recovered_sms, "recovered_call": recovered_call, "recovered_waiting": recovered_waiting, "sms_count": sms_count, "call_count": call_count, "waiting_count": waiting_count, } def _collect_metrics( self, day: int, new_clients: int, inflow_soft: int, outflow_hard: int, hard_result: dict, coll_result: dict, interest_income: float, ) -> DailyMetrics: current = soft = hard = closed = sold_count = 0 dpd_1_30 = dpd_31_60 = dpd_61_89 = 0 total_balance = 0.0 npl_balance = 0.0 past_due_balance = 0.0 for client in self.clients: loan = client.loan total_balance += loan.balance if loan.status == "current": current += 1 elif loan.status == "soft": soft += 1 past_due_balance += loan.balance if loan.dpd <= 30: dpd_1_30 += 1 elif loan.dpd <= 60: dpd_31_60 += 1 else: dpd_61_89 += 1 elif loan.status == "hard": hard += 1 npl_balance += loan.balance elif loan.status == "closed": closed += 1 elif loan.status == "sold": sold_count += 1 npl_pct = (npl_balance / total_balance * 100) if total_balance > 0 else 0.0 total_spent = coll_result["spent"] total_recovered = hard_result["recovered"] + coll_result["recovered"] cession_losses = hard_result["cession_losses"] cost_of_risk = ((cession_losses + total_spent) / total_balance) if total_balance > 0 else 0.0 return DailyMetrics( day=day, current_count=current, soft_count=soft, hard_count=hard, closed_count=closed, sold_count=sold_count, total_balance=total_balance, npl_balance=npl_balance, npl_pct=npl_pct, past_due_balance=past_due_balance, daily_budget_spent=total_spent, recovered_today=total_recovered, new_clients=new_clients, cured_today=coll_result["cured"], sold_today=hard_result["sold_count"], inflow_soft=inflow_soft, outflow_cured=coll_result["cured"], outflow_hard=outflow_hard, spent_sms=coll_result["spent_sms"], spent_call=coll_result["spent_call"], recovered_sms=coll_result["recovered_sms"], recovered_call=coll_result["recovered_call"], recovered_waiting=coll_result["recovered_waiting"], cost_of_risk=cost_of_risk, cession_losses=cession_losses, interest_income_today=interest_income, dpd_1_30_count=dpd_1_30, dpd_31_60_count=dpd_31_60, dpd_61_89_count=dpd_61_89, sms_count=coll_result["sms_count"], call_count=coll_result["call_count"], waiting_count=coll_result["waiting_count"], ) def _reset_weekly_counters(self, day: int) -> None: """Сброс недельных счётчиков по понедельникам (день 0, 7, 14...).""" if day % 7 == 0: for client in self.clients: client.calls_this_week = 0 client.sms_this_week = 0 def run(self, days: int | None = None) -> list[DailyMetrics]: """ Запускает симуляцию на заданное количество дней. """ if days is None: days = self.config.simulation.days # Тёплый старт: инициализация портфеля self._initialize_portfolio() self.daily_metrics = [] # День 0: срез портфеля до начала симуляции zero_hard = {"recovered": 0.0, "sold_count": 0, "cession_losses": 0.0} zero_coll = {"recovered": 0.0, "cured": 0, "spent": 0.0, "spent_sms": 0.0, "spent_call": 0.0, "recovered_sms": 0.0, "recovered_call": 0.0, "recovered_waiting": 0.0, "sms_count": 0, "call_count": 0, "waiting_count": 0} metrics_0 = self._collect_metrics(0, 0, 0, 0, zero_hard, zero_coll, 0.0) self.daily_metrics.append(metrics_0) # Основной цикл симуляции (день 1..days) for day in range(1, days + 1): self._reset_weekly_counters(day) # 1. Origination new_clients = self._origination(day) # 2. Amortization self._amortization() # 3. Interest accrual (soft + hard capped) self._accrue_interest() # 4. NIM income (current loans) interest_income = self._compute_interest_income() # 5. Hazard (current -> soft) inflow_soft = self._hazard(day) # 6. DPD Rolling (soft -> hard) outflow_hard, just_hardened = self._dpd_rolling(day) # 7. Hard Collection (cession) hard_result = self._hard_collection(just_hardened, day) # 8. Collection Strategy (Soft) coll_result = self._collection_strategy(day) # 9. Metrics metrics = self._collect_metrics( day, new_clients, inflow_soft, outflow_hard, hard_result, coll_result, interest_income ) self.daily_metrics.append(metrics) return self.daily_metrics def get_metrics_dict(self) -> list[dict]: """Возвращает метрики в виде списка словарей для JSON сериализации.""" return [ { "day": m.day, "current_count": m.current_count, "soft_count": m.soft_count, "hard_count": m.hard_count, "closed_count": m.closed_count, "sold_count": m.sold_count, "total_balance": m.total_balance, "npl_balance": m.npl_balance, "npl_pct": m.npl_pct, "past_due_balance": m.past_due_balance, "daily_budget_spent": m.daily_budget_spent, "recovered_today": m.recovered_today, "new_clients": m.new_clients, "cured_today": m.cured_today, "sold_today": m.sold_today, "inflow_soft": m.inflow_soft, "outflow_cured": m.outflow_cured, "outflow_hard": m.outflow_hard, "spent_sms": m.spent_sms, "spent_call": m.spent_call, "recovered_sms": m.recovered_sms, "recovered_call": m.recovered_call, "recovered_waiting": m.recovered_waiting, "cost_of_risk": m.cost_of_risk, "cession_losses": m.cession_losses, "interest_income_today": m.interest_income_today, "dpd_1_30_count": m.dpd_1_30_count, "dpd_31_60_count": m.dpd_31_60_count, "dpd_61_89_count": m.dpd_61_89_count, "sms_count": m.sms_count, "call_count": m.call_count, "waiting_count": m.waiting_count, } for m in self.daily_metrics ]