Shock Operations¶
Gaspatchio provides a composable shock system for actuarial stress testing. Think of shocks as lego bricks - simple operations you can combine to build complex regulatory scenarios.
This page teaches you:
- The building blocks - Individual shock operations (multiply, add, clip, etc.)
- How to filter - Apply shocks to specific segments or time periods
- How to compose - Chain operations together for complex transformations
- How to build regulatory scenarios - Solvency II SCR as a worked example
The Building Blocks¶
Every shock transforms assumption values. The basic operations are:
| Operation | What it does | Example use case |
|---|---|---|
multiply |
Scale by a factor | "Increase mortality by 20%" |
add |
Add a constant | "Shift rates up 50bps" |
set |
Replace with a value | "Assume zero lapses" |
clip |
Cap/floor values | "Lapse cannot exceed 100%" |
Multiplicative Shocks¶
Scale values by a factor. Use for percentage changes.
from gaspatchio.scenarios import MultiplicativeShock
# Increase mortality by 20%
shock = MultiplicativeShock(factor=1.2, table="mortality")
# Decrease lapse rates by 10%
shock = MultiplicativeShock(factor=0.9, table="lapse")
Config syntax:
{"table": "mortality", "multiply": 1.2}
Additive Shocks¶
Add a constant delta. Use for basis point shifts and absolute changes.
from gaspatchio.scenarios import AdditiveShock
# Add 50bps to discount rates
shock = AdditiveShock(delta=0.005, table="discount_rates")
# Subtract 1% from expense loading
shock = AdditiveShock(delta=-0.01, table="expenses")
Config syntax:
{"table": "discount_rates", "add": 0.005}
Override Shocks¶
Replace all values with a constant. Use for extreme scenarios and boundary testing.
from gaspatchio.scenarios import OverrideShock
# Set lapse rates to zero
shock = OverrideShock(value=0.0, table="lapse")
# Override discount rate to flat 5%
shock = OverrideShock(value=0.05, table="discount_rates")
Config syntax:
{"table": "lapse", "set": 0.0}
Clip Shocks¶
Cap and/or floor values to a range. Essential for keeping shocked values actuarially valid.
from gaspatchio.scenarios import ClipShock
# Cap lapse rates at 100%
shock = ClipShock(max_value=1.0, table="lapse")
# Floor mortality at 0.1%
shock = ClipShock(min_value=0.001, table="mortality")
# Clip to a valid range
shock = ClipShock(min_value=0.0, max_value=1.0, table="rates")
Config syntax:
{"table": "lapse", "clip": {"max": 1.0}}
{"table": "mortality", "clip": {"min": 0.001}}
{"table": "rates", "clip": {"min": 0.0, "max": 1.0}}
Syntactic sugar - use [min, max] arrays with null for unbounded:
{"table": "lapse", "clip": [null, 1.0]}
{"table": "rates", "clip": [0.0, 1.0]}
Filtering Shocks¶
Shocks can be scoped to a subset of rows — specific product segments, time windows, or any condition expressible against the table.
WHERE Clause: Dimension Filters¶
Apply shocks only to rows matching conditions on table dimensions.
from gaspatchio.scenarios import FilteredShock, MultiplicativeShock
# Increase early-duration lapse by 25%
shock = FilteredShock(
shock=MultiplicativeShock(factor=1.25),
where={"duration": {"lte": 3}},
table="lapse",
)
# Mortality shock for elderly lives
shock = FilteredShock(
shock=MultiplicativeShock(factor=1.15),
where={"attained_age": {"gte": 65}},
table="mortality",
)
# Complex filter with multiple conditions (AND logic)
shock = FilteredShock(
shock=AdditiveShock(delta=0.02),
where={"sex": "F", "smoker_status": "S"},
table="mortality",
)
Config syntax:
{"table": "lapse", "multiply": 1.25, "where": {"duration": {"lte": 3}}}
{"table": "mortality", "multiply": 1.15, "where": {"attained_age": {"gte": 65}}}
Supported filter operators:
| Operator | Meaning | Example |
|---|---|---|
eq |
Equals | {"sex": {"eq": "F"}} or just {"sex": "F"} |
ne |
Not equals | {"product": {"ne": "TERM"}} |
gt |
Greater than | {"age": {"gt": 60}} |
gte |
Greater than or equal | {"duration": {"gte": 5}} |
lt |
Less than | {"age": {"lt": 30}} |
lte |
Less than or equal | {"duration": {"lte": 3}} |
between |
Range (inclusive) | {"age": {"between": [30, 50]}} |
in |
In list | {"product": {"in": ["TERM", "WL"]}} |
not_in |
Not in list | {"status": {"not_in": ["LAPSED", "DEATH"]}} |
WHEN Clause: Time Conditions¶
Apply shocks only at specific projection times.
from gaspatchio.scenarios import TimeConditionalShock, AdditiveShock
# Mass lapse at t=0 (40% immediate surrender)
shock = TimeConditionalShock(
shock=AdditiveShock(delta=0.40),
when={"t": {"eq": 0}},
table="lapse",
)
# Expense shock for first 5 years only
shock = TimeConditionalShock(
shock=MultiplicativeShock(factor=1.10),
when={"t": {"lte": 5}},
table="expenses",
)
Config syntax:
{"table": "lapse", "add": 0.40, "when": {"t": {"eq": 0}}}
{"table": "expenses", "multiply": 1.10, "when": {"t": {"lte": 5}}}
Combining WHERE and WHEN¶
You can use both filters together:
{
"table": "lapse",
"multiply": 1.5,
"where": {"product": "TERM"},
"when": {"t": {"lte": 5}}
}
This reads: "Increase lapse by 50% for TERM products during the first 5 years."
Composing Operations¶
Complex regulatory scenarios often need multiple operations applied in sequence.
Pipeline Shocks¶
Chain operations together. Each step's output becomes the next step's input.
from gaspatchio.scenarios import PipelineShock, MultiplicativeShock, ClipShock
# Solvency II lapse up: multiply by 1.5, then cap at 100%
shock = PipelineShock(
shocks=[
MultiplicativeShock(factor=1.5),
ClipShock(max_value=1.0),
],
table="lapse",
)
Config syntax:
{
"table": "lapse",
"pipeline": [
{"multiply": 1.5},
{"clip": {"max": 1.0}}
]
}
Syntactic sugar - combine operation + clip in one config:
{"table": "lapse", "multiply": 1.5, "clip": [null, 1.0]}
This is equivalent to the pipeline above but more concise.
Max Shocks¶
Take the maximum (less severe) of two transformations. Essential for Solvency II lapse down.
from gaspatchio.scenarios import MaxShock, MultiplicativeShock, AdditiveShock
# Solvency II lapse down: max(lapse × 0.5, lapse - 0.2)
# This means: reduce by 50% OR reduce by 20pp, whichever is less severe
shock = MaxShock(
shock_a=MultiplicativeShock(factor=0.5),
shock_b=AdditiveShock(delta=-0.2),
table="lapse",
)
Config syntax:
{"table": "lapse", "max": [{"multiply": 0.5}, {"add": -0.2}]}
Min Shocks¶
Take the minimum (more severe) of two transformations.
from gaspatchio.scenarios import MinShock, MultiplicativeShock, OverrideShock
# Cap mortality at 10% even after stress
shock = MinShock(
shock_a=MultiplicativeShock(factor=1.5),
shock_b=OverrideShock(value=0.1),
table="mortality",
)
Config syntax:
{"table": "mortality", "min": [{"multiply": 1.5}, {"set": 0.1}]}
Parameter Shocks¶
Some model inputs are scalar parameters, not assumption tables. Use ParameterShock for these.
from gaspatchio.scenarios import ParameterShock
# Add 1% to expense inflation
shock = ParameterShock(param="expense_inflation", operation="add", value=0.01)
# Apply in model code
base_inflation = 0.02
shocked_inflation = shock.apply(base_inflation) # Returns 0.03
Config syntax:
{"param": "expense_inflation", "add": 0.01}
{"param": "discount_spread", "multiply": 1.5}
{"param": "commission_rate", "set": 0.05}
Parameter shocks are different
ParameterShock is NOT a Shock subclass. It doesn't generate Polars expressions -
instead, it provides an apply(base_value) method for your model code to use.
Configuration Syntax¶
The shock system supports LLM-friendly JSON/dict configs that can be generated from natural language.
For the why (auditability, reproducibility, and no regenerated assumption tables) plus richer actuarial examples, see Natural Language → Executable Configs.
Parsing Configs¶
from gaspatchio.scenarios import parse_shock_config, parse_scenario_config
# Parse a single shock
config = {"table": "mortality", "multiply": 1.2}
shock = parse_shock_config(config)
# Returns: MultiplicativeShock(factor=1.2, table="mortality")
# Parse a full scenario configuration
config = [
{"id": "BASE"},
{
"id": "STRESS",
"shocks": [
{"table": "mortality", "multiply": 1.2},
{"table": "lapse", "multiply": 0.8},
],
},
]
scenarios = parse_scenario_config(config)
# Returns: {"BASE": [], "STRESS": [MultiplicativeShock(...), MultiplicativeShock(...)]}
Full Config Schema¶
{
"id": "SCENARIO_NAME",
"shocks": [
{
"table": "table_name",
"column": "optional_column",
"pipeline": [
{"multiply": 1.5},
{"clip": {"max": 1.0}}
],
"where": {"dimension": {"operator": "value"}},
"when": {"t": {"operator": "value"}}
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
table |
string | Yes* | Target assumption table |
param |
string | Yes* | Target scalar parameter (*use one of table/param) |
column |
string | No | Specific column within table |
pipeline |
array | No | Sequence of operations |
where |
object | No | Dimension filter conditions |
when |
object | No | Time filter conditions |
| Operation | varies | Yes | One of: multiply, add, set, clip, max, min |
Putting It Together: Solvency II SCR¶
Here's how the building blocks combine into real regulatory scenarios.
The Solvency II Lapse Shocks¶
| Shock | Requirement | Config |
|---|---|---|
| Lapse Up | min(lapse × 1.5, 1.0) |
Pipeline: multiply then clip |
| Lapse Down | max(lapse × 0.5, lapse - 0.2) |
Max of two transformations |
| Mass Lapse | 40% surrender at t=0 | Time-conditional additive |
import polars as pl
from gaspatchio.assumptions import Table
from gaspatchio.scenarios import parse_scenario_config
# Baseline lapse table the shocks overlay (governed assumptions stay untouched)
lapse_table = Table(
name="lapse",
source=pl.DataFrame({
"duration": [1, 2, 3, 4, 5],
"lapse_rate": [0.08, 0.06, 0.05, 0.04, 0.03],
}),
dimensions={"duration": "duration"},
value="lapse_rate",
)
# Define Solvency II lapse scenarios
config = [
{"id": "BASE"},
{
"id": "LAPSE_UP",
"shocks": [
{
"table": "lapse",
"pipeline": [
{"multiply": 1.5},
{"clip": {"max": 1.0}}
]
}
]
},
{
"id": "LAPSE_DOWN",
"shocks": [
{"table": "lapse", "max": [{"multiply": 0.5}, {"add": -0.2}]}
]
},
{
"id": "MASS_LAPSE",
"shocks": [
{"table": "lapse", "add": 0.40, "when": {"t": {"eq": 0}}}
]
},
]
# Parse into shock objects, then pass to a ScenarioRun for an audit trail.
scenarios = parse_scenario_config(config)
from gaspatchio.scenarios import ScenarioRun, Sum
plan = ScenarioRun(
shocks=scenarios,
base_tables={"lapse": lapse_table},
aggregations=(Sum("bel").alias("bel"),),
)
print(plan.describe())
plan.describe() emits a human-readable summary of the shocks per scenario; plan.source_sha() captures the entire recipe as a content hash for the audit chain.
Complete Working Example¶
End-to-end: declare the lapse shocks as a JSON config, parse them into a ScenarioRun, run the plan, read per-scenario aggregates.
import polars as pl
from gaspatchio import ActuarialFrame
from gaspatchio.assumptions import Table
from gaspatchio.scenarios import (
ScenarioRun,
Sum,
parse_scenario_config,
)
# Sample lapse table
lapse_table = Table(
name="lapse",
source=pl.DataFrame({
"duration": [1, 2, 3, 4, 5],
"lapse_rate": [0.08, 0.06, 0.05, 0.04, 0.03],
}),
dimensions={"duration": "duration"},
value="lapse_rate",
)
# Model points
def policies():
return ActuarialFrame({
"policy_id": ["P001", "P002", "P003"],
"duration": [1, 3, 5],
})
# Shock recipes — JSON in, Shock objects out
shock_config = [
{"id": "BASE"},
{
"id": "LAPSE_UP",
"shocks": [{"table": "lapse", "pipeline": [{"multiply": 1.5}, {"clip": {"max": 1.0}}]}],
},
{
"id": "LAPSE_DOWN",
"shocks": [{"table": "lapse", "max": [{"multiply": 0.5}, {"add": -0.2}]}],
},
]
shocks = parse_scenario_config(shock_config)
# Model function — receives shocked tables per scenario. ScenarioRun stacks
# the lapse table with a scenario_id dimension so a single lookup resolves
# the shocked rate for whichever scenario the batch is running.
def model(af, *, tables, drivers=None):
rate = tables["lapse"].lookup(
duration=af["duration"],
scenario_id=af["scenario_id"],
)
return af.with_columns(rate.alias("shocked_lapse"))
# Run all three scenarios in one pass
plan = ScenarioRun(
shocks=shocks,
base_tables={"lapse": lapse_table},
aggregations=(Sum("shocked_lapse").alias("total_lapse").over("scenario_id"),),
)
result = plan.run(policies(), model, batch_size=1)
print(result.aggregations["total_lapse"].sort("scenario_id"))
Framework-Agnostic Patterns¶
While we used Solvency II as an example, these patterns apply to any regulatory framework:
| Framework | Common Shocks | Gaspatchio Pattern |
|---|---|---|
| Solvency II | Lapse up/down, mass lapse, expense inflation | Pipeline + clip, max, time-conditional |
| IFRS 17 | Risk adjustment scenarios | Multiplicative on all decrements |
| US RBC | C1-C4 factors | Multiplicative, often filtered by product |
| ORSA | Management actions, stress scenarios | Combined where + when filters |
| ALM | Interest rate shocks | Additive on curve tables |
The building blocks are universal - only the specific factors and combinations change.
API Quick Reference¶
See the Scenarios API for full function signatures.
| Function/Class | Purpose |
|---|---|
ScenarioRun |
Typed plan: shocks, base tables, aggregations, audit chain |
for_each_scenario() |
Bounded-memory scenario loop with built-in batching |
parse_shock_config() |
Parse a single shock dict → Shock object |
parse_scenario_config() |
Parse a scenario list → dict of shocks |
with_scenarios() |
Low-level cross-join of ActuarialFrame × scenarios |
Shock Classes:
| Class | Operation |
|---|---|
MultiplicativeShock |
Scale by factor |
AdditiveShock |
Add constant |
OverrideShock |
Replace with value |
ClipShock |
Cap/floor values |
PipelineShock |
Chain operations |
FilteredShock |
WHERE clause |
TimeConditionalShock |
WHEN clause |
MaxShock |
Maximum of two shocks |
MinShock |
Minimum of two shocks |
RelativeFloorShock |
Floor relative to original value (e.g. cannot decrease by more than delta) |
ParameterShock |
Scalar parameter shocks |
See Also¶
- What-If Analysis - Simple question → config translation
- Table Sensitivities - Python API for applying shocks to tables
- Scenarios Overview - High-level scenario concepts
- Performance - Optimizing large scenario runs