Spaces:
Sleeping
Sleeping
| """ | |
| ActuarialOS β Agent 15: Catastrophe Modelling | |
| =============================================== | |
| Reads from silver_act_cat_losses + bronze_act_cat_events | |
| Writes to gold_act_cat_model + gold_act_audit_log | |
| Functions: | |
| run_etl_cat_to_silver() β bronze cat events β silver_act_cat_losses | |
| train_agent15_model() β train XGBoost severity/frequency models | |
| run_agent15(payload) β full cat model for a LOB/peril | |
| run_agent15_batch(payload) β sweep all LOB/peril combinations | |
| Architecture: | |
| - Stochastic event set simulation (10,000 years) | |
| - Frequency: Negative Binomial by peril/state | |
| - Severity: Log-normal with peril-specific params | |
| - EP curves: OEP + AEP at standard return periods | |
| - PML: 1-in-100, 1-in-250 gross and net of reinsurance | |
| - Climate loading: +2.5% per decade for wind/flood/fire | |
| - XGBoost: ground-up to gross loss amplification factor | |
| - AAL decomposition by peril and state | |
| """ | |
| import os, json, logging, uuid | |
| from datetime import date, datetime | |
| import numpy as np | |
| import pandas as pd | |
| log = logging.getLogger(__name__) | |
| MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models') | |
| MODEL_PATH = os.path.join(MODELS_DIR, 'agent15_cat.pkl') | |
| # ββ Peril parameters (calibrated to industry benchmarks) ββββββ | |
| PERIL_PARAMS = { | |
| # freq_mean freq_disp sev_mu sev_sigma tail_idx | |
| 'WIND': dict(freq=3.2, disp=1.8, mu=15.2, sigma=1.4, tail=2.8), | |
| 'FLOOD': dict(freq=2.1, disp=1.5, mu=14.8, sigma=1.5, tail=2.5), | |
| 'QUAKE': dict(freq=0.8, disp=0.9, mu=16.1, sigma=1.8, tail=2.2), | |
| 'FIRE': dict(freq=1.4, disp=1.2, mu=14.2, sigma=1.3, tail=3.0), | |
| 'HAIL': dict(freq=4.5, disp=2.2, mu=13.9, sigma=1.2, tail=3.5), | |
| } | |
| # Climate change loading per decade by peril | |
| CLIMATE_LOADING = { | |
| 'WIND': 0.025, 'FLOOD': 0.035, 'QUAKE': 0.000, | |
| 'FIRE': 0.045, 'HAIL': 0.020, | |
| } | |
| # State TIV concentration weights | |
| STATE_TIV_WEIGHT = { | |
| 'FL':0.12,'TX':0.10,'CA':0.14,'NY':0.08,'IL':0.04,'OH':0.03, | |
| 'PA':0.04,'NC':0.04,'GA':0.03,'MI':0.03,'NJ':0.05,'VA':0.03, | |
| 'WA':0.03,'AZ':0.03,'CO':0.02,'TN':0.02,'MO':0.02,'IN':0.02, | |
| 'SC':0.02,'MD':0.03, | |
| } | |
| # LOB vulnerability factors (loss as % of TIV by peril) | |
| LOB_VULN = { | |
| 'HO': {'WIND':0.08,'FLOOD':0.12,'QUAKE':0.15,'FIRE':0.25,'HAIL':0.04}, | |
| 'AUTO':{'WIND':0.04,'FLOOD':0.18,'QUAKE':0.06,'FIRE':0.08,'HAIL':0.06}, | |
| 'CMP': {'WIND':0.06,'FLOOD':0.10,'QUAKE':0.12,'FIRE':0.20,'HAIL':0.03}, | |
| 'GL': {'WIND':0.02,'FLOOD':0.04,'QUAKE':0.05,'FIRE':0.06,'HAIL':0.01}, | |
| 'WC': {'WIND':0.01,'FLOOD':0.02,'QUAKE':0.03,'FIRE':0.02,'HAIL':0.01}, | |
| } | |
| RETURN_PERIODS = [2, 5, 10, 25, 50, 100, 200, 250, 500, 1000] | |
| LOBS = ['HO', 'AUTO', 'CMP', 'GL', 'WC'] | |
| PERILS = ['WIND', 'FLOOD', 'QUAKE', 'FIRE', 'HAIL'] | |
| FEATURE_COLS_15 = [ | |
| 'peril_enc', 'lob_enc', 'state_enc', | |
| 'log_tiv', 'vuln_factor', 'freq_mean', | |
| 'sev_mu', 'sev_sigma', 'climate_decade_load', | |
| 'state_tiv_weight', 'return_period_log', | |
| 'ground_up_log', 'reins_retention_log', | |
| ] | |
| PERIL_ENC = {p: i for i, p in enumerate(PERILS)} | |
| LOB_ENC = {'HO':0,'AUTO':1,'CMP':2,'GL':3,'WC':4} | |
| STATE_ENC = {s: i for i, s in enumerate(STATE_TIV_WEIGHT.keys())} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DB HELPERS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _get_engine(): | |
| from sqlalchemy import create_engine | |
| from sqlalchemy.pool import NullPool | |
| from urllib.parse import quote_plus as qp | |
| DB = dict( | |
| host = os.environ.get('MYSQL_ADDON_HOST', 'btvbbpqhvnttzvptguj3-mysql.services.clever-cloud.com'), | |
| port = int(os.environ.get('MYSQL_ADDON_PORT', '3306')), | |
| user = os.environ.get('MYSQL_ADDON_USER', 'utenclk29u394u1j'), | |
| password = os.environ.get('MYSQL_ADDON_PASSWORD', 'QXFZTmUtPnXrKFqZKpLQ'), | |
| database = os.environ.get('MYSQL_ADDON_DB', 'btvbbpqhvnttzvptguj3'), | |
| ) | |
| pwd = qp(DB['password']) | |
| return create_engine( | |
| f"mysql+pymysql://{DB['user']}:{pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4", | |
| poolclass=NullPool, connect_args={"connect_timeout": 15} | |
| ) | |
| def _safe_json(obj): | |
| import math | |
| if isinstance(obj, dict): return {k: _safe_json(v) for k,v in obj.items()} | |
| if isinstance(obj, list): return [_safe_json(v) for v in obj] | |
| if isinstance(obj, float): return None if (math.isnan(obj) or math.isinf(obj)) else round(obj, 4) | |
| if isinstance(obj, np.integer): return int(obj) | |
| if isinstance(obj, np.floating): | |
| v = float(obj); return None if (math.isnan(v) or math.isinf(v)) else round(v, 4) | |
| if isinstance(obj, np.ndarray): return obj.tolist() | |
| if isinstance(obj, (date, datetime)): return str(obj) | |
| return obj | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STOCHASTIC CAT MODEL | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def simulate_event_set(peril: str, lob: str, state: str, | |
| tiv: float, n_years: int = 10000, | |
| climate_years_forward: int = 10, | |
| seed: int = 15) -> np.ndarray: | |
| """ | |
| Simulate n_years of annual aggregate losses for a peril/LOB/state. | |
| Uses Negative Binomial frequency + Log-normal severity. | |
| Returns array of annual aggregate losses (length = n_years). | |
| """ | |
| rng = np.random.default_rng(seed + PERIL_ENC.get(peril, 0) * 100 + LOB_ENC.get(lob, 0)) | |
| params = PERIL_PARAMS.get(peril, PERIL_PARAMS['WIND']) | |
| vuln = LOB_VULN.get(lob, {}).get(peril, 0.05) | |
| tiv_w = STATE_TIV_WEIGHT.get(state, 0.03) | |
| # Climate loading: compound over forward decades | |
| decades = climate_years_forward / 10 | |
| cl_load = (1 + CLIMATE_LOADING.get(peril, 0)) ** decades | |
| # Frequency: Negative Binomial | |
| freq_mean = params['freq'] * tiv_w * cl_load | |
| freq_disp = params['disp'] | |
| # NB parameterisation: p = disp/(disp+mean), r = disp | |
| p_nb = freq_disp / (freq_disp + freq_mean) | |
| r_nb = freq_disp | |
| annual_losses = np.zeros(n_years) | |
| for yr in range(n_years): | |
| n_events = int(rng.negative_binomial(r_nb, p_nb)) | |
| if n_events == 0: | |
| continue | |
| # Severity: Log-normal scaled by TIV and vulnerability | |
| raw_sevs = rng.lognormal(params['mu'], params['sigma'], size=n_events) | |
| # Scale to portfolio TIV | |
| scale = tiv * vuln / np.exp(params['mu'] + params['sigma']**2 / 2) | |
| sevs = raw_sevs * scale | |
| annual_losses[yr] = float(np.sum(sevs)) | |
| return annual_losses | |
| def build_oep_curve(annual_losses: np.ndarray, | |
| return_periods: list = None) -> dict: | |
| """ | |
| Occurrence Exceedance Probability curve. | |
| OEP(T) = loss exceeded by largest event in T years on average. | |
| """ | |
| rps = return_periods or RETURN_PERIODS | |
| n = len(annual_losses) | |
| # OEP uses the maximum event per year | |
| oep = {} | |
| for rp in rps: | |
| pct = 1 - 1/rp | |
| loss = float(np.quantile(annual_losses, pct)) | |
| oep[rp] = round(loss, 2) | |
| return oep | |
| def build_aep_curve(annual_losses: np.ndarray, | |
| return_periods: list = None) -> dict: | |
| """ | |
| Annual Exceedance Probability curve. | |
| AEP(T) = annual aggregate loss exceeded once in T years. | |
| """ | |
| rps = return_periods or RETURN_PERIODS | |
| aep = {} | |
| for rp in rps: | |
| pct = 1 - 1/rp | |
| loss = float(np.quantile(annual_losses, pct)) | |
| aep[rp] = round(loss, 2) | |
| return aep | |
| def apply_reinsurance(annual_losses: np.ndarray, | |
| retention: float, limit: float) -> np.ndarray: | |
| """ | |
| Apply a per-occurrence XL reinsurance layer. | |
| Net = Retained + min(max(loss - retention, 0), limit) | |
| Simplified: apply to aggregate annual loss. | |
| """ | |
| ceded = np.minimum(np.maximum(annual_losses - retention, 0), limit) | |
| return annual_losses - ceded | |
| def compute_aal(annual_losses: np.ndarray) -> float: | |
| """Average Annual Loss.""" | |
| return round(float(np.mean(annual_losses)), 2) | |
| def compute_pml(oep_curve: dict, return_period: int) -> float: | |
| """Probable Maximum Loss at a given return period from OEP curve.""" | |
| return oep_curve.get(return_period, 0.0) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ETL: BRONZE β SILVER | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_etl_cat_to_silver(): | |
| """ | |
| Aggregate bronze_act_cat_events + bronze_act_losses (cat_flag=1) | |
| β silver_act_cat_losses with EP metrics. | |
| """ | |
| from sqlalchemy import text | |
| eng = _get_engine() | |
| log.info("[AGENT15-ETL] bronze β silver cat losses") | |
| with eng.connect() as conn: | |
| events = pd.read_sql(""" | |
| SELECT event_id, peril, event_date, | |
| industry_loss_bn, return_period_yrs, affected_states | |
| FROM bronze_act_cat_events | |
| """, conn) | |
| cat_losses = pd.read_sql(""" | |
| SELECT lob, state_code, | |
| SUM(paid_loss + case_reserve) AS gross_loss, | |
| SUM(paid_alae) AS paid_alae, | |
| COUNT(DISTINCT claim_id) AS claim_count, | |
| cat_event_id | |
| FROM bronze_act_losses | |
| WHERE cat_flag = 1 | |
| GROUP BY lob, state_code, cat_event_id | |
| """, conn) | |
| premiums = pd.read_sql(""" | |
| SELECT lob, state_code, SUM(earned_premium) AS earned_premium | |
| FROM bronze_act_premiums | |
| GROUP BY lob, state_code | |
| """, conn) | |
| rows = [] | |
| for lob in LOBS: | |
| for peril in PERILS: | |
| for state in list(STATE_TIV_WEIGHT.keys())[:10]: | |
| ep_row = premiums[(premiums.lob==lob) & (premiums.state_code==state)] | |
| ep = float(ep_row['earned_premium'].sum()) if not ep_row.empty else 500000.0 | |
| tiv = ep * 80 # approximate TIV from EP | |
| # Run stochastic simulation for EP curve values | |
| sim = simulate_event_set(peril, lob, state, tiv, | |
| n_years=5000, seed=15) | |
| oep = build_oep_curve(sim) | |
| aep = build_aep_curve(sim) | |
| aal = compute_aal(sim) | |
| # Net of reinsurance (simple XL: retention=PML_10, limit=PML_100-PML_10) | |
| ret = oep.get(10, 0) | |
| limit = max(0, oep.get(100, 0) - ret) | |
| sim_net = apply_reinsurance(sim, ret, limit) | |
| oep_net = build_oep_curve(sim_net) | |
| net_aal = compute_aal(sim_net) | |
| rows.append(dict( | |
| lob = lob, | |
| peril = peril, | |
| state_code = state, | |
| model_vendor = 'INTERNAL', | |
| gross_loss = aal, | |
| ceded_loss = round(aal - net_aal, 2), | |
| ground_up_loss = round(aal * 1.12, 2), | |
| aep_1_in_10 = aep.get(10), | |
| aep_1_in_50 = aep.get(50), | |
| aep_1_in_100 = aep.get(100), | |
| aep_1_in_250 = aep.get(250), | |
| oep_1_in_10 = oep.get(10), | |
| oep_1_in_100 = oep.get(100), | |
| oep_1_in_250 = oep.get(250), | |
| pml_1_in_100 = oep.get(100), | |
| pml_1_in_250 = oep.get(250), | |
| aal = aal, | |
| )) | |
| silver_df = pd.DataFrame(rows).where(pd.notna(pd.DataFrame(rows)), other=None) | |
| with eng.begin() as conn: | |
| conn.execute(text("DELETE FROM silver_act_cat_losses")) | |
| silver_df.to_sql('silver_act_cat_losses', conn, | |
| if_exists='append', index=False, method='multi', chunksize=200) | |
| log.info(f"[AGENT15-ETL] silver_act_cat_losses: {len(silver_df)} rows") | |
| return {'cat_rows': len(silver_df)} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ML: GROUND-UP TO GROSS AMPLIFICATION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_features_15(peril: str, lob: str, state: str, | |
| tiv: float, return_period: int, | |
| ground_up: float, retention: float = 1e6) -> dict: | |
| params = PERIL_PARAMS.get(peril, PERIL_PARAMS['WIND']) | |
| return { | |
| 'peril_enc': PERIL_ENC.get(peril, 0), | |
| 'lob_enc': LOB_ENC.get(lob, 0), | |
| 'state_enc': STATE_ENC.get(state, 0), | |
| 'log_tiv': float(np.log(max(tiv, 1))), | |
| 'vuln_factor': LOB_VULN.get(lob, {}).get(peril, 0.05), | |
| 'freq_mean': params['freq'], | |
| 'sev_mu': params['mu'], | |
| 'sev_sigma': params['sigma'], | |
| 'climate_decade_load': CLIMATE_LOADING.get(peril, 0.0), | |
| 'state_tiv_weight': STATE_TIV_WEIGHT.get(state, 0.03), | |
| 'return_period_log': float(np.log(max(return_period, 1))), | |
| 'ground_up_log': float(np.log(max(ground_up, 1))), | |
| 'reins_retention_log': float(np.log(max(retention, 1))), | |
| } | |
| def _generate_training_data_15(n=5000, seed=15): | |
| rng = np.random.default_rng(seed) | |
| rows = [] | |
| for _ in range(n): | |
| peril = rng.choice(PERILS) | |
| lob = rng.choice(LOBS) | |
| state = rng.choice(list(STATE_TIV_WEIGHT.keys())) | |
| tiv = float(rng.uniform(1e7, 5e9)) | |
| rp = int(rng.choice(RETURN_PERIODS)) | |
| ret = float(rng.uniform(5e5, 1e7)) | |
| # Simulate and get gross PML at this RP | |
| sim = simulate_event_set(peril, lob, state, tiv, n_years=2000, seed=int(rng.integers(0, 9999))) | |
| oep = build_oep_curve(sim, [rp]) | |
| gross_pml = oep[rp] | |
| ground_up = gross_pml * float(rng.uniform(0.85, 0.95)) | |
| feats = _build_features_15(peril, lob, state, tiv, rp, ground_up, ret) | |
| feats['target_gross_pml'] = gross_pml | |
| rows.append(feats) | |
| return pd.DataFrame(rows) | |
| def train_agent15_model(): | |
| """Train XGBoost for gross PML amplification from ground-up loss.""" | |
| import joblib | |
| from xgboost import XGBRegressor | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.metrics import mean_absolute_error | |
| os.makedirs(MODELS_DIR, exist_ok=True) | |
| log.info("[AGENT15] Generating training data...") | |
| df = _generate_training_data_15(n=6000) | |
| X = df[FEATURE_COLS_15] | |
| y = df['target_gross_pml'] | |
| X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=15) | |
| model = XGBRegressor( | |
| n_estimators=300, max_depth=6, learning_rate=0.05, | |
| subsample=0.85, colsample_bytree=0.85, | |
| min_child_weight=5, reg_alpha=0.1, | |
| random_state=15, verbosity=0 | |
| ) | |
| model.fit(X_tr, y_tr, eval_set=[(X_te, y_te)], verbose=False) | |
| mae = mean_absolute_error(y_te, model.predict(X_te)) | |
| mae_pct = mae / y_te.mean() if y_te.mean() > 0 else 0 | |
| log.info(f"[AGENT15] XGBoost MAE: {mae:,.0f} ({mae_pct:.2%} of mean)") | |
| bundle = dict( | |
| model=model, feature_cols=FEATURE_COLS_15, | |
| mae=mae, mae_pct=mae_pct, trained_at=str(date.today()) | |
| ) | |
| joblib.dump(bundle, MODEL_PATH) | |
| log.info(f"[AGENT15] Model saved β {MODEL_PATH}") | |
| return bundle | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MAIN AGENT ENTRY POINTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_agent15(payload: dict) -> dict: | |
| """ | |
| Full cat model for a LOB/peril combination. | |
| payload keys: | |
| lob, peril, [state_code], [tiv], | |
| [n_years (default 10000)], | |
| [climate_years_forward (default 10)], | |
| [reins_retention], [reins_limit], | |
| [model_scenario: BASE|STRESSED|CLIMATE_ADJ], | |
| [run_id] | |
| Returns OEP/AEP curves, PML, AAL, capital requirement, SHAP. | |
| """ | |
| from sqlalchemy import text | |
| lob = str(payload.get('lob', 'HO')) | |
| peril = str(payload.get('peril', 'WIND')).upper() | |
| state = str(payload.get('state_code', 'FL')) | |
| run_id = payload.get('run_id') or f"AG15-{uuid.uuid4().hex[:12].upper()}" | |
| scenario = str(payload.get('model_scenario', 'BASE')) | |
| n_years = int(payload.get('n_years', 10000)) | |
| cl_fwd = int(payload.get('climate_years_forward', 10)) | |
| # TIV: from payload or estimated from silver EP | |
| tiv = payload.get('tiv') | |
| if not tiv: | |
| try: | |
| eng = _get_engine() | |
| with eng.connect() as conn: | |
| ep_row = pd.read_sql(""" | |
| SELECT SUM(earned_premium) AS ep | |
| FROM silver_act_loss_ratios | |
| WHERE lob=%s AND state_code=%s | |
| """, conn, params=(lob, state)) | |
| tiv = float(ep_row['ep'].iloc[0] or 5e7) * 80 | |
| except Exception: | |
| tiv = 5e7 | |
| tiv = float(tiv) | |
| log.info(f"[AGENT15] {lob}/{peril}/{state} scenario={scenario} TIV={tiv:,.0f} run={run_id}") | |
| # Scenario adjustments | |
| stress_mult = 1.0 | |
| if scenario == 'STRESSED': | |
| stress_mult = 1.25 | |
| elif scenario == 'CLIMATE_ADJ': | |
| cl_fwd = 30 # 3 decades forward | |
| # ββ Stochastic simulation βββββββββββββββββββββββββββββββββ | |
| sim_gross = simulate_event_set( | |
| peril, lob, state, tiv * stress_mult, | |
| n_years=n_years, climate_years_forward=cl_fwd, | |
| seed=PERIL_ENC.get(peril,0) * 1000 + LOB_ENC.get(lob,0) * 100 | |
| ) | |
| oep_gross = build_oep_curve(sim_gross) | |
| aep_gross = build_aep_curve(sim_gross) | |
| aal_gross = compute_aal(sim_gross) | |
| # Net of reinsurance | |
| retention = float(payload.get('reins_retention', oep_gross.get(10, 1e6))) | |
| limit = float(payload.get('reins_limit', | |
| max(0, oep_gross.get(100, retention*3) - retention))) | |
| sim_net = apply_reinsurance(sim_gross, retention, limit) | |
| oep_net = build_oep_curve(sim_net) | |
| aep_net = build_aep_curve(sim_net) | |
| aal_net = compute_aal(sim_net) | |
| # Key metrics | |
| pml_100_gross = oep_gross.get(100, 0) | |
| pml_250_gross = oep_gross.get(250, 0) | |
| pml_100_net = oep_net.get(100, 0) | |
| pml_250_net = oep_net.get(250, 0) | |
| capital_req = pml_250_net # 1-in-250 net PML = cat capital | |
| rein_benefit = pml_100_gross - pml_100_net | |
| climate_load = (1 + CLIMATE_LOADING.get(peril, 0)) ** (cl_fwd/10) - 1 | |
| # ββ ML amplification model ββββββββββββββββββββββββββββββββ | |
| ml_pml_100 = None | |
| shap_summary = {} | |
| top_drivers = [] | |
| try: | |
| import joblib, shap as shap_lib | |
| bundle = joblib.load(MODEL_PATH) | |
| ground_up = pml_100_gross * 0.90 | |
| feats = _build_features_15(peril, lob, state, tiv, 100, ground_up, retention) | |
| X = pd.DataFrame([feats])[bundle['feature_cols']] | |
| ml_pml_100 = round(float(bundle['model'].predict(X)[0]), 2) | |
| # SHAP | |
| explainer = shap_lib.TreeExplainer(bundle['model']) | |
| shap_vals = explainer.shap_values(X) | |
| shap_dict = {col: round(float(shap_vals[0][i]), 4) | |
| for i, col in enumerate(bundle['feature_cols'])} | |
| top_drivers = sorted(shap_dict.items(), key=lambda x: abs(x[1]), reverse=True)[:5] | |
| shap_summary = shap_dict | |
| except Exception as e: | |
| log.warning(f"[AGENT15] ML unavailable: {e}") | |
| # ββ Full EP curve for UI ββββββββββββββββββββββββββββββββββ | |
| oep_curve_full = [{'rp': rp, 'gross': oep_gross.get(rp, 0), | |
| 'net': oep_net.get(rp, 0)} for rp in RETURN_PERIODS] | |
| aep_curve_full = [{'rp': rp, 'gross': aep_gross.get(rp, 0), | |
| 'net': aep_net.get(rp, 0)} for rp in RETURN_PERIODS] | |
| # ββ State breakdown (AAL by state for this peril/LOB) βββββ | |
| state_aal = {} | |
| for s, w in list(STATE_TIV_WEIGHT.items())[:10]: | |
| s_sim = simulate_event_set(peril, lob, s, tiv * w / STATE_TIV_WEIGHT.get(state, 0.05), | |
| n_years=2000, seed=PERIL_ENC.get(peril,0)*500+STATE_ENC.get(s,0)) | |
| state_aal[s] = compute_aal(s_sim) | |
| result = dict( | |
| run_id = run_id, | |
| lob = lob, | |
| peril = peril, | |
| state_code = state, | |
| model_scenario = scenario, | |
| tiv = tiv, | |
| n_years_simulated= n_years, | |
| climate_years_fwd= cl_fwd, | |
| climate_loading = round(climate_load, 4), | |
| # AAL | |
| gross_aal = aal_gross, | |
| net_aal = aal_net, | |
| # PML | |
| gross_pml_100 = pml_100_gross, | |
| gross_pml_250 = pml_250_gross, | |
| net_pml_100 = pml_100_net, | |
| net_pml_250 = pml_250_net, | |
| ml_pml_100 = ml_pml_100, | |
| # Capital | |
| capital_requirement = capital_req, | |
| reinsurance_benefit = rein_benefit, | |
| # Reinsurance structure used | |
| reins_retention = retention, | |
| reins_limit = limit, | |
| # EP curves | |
| oep_curve = oep_curve_full, | |
| aep_curve = aep_curve_full, | |
| # Explainability | |
| shap_summary = shap_summary, | |
| top_drivers = [{'feature': k, 'shap': v} for k, v in top_drivers], | |
| state_aal_breakdown = state_aal, | |
| ) | |
| # ββ Persist to gold βββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| _persist_gold_15(result) | |
| except Exception as e: | |
| log.warning(f"[AGENT15] Gold persist failed: {e}") | |
| return _safe_json(result) | |
| def _persist_gold_15(result: dict): | |
| from sqlalchemy import text | |
| eng = _get_engine() | |
| with eng.begin() as conn: | |
| conn.execute(text(""" | |
| INSERT INTO gold_act_cat_model ( | |
| run_id, lob, peril, state_code, model_scenario, | |
| gross_aal, net_aal, gross_pml_100, gross_pml_250, | |
| net_pml_100, net_pml_250, | |
| oep_curve_json, aep_curve_json, | |
| capital_requirement, reinsurance_benefit, | |
| climate_loading, model_vendor, model_version | |
| ) VALUES ( | |
| :run_id, :lob, :peril, :state, :scenario, | |
| :g_aal, :n_aal, :g_pml100, :g_pml250, | |
| :n_pml100, :n_pml250, | |
| :oep, :aep, | |
| :cap, :rein_ben, | |
| :cl_load, 'INTERNAL', '1.0' | |
| ) | |
| """), dict( | |
| run_id = result['run_id'], | |
| lob = result['lob'], | |
| peril = result['peril'], | |
| state = result['state_code'], | |
| scenario = result['model_scenario'], | |
| g_aal = result['gross_aal'], | |
| n_aal = result['net_aal'], | |
| g_pml100 = result['gross_pml_100'], | |
| g_pml250 = result['gross_pml_250'], | |
| n_pml100 = result['net_pml_100'], | |
| n_pml250 = result['net_pml_250'], | |
| oep = json.dumps(_safe_json(result['oep_curve'])), | |
| aep = json.dumps(_safe_json(result['aep_curve'])), | |
| cap = result['capital_requirement'], | |
| rein_ben = result['reinsurance_benefit'], | |
| cl_load = result['climate_loading'], | |
| )) | |
| # Audit log | |
| conn.execute(text(""" | |
| INSERT INTO gold_act_audit_log ( | |
| run_id, agent_name, agent_version, lob, | |
| analysis_date, function_performed, | |
| input_summary_json, output_summary_json, | |
| decision_factors_json, assumptions_json, confidence_score | |
| ) VALUES ( | |
| :run_id, 'agent15_cat', '1.0', :lob, | |
| :adate, 'CAT_MODEL_EP_CURVES', | |
| :inp, :out, :factors, :assump, 0.82 | |
| ) | |
| """), dict( | |
| run_id = result['run_id'], | |
| lob = result['lob'], | |
| adate = date.today(), | |
| inp = json.dumps(_safe_json({ | |
| 'peril': result['peril'], 'state': result['state_code'], | |
| 'tiv': result['tiv'], 'scenario': result['model_scenario'], | |
| })), | |
| out = json.dumps(_safe_json({ | |
| 'gross_aal': result['gross_aal'], | |
| 'pml_100_gross':result['gross_pml_100'], | |
| 'pml_250_net': result['net_pml_250'], | |
| 'capital_req': result['capital_requirement'], | |
| })), | |
| factors= json.dumps(_safe_json(result.get('top_drivers', []))), | |
| assump = json.dumps({ | |
| 'model': 'STOCHASTIC_SIMULATION', | |
| 'n_years': result['n_years_simulated'], | |
| 'frequency': 'NEGATIVE_BINOMIAL', | |
| 'severity': 'LOG_NORMAL', | |
| 'climate_loading': CLIMATE_LOADING, | |
| }), | |
| )) | |
| log.info(f"[AGENT15] Gold persisted β run_id={result['run_id']}") | |
| def run_agent15_batch(payload: dict = None) -> dict: | |
| """ | |
| Run all LOB Γ Peril Γ scenario combinations. | |
| payload keys: [lob_filter], [peril_filter], [scenario], [run_id] | |
| """ | |
| payload = payload or {} | |
| run_id = payload.get('run_id') or f"AG15-BATCH-{uuid.uuid4().hex[:8].upper()}" | |
| scenario = payload.get('model_scenario', 'BASE') | |
| log.info(f"[AGENT15-BATCH] Starting scenario={scenario} run={run_id}") | |
| lobs_to_run = [payload['lob_filter']] if payload.get('lob_filter') else LOBS | |
| perils_to_run = [payload['peril_filter']] if payload.get('peril_filter') else PERILS | |
| state = payload.get('state_code', 'FL') | |
| results, errors = [], [] | |
| for lob in lobs_to_run: | |
| for peril in perils_to_run: | |
| try: | |
| r = run_agent15({ | |
| 'lob': lob, 'peril': peril, | |
| 'state_code': state, | |
| 'model_scenario': scenario, | |
| 'run_id': run_id, | |
| 'n_years': int(payload.get('n_years', 10000)), | |
| }) | |
| results.append(r) | |
| except Exception as e: | |
| errors.append({'lob': lob, 'peril': peril, 'error': str(e)}) | |
| total_capital = sum(r.get('capital_requirement', 0) for r in results) | |
| total_aal = sum(r.get('gross_aal', 0) for r in results) | |
| avg_cl_load = np.mean([r.get('climate_loading', 0) for r in results]) if results else 0 | |
| summary = dict( | |
| run_id=run_id, scenario=scenario, | |
| total_combos=len(results), errors=len(errors), | |
| total_capital_requirement=round(total_capital, 2), | |
| total_gross_aal=round(total_aal, 2), | |
| avg_climate_loading=round(float(avg_cl_load), 4), | |
| max_pml_100=round(max((r.get('gross_pml_100',0) for r in results), default=0), 2), | |
| ) | |
| log.info(f"[AGENT15-BATCH] Complete: {summary}") | |
| return {'run_id': run_id, 'summary': summary, | |
| 'results': _safe_json(results), 'errors': errors} | |