NAIC VM-20 Compliance Frameworks

A NAIC VM-20 compliance framework is the engineered control plane that turns the principle-based reserving requirements of the NAIC Valuation Manual into a reproducible, examiner-ready filing pipeline: it computes the Net Premium Reserve, the Deterministic Reserve, and the Stochastic Reserve, reconciles them into a single minimum reserve, and proves every figure traces back to an approved assumption set. This guide shows how to build that framework in Python so reserve adequacy is a property the system guarantees by construction rather than a spreadsheet checked the night before the filing deadline. It sits inside the broader Regulatory Architecture & Compliance Mapping discipline that binds statutory text to production code.

VM-20 compliance framework: requirement matrix to clause-tagged audit trail The NAIC VM-20 requirement matrix drives three parallel computational tracks — scenario generation, liability valuation, and capital requirement — which converge on an aggregation and reconciliation stage that emits a single minimum reserve into a clause-tagged, examiner-ready audit trail. NAIC VM-20 requirement matrix Scenario generation economic paths · seeds Liability valuation cash-flow projection Capital requirement CTE tail · exclusion tests Aggregation & reconciliation Clause-tagged audit trail examiner-ready

The Problem This Framework Solves

VM-20 (“Requirements for Principle-Based Reserves for Life Products”) replaced formulaic reserving for most US life business with a three-piece calculation, and the difficulty is not any single number — it is proving the three numbers are mutually consistent, correctly aggregated, and reconstructable on demand. Under VM-20 Section 2, the minimum reserve for a policy group is the greatest of the three components, subject to the exclusion tests, and each component pulls from a different computational path:

  • Net Premium Reserve (NPR) — VM-20 Section 3 — a seriatim, formula-driven floor computed with prescribed mortality and valuation interest rates.
  • Deterministic Reserve (DR) — VM-20 Section 4 — the greatest present value of accumulated deficiencies over a single prescribed economic scenario, run through a full cash-flow model with prudent-estimate assumptions.
  • Stochastic Reserve (SR) — VM-20 Section 5 — the CTE 70 (Conditional Tail Expectation at the 70th percentile) of the modeled reserve across a full set of stochastic economic scenarios.

The minimum reserve, ignoring reinsurance and deferred-premium adjustments, is conceptually:

MinReserve=max(NPR,  DR,  SR)\text{MinReserve} = \max\bigl(\text{NPR},\; \text{DR},\; \text{SR}\bigr)

and the stochastic component is the average of the worst 30% of scenario reserves:

SR=CTE70=10.30niWRi,W=worst 30% of {R1,,Rn}\text{SR} = \text{CTE}_{70} = \frac{1}{\lceil 0.30\,n \rceil} \sum_{i \in W} R_i, \qquad W = \text{worst } 30\% \text{ of } \{R_1, \dots, R_n\}

where each scenario reserve (R_i) is itself the greatest present value of accumulated deficiency along that scenario’s path. A company may skip the SR if it passes the Stochastic Exclusion Test in VM-20 Section 6 (the ratio metric must sit at or below 6.0%), and may skip the DR under the corresponding Deterministic Exclusion Test — but the decision to exclude is itself a documented, reproducible artifact an examiner will re-derive. The failure this framework prevents is the one every valuation actuary fears: a filed reserve that cannot be reproduced, because the exclusion test, the scenario seed, or the assumption version was never captured. That gap is what turns a routine examination into a re-opening and a capital add-on.

Architecture and Scope

This framework owns the reserve aggregation and reconciliation layer — it consumes validated assumptions and scenario reserves produced upstream and emits a single, clause-tagged minimum reserve plus the evidence package. It deliberately does not own scenario generation or assumption setting; those are separate subsystems it depends on. The diagram above shows the VM-20 requirement matrix fanning out into the three computational tracks, converging on an aggregation step, and terminating in an audit record where every output figure carries the VM-20 clause it satisfies.

