mihir2007/Cyber-Risk
0
1"""2quant_engine.py3---------------4AIML Probability & Financial Impact Engine.5 6Pipeline7--------81. Likelihood calibration: converts (CVSS, EPSS, attack-graph exposure,9 active control efficacy) into a calibrated annual event frequency (λ)10 per asset via a logistic-sigmoid link function.112. Financial impact modeling: builds a per-event composite loss distribution12 from outage cost, data-breach cost, Indian regulatory penalties13 (DPDPA 2023, RBI CSF, SEBI CSCRF), and forensics/incident-response cost.143. Monte Carlo simulation: a vectorized compound Poisson process (Poisson15 frequency x per-event severity) run for 10,000 iterations per asset,16 from which Expected Annual Loss (EAL), Value at Risk (VaR 95 / 99), and17 a loss exceedance curve are derived, then persisted to `SimulationRun`.18 19All monetary values are in Indian Rupees (₹).20"""21 22from __future__ import annotations23 24import json25import math26from dataclasses import dataclass, field27 28import numpy as np29from scipy import stats30from sqlalchemy.orm import Session31 32from graph_engine import (33 AssetGraphExposure,34 AssetLineageProfile,35 AttackGraphEngine,36 build_exposure_map,37)38from models import Asset, SecurityControl, SimulationRun39 40# --------------------------------------------------------------------------- #41# Global calibration constants42# --------------------------------------------------------------------------- #43N_ITERATIONS = 10_00044RNG_SEED = 202445 46# Logistic-sigmoid likelihood calibration: p = sigmoid(k * (score - x0)).47# `score` combines CVSS (0-10), 10*EPSS (0-10), and 10*GraphWeight (0-10)48# onto a comparable 0-30 raw scale, plus a small per-extra-vulnerability49# bonus (see `calibrate_asset_annual_frequency`). x0/k are centered so that50# only a genuinely severe, highly-exploitable, internet-exposed, multi-CVE51# posture pushes the sigmoid near saturation -- avoiding every Critical52# asset being (unrealistically) treated as certain to be breached.53SIGMOID_K = 0.3054SIGMOID_X0 = 20.055 56# Ceiling on realistic annual breach *event* frequency by asset tier, applied57# as a multiplier on the sigmoid-calibrated exploit probability to produce58# the final Poisson rate (lambda). This bounds even a maximally exploitable,59# maximally exposed Critical asset to a plausible worst-case number of60# successful compromises per year, rather than letting compounded61# probabilities imply near-continuous breach activity.62TIER_MAX_ANNUAL_FREQUENCY = {"Critical": 4.0, "Medium": 1.5, "Low": 0.5}63 64# Residual/zero-day risk applied even to assets with no *currently known,65# unpatched* CVEs -- no real system has zero risk.66BASELINE_EXPLOIT_PROBABILITY = 0.0267 68# Small additive bonus to the combined raw score per extra unpatched69# vulnerability beyond the first, modeling increased attack surface without70# re-introducing runaway multiplicative compounding across many CVEs.71PER_EXTRA_VULN_SCORE_BONUS = 0.572 73# Indian regulatory constants (₹)74DPDPA_MAX_PENALTY_INR = 2_500_000_000.0 # ₹250 Crore75RBI_SEBI_FINE_MIN_INR = 1_000_000.0 # ₹10 Lakh76RBI_SEBI_FINE_MAX_INR = 10_000_000.0 # ₹1 Crore77FORENSICS_COST_MIN_INR = 1_500_000.0 # ₹15 Lakh78FORENSICS_COST_MAX_INR = 3_500_000.0 # ₹35 Lakh79 80PII_RECORD_COST_INR = 1_200.081FINANCIAL_RECORD_COST_INR = 2_500.082 83# Reportable-incident downtime threshold (hours) above which RBI/SEBI84# incident-reporting fines become applicable for regulated assets.85REPORTABLE_DOWNTIME_HOURS = 2.086 87LOSS_EXCEEDANCE_RETURN_PERIODS = (10, 50, 100)88 89 90# --------------------------------------------------------------------------- #91# Helper: Beta-PERT sampling92# --------------------------------------------------------------------------- #93def _pert_rvs(94 min_val: float, mode: float, max_val: float, size: tuple[int, ...], rng: np.random.Generator95) -> np.ndarray:96 """97 Draws samples from a Beta-PERT distribution parameterized by98 (minimum, most-likely, maximum), a standard choice for expert-elicited99 risk quantities (e.g. downtime hours, breach-record fractions) where a100 full historical loss dataset is unavailable.101 102 The PERT distribution is a reparameterized Beta distribution with103 shape parameters derived from the three-point estimate:104 alpha = 1 + 4 * (mode - min) / (max - min)105 beta = 1 + 4 * (max - mode) / (max - min)106 """107 if max_val <= min_val:108 return np.full(size, min_val, dtype=float)109 alpha = 1.0 + 4.0 * (mode - min_val) / (max_val - min_val)110 beta = 1.0 + 4.0 * (max_val - mode) / (max_val - min_val)111 unit_samples = stats.beta.rvs(alpha, beta, size=size, random_state=rng)112 return min_val + unit_samples * (max_val - min_val)113 114 115# --------------------------------------------------------------------------- #116# Likelihood calibration117# --------------------------------------------------------------------------- #118def _sigmoid(x: np.ndarray | float) -> np.ndarray | float:119 return 1.0 / (1.0 + np.exp(-x))120 121 122def _vulnerability_raw_score(cvss_score: float, epss_score: float, graph_weight: float) -> float:123 """124 Combines CVSS (0-10), 10*EPSS (0-10), and 10*GraphWeight (0-10) onto a125 comparable 0-30 raw severity/exposure scale, prior to the sigmoid link.126 """127 return cvss_score + 10.0 * epss_score + 10.0 * graph_weight128 129 130def calibrate_vulnerability_exploit_probability(131 cvss_score: float,132 epss_score: float,133 graph_weight: float,134 active_control_efficacies: list[float],135) -> float:136 """137 Computes the calibrated probability that a single, isolated vulnerability138 would be exploited in a given attack attempt, using the logistic-sigmoid139 link function:140 141 p = sigmoid(k * (CVSS + 10*EPSS + 10*GraphWeight - x0))142 * PRODUCT_c (1 - efficacy_c) for c in active controls143 144 This is exposed primarily for per-CVE reporting/auditing; asset-level145 annual frequency is calibrated by `calibrate_asset_annual_frequency`,146 which combines an asset's *worst* vulnerability with a bounded bonus for147 additional exposure rather than compounding every CVE multiplicatively148 (which would unrealistically saturate risk for any multi-CVE asset).149 """150 raw_score = _vulnerability_raw_score(cvss_score, epss_score, graph_weight)151 base_probability = float(_sigmoid(SIGMOID_K * (raw_score - SIGMOID_X0)))152 153 control_survival_factor = 1.0154 for efficacy in active_control_efficacies:155 control_survival_factor *= (1.0 - efficacy)156 157 return base_probability * control_survival_factor158 159 160def calibrate_asset_annual_frequency(161 asset: Asset,162 graph_exposure: AssetGraphExposure,163 active_controls: list[SecurityControl],164) -> float:165 """166 Calibrates the annual breach-event frequency (lambda) for an asset,167 suitable as a Poisson rate parameter.168 169 Rather than compounding every unpatched vulnerability's probability via170 a probabilistic OR (which saturates to near-certainty for any asset with171 more than a handful of CVEs), the combined exposure score is built from172 the single *worst* unpatched vulnerability plus a small additive bonus173 per additional unpatched vulnerability (representing incremental attack174 surface without runaway compounding). That score is passed through the175 sigmoid link and then scaled by a tier-specific ceiling on realistic176 annual breach frequency (`TIER_MAX_ANNUAL_FREQUENCY`), so even a177 maximally exploitable, maximally exposed asset is bounded to a plausible178 worst-case number of successful compromises per year.179 180 Active security controls reduce the exploit probability multiplicatively181 via their `likelihood_reduction` efficacy.182 """183 controls_for_asset = [184 c185 for c in active_controls186 if c.target_tier == "All" or c.target_tier == asset.tier187 ]188 control_survival_factor = 1.0189 for control in controls_for_asset:190 control_survival_factor *= (1.0 - control.likelihood_reduction)191 192 tier_max_frequency = TIER_MAX_ANNUAL_FREQUENCY.get(asset.tier, 1.5)193 unpatched_vulns = [v for v in asset.vulnerabilities if not v.is_patched]194 195 if not unpatched_vulns:196 exploit_probability = BASELINE_EXPLOIT_PROBABILITY197 else:198 raw_scores = [199 _vulnerability_raw_score(200 v.cvss_score, v.epss_score, graph_exposure.path_exposure_coefficient201 )202 for v in unpatched_vulns203 ]204 combined_score = max(raw_scores) + PER_EXTRA_VULN_SCORE_BONUS * (len(unpatched_vulns) - 1)205 exploit_probability = float(_sigmoid(SIGMOID_K * (combined_score - SIGMOID_X0)))206 207 return tier_max_frequency * exploit_probability * control_survival_factor208 209 210# --------------------------------------------------------------------------- #211# Financial impact modeling212# --------------------------------------------------------------------------- #213def _downtime_hours_params(tier: str) -> tuple[float, float, float]:214 """Beta-PERT (min, mode, max) downtime hours per incident, by asset tier."""215 return {216 "Critical": (1.0, 4.0, 24.0),217 "Medium": (0.5, 2.0, 12.0),218 "Low": (0.25, 1.0, 6.0),219 }.get(tier, (0.5, 2.0, 12.0))220 221 222def _breach_fraction_params(tier: str) -> tuple[float, float, float]:223 """224 Beta-PERT (min, mode, max) fraction of an asset's PII/financial records225 exposed in a *data-breach* incident (not every incident is a breach;226 see `_sample_composite_event_losses` for the breach-occurrence gate).227 """228 return {229 "Critical": (0.02, 0.10, 0.60),230 "Medium": (0.01, 0.05, 0.35),231 "Low": (0.005, 0.02, 0.15),232 }.get(tier, (0.01, 0.05, 0.35))233 234 235def _sample_composite_event_losses(236 asset: Asset,237 n_events: int,238 rng: np.random.Generator,239 asset_lineage: Optional[AssetLineageProfile] = None,240) -> np.ndarray:241 """242 Samples the per-event composite loss (₹) for `n_events` independent243 security incidents affecting `asset`, combining five cost components:244 245 1. Outage cost = downtime_hours (Beta-PERT) x revenue_per_minute x 60246 2. Data breach cost = breach_fraction (Beta-PERT) x records x per-record cost,247 gated by a per-incident breach-occurrence Bernoulli draw248 (incorporates inherited records if asset is an unauthorized sink)249 3. Regulatory penalty:250 - DPDPA 2023 penalty, scaled by breach severity and record volume251 - RBI CSF / SEBI CSCRF fine, gated by regulated status and a252 reportable-downtime threshold253 - Cross-border statutory penalty254 - Downstream unauthorized sub-processor violation fine under DPDPA Section 8(4) & RBI Guidelines255 4. Forensics & incident response cost (Uniform ₹15L - ₹35L)256 257 Returns an array of shape (n_events,) of total composite losses in ₹.258 """259 if n_events == 0:260 return np.array([], dtype=float)261 262 size = (n_events,)263 264 # --- 1. Outage cost --------------------------------------------------- #265 dt_min, dt_mode, dt_max = _downtime_hours_params(asset.tier)266 downtime_hours = _pert_rvs(dt_min, dt_mode, dt_max, size, rng)267 outage_cost = downtime_hours * asset.revenue_per_minute * 60.0268 269 # --- 2. Data breach cost ------------------------------------------------#270 breach_occurs = rng.random(size) < {"Critical": 0.55, "Medium": 0.35, "Low": 0.20}.get(271 asset.tier, 0.35272 )273 bf_min, bf_mode, bf_max = _breach_fraction_params(asset.tier)274 breach_fraction = _pert_rvs(bf_min, bf_mode, bf_max, size, rng)275 276 records_pii = float(asset.pii_records_count)277 records_fin = float(asset.financial_records_count)278 279 # Downstream unauthorized leak liability calculation280 subprocessor_violation_fine = np.zeros(size, dtype=float)281 if asset_lineage is not None and getattr(asset_lineage, "is_unauthorized_sink", False):282 # Asset C exposes Origin A's records283 records_pii = float(asset.pii_records_count + getattr(asset_lineage, "inherited_pii_count", 0))284 records_fin = float(asset.financial_records_count + getattr(asset_lineage, "inherited_fin_count", 0))285 286 # Statutory third-party failure fine under DPDPA Section 8(4) & RBI Guidelines:287 # Dual penalty: Primary exfiltration penalty + failure of data principal consent oversight288 subprocessor_violation_fine = np.where(289 breach_occurs,290 rng.uniform(15_000_000.0, 40_000_000.0, size=size), # ₹1.5 Cr to ₹4 Cr291 0.0,292 )293 294 records_exposed_pii = breach_fraction * records_pii295 records_exposed_fin = breach_fraction * records_fin296 data_breach_cost = np.where(297 breach_occurs,298 records_exposed_pii * PII_RECORD_COST_INR299 + records_exposed_fin * FINANCIAL_RECORD_COST_INR,300 0.0,301 )302 303 # --- 3a. DPDPA 2023 penalty -------------------------------------------- #304 total_records_breached = records_exposed_pii + records_exposed_fin305 severity_factor = np.clip(total_records_breached / 5_000_000.0, 0.0, 1.0)306 discretion_multiplier = np.clip(307 stats.lognorm.rvs(s=0.5, scale=0.06, size=size, random_state=rng), 0.0, 1.0308 )309 dpdpa_penalty = np.where(310 breach_occurs,311 DPDPA_MAX_PENALTY_INR * severity_factor * discretion_multiplier,312 0.0,313 )314 315 # --- 3b. RBI CSF / SEBI CSCRF fine -------------------------------------- #316 is_regulated = asset.is_rbi_regulated or asset.is_sebi_regulated317 reportable_incident = downtime_hours >= REPORTABLE_DOWNTIME_HOURS318 rbi_sebi_fine = np.where(319 is_regulated & reportable_incident,320 rng.uniform(RBI_SEBI_FINE_MIN_INR, RBI_SEBI_FINE_MAX_INR, size=size),321 0.0,322 )323 324 # --- 3c. Cross-border statutory penalty --------------------------------- #325 is_rbi_reg = getattr(asset, "is_rbi_regulated", False)326 is_rbi_loc = getattr(asset, "is_rbi_localization_compliant", True)327 cb_enabled = getattr(asset, "cross_border_transfer_enabled", False)328 legal_mech = getattr(asset, "transfer_legal_mechanism", "None") or "None"329 330 cross_border_violation = (331 (is_rbi_reg and not is_rbi_loc)332 or (cb_enabled and legal_mech in (None, "None"))333 )334 cross_border_fine = np.where(335 cross_border_violation & breach_occurs,336 rng.uniform(20_000_000.0, 50_000_000.0, size=size), # ₹2 Cr to ₹5 Cr statutory enforcement fine337 0.0,338 )339 340 # --- 4. Forensics & incident response cost ------------------------------#341 forensics_cost = rng.uniform(FORENSICS_COST_MIN_INR, FORENSICS_COST_MAX_INR, size=size)342 343 total_loss = (344 outage_cost345 + data_breach_cost346 + dpdpa_penalty347 + rbi_sebi_fine348 + cross_border_fine349 + subprocessor_violation_fine350 + forensics_cost351 )352 return total_loss353 354 355# --------------------------------------------------------------------------- #356# Monte Carlo Simulation Engine357# --------------------------------------------------------------------------- #358@dataclass359class AssetSimulationResult:360 asset_id: int361 hostname: str362 tier: str363 asset_type: str364 annual_event_frequency: float365 eal_inr: float366 var_95_inr: float367 var_99_inr: float368 annual_loss_samples: np.ndarray = field(repr=False)369 370 371@dataclass372class EnterpriseSimulationResult:373 total_eal_inr: float374 var_95_inr: float375 var_99_inr: float376 per_asset_results: list[AssetSimulationResult]377 aggregate_loss_samples: np.ndarray = field(repr=False)378 total_regulatory_fine_exposure_inr: float379 iterations: int = N_ITERATIONS380 unauthorized_subprocessor_count: int = 0381 shadow_leakage_exposure_inr: float = 0.0382 identified_leak_vectors: list[Any] = field(default_factory=list)383 384 385class MonteCarloRiskEngine:386 """387 Runs a vectorized compound-Poisson Monte Carlo simulation across all388 assets to quantify Expected Annual Loss (EAL) and Value at Risk (VaR).389 390 Vectorization strategy: for each asset, `n_iterations` Poisson draws391 give the number of loss *events* in each simulated year. Rather than392 looping event-by-event, we draw a (n_iterations x max_events) matrix of393 candidate per-event severities and mask out entries beyond each394 iteration's actual event count, then sum across events per iteration.395 This keeps the simulation fully vectorized in NumPy even though the396 event count varies stochastically per iteration.397 """398 399 def __init__(400 self,401 assets: list[Asset],402 active_controls: list[SecurityControl] | None = None,403 n_iterations: int = N_ITERATIONS,404 seed: int = RNG_SEED,405 session: Session | None = None,406 ) -> None:407 self.assets = assets408 self.active_controls = active_controls or []409 self.n_iterations = n_iterations410 self.rng = np.random.default_rng(seed)411 self.session = session412 self.graph_engine = AttackGraphEngine(413 assets=assets,414 active_controls=self.active_controls,415 session=self.session,416 )417 self.exposure_map = self.graph_engine.compute_exposure()418 self.lineage_flows, self.lineage_profiles = self.graph_engine.trace_transitive_data_lineage()419 420 def _simulate_asset(self, asset: Asset) -> AssetSimulationResult:421 exposure = self.exposure_map[asset.id]422 lam = calibrate_asset_annual_frequency(asset, exposure, self.active_controls)423 424 event_counts = self.rng.poisson(lam=lam, size=self.n_iterations)425 max_events = int(event_counts.max()) if event_counts.size else 0426 427 if max_events == 0:428 annual_losses = np.zeros(self.n_iterations, dtype=float)429 else:430 lineage_prof = self.lineage_profiles.get(asset.id)431 candidate_losses = _sample_composite_event_losses(432 asset, self.n_iterations * max_events, self.rng, asset_lineage=lineage_prof433 ).reshape(self.n_iterations, max_events)434 event_slot_mask = np.arange(max_events)[None, :] < event_counts[:, None]435 annual_losses = (candidate_losses * event_slot_mask).sum(axis=1)436 437 eal = float(np.mean(annual_losses))438 var_95 = float(np.percentile(annual_losses, 95))439 var_99 = float(np.percentile(annual_losses, 99))440 441 return AssetSimulationResult(442 asset_id=asset.id,443 hostname=asset.hostname,444 tier=asset.tier,445 asset_type=asset.asset_type,446 annual_event_frequency=round(lam, 4),447 eal_inr=eal,448 var_95_inr=var_95,449 var_99_inr=var_99,450 annual_loss_samples=annual_losses,451 )452 453 def run(self) -> EnterpriseSimulationResult:454 """Executes the full enterprise Monte Carlo simulation."""455 per_asset_results = [self._simulate_asset(asset) for asset in self.assets]456 457 if per_asset_results:458 aggregate_losses = np.sum(459 [r.annual_loss_samples for r in per_asset_results], axis=0460 )461 else:462 aggregate_losses = np.zeros(self.n_iterations, dtype=float)463 464 total_eal = float(np.mean(aggregate_losses))465 var_95 = float(np.percentile(aggregate_losses, 95))466 var_99 = float(np.percentile(aggregate_losses, 99))467 468 # Regulatory-fine-only exposure estimate (DPDPA + RBI/SEBI + Cross-Border + Sub-Processor),469 # reported as the 95th percentile of a dedicated re-simulation of the470 # penalty components alone -- useful for the compliance dashboard.471 total_regulatory_exposure = self._estimate_regulatory_exposure()472 473 unauthorized_vectors = [474 f for f in self.lineage_flows475 if (not f.is_authorized or not f.has_user_consent or "Unauthorized" in f.detection_status)476 ]477 unauthorized_count = len(unauthorized_vectors)478 479 shadow_leakage_exposure = 0.0480 for r in per_asset_results:481 prof = self.lineage_profiles.get(r.asset_id)482 if prof and prof.is_unauthorized_sink:483 shadow_leakage_exposure += r.eal_inr484 485 return EnterpriseSimulationResult(486 total_eal_inr=total_eal,487 var_95_inr=var_95,488 var_99_inr=var_99,489 per_asset_results=per_asset_results,490 aggregate_loss_samples=aggregate_losses,491 total_regulatory_fine_exposure_inr=total_regulatory_exposure,492 iterations=self.n_iterations,493 unauthorized_subprocessor_count=unauthorized_count,494 shadow_leakage_exposure_inr=shadow_leakage_exposure,495 identified_leak_vectors=self.lineage_flows,496 )497 498 def _estimate_regulatory_exposure(self) -> float:499 """500 Isolates the regulatory-penalty share of the 95th-percentile annual501 loss across all assets, by re-deriving DPDPA + RBI/SEBI penalty502 samples with the same seeded RNG stream logic used in the main run.503 A fresh, independently seeded generator is used here so this504 estimate does not perturb the primary EAL/VaR simulation stream.505 """506 local_rng = np.random.default_rng(RNG_SEED + 1)507 penalty_totals = np.zeros(self.n_iterations, dtype=float)508 509 for asset in self.assets:510 exposure = self.exposure_map[asset.id]511 lam = calibrate_asset_annual_frequency(asset, exposure, self.active_controls)512 event_counts = local_rng.poisson(lam=lam, size=self.n_iterations)513 max_events = int(event_counts.max()) if event_counts.size else 0514 if max_events == 0:515 continue516 517 n = self.n_iterations * max_events518 size = (n,)519 breach_occurs = local_rng.random(size) < {520 "Critical": 0.55,521 "Medium": 0.35,522 "Low": 0.20,523 }.get(asset.tier, 0.35)524 bf_min, bf_mode, bf_max = _breach_fraction_params(asset.tier)525 breach_fraction = _pert_rvs(bf_min, bf_mode, bf_max, size, local_rng)526 527 records_pii = float(asset.pii_records_count)528 records_fin = float(asset.financial_records_count)529 lineage_prof = self.lineage_profiles.get(asset.id)530 subprocessor_fine = np.zeros(size, dtype=float)531 if lineage_prof is not None and getattr(lineage_prof, "is_unauthorized_sink", False):532 records_pii = float(asset.pii_records_count + getattr(lineage_prof, "inherited_pii_count", 0))533 records_fin = float(asset.financial_records_count + getattr(lineage_prof, "inherited_fin_count", 0))534 subprocessor_fine = np.where(535 breach_occurs,536 local_rng.uniform(15_000_000.0, 40_000_000.0, size=size),537 0.0,538 )539 540 records_breached = breach_fraction * (records_pii + records_fin)541 severity_factor = np.clip(records_breached / 5_000_000.0, 0.0, 1.0)542 discretion_multiplier = np.clip(543 stats.lognorm.rvs(s=0.5, scale=0.06, size=size, random_state=local_rng), 0.0, 1.0544 )545 dpdpa = np.where(546 breach_occurs, DPDPA_MAX_PENALTY_INR * severity_factor * discretion_multiplier, 0.0547 )548 549 downtime_hours = _pert_rvs(*_downtime_hours_params(asset.tier), size, local_rng)550 is_regulated = asset.is_rbi_regulated or asset.is_sebi_regulated551 reportable = downtime_hours >= REPORTABLE_DOWNTIME_HOURS552 rbi_sebi = np.where(553 is_regulated & reportable,554 local_rng.uniform(RBI_SEBI_FINE_MIN_INR, RBI_SEBI_FINE_MAX_INR, size=size),555 0.0,556 )557 558 is_rbi_reg = getattr(asset, "is_rbi_regulated", False)559 is_rbi_loc = getattr(asset, "is_rbi_localization_compliant", True)560 cb_enabled = getattr(asset, "cross_border_transfer_enabled", False)561 legal_mech = getattr(asset, "transfer_legal_mechanism", "None") or "None"562 563 cross_border_violation = (564 (is_rbi_reg and not is_rbi_loc)565 or (cb_enabled and legal_mech in (None, "None"))566 )567 cross_border_fine = np.where(568 cross_border_violation & breach_occurs,569 local_rng.uniform(20_000_000.0, 50_000_000.0, size=size),570 0.0,571 )572 573 per_event_penalty = (dpdpa + rbi_sebi + cross_border_fine + subprocessor_fine).reshape(574 self.n_iterations, max_events575 )576 event_slot_mask = np.arange(max_events)[None, :] < event_counts[:, None]577 penalty_totals += (per_event_penalty * event_slot_mask).sum(axis=1)578 579 return float(np.percentile(penalty_totals, 95)) if self.assets else 0.0580 581 def loss_exceedance_curve(582 self, aggregate_losses: np.ndarray583 ) -> list[tuple[int, float]]:584 """585 Computes Loss Exceedance Curve points for standard return periods.586 A "1-in-T-year loss" is the loss level exceeded with probability587 1/T, i.e. the (1 - 1/T) quantile of the annual aggregate loss588 distribution.589 """590 points: list[tuple[int, float]] = []591 for t in LOSS_EXCEEDANCE_RETURN_PERIODS:592 quantile = 1.0 - (1.0 / t)593 loss_at_quantile = float(np.percentile(aggregate_losses, quantile * 100.0))594 points.append((t, loss_at_quantile))595 return points596 597 598def run_enterprise_simulation(599 db: Session,600 assets: list[Asset],601 active_controls: list[SecurityControl] | None = None,602 persist: bool = True,603 allocated_budget_inr: float | None = None,604) -> EnterpriseSimulationResult:605 """606 Convenience entry point: runs the full Monte Carlo simulation and607 optionally persists the aggregate result to the `SimulationRun` table.608 """609 engine = MonteCarloRiskEngine(assets=assets, active_controls=active_controls, session=db)610 result = engine.run()611 612 if persist:613 selected_codes = [c.code for c in (active_controls or [])]614 run_record = SimulationRun(615 total_eal_inr=result.total_eal_inr,616 var_95_inr=result.var_95_inr,617 var_99_inr=result.var_99_inr,618 allocated_budget_inr=allocated_budget_inr,619 selected_controls_json=json.dumps(selected_codes),620 )621 db.add(run_record)622 db.commit()623 db.refresh(run_record)624 625 return result626 627 