2. Basic Simulation

This tutorial covers the core simulation mechanics in detail, including how the business model evolves over time and how to interpret simulation results.

Tip: For a quick insured-vs-uninsured comparison without managing individual objects, use run_analysis() — see Tutorial 1: Getting Started.

2.1. The Widget Manufacturer Model

The framework models a manufacturing business with the following financial dynamics:

  1. Revenue Generation: Revenue = Assets × Asset Turnover Ratio

  2. Operating Income: Operating Income = Revenue × Operating Margin

  3. Net Income: Net Income = Operating Income × (1 - Tax Rate)

  4. Growth: Assets grow through retained earnings

2.1.1. Creating a Manufacturer

from ergodic_insurance import Config, ManufacturerConfig, WidgetManufacturer

# Quick start — use defaults ($10M assets, 8% margin, 50-year horizon)
config = Config()
manufacturer = WidgetManufacturer(config.manufacturer)

# Or customize specific parameters
config = Config(
    manufacturer=ManufacturerConfig(
        initial_assets=10_000_000,
        asset_turnover_ratio=1.0,
        base_operating_margin=0.08,
        retention_ratio=1.0,
        ppe_ratio=0.5,
    )
)
manufacturer = WidgetManufacturer(config.manufacturer)

# Check initial state
print(f"Assets: ${manufacturer.assets:,.0f}")
print(f"Equity: ${manufacturer.equity:,.0f}")
print(f"Expected Revenue: ${manufacturer.assets * config.manufacturer.asset_turnover_ratio:,.0f}")

2.2. Running a Year Step-by-Step

The step() method advances the simulation by one year:

# Run one year of operations
metrics = manufacturer.step(
    growth_rate=0.05           # 5% asset growth target
)

# Examine year-end metrics
print(f"Revenue: ${metrics['revenue']:,.0f}")
print(f"Operating Income: ${metrics['operating_income']:,.0f}")
print(f"Net Income: ${metrics['net_income']:,.0f}")
print(f"New Assets: ${metrics['assets']:,.0f}")
print(f"ROE: {metrics['roe']:.2%}")

2.3. Processing Insurance Claims

When a loss occurs, the manufacturer processes it through insurance:

# Simulate a large loss event
claim_amount = 2_000_000  # $2M claim

# Process through insurance with deductible and limit
manufacturer.process_insurance_claim(
    claim_amount=claim_amount,
    deductible=100_000,        # $100K retained by company
    insurance_limit=10_000_000  # $10M policy limit
)

# Run the year with the claim impact
metrics = manufacturer.step(
    letter_of_credit_rate=0.015  # 1.5% collateral cost
)

print(f"Net Income after claim: ${metrics['net_income']:,.0f}")
print(f"Outstanding Claims: ${metrics['claim_liabilities']:,.0f}")

2.4. Understanding Claim Development

Claims don’t pay out all at once. The framework models realistic claim development:

# Claim payment schedule (cumulative percentages)
# Year 1:  10%
# Year 2:  30% (+20%)
# Year 3:  50% (+20%)
# Year 4:  65% (+15%)
# Year 5:  75% (+10%)
# Year 6:  83% (+8%)
# Year 7:  90% (+7%)
# Year 8:  95% (+5%)
# Year 9:  98% (+3%)
# Year 10: 100% (+2%)

# The manufacturer tracks collateral requirements during development
print(f"Current Collateral: ${manufacturer.collateral:,.0f}")

2.5. The Simulation Class

For multi-year simulations, use the Simulation class:

from ergodic_insurance import Simulation, ManufacturingLossGenerator

# Reset manufacturer
manufacturer = WidgetManufacturer(config)

# Create claim generator
claims = ManufacturingLossGenerator.create_simple(
    frequency=0.15,      # 15% annual claim probability
    severity_mean=800_000,    # $800K average claim
    severity_std=960_000,     # High variability (1.2x mean)
    seed=42                   # Reproducible results
)

# Create simulation
sim = Simulation(
    manufacturer=manufacturer,
    loss_generator=claims,
    time_horizon=30           # 30-year horizon
)

# Run simulation
results = sim.run()

2.6. Analyzing Simulation Results

2.6.1. Basic Statistics

import numpy as np

print("=== Simulation Summary ===")
print(f"Survived: {'Yes' if results.insolvency_year is None else 'No'}")
print(f"Final Assets: ${results.assets[-1]:,.0f}")
print(f"Final Equity: ${results.equity[-1]:,.0f}")
print(f"Total Claims: {results.claim_counts.sum():.0f}")
print(f"Total Claim Amounts: ${results.claim_amounts.sum():,.0f}")

# ROE analysis
valid_roe = results.roe[~np.isnan(results.roe)]
print(f"\nROE Statistics:")
print(f"  Mean: {np.mean(valid_roe):.2%}")
print(f"  Median: {np.median(valid_roe):.2%}")
print(f"  Std Dev: {np.std(valid_roe):.2%}")
print(f"  Min: {np.min(valid_roe):.2%}")
print(f"  Max: {np.max(valid_roe):.2%}")