The three-component VM-20 reserve pipeline: NPR, DR, SR into max(), reconciliation, and audit Three reserve tracks — Net Premium Reserve (VM-20 Section 3), Deterministic Reserve (Section 4), and Stochastic Reserve (Section 5, CTE 70) — converge on a Section 2 max() aggregation node that picks the binding component; the result passes a reconciliation gate that requires agreement with the independent projection model within tolerance before it is sealed into a clause-tagged audit ledger. Net Premium Reserve (NPR) VM-20 · Section 3 Seriatim formula floor prescribed mortality & interest Deterministic Reserve (DR) VM-20 · Section 4 Greatest PV of deficiency single prescribed scenario Stochastic Reserve (SR) VM-20 · Section 5 CTE 70 over the stochastic scenario set max( · ) Section 2 aggregation picks binding component Reconciliation gate |Δ| ≤ tolerance vs. projection model Clause-tagged audit ledger each figure ↔ VM-20 clause

Prerequisites

Before wiring the reconciliation layer, the reader should have the following in place:

  • Python packages. pydantic>=2.0 for boundary schema enforcement, numpy>=1.24 for vectorized present-value and CTE math, pandas>=2.0 for seriatim cohort framing, and pytest for regression fixtures. Reproducibility depends on pinning every version in the run manifest.
  • Actuarial data contracts. An in-force extract keyed by policy_id carrying valuation_date, product_code, face_amount, issue_date, and the reserve inputs; a prescribed-assumption set (valuation mortality, valuation interest) for the NPR; and a prudent-estimate assumption set for the DR and SR.
  • Upstream systems. Scenario reserves arrive from a stochastic scenario generation engine, calibrated to the prescribed economic scenarios and reconciled with economic scenario mapping and yield-curve alignment. Mortality and behavioral inputs are governed by the Assumption Validation & Rule Engine Design control plane, with decrements sourced from the mortality and morbidity and policy lapse and surrender engines. Cleaned, typed input flows in from the Actuarial Model Ingestion & Testing Workflows pipeline.
  • Regulatory context. Familiarity with the VM-20 Section 2 minimum-reserve definition, the VM-31 PBR Actuarial Report documentation requirement, and the cross-jurisdictional expectations of the OSFI Model Risk Management Guidelines for teams filing on both sides of the border.

Core Implementation

The reconciliation layer begins where every reliable pipeline begins: a schema that rejects a malformed reserve record at the boundary. Using Pydantic schema enforcement means a negative reserve, a scenario set that is too small to support CTE 70, or a valuation date that disagrees with the run manifest fails loudly at ingestion rather than silently mis-stating a quarter’s balance sheet.

from datetime import date
import numpy as np
from pydantic import BaseModel, Field, field_validator, model_validator


class VM20ReserveInputs(BaseModel):
    """One reserve group's VM-20 components, validated at the boundary."""

    policy_group_id: str
    valuation_date: date
    net_premium_reserve: float = Field(ge=0.0)
    deterministic_reserve: float = Field(ge=0.0)
    scenario_reserves: list[float] = Field(min_length=50)
    cte_level: float = Field(default=0.70, ge=0.0, lt=1.0)
    reconciliation_tolerance: float = Field(default=0.005, gt=0.0)

    @field_validator("scenario_reserves")
    @classmethod
    def _finite_reserves(cls, values: list[float]) -> list[float]:
        arr = np.asarray(values, dtype=np.float64)
        if not np.isfinite(arr).all():
            raise ValueError("scenario_reserves contains NaN or inf")
        if (arr < 0).any():
            raise ValueError("scenario_reserves must be non-negative")
        return values

    @model_validator(mode="after")
    def _enough_scenarios_for_cte(self) -> "VM20ReserveInputs":
        tail = int(np.ceil((1.0 - self.cte_level) * len(self.scenario_reserves)))
        if tail < 1:
            raise ValueError("scenario set too small to support the requested CTE level")
        return self


def stochastic_reserve(scenario_reserves: list[float], cte_level: float = 0.70) -> float:
    """CTE(70): mean of the worst (1 - cte_level) fraction of scenario reserves."""
    arr = np.sort(np.asarray(scenario_reserves, dtype=np.float64))[::-1]
    tail_count = int(np.ceil((1.0 - cte_level) * arr.size))
    return float(arr[:tail_count].mean())


