CoolFace
Apppublic

ITNovaML/PCAgentinAI

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
agent15_cat.py674 linesDownload Raw Back to root
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