PCAgentinAI / agent15_cat.py
ITNovaML's picture
Add ActuarialOS agents 13-16 and API routes
058e2ee
Raw
History Blame Contribute Delete
28.1 kB
"""
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}