2.6.2. Time-Weighted vs Simple Average ROE

The time-weighted ROE captures the true compound growth experience:

# Simple arithmetic mean
simple_avg = np.mean(valid_roe)

# Time-weighted (geometric) mean
time_weighted = results.calculate_time_weighted_roe()

# Rolling ROE for trend analysis
rolling_5yr = results.calculate_rolling_roe(window=5)

print(f"Simple Average ROE: {simple_avg:.2%}")
print(f"Time-Weighted ROE: {time_weighted:.2%}")
print(f"Difference: {(simple_avg - time_weighted):.2%}")

# The difference indicates non-ergodic behavior
if simple_avg > time_weighted:
    print("-> Volatility drag is reducing actual growth")

2.6.3. Export to DataFrame

import pandas as pd

# Full time series
df = results.to_dataframe()

# Add derived metrics
df['cumulative_return'] = (df['equity'] / df['equity'].iloc[0]) - 1
df['year_over_year_growth'] = df['assets'].pct_change()

# Display
print(df.head(10))

# Save to file
df.to_csv('simulation_results.csv', index=False)

2.7. Visualizing Results

import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

# Asset growth
axes[0, 0].plot(results.years, results.assets / 1e6)
axes[0, 0].set_xlabel('Year')
axes[0, 0].set_ylabel('Assets ($M)')
axes[0, 0].set_title('Asset Growth Over Time')
axes[0, 0].grid(True, alpha=0.3)

# Equity evolution
axes[0, 1].plot(results.years, results.equity / 1e6)
axes[0, 1].set_xlabel('Year')
axes[0, 1].set_ylabel('Equity ($M)')
axes[0, 1].set_title('Equity Evolution')
axes[0, 1].grid(True, alpha=0.3)

# ROE over time
axes[1, 0].bar(results.years, results.roe * 100, alpha=0.7)
axes[1, 0].axhline(y=simple_avg * 100, color='r', linestyle='--', label='Mean')
axes[1, 0].set_xlabel('Year')
axes[1, 0].set_ylabel('ROE (%)')
axes[1, 0].set_title('Annual Return on Equity')
axes[1, 0].legend()
axes[1, 0].grid(True, alpha=0.3)

# Claim events
axes[1, 1].bar(results.years, results.claim_amounts / 1e6, color='orange', alpha=0.7)
axes[1, 1].set_xlabel('Year')
axes[1, 1].set_ylabel('Claim Amount ($M)')
axes[1, 1].set_title('Claim Events')
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('simulation_visualization.png', dpi=150)
plt.show()

2.8. Handling Insolvency

When equity falls below the insolvency tolerance, the simulation terminates:

# Check for insolvency
if results.insolvency_year is not None:
    print(f"Company went insolvent in year {results.insolvency_year}")

    # Analyze pre-insolvency trajectory
    pre_insolvency = results.equity[:results.insolvency_year + 1]
    print(f"Peak equity: ${max(pre_insolvency):,.0f}")
    print(f"Final equity: ${pre_insolvency[-1]:,.0f}")
else:
    print("Company survived the full simulation period")

    # Calculate compound annual growth rate (CAGR)
    initial = results.equity[0]
    final = results.equity[-1]
    years = len(results.years)
    cagr = (final / initial) ** (1 / years) - 1
    print(f"Equity CAGR: {cagr:.2%}")

2.9. Multiple Simulations with Different Seeds

To understand the range of outcomes:

from ergodic_insurance import ManufacturerConfig, WidgetManufacturer, ManufacturingLossGenerator, Simulation

# Run multiple simulations
n_simulations = 10
outcomes = []

for seed in range(n_simulations):
    # Fresh manufacturer for each run
    mfg = WidgetManufacturer(config)

    # Claim generator with different seed
    claims = ManufacturingLossGenerator.create_simple(
        frequency=0.15,
        severity_mean=800_000,
        severity_std=960_000,
        seed=seed
    )

    # Run simulation
    sim = Simulation(manufacturer=mfg, loss_generator=claims, time_horizon=30)
    results = sim.run()

    outcomes.append({
        'seed': seed,
        'survived': results.insolvency_year is None,
        'final_equity': results.equity[-1] if results.insolvency_year is None else 0,
        'time_weighted_roe': results.calculate_time_weighted_roe()
    })

# Summarize outcomes
survived = sum(1 for o in outcomes if o['survived'])
print(f"\nSurvival Rate: {survived}/{n_simulations} ({survived/n_simulations:.0%})")
print(f"Mean Final Equity (survivors): ${np.mean([o['final_equity'] for o in outcomes if o['survived']]):,.0f}")
print(f"Mean Time-Weighted ROE: {np.mean([o['time_weighted_roe'] for o in outcomes]):.2%}")

2.10. Next Steps