creditriskengine 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. creditriskengine/__init__.py +42 -0
  2. creditriskengine/core/__init__.py +46 -0
  3. creditriskengine/core/audit.py +191 -0
  4. creditriskengine/core/config.py +31 -0
  5. creditriskengine/core/exceptions.py +29 -0
  6. creditriskengine/core/exposure.py +117 -0
  7. creditriskengine/core/logging_config.py +88 -0
  8. creditriskengine/core/portfolio.py +48 -0
  9. creditriskengine/core/types.py +151 -0
  10. creditriskengine/ecl/__init__.py +17 -0
  11. creditriskengine/ecl/cecl/__init__.py +24 -0
  12. creditriskengine/ecl/cecl/cecl_calc.py +85 -0
  13. creditriskengine/ecl/cecl/methods.py +104 -0
  14. creditriskengine/ecl/cecl/qualitative.py +70 -0
  15. creditriskengine/ecl/ifrs9/__init__.py +30 -0
  16. creditriskengine/ecl/ifrs9/ecl_calc.py +142 -0
  17. creditriskengine/ecl/ifrs9/forward_looking.py +61 -0
  18. creditriskengine/ecl/ifrs9/lifetime_pd.py +110 -0
  19. creditriskengine/ecl/ifrs9/scenarios.py +70 -0
  20. creditriskengine/ecl/ifrs9/sicr.py +73 -0
  21. creditriskengine/ecl/ifrs9/staging.py +78 -0
  22. creditriskengine/ecl/ifrs9/ttc_to_pit.py +98 -0
  23. creditriskengine/ecl/ind_as109/__init__.py +14 -0
  24. creditriskengine/ecl/ind_as109/ind_as_ecl.py +97 -0
  25. creditriskengine/models/__init__.py +76 -0
  26. creditriskengine/models/concentration/__init__.py +17 -0
  27. creditriskengine/models/concentration/concentration.py +141 -0
  28. creditriskengine/models/concentration/granularity.py +12 -0
  29. creditriskengine/models/concentration/hhi.py +11 -0
  30. creditriskengine/models/concentration/sector.py +11 -0
  31. creditriskengine/models/ead/__init__.py +21 -0
  32. creditriskengine/models/ead/ccf.py +9 -0
  33. creditriskengine/models/ead/ead_estimation.py +9 -0
  34. creditriskengine/models/ead/ead_model.py +195 -0
  35. creditriskengine/models/ead/regulatory_ccf.py +9 -0
  36. creditriskengine/models/lgd/__init__.py +29 -0
  37. creditriskengine/models/lgd/cure_rate.py +261 -0
  38. creditriskengine/models/lgd/downturn_lgd.py +9 -0
  39. creditriskengine/models/lgd/lgd_model.py +210 -0
  40. creditriskengine/models/lgd/regulatory_lgd.py +9 -0
  41. creditriskengine/models/lgd/workout_lgd.py +9 -0
  42. creditriskengine/models/pd/__init__.py +81 -0
  43. creditriskengine/models/pd/binning.py +474 -0
  44. creditriskengine/models/pd/calibration.py +12 -0
  45. creditriskengine/models/pd/logistic.py +9 -0
  46. creditriskengine/models/pd/margin_of_conservatism.py +270 -0
  47. creditriskengine/models/pd/rating_scale.py +13 -0
  48. creditriskengine/models/pd/scorecard.py +346 -0
  49. creditriskengine/models/pd/structural.py +213 -0
  50. creditriskengine/models/pd/term_structure.py +162 -0
  51. creditriskengine/models/pd/transition_matrix.py +183 -0
  52. creditriskengine/models/pd/ttc_calibration.py +10 -0
  53. creditriskengine/models/pd/zscore.py +262 -0
  54. creditriskengine/portfolio/__init__.py +43 -0
  55. creditriskengine/portfolio/copula.py +194 -0
  56. creditriskengine/portfolio/economic_capital.py +55 -0
  57. creditriskengine/portfolio/stress_testing.py +1323 -0
  58. creditriskengine/portfolio/var.py +369 -0
  59. creditriskengine/portfolio/vasicek.py +198 -0
  60. creditriskengine/py.typed +0 -0
  61. creditriskengine/regulatory/__init__.py +11 -0
  62. creditriskengine/regulatory/australia/apra.yml +137 -0
  63. creditriskengine/regulatory/bcbs/bcbs_d424.yml +111 -0
  64. creditriskengine/regulatory/brazil/bcb.yml +143 -0
  65. creditriskengine/regulatory/canada/osfi.yml +139 -0
  66. creditriskengine/regulatory/china/nfra.yml +142 -0
  67. creditriskengine/regulatory/eu/crr3.yml +188 -0
  68. creditriskengine/regulatory/hongkong/hkma.yml +121 -0
  69. creditriskengine/regulatory/india/rbi.yml +123 -0
  70. creditriskengine/regulatory/japan/jfsa.yml +131 -0
  71. creditriskengine/regulatory/loader.py +72 -0
  72. creditriskengine/regulatory/malaysia/bnm.yml +147 -0
  73. creditriskengine/regulatory/saudi/sama.yml +114 -0
  74. creditriskengine/regulatory/schema.py +352 -0
  75. creditriskengine/regulatory/singapore/mas_637.yml +114 -0
  76. creditriskengine/regulatory/southafrica/sarb.yml +132 -0
  77. creditriskengine/regulatory/southkorea/fss.yml +134 -0
  78. creditriskengine/regulatory/uae/cbuae.yml +113 -0
  79. creditriskengine/regulatory/uk/pra_basel31.yml +143 -0
  80. creditriskengine/regulatory/us/us_endgame.yml +108 -0
  81. creditriskengine/reporting/__init__.py +27 -0
  82. creditriskengine/reporting/corep.py +453 -0
  83. creditriskengine/reporting/fr_y14.py +485 -0
  84. creditriskengine/reporting/model_doc.py +627 -0
  85. creditriskengine/reporting/pillar3.py +285 -0
  86. creditriskengine/reporting/reports.py +147 -0
  87. creditriskengine/reporting/templates/.gitkeep +0 -0
  88. creditriskengine/reporting/templates/model_doc.html.j2 +298 -0
  89. creditriskengine/rwa/__init__.py +15 -0
  90. creditriskengine/rwa/base.py +95 -0
  91. creditriskengine/rwa/capital_buffers.py +519 -0
  92. creditriskengine/rwa/crm.py +411 -0
  93. creditriskengine/rwa/cva.py +403 -0
  94. creditriskengine/rwa/irb/__init__.py +21 -0
  95. creditriskengine/rwa/irb/advanced.py +358 -0
  96. creditriskengine/rwa/irb/asset_classes.py +207 -0
  97. creditriskengine/rwa/irb/correlation.py +98 -0
  98. creditriskengine/rwa/irb/formulas.py +425 -0
  99. creditriskengine/rwa/irb/foundation.py +336 -0
  100. creditriskengine/rwa/irb/maturity.py +88 -0
  101. creditriskengine/rwa/irb/slotting.py +208 -0
  102. creditriskengine/rwa/leverage_ratio.py +256 -0
  103. creditriskengine/rwa/market_risk.py +294 -0
  104. creditriskengine/rwa/operational_risk.py +229 -0
  105. creditriskengine/rwa/output_floor.py +216 -0
  106. creditriskengine/rwa/securitisation.py +576 -0
  107. creditriskengine/rwa/standardized/__init__.py +25 -0
  108. creditriskengine/rwa/standardized/cre.py +194 -0
  109. creditriskengine/rwa/standardized/credit_risk_sa.py +661 -0
  110. creditriskengine/rwa/standardized/risk_weights.py +267 -0
  111. creditriskengine/validation/__init__.py +32 -0
  112. creditriskengine/validation/backtesting.py +338 -0
  113. creditriskengine/validation/benchmarking.py +316 -0
  114. creditriskengine/validation/calibration.py +233 -0
  115. creditriskengine/validation/discrimination.py +244 -0
  116. creditriskengine/validation/reporting.py +427 -0
  117. creditriskengine/validation/stability.py +123 -0
  118. creditriskengine-0.2.0.dist-info/METADATA +274 -0
  119. creditriskengine-0.2.0.dist-info/RECORD +121 -0
  120. creditriskengine-0.2.0.dist-info/WHEEL +4 -0
  121. creditriskengine-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,42 @@