def minimum_reserve(inputs: VM20ReserveInputs) -> dict:
    """Aggregate the three VM-20 components into the reported minimum reserve."""
    sr = stochastic_reserve(inputs.scenario_reserves, inputs.cte_level)
    components = {
        "NPR": inputs.net_premium_reserve,
        "DR": inputs.deterministic_reserve,
        "SR": sr,
    }
    binding_component = max(components, key=components.get)
    reserve = components[binding_component]

    # Reserve adequacy ratio: how far the binding reserve sits above the NPR floor.
    reserve_adequacy_ratio = reserve / inputs.net_premium_reserve if inputs.net_premium_reserve else float("inf")

    return {
        "policy_group_id": inputs.policy_group_id,
        "valuation_date": inputs.valuation_date.isoformat(),
        "components": components,
        "binding_component": binding_component,
        "minimum_reserve": reserve,
        "reserve_adequacy_ratio": reserve_adequacy_ratio,
    }

The stochastic_reserve helper is the mathematical heart of Section 5: sort descending, take the ceiling of the tail fraction, average it. Isolating it as a pure function is what makes it unit-testable against a hand-worked example. The minimum_reserve function then applies the Section 2 max and reports which component binds — a fact examiners specifically ask for, because a group where the SR binds is a group whose tail assumptions deserve extra scrutiny.

Configuration and Tuning

Every threshold in a VM-20 framework is a regulatory parameter, not a magic number, so each belongs in versioned configuration that is itself captured in the audit trail. The most consequential knobs:

from pydantic import BaseModel, Field


class VM20Config(BaseModel):
    # Section 5: CTE level for the stochastic reserve. Prescribed at 70%.
    cte_level: float = Field(default=0.70, ge=0.50, le=0.99)

    # Section 6: stochastic exclusion test ratio ceiling (pass if <= 6.0%).
    stochastic_exclusion_ratio_max: float = Field(default=0.06, gt=0.0)

    # Reconciliation band between independently computed reserve figures.
    reconciliation_tolerance: float = Field(default=0.005, gt=0.0)

    # Minimum scenario count for a credible CTE(70) tail (2000 is a common floor).
    min_scenario_count: int = Field(default=1000, ge=100)

    # Prescribed-scenario generator version, pinned for reproducibility.
    scenario_generator_version: str = "GOES-2024.1"

Three tuning notes matter in production. First, the reconciliation_tolerance of 0.5% is not a VM-20 figure — it is your control on the gap between the reserve the projection model reports and the reserve the reconciliation layer independently recomputes; a breach means the two implementations have diverged and neither can be trusted. Second, min_scenario_count trades runtime against tail stability: too few scenarios and the CTE 70 estimate jitters between valuations, manufacturing spurious reserve volatility. Third, scenario_generator_version must be pinned and logged, because a silent generator upgrade changes the prescribed scenarios and therefore the reserve, with no code change to blame.

Step-by-Step Walkthrough

Bring the pieces above together in this order for a single reserve group:

  1. Ingest and type the inputs. Load the in-force extract and assumption sets through the ingestion pipeline, then instantiate VM20ReserveInputs; a validation error here stops the run before any reserve is mis-stated.
  2. Run the exclusion tests. Evaluate the Section 6 Stochastic and Deterministic Exclusion Tests against stochastic_exclusion_ratio_max; record the pass/fail and the ratio, because the decision to omit a component is a filed conclusion.
  3. Compute the NPR. Apply the Section 3 seriatim formula with prescribed valuation mortality and interest — this is the deterministic floor.
  4. Compute the DR. Run the single prescribed scenario through the cash-flow model and take the greatest present value of accumulated deficiency (Section 4).
  5. Compute the SR. Feed the stochastic scenario reserves into stochastic_reserve() to obtain CTE 70 (Section 5).
  6. Aggregate and reconcile. Call minimum_reserve() to apply the Section 2 max, then assert the result agrees with the projection model within reconciliation_tolerance.
  7. Seal the record. Write the components, the binding component, the exclusion-test outcomes, the assumption versions, and the scenario seed to the actuarial audit trail so the filing is reproducible. A fuller treatment of clause-by-clause traceability lives in How to Map Actuarial Models to NAIC VM-20 Requirements.

