ITNovaML/PCAgentinAI
0
1"""2ActuarialOS — Agent 15: Catastrophe Modelling3===============================================4Reads from silver_act_cat_losses + bronze_act_cat_events5Writes to gold_act_cat_model + gold_act_audit_log6 7Functions:8 run_etl_cat_to_silver() — bronze cat events → silver_act_cat_losses9 train_agent15_model() — train XGBoost severity/frequency models10 run_agent15(payload) — full cat model for a LOB/peril11 run_agent15_batch(payload) — sweep all LOB/peril combinations12 13Architecture:14 - Stochastic event set simulation (10,000 years)15 - Frequency: Negative Binomial by peril/state16 - Severity: Log-normal with peril-specific params17 - EP curves: OEP + AEP at standard return periods18 - PML: 1-in-100, 1-in-250 gross and net of reinsurance19 - Climate loading: +2.5% per decade for wind/flood/fire20 - XGBoost: ground-up to gross loss amplification factor21 - AAL decomposition by peril and state22"""23 24import os, json, logging, uuid25from datetime import date, datetime26 27import numpy as np28import pandas as pd29 30log = logging.getLogger(__name__)31 32MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models')33MODEL_PATH = os.path.join(MODELS_DIR, 'agent15_cat.pkl')34 35# ── Peril parameters (calibrated to industry benchmarks) ──────36PERIL_PARAMS = {37 # freq_mean freq_disp sev_mu sev_sigma tail_idx38 'WIND': dict(freq=3.2, disp=1.8, mu=15.2, sigma=1.4, tail=2.8),39 'FLOOD': dict(freq=2.1, disp=1.5, mu=14.8, sigma=1.5, tail=2.5),40 'QUAKE': dict(freq=0.8, disp=0.9, mu=16.1, sigma=1.8, tail=2.2),41 'FIRE': dict(freq=1.4, disp=1.2, mu=14.2, sigma=1.3, tail=3.0),42 'HAIL': dict(freq=4.5, disp=2.2, mu=13.9, sigma=1.2, tail=3.5),43}44 45# Climate change loading per decade by peril46CLIMATE_LOADING = {47 'WIND': 0.025, 'FLOOD': 0.035, 'QUAKE': 0.000,48 'FIRE': 0.045, 'HAIL': 0.020,49}50 51# State TIV concentration weights52STATE_TIV_WEIGHT = {53 'FL':0.12,'TX':0.10,'CA':0.14,'NY':0.08,'IL':0.04,'OH':0.03,54 'PA':0.04,'NC':0.04,'GA':0.03,'MI':0.03,'NJ':0.05,'VA':0.03,55 'WA':0.03,'AZ':0.03,'CO':0.02,'TN':0.02,'MO':0.02,'IN':0.02,56 'SC':0.02,'MD':0.03,57}58 59# LOB vulnerability factors (loss as % of TIV by peril)60LOB_VULN = {61 'HO': {'WIND':0.08,'FLOOD':0.12,'QUAKE':0.15,'FIRE':0.25,'HAIL':0.04},62 'AUTO':{'WIND':0.04,'FLOOD':0.18,'QUAKE':0.06,'FIRE':0.08,'HAIL':0.06},63 'CMP': {'WIND':0.06,'FLOOD':0.10,'QUAKE':0.12,'FIRE':0.20,'HAIL':0.03},64 'GL': {'WIND':0.02,'FLOOD':0.04,'QUAKE':0.05,'FIRE':0.06,'HAIL':0.01},65 'WC': {'WIND':0.01,'FLOOD':0.02,'QUAKE':0.03,'FIRE':0.02,'HAIL':0.01},66}67 68RETURN_PERIODS = [2, 5, 10, 25, 50, 100, 200, 250, 500, 1000]69LOBS = ['HO', 'AUTO', 'CMP', 'GL', 'WC']70PERILS = ['WIND', 'FLOOD', 'QUAKE', 'FIRE', 'HAIL']71 72FEATURE_COLS_15 = [73 'peril_enc', 'lob_enc', 'state_enc',74 'log_tiv', 'vuln_factor', 'freq_mean',75 'sev_mu', 'sev_sigma', 'climate_decade_load',76 'state_tiv_weight', 'return_period_log',77 'ground_up_log', 'reins_retention_log',78]79 80PERIL_ENC = {p: i for i, p in enumerate(PERILS)}81LOB_ENC = {'HO':0,'AUTO':1,'CMP':2,'GL':3,'WC':4}82STATE_ENC = {s: i for i, s in enumerate(STATE_TIV_WEIGHT.keys())}83 84 85# ══════════════════════════════════════════════════════════════86# DB HELPERS87# ══════════════════════════════════════════════════════════════88 89def _get_engine():90 from sqlalchemy import create_engine91 from sqlalchemy.pool import NullPool92 from urllib.parse import quote_plus as qp93 DB = dict(94 host = os.environ.get('MYSQL_ADDON_HOST', 'btvbbpqhvnttzvptguj3-mysql.services.clever-cloud.com'),95 port = int(os.environ.get('MYSQL_ADDON_PORT', '3306')),96 user = os.environ.get('MYSQL_ADDON_USER', 'utenclk29u394u1j'),97 password = os.environ.get('MYSQL_ADDON_PASSWORD', 'QXFZTmUtPnXrKFqZKpLQ'),98 database = os.environ.get('MYSQL_ADDON_DB', 'btvbbpqhvnttzvptguj3'),99 )100 pwd = qp(DB['password'])101 return create_engine(102 f"mysql+pymysql://{DB['user']}:{pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4",103 poolclass=NullPool, connect_args={"connect_timeout": 15}104 )105 106 107def _safe_json(obj):108 import math109 if isinstance(obj, dict): return {k: _safe_json(v) for k,v in obj.items()}110 if isinstance(obj, list): return [_safe_json(v) for v in obj]111 if isinstance(obj, float): return None if (math.isnan(obj) or math.isinf(obj)) else round(obj, 4)112 if isinstance(obj, np.integer): return int(obj)113 if isinstance(obj, np.floating):114 v = float(obj); return None if (math.isnan(v) or math.isinf(v)) else round(v, 4)115 if isinstance(obj, np.ndarray): return obj.tolist()116 if isinstance(obj, (date, datetime)): return str(obj)117 return obj118 119 120# ══════════════════════════════════════════════════════════════121# STOCHASTIC CAT MODEL122# ══════════════════════════════════════════════════════════════123 124def simulate_event_set(peril: str, lob: str, state: str,125 tiv: float, n_years: int = 10000,126 climate_years_forward: int = 10,127 seed: int = 15) -> np.ndarray:128 """129 Simulate n_years of annual aggregate losses for a peril/LOB/state.130 Uses Negative Binomial frequency + Log-normal severity.131 Returns array of annual aggregate losses (length = n_years).132 """133 rng = np.random.default_rng(seed + PERIL_ENC.get(peril, 0) * 100 + LOB_ENC.get(lob, 0))134 params = PERIL_PARAMS.get(peril, PERIL_PARAMS['WIND'])135 vuln = LOB_VULN.get(lob, {}).get(peril, 0.05)136 tiv_w = STATE_TIV_WEIGHT.get(state, 0.03)137 138 # Climate loading: compound over forward decades139 decades = climate_years_forward / 10140 cl_load = (1 + CLIMATE_LOADING.get(peril, 0)) ** decades141 142 # Frequency: Negative Binomial143 freq_mean = params['freq'] * tiv_w * cl_load144 freq_disp = params['disp']145 # NB parameterisation: p = disp/(disp+mean), r = disp146 p_nb = freq_disp / (freq_disp + freq_mean)147 r_nb = freq_disp148 149 annual_losses = np.zeros(n_years)150 for yr in range(n_years):151 n_events = int(rng.negative_binomial(r_nb, p_nb))152 if n_events == 0:153 continue154 # Severity: Log-normal scaled by TIV and vulnerability155 raw_sevs = rng.lognormal(params['mu'], params['sigma'], size=n_events)156 # Scale to portfolio TIV157 scale = tiv * vuln / np.exp(params['mu'] + params['sigma']**2 / 2)158 sevs = raw_sevs * scale159 annual_losses[yr] = float(np.sum(sevs))160 161 return annual_losses162 163 164def build_oep_curve(annual_losses: np.ndarray,165 return_periods: list = None) -> dict:166 """167 Occurrence Exceedance Probability curve.168 OEP(T) = loss exceeded by largest event in T years on average.169 """170 rps = return_periods or RETURN_PERIODS171 n = len(annual_losses)172 # OEP uses the maximum event per year173 oep = {}174 for rp in rps:175 pct = 1 - 1/rp176 loss = float(np.quantile(annual_losses, pct))177 oep[rp] = round(loss, 2)178 return oep179 180 181def build_aep_curve(annual_losses: np.ndarray,182 return_periods: list = None) -> dict:183 """184 Annual Exceedance Probability curve.185 AEP(T) = annual aggregate loss exceeded once in T years.186 """187 rps = return_periods or RETURN_PERIODS188 aep = {}189 for rp in rps:190 pct = 1 - 1/rp191 loss = float(np.quantile(annual_losses, pct))192 aep[rp] = round(loss, 2)193 return aep194 195 196def apply_reinsurance(annual_losses: np.ndarray,197 retention: float, limit: float) -> np.ndarray:198 """199 Apply a per-occurrence XL reinsurance layer.200 Net = Retained + min(max(loss - retention, 0), limit)201 Simplified: apply to aggregate annual loss.202 """203 ceded = np.minimum(np.maximum(annual_losses - retention, 0), limit)204 return annual_losses - ceded205 206 207def compute_aal(annual_losses: np.ndarray) -> float:208 """Average Annual Loss."""209 return round(float(np.mean(annual_losses)), 2)210 211 212def compute_pml(oep_curve: dict, return_period: int) -> float:213 """Probable Maximum Loss at a given return period from OEP curve."""214 return oep_curve.get(return_period, 0.0)215 216 217# ══════════════════════════════════════════════════════════════218# ETL: BRONZE → SILVER219# ══════════════════════════════════════════════════════════════220 221def run_etl_cat_to_silver():222 """223 Aggregate bronze_act_cat_events + bronze_act_losses (cat_flag=1)224 → silver_act_cat_losses with EP metrics.225 """226 from sqlalchemy import text227 eng = _get_engine()228 log.info("[AGENT15-ETL] bronze → silver cat losses")229 230 with eng.connect() as conn:231 events = pd.read_sql("""232 SELECT event_id, peril, event_date,233 industry_loss_bn, return_period_yrs, affected_states234 FROM bronze_act_cat_events235 """, conn)236 237 cat_losses = pd.read_sql("""238 SELECT lob, state_code,239 SUM(paid_loss + case_reserve) AS gross_loss,240 SUM(paid_alae) AS paid_alae,241 COUNT(DISTINCT claim_id) AS claim_count,242 cat_event_id243 FROM bronze_act_losses244 WHERE cat_flag = 1245 GROUP BY lob, state_code, cat_event_id246 """, conn)247 248 premiums = pd.read_sql("""249 SELECT lob, state_code, SUM(earned_premium) AS earned_premium250 FROM bronze_act_premiums251 GROUP BY lob, state_code252 """, conn)253 254 rows = []255 for lob in LOBS:256 for peril in PERILS:257 for state in list(STATE_TIV_WEIGHT.keys())[:10]:258 ep_row = premiums[(premiums.lob==lob) & (premiums.state_code==state)]259 ep = float(ep_row['earned_premium'].sum()) if not ep_row.empty else 500000.0260 tiv = ep * 80 # approximate TIV from EP261 262 # Run stochastic simulation for EP curve values263 sim = simulate_event_set(peril, lob, state, tiv,264 n_years=5000, seed=15)265 oep = build_oep_curve(sim)266 aep = build_aep_curve(sim)267 aal = compute_aal(sim)268 269 # Net of reinsurance (simple XL: retention=PML_10, limit=PML_100-PML_10)270 ret = oep.get(10, 0)271 limit = max(0, oep.get(100, 0) - ret)272 sim_net = apply_reinsurance(sim, ret, limit)273 oep_net = build_oep_curve(sim_net)274 net_aal = compute_aal(sim_net)275 276 rows.append(dict(277 lob = lob,278 peril = peril,279 state_code = state,280 model_vendor = 'INTERNAL',281 gross_loss = aal,282 ceded_loss = round(aal - net_aal, 2),283 ground_up_loss = round(aal * 1.12, 2),284 aep_1_in_10 = aep.get(10),285 aep_1_in_50 = aep.get(50),286 aep_1_in_100 = aep.get(100),287 aep_1_in_250 = aep.get(250),288 oep_1_in_10 = oep.get(10),289 oep_1_in_100 = oep.get(100),290 oep_1_in_250 = oep.get(250),291 pml_1_in_100 = oep.get(100),292 pml_1_in_250 = oep.get(250),293 aal = aal,294 ))295 296 silver_df = pd.DataFrame(rows).where(pd.notna(pd.DataFrame(rows)), other=None)297 with eng.begin() as conn:298 conn.execute(text("DELETE FROM silver_act_cat_losses"))299 silver_df.to_sql('silver_act_cat_losses', conn,300 if_exists='append', index=False, method='multi', chunksize=200)301 302 log.info(f"[AGENT15-ETL] silver_act_cat_losses: {len(silver_df)} rows")303 return {'cat_rows': len(silver_df)}304 305 306# ══════════════════════════════════════════════════════════════307# ML: GROUND-UP TO GROSS AMPLIFICATION308# ══════════════════════════════════════════════════════════════309 310def _build_features_15(peril: str, lob: str, state: str,311 tiv: float, return_period: int,312 ground_up: float, retention: float = 1e6) -> dict:313 params = PERIL_PARAMS.get(peril, PERIL_PARAMS['WIND'])314 return {315 'peril_enc': PERIL_ENC.get(peril, 0),316 'lob_enc': LOB_ENC.get(lob, 0),317 'state_enc': STATE_ENC.get(state, 0),318 'log_tiv': float(np.log(max(tiv, 1))),319 'vuln_factor': LOB_VULN.get(lob, {}).get(peril, 0.05),320 'freq_mean': params['freq'],321 'sev_mu': params['mu'],322 'sev_sigma': params['sigma'],323 'climate_decade_load': CLIMATE_LOADING.get(peril, 0.0),324 'state_tiv_weight': STATE_TIV_WEIGHT.get(state, 0.03),325 'return_period_log': float(np.log(max(return_period, 1))),326 'ground_up_log': float(np.log(max(ground_up, 1))),327 'reins_retention_log': float(np.log(max(retention, 1))),328 }329 330 331def _generate_training_data_15(n=5000, seed=15):332 rng = np.random.default_rng(seed)333 rows = []334 for _ in range(n):335 peril = rng.choice(PERILS)336 lob = rng.choice(LOBS)337 state = rng.choice(list(STATE_TIV_WEIGHT.keys()))338 tiv = float(rng.uniform(1e7, 5e9))339 rp = int(rng.choice(RETURN_PERIODS))340 ret = float(rng.uniform(5e5, 1e7))341 342 # Simulate and get gross PML at this RP343 sim = simulate_event_set(peril, lob, state, tiv, n_years=2000, seed=int(rng.integers(0, 9999)))344 oep = build_oep_curve(sim, [rp])345 gross_pml = oep[rp]346 ground_up = gross_pml * float(rng.uniform(0.85, 0.95))347 348 feats = _build_features_15(peril, lob, state, tiv, rp, ground_up, ret)349 feats['target_gross_pml'] = gross_pml350 rows.append(feats)351 352 return pd.DataFrame(rows)353 354 355def train_agent15_model():356 """Train XGBoost for gross PML amplification from ground-up loss."""357 import joblib358 from xgboost import XGBRegressor359 from sklearn.model_selection import train_test_split360 from sklearn.metrics import mean_absolute_error361 362 os.makedirs(MODELS_DIR, exist_ok=True)363 log.info("[AGENT15] Generating training data...")364 df = _generate_training_data_15(n=6000)365 X = df[FEATURE_COLS_15]366 y = df['target_gross_pml']367 368 X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=15)369 370 model = XGBRegressor(371 n_estimators=300, max_depth=6, learning_rate=0.05,372 subsample=0.85, colsample_bytree=0.85,373 min_child_weight=5, reg_alpha=0.1,374 random_state=15, verbosity=0375 )376 model.fit(X_tr, y_tr, eval_set=[(X_te, y_te)], verbose=False)377 mae = mean_absolute_error(y_te, model.predict(X_te))378 mae_pct = mae / y_te.mean() if y_te.mean() > 0 else 0379 log.info(f"[AGENT15] XGBoost MAE: {mae:,.0f} ({mae_pct:.2%} of mean)")380 381 bundle = dict(382 model=model, feature_cols=FEATURE_COLS_15,383 mae=mae, mae_pct=mae_pct, trained_at=str(date.today())384 )385 joblib.dump(bundle, MODEL_PATH)386 log.info(f"[AGENT15] Model saved → {MODEL_PATH}")387 return bundle388 389 390# ══════════════════════════════════════════════════════════════391# MAIN AGENT ENTRY POINTS392# ══════════════════════════════════════════════════════════════393 394def run_agent15(payload: dict) -> dict:395 """396 Full cat model for a LOB/peril combination.397 398 payload keys:399 lob, peril, [state_code], [tiv],400 [n_years (default 10000)],401 [climate_years_forward (default 10)],402 [reins_retention], [reins_limit],403 [model_scenario: BASE|STRESSED|CLIMATE_ADJ],404 [run_id]405 406 Returns OEP/AEP curves, PML, AAL, capital requirement, SHAP.407 """408 from sqlalchemy import text409 410 lob = str(payload.get('lob', 'HO'))411 peril = str(payload.get('peril', 'WIND')).upper()412 state = str(payload.get('state_code', 'FL'))413 run_id = payload.get('run_id') or f"AG15-{uuid.uuid4().hex[:12].upper()}"414 scenario = str(payload.get('model_scenario', 'BASE'))415 n_years = int(payload.get('n_years', 10000))416 cl_fwd = int(payload.get('climate_years_forward', 10))417 418 # TIV: from payload or estimated from silver EP419 tiv = payload.get('tiv')420 if not tiv:421 try:422 eng = _get_engine()423 with eng.connect() as conn:424 ep_row = pd.read_sql("""425 SELECT SUM(earned_premium) AS ep426 FROM silver_act_loss_ratios427 WHERE lob=%s AND state_code=%s428 """, conn, params=(lob, state))429 tiv = float(ep_row['ep'].iloc[0] or 5e7) * 80430 except Exception:431 tiv = 5e7432 tiv = float(tiv)433 434 log.info(f"[AGENT15] {lob}/{peril}/{state} scenario={scenario} TIV={tiv:,.0f} run={run_id}")435 436 # Scenario adjustments437 stress_mult = 1.0438 if scenario == 'STRESSED':439 stress_mult = 1.25440 elif scenario == 'CLIMATE_ADJ':441 cl_fwd = 30 # 3 decades forward442 443 # ── Stochastic simulation ─────────────────────────────────444 sim_gross = simulate_event_set(445 peril, lob, state, tiv * stress_mult,446 n_years=n_years, climate_years_forward=cl_fwd,447 seed=PERIL_ENC.get(peril,0) * 1000 + LOB_ENC.get(lob,0) * 100448 )449 450 oep_gross = build_oep_curve(sim_gross)451 aep_gross = build_aep_curve(sim_gross)452 aal_gross = compute_aal(sim_gross)453 454 # Net of reinsurance455 retention = float(payload.get('reins_retention', oep_gross.get(10, 1e6)))456 limit = float(payload.get('reins_limit',457 max(0, oep_gross.get(100, retention*3) - retention)))458 sim_net = apply_reinsurance(sim_gross, retention, limit)459 oep_net = build_oep_curve(sim_net)460 aep_net = build_aep_curve(sim_net)461 aal_net = compute_aal(sim_net)462 463 # Key metrics464 pml_100_gross = oep_gross.get(100, 0)465 pml_250_gross = oep_gross.get(250, 0)466 pml_100_net = oep_net.get(100, 0)467 pml_250_net = oep_net.get(250, 0)468 capital_req = pml_250_net # 1-in-250 net PML = cat capital469 rein_benefit = pml_100_gross - pml_100_net470 climate_load = (1 + CLIMATE_LOADING.get(peril, 0)) ** (cl_fwd/10) - 1471 472 # ── ML amplification model ────────────────────────────────473 ml_pml_100 = None474 shap_summary = {}475 top_drivers = []476 try:477 import joblib, shap as shap_lib478 bundle = joblib.load(MODEL_PATH)479 ground_up = pml_100_gross * 0.90480 feats = _build_features_15(peril, lob, state, tiv, 100, ground_up, retention)481 X = pd.DataFrame([feats])[bundle['feature_cols']]482 ml_pml_100 = round(float(bundle['model'].predict(X)[0]), 2)483 484 # SHAP485 explainer = shap_lib.TreeExplainer(bundle['model'])486 shap_vals = explainer.shap_values(X)487 shap_dict = {col: round(float(shap_vals[0][i]), 4)488 for i, col in enumerate(bundle['feature_cols'])}489 top_drivers = sorted(shap_dict.items(), key=lambda x: abs(x[1]), reverse=True)[:5]490 shap_summary = shap_dict491 except Exception as e:492 log.warning(f"[AGENT15] ML unavailable: {e}")493 494 # ── Full EP curve for UI ──────────────────────────────────495 oep_curve_full = [{'rp': rp, 'gross': oep_gross.get(rp, 0),496 'net': oep_net.get(rp, 0)} for rp in RETURN_PERIODS]497 aep_curve_full = [{'rp': rp, 'gross': aep_gross.get(rp, 0),498 'net': aep_net.get(rp, 0)} for rp in RETURN_PERIODS]499 500 # ── State breakdown (AAL by state for this peril/LOB) ─────501 state_aal = {}502 for s, w in list(STATE_TIV_WEIGHT.items())[:10]:503 s_sim = simulate_event_set(peril, lob, s, tiv * w / STATE_TIV_WEIGHT.get(state, 0.05),504 n_years=2000, seed=PERIL_ENC.get(peril,0)*500+STATE_ENC.get(s,0))505 state_aal[s] = compute_aal(s_sim)506 507 result = dict(508 run_id = run_id,509 lob = lob,510 peril = peril,511 state_code = state,512 model_scenario = scenario,513 tiv = tiv,514 n_years_simulated= n_years,515 climate_years_fwd= cl_fwd,516 climate_loading = round(climate_load, 4),517 # AAL518 gross_aal = aal_gross,519 net_aal = aal_net,520 # PML521 gross_pml_100 = pml_100_gross,522 gross_pml_250 = pml_250_gross,523 net_pml_100 = pml_100_net,524 net_pml_250 = pml_250_net,525 ml_pml_100 = ml_pml_100,526 # Capital527 capital_requirement = capital_req,528 reinsurance_benefit = rein_benefit,529 # Reinsurance structure used530 reins_retention = retention,531 reins_limit = limit,532 # EP curves533 oep_curve = oep_curve_full,534 aep_curve = aep_curve_full,535 # Explainability536 shap_summary = shap_summary,537 top_drivers = [{'feature': k, 'shap': v} for k, v in top_drivers],538 state_aal_breakdown = state_aal,539 )540 541 # ── Persist to gold ───────────────────────────────────────542 try:543 _persist_gold_15(result)544 except Exception as e:545 log.warning(f"[AGENT15] Gold persist failed: {e}")546 547 return _safe_json(result)548 549 550def _persist_gold_15(result: dict):551 from sqlalchemy import text552 eng = _get_engine()553 554 with eng.begin() as conn:555 conn.execute(text("""556 INSERT INTO gold_act_cat_model (557 run_id, lob, peril, state_code, model_scenario,558 gross_aal, net_aal, gross_pml_100, gross_pml_250,559 net_pml_100, net_pml_250,560 oep_curve_json, aep_curve_json,561 capital_requirement, reinsurance_benefit,562 climate_loading, model_vendor, model_version563 ) VALUES (564 :run_id, :lob, :peril, :state, :scenario,565 :g_aal, :n_aal, :g_pml100, :g_pml250,566 :n_pml100, :n_pml250,567 :oep, :aep,568 :cap, :rein_ben,569 :cl_load, 'INTERNAL', '1.0'570 )571 """), dict(572 run_id = result['run_id'],573 lob = result['lob'],574 peril = result['peril'],575 state = result['state_code'],576 scenario = result['model_scenario'],577 g_aal = result['gross_aal'],578 n_aal = result['net_aal'],579 g_pml100 = result['gross_pml_100'],580 g_pml250 = result['gross_pml_250'],581 n_pml100 = result['net_pml_100'],582 n_pml250 = result['net_pml_250'],583 oep = json.dumps(_safe_json(result['oep_curve'])),584 aep = json.dumps(_safe_json(result['aep_curve'])),585 cap = result['capital_requirement'],586 rein_ben = result['reinsurance_benefit'],587 cl_load = result['climate_loading'],588 ))589 590 # Audit log591 conn.execute(text("""592 INSERT INTO gold_act_audit_log (593 run_id, agent_name, agent_version, lob,594 analysis_date, function_performed,595 input_summary_json, output_summary_json,596 decision_factors_json, assumptions_json, confidence_score597 ) VALUES (598 :run_id, 'agent15_cat', '1.0', :lob,599 :adate, 'CAT_MODEL_EP_CURVES',600 :inp, :out, :factors, :assump, 0.82601 )602 """), dict(603 run_id = result['run_id'],604 lob = result['lob'],605 adate = date.today(),606 inp = json.dumps(_safe_json({607 'peril': result['peril'], 'state': result['state_code'],608 'tiv': result['tiv'], 'scenario': result['model_scenario'],609 })),610 out = json.dumps(_safe_json({611 'gross_aal': result['gross_aal'],612 'pml_100_gross':result['gross_pml_100'],613 'pml_250_net': result['net_pml_250'],614 'capital_req': result['capital_requirement'],615 })),616 factors= json.dumps(_safe_json(result.get('top_drivers', []))),617 assump = json.dumps({618 'model': 'STOCHASTIC_SIMULATION',619 'n_years': result['n_years_simulated'],620 'frequency': 'NEGATIVE_BINOMIAL',621 'severity': 'LOG_NORMAL',622 'climate_loading': CLIMATE_LOADING,623 }),624 ))625 626 log.info(f"[AGENT15] Gold persisted — run_id={result['run_id']}")627 628 629def run_agent15_batch(payload: dict = None) -> dict:630 """631 Run all LOB × Peril × scenario combinations.632 payload keys: [lob_filter], [peril_filter], [scenario], [run_id]633 """634 payload = payload or {}635 run_id = payload.get('run_id') or f"AG15-BATCH-{uuid.uuid4().hex[:8].upper()}"636 scenario = payload.get('model_scenario', 'BASE')637 log.info(f"[AGENT15-BATCH] Starting scenario={scenario} run={run_id}")638 639 lobs_to_run = [payload['lob_filter']] if payload.get('lob_filter') else LOBS640 perils_to_run = [payload['peril_filter']] if payload.get('peril_filter') else PERILS641 state = payload.get('state_code', 'FL')642 643 results, errors = [], []644 for lob in lobs_to_run:645 for peril in perils_to_run:646 try:647 r = run_agent15({648 'lob': lob, 'peril': peril,649 'state_code': state,650 'model_scenario': scenario,651 'run_id': run_id,652 'n_years': int(payload.get('n_years', 10000)),653 })654 results.append(r)655 except Exception as e:656 errors.append({'lob': lob, 'peril': peril, 'error': str(e)})657 658 total_capital = sum(r.get('capital_requirement', 0) for r in results)659 total_aal = sum(r.get('gross_aal', 0) for r in results)660 avg_cl_load = np.mean([r.get('climate_loading', 0) for r in results]) if results else 0661 662 summary = dict(663 run_id=run_id, scenario=scenario,664 total_combos=len(results), errors=len(errors),665 total_capital_requirement=round(total_capital, 2),666 total_gross_aal=round(total_aal, 2),667 avg_climate_loading=round(float(avg_cl_load), 4),668 max_pml_100=round(max((r.get('gross_pml_100',0) for r in results), default=0), 2),669 )670 log.info(f"[AGENT15-BATCH] Complete: {summary}")671 672 return {'run_id': run_id, 'summary': summary,673 'results': _safe_json(results), 'errors': errors}674 