1
+ """
2
+ CreditRiskEngine — Production-grade credit risk analytics.
3
+
4
+ Modules:
5
+ core: Data models and type definitions
6
+ rwa: Risk-weighted asset calculation (SA and IRB)
7
+ ecl: Expected credit loss engines (IFRS 9, CECL)
8
+ models: PD, LGD, EAD modeling pipelines
9
+ validation: Model validation toolkit
10
+ portfolio: Portfolio credit risk models
11
+ reporting: Regulatory reporting
12
+ """
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ # Public API — convenient top-level imports
17
+ from creditriskengine.core.exposure import Collateral, Exposure
18
+ from creditriskengine.core.portfolio import Portfolio
19
+ from creditriskengine.core.types import (
20
+ CollateralType,
21
+ CreditQualityStep,
22
+ CreditRiskApproach,
23
+ IFRS9Stage,
24
+ IRBAssetClass,
25
+ IRBRetailSubClass,
26
+ Jurisdiction,
27
+ SAExposureClass,
28
+ )
29
+
30
+ __all__ = [
31
+ "Collateral",
32
+ "CollateralType",
33
+ "CreditQualityStep",
34
+ "CreditRiskApproach",
35
+ "Exposure",
36
+ "IFRS9Stage",
37
+ "IRBAssetClass",
38
+ "IRBRetailSubClass",
39
+ "Jurisdiction",
40
+ "Portfolio",
41
+ "SAExposureClass",
42
+ ]
@@ -0,0 +1,46 @@
1
+ """Core types, data models, and configuration for creditriskengine.
2
+
3
+ Provides regulatory type enumerations, exposure/portfolio data models,
4
+ configuration loading, and the exception hierarchy.
5
+ """
6
+
7
+ from creditriskengine.core.exceptions import (
8
+ CalculationError,
9
+ ConfigurationError,
10
+ CreditRiskEngineError,
11
+ DataError,
12
+ RegulatoryError,
13
+ ValidationError,
14
+ )
15
+ from creditriskengine.core.exposure import Collateral, Exposure
16
+ from creditriskengine.core.portfolio import Portfolio
17
+ from creditriskengine.core.types import (
18
+ CollateralType,
19
+ CreditQualityStep,
20
+ CreditRiskApproach,
21
+ IFRS9Stage,
22
+ IRBAssetClass,
23
+ IRBRetailSubClass,
24
+ Jurisdiction,
25
+ SAExposureClass,
26
+ )
27
+
28
+ __all__ = [
29
+ "CreditRiskEngineError",
30
+ "ConfigurationError",
31
+ "ValidationError",
32
+ "RegulatoryError",
33
+ "CalculationError",
34
+ "DataError",
35
+ "Jurisdiction",
36
+ "CreditRiskApproach",
37
+ "IRBAssetClass",
38
+ "IRBRetailSubClass",
39
+ "SAExposureClass",
40
+ "CreditQualityStep",
41
+ "IFRS9Stage",
42
+ "CollateralType",
43
+ "Exposure",
44
+ "Collateral",
45
+ "Portfolio",
46
+ ]
@@ -0,0 +1,191 @@
1
+ """Audit trail and lineage tracking for capital calculations.
2
+
3
+ Provides immutable calculation records and an audit trail for governance
4
+ and regulatory compliance. Every RWA or ECL calculation can be recorded
5
+ with its full input/output context so that supervisory reviews, model
6
+ validation teams, and internal audit can trace any number back to its
7
+ regulatory basis.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections import Counter
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ import pandas as pd
19
+
20
+ import creditriskengine
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CalculationRecord:
25
+ """Immutable record of a single capital calculation."""
26
+
27
+ exposure_id: str
28
+ timestamp: datetime
29
+ approach: str
30
+ jurisdiction: str
31
+ inputs: dict[str, Any]
32
+ outputs: dict[str, Any]
33
+ regulatory_reference: str
34
+ engine_version: str
35
+ warnings: list[str] = field(default_factory=list)
36
+
37
+
38
+ class AuditTrail:
39
+ """Stores and queries :class:`CalculationRecord` entries.
40
+
41
+ Parameters
42
+ ----------
43
+ records : list[CalculationRecord] | None
44
+ Optional pre-existing records to initialise the trail with.
45
+ """
46
+
47
+ def __init__(self, records: list[CalculationRecord] | None = None) -> None:
48
+ self._records: list[CalculationRecord] = list(records) if records else []
49
+
50
+ # -- mutators -------------------------------------------------------------
51
+
52
+ def record(
53
+ self,
54
+ exposure_id: str,
55
+ approach: str,
56
+ jurisdiction: str,
57
+ inputs: dict[str, Any],
58
+ outputs: dict[str, Any],
59
+ regulatory_reference: str,
60
+ warnings: list[str] | None = None,
61
+ ) -> CalculationRecord:
62
+ """Create and store a new :class:`CalculationRecord`.
63
+
64
+ ``engine_version`` and ``timestamp`` are captured automatically.
65
+
66
+ Returns
67
+ -------
68
+ CalculationRecord
69
+ The newly created record.
70
+ """
71
+ rec = CalculationRecord(
72
+ exposure_id=exposure_id,
73
+ timestamp=datetime.now(UTC),
74
+ approach=approach,
75
+ jurisdiction=jurisdiction,
76
+ inputs=dict(inputs),
77
+ outputs=dict(outputs),
78
+ regulatory_reference=regulatory_reference,
79
+ engine_version=creditriskengine.__version__,
80
+ warnings=list(warnings) if warnings else [],
81
+ )
82
+ self._records.append(rec)
83
+ return rec
84
+
85
+ # -- queries --------------------------------------------------------------
86
+
87
+ @property
88
+ def records(self) -> list[CalculationRecord]:
89
+ """Return a shallow copy of the internal record list."""
90
+ return list(self._records)
91
+
92
+ def get_records(
93
+ self,
94
+ exposure_id: str | None = None,
95
+ approach: str | None = None,
96
+ ) -> list[CalculationRecord]:
97
+ """Return records matching the given filters.
98
+
99
+ Parameters
100
+ ----------
101
+ exposure_id : str | None
102
+ If provided, only records for this exposure are returned.
103
+ approach : str | None
104
+ If provided, only records using this approach are returned.
105
+ """
106
+ result = self._records
107
+ if exposure_id is not None:
108
+ result = [r for r in result if r.exposure_id == exposure_id]
109
+ if approach is not None:
110
+ result = [r for r in result if r.approach == approach]
111
+ return result
112
+
113
+ # -- export / reporting ---------------------------------------------------
114
+
115
+ def to_dataframe(self) -> pd.DataFrame:
116
+ """Convert the audit trail to a :class:`pandas.DataFrame`."""
117
+ if not self._records:
118
+ return pd.DataFrame(
119
+ columns=[
120
+ "exposure_id",
121
+ "timestamp",
122
+ "approach",
123
+ "jurisdiction",
124
+ "inputs",
125
+ "outputs",
126
+ "regulatory_reference",
127
+ "engine_version",
128
+ "warnings",
129
+ ],
130
+ )
131
+ rows = []
132
+ for r in self._records:
133
+ rows.append(
134
+ {
135
+ "exposure_id": r.exposure_id,
136
+ "timestamp": r.timestamp,
137
+ "approach": r.approach,
138
+ "jurisdiction": r.jurisdiction,
139
+ "inputs": r.inputs,
140
+ "outputs": r.outputs,
141
+ "regulatory_reference": r.regulatory_reference,
142
+ "engine_version": r.engine_version,
143
+ "warnings": r.warnings,
144
+ }
145
+ )
146
+ return pd.DataFrame(rows)
147
+
148
+ def export_json(self, filepath: str) -> None:
149
+ """Write the audit trail to a JSON file.
150
+
151
+ Parameters
152
+ ----------
153
+ filepath : str
154
+ Destination path (will be overwritten if it exists).
155
+ """
156
+
157
+ data = []
158
+ for r in self._records:
159
+ data.append(
160
+ {
161
+ "exposure_id": r.exposure_id,
162
+ "timestamp": r.timestamp.isoformat(),
163
+ "approach": r.approach,
164
+ "jurisdiction": r.jurisdiction,
165
+ "inputs": r.inputs,
166
+ "outputs": r.outputs,
167
+ "regulatory_reference": r.regulatory_reference,
168
+ "engine_version": r.engine_version,
169
+ "warnings": r.warnings,
170
+ }
171
+ )
172
+ with open(filepath, "w") as fh:
173
+ json.dump(data, fh, indent=2)
174
+
175
+ def summary(self) -> dict[str, dict[str, int]]:
176
+ """Aggregate statistics for the audit trail.
177
+
178
+ Returns
179
+ -------
180
+ dict
181
+ ``{"by_approach": {<approach>: <count>}, "by_jurisdiction": {<jurisdiction>: <count>}}``
182
+ """
183
+ by_approach: Counter[str] = Counter()
184
+ by_jurisdiction: Counter[str] = Counter()
185
+ for r in self._records:
186
+ by_approach[r.approach] += 1
187
+ by_jurisdiction[r.jurisdiction] += 1
188
+ return {
189
+ "by_approach": dict(by_approach),
190
+ "by_jurisdiction": dict(by_jurisdiction),
191
+ }
@@ -0,0 +1,31 @@
1
+ """Configuration loader for creditriskengine.
2
+
3
+ This module delegates to ``creditriskengine.regulatory.loader`` which is
4
+ the canonical implementation. It is kept for backward-compatibility so
5
+ that ``from creditriskengine.core.config import load_jurisdiction_config``
6
+ continues to work.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from creditriskengine.core.types import Jurisdiction
13
+ from creditriskengine.regulatory.loader import load_config
14
+
15
+
16
+ def load_jurisdiction_config(
17
+ jurisdiction: Jurisdiction,
18
+ config_dir: Path | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Load regulatory configuration YAML for a jurisdiction.
21
+
22
+ Delegates to :func:`creditriskengine.regulatory.loader.load_config`.
23
+
24
+ Args:
25
+ jurisdiction: Target jurisdiction.
26
+ config_dir: Override path to regulatory config directory.
27
+
28
+ Returns:
29
+ Parsed YAML configuration dict.
30
+ """
31
+ return load_config(jurisdiction, config_dir=config_dir)
@@ -0,0 +1,29 @@
1
+ """Custom exception hierarchy for creditriskengine."""
2
+
3
+
4
+ class CreditRiskEngineError(Exception):
5
+ """Base exception for all creditriskengine errors."""
6
+
7
+
8
+ class ConfigurationError(CreditRiskEngineError):
9
+ """Raised when configuration is invalid or missing."""
10
+
11
+
12
+ class JurisdictionError(CreditRiskEngineError):
13
+ """Raised when jurisdiction-specific config is not found."""
14
+
15
+
16
+ class ValidationError(CreditRiskEngineError):
17
+ """Raised when input validation fails."""
18
+
19
+
20
+ class RegulatoryError(CreditRiskEngineError):
21
+ """Raised when regulatory constraints are violated."""
22
+
23
+
24
+ class CalculationError(CreditRiskEngineError):
25
+ """Raised when a calculation fails or produces invalid results."""
26
+
27
+
28
+ class DataError(CreditRiskEngineError):
29
+ """Raised when input data is malformed or insufficient."""
@@ -0,0 +1,117 @@
1
+ """
2
+ Exposure and facility data models.
3
+
4
+ Data models represent the minimum set of attributes required for
5
+ regulatory capital calculation under both SA and IRB approaches.
6
+ """
7
+
8
+ from datetime import date
9
+
10
+ from pydantic import BaseModel, Field, field_validator
11
+
12
+ from creditriskengine.core.types import (
13
+ CollateralType,
14
+ CreditQualityStep,
15
+ CreditRiskApproach,
16
+ IFRS9Stage,
17
+ IRBAssetClass,
18
+ IRBCorporateSubClass,
19
+ IRBRetailSubClass,
20
+ Jurisdiction,
21
+ SAExposureClass,
22
+ )
23
+
24
+
25
+ class Collateral(BaseModel):
26
+ """Collateral pledged against an exposure."""
27
+ collateral_type: CollateralType
28
+ value: float = Field(ge=0, description="Current market/appraised value")
29
+ currency: str = Field(default="USD", max_length=3)
30
+ haircut: float | None = Field(
31
+ default=None, ge=0, le=1, description="Supervisory or own-estimate haircut"
32
+ )
33
+ ltv: float | None = Field(default=None, ge=0, description="Loan-to-value ratio at origination")
34
+
35
+
36
+ class Exposure(BaseModel):
37
+ """
38
+ Single credit exposure / facility for capital calculation.
39
+
40
+ Contains all fields needed for SA, F-IRB, and A-IRB RWA
41
+ computation, IFRS 9/CECL ECL calculation, and model validation.
42
+ """
43
+ # ---- Identifiers ----
44
+ exposure_id: str
45
+ counterparty_id: str
46
+
47
+ # ---- Amounts ----
48
+ ead: float = Field(ge=0, description="Exposure at Default amount")
49
+ drawn_amount: float = Field(ge=0, description="Current drawn/outstanding balance")
50
+ undrawn_commitment: float = Field(default=0, ge=0, description="Undrawn committed amount")
51
+
52
+ # ---- Classification ----
53
+ jurisdiction: Jurisdiction
54
+ approach: CreditRiskApproach
55
+ sa_exposure_class: SAExposureClass | None = None
56
+ irb_asset_class: IRBAssetClass | None = None
57
+ irb_corporate_subclass: IRBCorporateSubClass | None = None
58
+ irb_retail_subclass: IRBRetailSubClass | None = None
59
+
60
+ # ---- SA Parameters ----
61
+ credit_quality_step: CreditQualityStep | None = None
62
+ is_investment_grade: bool | None = None
63
+
64
+ # ---- IRB Parameters ----
65
+ pd: float | None = Field(default=None, ge=0, le=1, description="Probability of Default")
66
+ lgd: float | None = Field(default=None, ge=0, le=1, description="Loss Given Default")
67
+ ead_model: float | None = Field(default=None, ge=0, description="EAD from internal model")
68
+ maturity_years: float | None = Field(
69
+ default=None, ge=0, le=30, description="Effective residual maturity M in years"
70
+ )
71
+ turnover_eur_millions: float | None = Field(
72
+ default=None, ge=0, description="Annual turnover for SME firm-size adjustment"
73
+ )
74
+
75
+ # ---- Collateral and CRM ----
76
+ collaterals: list[Collateral] = Field(default_factory=list)
77
+ is_guaranteed: bool = False
78
+ guarantor_risk_weight: float | None = None
79
+
80
+ # ---- Real Estate ----
81
+ property_value: float | None = Field(default=None, ge=0)
82
+ ltv_ratio: float | None = Field(default=None, ge=0)
83
+ is_income_producing: bool = False
84
+ is_materially_dependent_on_cashflows: bool = False
85
+
86
+ # ---- IFRS 9 / ECL ----
87
+ ifrs9_stage: IFRS9Stage | None = None
88
+ days_past_due: int = Field(default=0, ge=0)
89
+ origination_date: date | None = None
90
+ maturity_date: date | None = None
91
+ origination_pd: float | None = Field(
92
+ default=None, ge=0, le=1, description="12-month PD at origination"
93
+ )
94
+ current_pd: float | None = Field(default=None, ge=0, le=1, description="Current 12-month PD")
95
+ is_credit_impaired: bool = False
96
+ effective_interest_rate: float | None = Field(
97
+ default=None, ge=0, description="EIR for ECL discounting"
98
+ )
99
+
100
+ # ---- Flags ----
101
+ is_defaulted: bool = False
102
+ is_in_default_workout: bool = False
103
+ currency: str = Field(default="USD", max_length=3)
104
+
105
+ @field_validator("pd")
106
+ @classmethod
107
+ def pd_floor_check(cls, v: float | None) -> float | None:
108
+ """Validate PD is between 0 and 1 (inclusive).
109
+
110
+ Note: The Basel III PD floor of 0.03% (CRE32.13) is enforced at the
111
+ IRB calculation level (``rwa.irb.formulas``), not at the data model
112
+ level, because SA and ECL workflows accept PDs below the IRB floor.
113
+ """
114
+ if v is not None and not 0 <= v <= 1:
115
+ msg = f"PD must be between 0 and 1, got {v}"
116
+ raise ValueError(msg)
117
+ return v
@@ -0,0 +1,88 @@
1
+ """
2
+ Structured logging configuration for CreditRiskEngine.
3
+
4
+ Provides sensible defaults for production and development use.
5
+ Supports JSON structured logging for log aggregation systems
6
+ (ELK, Splunk, CloudWatch, etc.).
7
+
8
+ Usage:
9
+ from creditriskengine.core.logging_config import configure_logging
10
+ configure_logging() # Development defaults
11
+ configure_logging(level="WARNING", json_format=True) # Production
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import sys
17
+ from datetime import UTC, datetime
18
+ from typing import Any
19
+
20
+
21
+ class JSONFormatter(logging.Formatter):
22
+ """Structured JSON log formatter for production environments.
23
+
24
+ Produces one JSON object per log line, suitable for ingestion by
25
+ log aggregation tools (ELK stack, Splunk, AWS CloudWatch, etc.).
26
+ """
27
+
28
+ def format(self, record: logging.LogRecord) -> str:
29
+ log_entry: dict[str, Any] = {
30
+ "timestamp": datetime.fromtimestamp(
31
+ record.created, tz=UTC
32
+ ).isoformat(),
33
+ "level": record.levelname,
34
+ "logger": record.name,
35
+ "message": record.getMessage(),
36
+ "module": record.module,
37
+ "function": record.funcName,
38
+ "line": record.lineno,
39
+ }
40
+ if record.exc_info and record.exc_info[1]:
41
+ log_entry["exception"] = self.formatException(record.exc_info)
42
+ # Include any extra fields attached to the record
43
+ for key in ("exposure_id", "jurisdiction", "approach", "calculation_id"):
44
+ val = getattr(record, key, None)
45
+ if val is not None:
46
+ log_entry[key] = val
47
+ return json.dumps(log_entry, default=str)
48
+
49
+
50
+ def configure_logging(
51
+ level: str = "INFO",
52
+ json_format: bool = False,
53
+ log_file: str | None = None,
54
+ ) -> None:
55
+ """Configure logging for CreditRiskEngine.
56
+
57
+ Args:
58
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
59
+ json_format: If True, use JSON structured format (recommended for production).
60
+ log_file: Optional file path for log output. If None, logs to stderr.
61
+
62
+ Example:
63
+ >>> from creditriskengine.core.logging_config import configure_logging
64
+ >>> configure_logging(level="DEBUG") # Development
65
+ >>> configure_logging(level="WARNING", json_format=True) # Production
66
+ >>> configure_logging(level="INFO", log_file="/var/log/cre.log")
67
+ """
68
+ root_logger = logging.getLogger("creditriskengine")
69
+ root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
70
+
71
+ # Remove existing handlers to avoid duplicates
72
+ root_logger.handlers.clear()
73
+
74
+ if json_format:
75
+ formatter = JSONFormatter()
76
+ else:
77
+ formatter = logging.Formatter(
78
+ fmt="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
79
+ datefmt="%Y-%m-%d %H:%M:%S",
80
+ )
81
+
82
+ if log_file:
83
+ handler: logging.Handler = logging.FileHandler(log_file, encoding="utf-8")
84
+ else:
85
+ handler = logging.StreamHandler(sys.stderr)
86
+
87
+ handler.setFormatter(formatter)
88
+ root_logger.addHandler(handler)
@@ -0,0 +1,48 @@
1
+ """Portfolio container for credit exposures."""
2
+
3
+ from collections.abc import Iterator
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from creditriskengine.core.exposure import Exposure
8
+ from creditriskengine.core.types import CreditRiskApproach, Jurisdiction
9
+
10
+
11
+ class Portfolio(BaseModel):
12
+ """Container for a portfolio of credit exposures.
13
+
14
+ Provides iteration, filtering, and aggregation over exposures.
15
+ """
16
+
17
+ model_config = ConfigDict(arbitrary_types_allowed=True)
18
+
19
+ name: str = "Unnamed Portfolio"
20
+ jurisdiction: Jurisdiction | None = None
21
+ approach: CreditRiskApproach | None = None
22
+ exposures: list[Exposure] = Field(default_factory=list)
23
+
24
+ def add_exposure(self, exposure: Exposure) -> None:
25
+ """Add an exposure to the portfolio."""
26
+ self.exposures.append(exposure)
27
+
28
+ def total_ead(self) -> float:
29
+ """Total Exposure at Default across all exposures."""
30
+ return sum(e.ead for e in self.exposures)
31
+
32
+ def __len__(self) -> int:
33
+ return len(self.exposures)
34
+
35
+ def __iter__(self) -> Iterator[Exposure]: # type: ignore[override]
36
+ return iter(self.exposures)
37
+
38
+ def filter_by_approach(self, approach: CreditRiskApproach) -> list[Exposure]:
39
+ """Return exposures using a specific calculation approach."""
40
+ return [e for e in self.exposures if e.approach == approach]
41
+
42
+ def filter_defaulted(self) -> list[Exposure]:
43
+ """Return defaulted exposures."""
44
+ return [e for e in self.exposures if e.is_defaulted]
45
+
46
+ def filter_non_defaulted(self) -> list[Exposure]:
47
+ """Return non-defaulted exposures."""
48
+ return [e for e in self.exposures if not e.is_defaulted]