Validation and Testing

Correctness in a reserving framework is not “the code runs” — it is “the number equals the number an independent reviewer would compute.” Three layers of testing make that provable:

import numpy as np
import pytest
from vm20 import VM20ReserveInputs, stochastic_reserve, minimum_reserve


def test_cte70_matches_hand_worked_tail():
    # 10 scenarios; worst 30% -> ceil(0.30 * 10) = 3 scenarios averaged.
    reserves = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
    assert stochastic_reserve(reserves, cte_level=0.70) == pytest.approx(900.0)


def test_minimum_reserve_reports_binding_component():
    inputs = VM20ReserveInputs(
        policy_group_id="UL-2024-A",
        valuation_date="2024-12-31",
        net_premium_reserve=1_000_000.0,
        deterministic_reserve=1_050_000.0,
        scenario_reserves=[1_100_000.0] * 60,
    )
    result = minimum_reserve(inputs)
    assert result["binding_component"] == "SR"
    assert result["minimum_reserve"] == pytest.approx(1_100_000.0)


def test_reconciliation_within_tolerance():
    inputs = VM20ReserveInputs(
        policy_group_id="UL-2024-A",
        valuation_date="2024-12-31",
        net_premium_reserve=1_000_000.0,
        deterministic_reserve=1_050_000.0,
        scenario_reserves=list(np.full(2000, 1_020_000.0)),
    )
    computed = minimum_reserve(inputs)["minimum_reserve"]
    projection_model_reserve = 1_022_000.0  # from the independent engine
    drift = abs(computed - projection_model_reserve) / projection_model_reserve
    assert drift <= inputs.reconciliation_tolerance

Beyond unit tests, run a reconciliation checkpoint every valuation that recomputes the minimum reserve in a clean environment from the sealed manifest and asserts it matches the filed figure to the cent — this is the mechanical proof of reproducibility VM-31 expects. Layer on regression fixtures that pin last quarter’s reserve so an unintended assumption or code change surfaces as a failing test, not a restated filing. Finally, apply drift monitoring via the dynamic threshold tuning engine so that when emerging experience pushes an assumption past its Population Stability Index band, the framework flags recalibration before the next filing rather than after an examiner does.

Failure Modes and Gotchas

  • Off-by-one in the CTE tail. CTE 70 averages the ceiling of the worst 30%, not exactly 30%. With 60 scenarios that is 18, not 18.0 rounded down to 17. Using int(0.30 * n) instead of ceil understates the reserve on small scenario sets — a defect an examiner will catch instantly.
  • Silent scenario-generator drift. If scenario_generator_version is not pinned and logged, a generator upgrade changes the prescribed scenarios and moves the SR with no code diff to explain it. Reproducibility dies quietly. Pin it in config and seal it in the trail.
  • Float non-determinism across environments. Summing present values in a different order, or on a different BLAS build, can shift a reserve by pennies and trip the reconciliation gate. Use a canonical accumulation order and a fixed float format when serializing figures for hashing.
  • Exclusion-test amnesia. Passing the Section 6 test and skipping the SR is legitimate — but only if the ratio and the decision are recorded. An unlogged exclusion is indistinguishable from a forgotten calculation under examination.
  • PII leaking into the reserve record. Seriatim inputs carry policyholder identifiers. Tokenize them through the data security and PII boundary layer before any figure is written to the trail, and lean on the pandas and NumPy pipeline to aggregate to the reporting cohort so raw records never reach the submission boundary.
  • Missing the window. All of this is moot if the sealed package arrives late; wire the run into the NAIC filing deadline alerts so a reconciliation failure is escalated with time to remediate.

Up a level: Regulatory Architecture & Compliance Mapping


Regulatory references: NAIC Valuation Manual VM-20 (Requirements for Principle-Based Reserves for Life Products) and VM-31 (PBR Actuarial Report).