When to Use
Use this skill when a risk limit has been breached and the response has to be proportionate to how far past the limit the metric is and how long it has stayed there. A control with only two settings — ignore, or liquidate — either tolerates a 105% drawdown breach that runs for two hours, or force-flattens a book over a 101% blip. This engine turns the breach into a tier and returns the action, the notification channels, and the acknowledgement deadline that tier warrants, plus an audit row explaining why.
That graduation is what MiFID II RTS 6 Art. 17 contemplates for a triggered post-trade control: "appropriate action, which may include adjusting or shutting down the relevant trading algorithm or trading system or an orderly withdrawal from the market" — a ladder, not a switch.
Typical inputs: daily drawdown, gross/net exposure, leverage, VaR, position count, order-to-trade ratio (ceilings), and free margin, cash buffer or collateral coverage (floors).
When NOT to Use
- As the enforcement layer. This engine decides; it does not act. It cancels no orders, flattens no positions and trips no kill switch.
actionis an instruction to your enforcement layer — and under SEC Rule 15c3-5(d) that layer must be "under the direct and exclusive control of the broker or dealer". For the enforcement side seekill-switch-and-drawdown-circuit-breakersandstrategy-level-kill-switch-vs-portfolio-level-kill-switch. - As the notifier. There is no PagerDuty, Slack, e-mail or ticketing integration.
notification_channelsis routing intent, not proof of delivery, andack_deadline_secondsis an SLA for your notifier to enforce — the engine runs no timer and tracks no acknowledgement. Roster resolution and ack-SLA auditing belong toon-call-rotation-and-escalation-for-trading-systems. - As the breach detector. It evaluates one observation you hand it. It does not poll metrics, does not hold a clock, and cannot measure how long a breach has persisted —
duration_secondsis the caller's to supply. A caller that always passes0.0gets no duration escalation, however long the breach actually runs. - As the source of your limits. The ladder is a response policy, not a calibration. Derive the limits themselves from
risk-limit-calibration-against-historical-drawdowns; the 1.0/1.2/1.5/2.0 multipliers here are house defaults with no empirical or regulatory basis. - As a pre-trade control. RTS 6 Art. 15 and Rule 15c3-5(c)(1)(i) require orders that would breach a threshold to be blocked or rejected before entry. This is a post-breach response: by the time it runs, the exposure exists. It supplements a pre-trade gateway and never replaces one.
- Inside the alert-latency budget without measuring it. For an EU firm, RTS 6 Art. 16 requires real-time alerts "within five seconds after the relevant event". This engine's decision is microseconds, but your metric computation, transport and notifier are not — budget the whole path, per
risk-control-latency-budget.
Prerequisites
- A breach observation:
event_id,metric_name,strategy_id,current_value,limit_value,timestamp_iso(ISO-8601 with a UTC offset — a naive timestamp is rejected), and optionallyduration_secondsanddirection. - A decision on
directionper metric, because it selects the ratio formula.UPPER(default) for ceilings;LOWERfor floors. - For
UPPER,current_valuesupplied as a non-negative magnitude. A drawdown passed as-25000against a limit of10000is rejected, not reinterpreted. - An escalation ladder that is genuinely a ladder: thresholds strictly ascending, severity and action non-decreasing along it, every tier routed to at least one channel. The engine validates this at construction.
- An enforcement layer and a notifier to consume
actionandnotification_channels, plus somewhere durable to persist the audit trail.
Workflow
-
Compute the breach ratio in the right direction:
UPPER: $\text{ratio} = \text{current} / \text{limit}$.LOWER: $\text{ratio} = 1 + (\text{limit} - \text{current}) / \text{limit}$, floored at 0 — sitting exactly on the floor is 1.0, and an exhausted buffer is 2.0. That mapping is a house calibration so one ladder serves both directions, not a standard.- Decision point — a negative
UPPERmetric is an error, not a datum. The engine cannot distinguish "drawdown expressed as a negative" from a genuine two-sided exposure by inspection. Guessing produced a ratio of $-2.5$ for a 2.5x drawdown, which matched no tier and returnedNONE. Pass the magnitude, or setLOWER. - Decision point — compare on the exact ratio, never a rounded one. Rounding to 4dp before comparison turns 1.99996x into 2.0000 and force-liquidates a book at a threshold it never reached. Round for display only.
-
Match the ratio to the highest tier it satisfies:
- Walk the ladder downward; the first tier with $\text{ratio} \ge \text{threshold}$ wins. Thresholds are inclusive: exactly 1.0x is a breach.
- Decision point — a sub-threshold event is still recorded. A
BreachEventthat evaluates below the lowest tier is evidence about the upstream detector, so it produces aNONEdecision that is written to the audit trail rather than discarded.
-
Apply duration escalation as a whole-rung promotion:
- At or beyond
sustained_breach_seconds(default 300 s), promote to the next rung — severity, action, channels and acknowledgement deadline together. - Decision point — promoting the action alone is the bug this replaces. Raising a sustained AMBER breach to a RED/HALT while leaving its alerts on Slack and e-mail with the 300 s AMBER deadline produces exactly the unrouted-critical-notification failure this skill exists to prevent. Promote the rung, not the verb.
- Decision point — promotion is positional, so custom ladders work. Escalation moves to
policies[i+1], whatever actions that ladder is built from. A hard-codedWARN→REDUCE→HALTchain silently escalates nothing for a ladder usingTHROTTLEorGLOBAL_KILL_SWITCH, and never escalates a RED breach at all. - Decision point — the top rung does not promote. A sustained CRITICAL breach stays CRITICAL and the audit note says so. There is nothing above it to promote to.
- At or beyond
-
Latch the incident, then route and record:
- Decision point — escalation ratchets. A metric oscillating around a threshold must not cancel an in-flight FLATTEN on the next tick, so a later observation of the same
(strategy_id, metric_name)cannot return a weaker action than the incident has already reached. De-escalation is deliberate and logged:reset_incident(). - Decision point — do not re-fire a destructive action on a retried alert. An identical
event_idwith an identical payload returns the original decision markedis_replay=Trueand adds no second audit row. The sameevent_idwith a changed payload is a re-evaluation of an ongoing breach — the normal way a monitor reports a growing duration — and is processed. - Hand
actionto the enforcement layer andnotification_channelsto the notifier, then persist the audit row.
- Decision point — escalation ratchets. A metric oscillating around a threshold must not cancel an in-flight FLATTEN on the next tick, so a later observation of the same
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- A NaN risk metric reads as "no breach":
nan >= thresholdisFalsefor every tier, so a corrupt feed silently disables the entire escalation matrix — no alert, no action, and the absence of an alarm looks exactly like safety. The engine rejects non-finite input instead. - Sign-convention mismatch between the detector and the matrix: the single most likely way this control fails in production is a drawdown reported as a negative number. It produces a negative ratio, matches no tier, and returns
NONEfor a catastrophic breach. - Rounding the ratio before comparing it: a display concern that becomes a trading action. 1.99996x rounded to 2.0000 triggers a CRITICAL force-flatten below the threshold.
- Escalating the action without escalating the routing: a HALT announced only on Slack at 03:00 is a HALT nobody knows about until the desk opens.
- Retrying an alert that already flattened: alert pipelines retry on timeout, and
FLATTENis not idempotent at the enforcement layer. Dedupe onevent_idand payload, not onevent_idalone — deduping on the id alone would freeze an ongoing breach at its first decision and prevent duration escalation entirely. - Letting a metric oscillate its way out of an escalation: without latching, one tick back under the threshold silently downgrades an active CRITICAL incident to a WARN.
- Treating the defaults as standards: the 1.0/1.2/1.5/2.0 multipliers, the 300 s sustained window and the 900/300/120/60 s acknowledgement deadlines are house defaults. No regulator prescribes them; do not cite them as compliance evidence.
- Sharing a mutable ladder between engines: when
DEFAULT_POLICIESis a module-level list of mutable policies, one component reassigning a tier's action changes the response of every other engine in the process. The ladder and its rungs are frozen for this reason. - Trusting
ack_deadline_secondsto be enforced: it is a number in a record. Nothing in this engine watches the clock or notices that no one acknowledged. - Configuring a ladder that is not monotone: a higher tier with a milder action or a lower severity means a worse breach gets a weaker response. Rejected at construction rather than discovered during an incident.
- Relying on this instead of a pre-trade control: by the time an escalation decision exists, the position exists. RTS 6 Art. 15 and Rule 15c3-5(c)(1)(i) are about preventing entry.
Verification
- Instantiate
RiskEscalationMatrix()and process a 2.5xDAILY_DRAWDOWNbreach ($25000/10000$): expectCRITICAL,FLATTEN, channels exactly(PAGERDUTY, COMPLIANCE_TICKET),ack_deadline_seconds == 60. - Tier boundaries are inclusive and exact: ratios 1.0, 1.2, 1.5 and 2.0 must map to WARN/INFO/900s, REDUCE/AMBER/300s, HALT/RED/120s and FLATTEN/CRITICAL/60s respectively; 1.4999 must stay on the 1.2 rung; 1.99996 must match the 1.5 rung, not 2.0 (the rounding regression).
- Duration escalation promotes a whole rung: 1.3x held 600 s must return
HALTwithPAGERDUTYrouting andack_deadline_seconds == 120— not HALT on the AMBER channels. 1.6x held 4 h must reachFLATTEN. 2.5x held 24 h must stayFLATTENwithis_duration_escalated is False. The boundary is inclusive: 300.0 s is sustained, 299.999 s is not. LOWERdirection, computed by hand against a 50,000 floor: 60,000 → ratio 0.8,NONE; 50,000 → 1.0,WARN; 40,000 → 1.2,REDUCE; 0 → 2.0,FLATTEN; 125,000 → floored at 0.0.- Fail-closed checks — each must raise:
NaN/Infmetric, negativeUPPERmetric,limit_value <= 0, negative duration, blankevent_id/metric_name/strategy_id, a boolean metric, a naive or unparseabletimestamp_iso, andevaluate(1e9, 0.0). A numeric string ("2.5") from a JSON payload must be accepted. - Ladder validation must reject: an empty
policieslist (it must not silently restore the defaults), duplicate thresholds, a weakening action, a decreasing severity, an unrouted tier, a non-positive ack timeout, and non-ascending legacy levels including all-equal ones. - Latching and replay: after a FLATTEN, a 1.05x observation of the same strategy/metric must still return
FLATTENwithis_latched is True;reset_incident()must restoreWARN; a byte-identical resubmission must yield exactly one FLATTEN row withis_replay is Trueon the second call, while the same id with a longer duration must be re-evaluated. - Audit rows must be frozen (assignment raises), must carry
current_value,limit_value,duration_secondsand the normalised UTCtimestamp_iso, and must include sub-thresholdNONEdecisions. - Run
python -m unittest discover -s skills/risk-limit-breach-escalation-matrix/scriptsand confirm 100% pass rate.
Related Skills
kill-switch-and-drawdown-circuit-breakersstrategy-level-kill-switch-vs-portfolio-level-kill-switchrisk-control-bypass-audit-loggingrisk-limit-calibration-against-historical-drawdownson-call-rotation-and-escalation-for-trading-systemsposition-limit-breach-simulation-fire-drillsrisk-control-latency-budgetmargin-utilization-circuit-breaker