From 6748b704b271e91d3aaaccecc226ce017481b897 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 14 Aug 2026 14:37:24 -0400 Subject: [PATCH] feat(library): six book-control rules for the risk category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The risk category shipped one rule, max_drawdown_breaker — which is also the rule now sitting above the fold in the README, because it is the one that shows host governance without implying a trade. The category that best demonstrates the product had the least in it. Adds six controls, each with a distinct failure it is the only rule to catch: daily_loss_limit DAY_LOSS_PCT >= 2 position_concentration_cap MAX_POSITION_PCT >= 25 correlation_cluster_guard CLUSTER_EXPOSURE_PCT >= 40 stale_data_halt FEED_AGE_SEC >= 30 leverage_ceiling GROSS_LEVERAGE >= 3 consecutive_loss_circuit CONSECUTIVE_LOSSES >= 4 All six emit only pause/observe and escalate to a named agent — none proposes a direction, so the category carries no performance or advice implication. Every series is nonnegative and rises as the situation worsens, so each control is a >= against a ceiling. Same direction on every rule makes a stack of them readable at a glance, and the new "Book-control signals" table in the library README documents the feed contract for all six. The comment headers carry the distinctions that make the rules worth having separately, rather than restating the threshold: - daily_loss_limit vs max_drawdown_breaker: peak-to-trough over the book's life can sit quiet through a catastrophic day if the book was up first. - position_concentration_cap vs leverage_ceiling: distribution vs total size. A 1x book entirely in one name passes the leverage limit. - correlation_cluster_guard vs position_concentration_cap: one instrument vs many instruments that are the same bet. - stale_data_halt vs event_liquidity_halt: can the book be traded, vs are the numbers real at all. - consecutive_loss_circuit vs daily_loss_limit: sequence vs magnitude. Four small losses can cost almost nothing and still be the clearest evidence the regime changed. IR fixtures are generated from compile_to_dict rather than hand-written, so the pairs match byte-for-byte by construction. Five execution tests cover the behaviour worth pinning: the >= boundary fires (a limit that only trips past its number lets a book sit on the limit), stale data emits PAUSE before OBSERVE in log order, concentration and leverage each stay silent on the other's failure, and a losing streak that resets never trips the circuit. 401 passed, 2 skipped. Library 26 -> 32 strategies. --- README.md | 2 +- nano/library/README.md | 15 +++- .../risk/consecutive_loss_circuit.nano | 32 ++++++++ .../risk/consecutive_loss_circuit_ir.json | 29 +++++++ .../risk/correlation_cluster_guard.nano | 33 ++++++++ .../risk/correlation_cluster_guard_ir.json | 33 ++++++++ nano/library/risk/daily_loss_limit.nano | 28 +++++++ nano/library/risk/daily_loss_limit_ir.json | 29 +++++++ nano/library/risk/leverage_ceiling.nano | 28 +++++++ nano/library/risk/leverage_ceiling_ir.json | 29 +++++++ .../risk/position_concentration_cap.nano | 28 +++++++ .../risk/position_concentration_cap_ir.json | 29 +++++++ nano/library/risk/stale_data_halt.nano | 32 ++++++++ nano/library/risk/stale_data_halt_ir.json | 33 ++++++++ tests/test_library.py | 77 +++++++++++++++++++ 15 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 nano/library/risk/consecutive_loss_circuit.nano create mode 100644 nano/library/risk/consecutive_loss_circuit_ir.json create mode 100644 nano/library/risk/correlation_cluster_guard.nano create mode 100644 nano/library/risk/correlation_cluster_guard_ir.json create mode 100644 nano/library/risk/daily_loss_limit.nano create mode 100644 nano/library/risk/daily_loss_limit_ir.json create mode 100644 nano/library/risk/leverage_ceiling.nano create mode 100644 nano/library/risk/leverage_ceiling_ir.json create mode 100644 nano/library/risk/position_concentration_cap.nano create mode 100644 nano/library/risk/position_concentration_cap_ir.json create mode 100644 nano/library/risk/stale_data_halt.nano create mode 100644 nano/library/risk/stale_data_halt_ir.json diff --git a/README.md b/README.md index 8efc674..1d4b8ec 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The same corpus is browsable at **[aethersystems.net/nano](https://aethersystems | Momentum | Mean reversion | Trend | Volatility | Volume | Risk | Event volatility | | --- | --- | --- | --- | --- | --- | --- | -| 4 strategies | 3 strategies | 3 strategies | 2 strategies | 2 strategies | 1 strategy | 11 strategies | +| 4 strategies | 3 strategies | 3 strategies | 2 strategies | 2 strategies | 7 strategies | 11 strategies | The library is a conformance corpus, not a performance claim, live signal service, or trading recommendation. diff --git a/nano/library/README.md b/nano/library/README.md index c68579d..c334534 100644 --- a/nano/library/README.md +++ b/nano/library/README.md @@ -24,7 +24,7 @@ The current library contains 26 strategies across seven familiar categories. Bro | `trend/` | `golden_cross`, `macd_histogram_flip`, `donchian_breakout` | | `volatility/` | `atr_volatility_halt`, `bb_squeeze_breakout` | | `volume/` | `volume_spike_confirmation`, `obv_trend` | -| `risk/` | `max_drawdown_breaker` | +| `risk/` | `max_drawdown_breaker`, `daily_loss_limit`, `position_concentration_cap`, `correlation_cluster_guard`, `stale_data_halt`, `leverage_ceiling`, `consecutive_loss_circuit` | | `event_volatility/` | `event_impulse_pullback_long`, `event_impulse_pullback_short`, `cpi_impulse_pullback_long`, `cpi_impulse_pullback_short`, `event_false_first_move_long`, `event_false_first_move_short`, `event_second_leg_long`, `event_second_leg_short`, `event_liquidity_halt`, `event_release_integrity_halt`, `event_whipsaw_halt` | ## Signal conventions @@ -50,6 +50,19 @@ Library entries compare **host-provided named signal series** with numeric liter | OBV trend | `OBV_SLOPE(20)` | Linear-regression slope of OBV | | equity drawdown | `DRAWDOWN` | Portfolio drawdown percentage | +### Book-control signals (`risk/`) + +Risk entries read **portfolio and infrastructure state**, not bar indicators. Every series is nonnegative and rises as the situation worsens, so a control is always a `>=` against a ceiling — the same direction on every rule, which is what makes a stack of them readable at a glance. These rules emit `pause` and `observe` only; none of them proposes a direction. + +| Book measurement | Nano signal | Feed convention | +| --- | --- | --- | +| session loss | `DAY_LOSS_PCT` | Realised session loss as a positive percentage of starting equity; host owns the session boundary | +| largest position | `MAX_POSITION_PCT` | Largest single position as a percentage of gross exposure | +| cluster exposure | `CLUSTER_EXPOSURE_PCT` | Largest correlated-cluster exposure as a percentage of gross; host owns cluster assignment | +| feed freshness | `FEED_AGE_SEC` | Seconds since the last accepted tick | +| gross leverage | `GROSS_LEVERAGE` | Gross exposure divided by equity | +| losing streak | `CONSECUTIVE_LOSSES` | Count of consecutive losing closed decisions; host defines close and loss | + ### Macro-event signals (`event_volatility/`) Event strategies consume a **host event engine** rather than bar indicators: the host arms a scheduled release window, measures the post-release tape deterministically, and publishes nonnegative bounded scores. Bull and bear terms are always separate series (never one signed value), which is what keeps twin-armed branches mutually exclusive and inside baseline IR. The full measurement definitions live in [`docs/event_signal_contract.md`](../../docs/event_signal_contract.md). diff --git a/nano/library/risk/consecutive_loss_circuit.nano b/nano/library/risk/consecutive_loss_circuit.nano new file mode 100644 index 0000000..0430385 --- /dev/null +++ b/nano/library/risk/consecutive_loss_circuit.nano @@ -0,0 +1,32 @@ +// Consecutive loss circuit: after four losing closed decisions in a row, pause +// and escalate to the risk desk for review. +// REGIME: all of them, though it is most informative in a regime the strategy +// was not built for - which is precisely when a run of losses appears. +// CONDITIONS: none. The host decides what closes a decision and what counts as +// a loss; Nano only reads the count. +// INVALIDATION: none. The counter is reset by the host, deliberately, after +// review. +// SHAPE: 5m; the count only changes when a decision closes, so a faster cadence +// re-reads an unchanged number. +// NOT daily_loss_limit: that measures magnitude, this measures sequence. Four +// small losses in a row can cost almost nothing and still be the clearest +// evidence available that the regime has changed. A book can trip this while +// nowhere near any loss limit, which is the point. +// CALIBRATED ON: strategies that close decisions discretely. A continuously +// rebalanced book has no natural notion of a consecutive loss and should not +// arm this. +strategy ConsecutiveLossCircuit { + + agent RiskDesk + + every 5m { + + if CONSECUTIVE_LOSSES >= 4 { + + pause() + + } + + } + +} diff --git a/nano/library/risk/consecutive_loss_circuit_ir.json b/nano/library/risk/consecutive_loss_circuit_ir.json new file mode 100644 index 0000000..4df93c9 --- /dev/null +++ b/nano/library/risk/consecutive_loss_circuit_ir.json @@ -0,0 +1,29 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "ConsecutiveLossCircuit", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "5m" + }, + { + "type": "Condition", + "signal": "CONSECUTIVE_LOSSES", + "operator": ">=", + "value": 4 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/nano/library/risk/correlation_cluster_guard.nano b/nano/library/risk/correlation_cluster_guard.nano new file mode 100644 index 0000000..e9e9589 --- /dev/null +++ b/nano/library/risk/correlation_cluster_guard.nano @@ -0,0 +1,33 @@ +// Correlation cluster guard: pause when one correlated cluster carries 40 +// percent or more of gross exposure, and flag it for review. +// REGIME: matters most in a stress regime, when correlations converge and a +// book that looked diversified stops being diversified. +// CONDITIONS: the host must publish a cluster assignment. Nano does not compute +// correlation - CLUSTER_EXPOSURE_PCT is the host's answer to "how much of the +// book is really one bet". +// INVALIDATION: the cluster breaking up, which is a host measurement and not +// something this rule can observe. +// SHAPE: 15m; cluster membership is a rolling estimate and re-checking it +// faster than it updates only adds noise. +// NOT position_concentration_cap: that one counts a single instrument. This one +// counts many instruments that happen to be the same trade. A book can pass the +// concentration cap on every line and fail this badly. +// CALIBRATED ON: multi-asset books. A single-sector mandate is one cluster by +// construction and should not arm this without raising the threshold. +strategy CorrelationClusterGuard { + + agent RiskDesk + + every 15m { + + if CLUSTER_EXPOSURE_PCT >= 40 { + + pause() + + observe() + + } + + } + +} diff --git a/nano/library/risk/correlation_cluster_guard_ir.json b/nano/library/risk/correlation_cluster_guard_ir.json new file mode 100644 index 0000000..3c80e42 --- /dev/null +++ b/nano/library/risk/correlation_cluster_guard_ir.json @@ -0,0 +1,33 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "CorrelationClusterGuard", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "15m" + }, + { + "type": "Condition", + "signal": "CLUSTER_EXPOSURE_PCT", + "operator": ">=", + "value": 40 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Intent", + "action": "OBSERVE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/nano/library/risk/daily_loss_limit.nano b/nano/library/risk/daily_loss_limit.nano new file mode 100644 index 0000000..cd36e53 --- /dev/null +++ b/nano/library/risk/daily_loss_limit.nano @@ -0,0 +1,28 @@ +// Daily loss limit: when the session's realised loss reaches 2 percent of +// starting equity, pause all proposals and hand control to the risk desk. +// REGIME: all of them. This is a control, not a directional hypothesis. +// CONDITIONS: none. It is armed from the session open. +// INVALIDATION: none. A limit is not a trade. It resets on the next session +// boundary, which the host owns - Nano has no clock of its own. +// SHAPE: 1m, so a fast tape cannot run through the limit between checks. +// NOT max_drawdown_breaker: that measures peak-to-trough over the life of the +// book and can sit quiet through a catastrophic single day if the book was up +// beforehand. This one resets daily and catches exactly that case. Both should +// be armed; neither subsumes the other. +// CALIBRATED ON: nothing instrument-specific. 2 percent is a session-level +// figure and travels unchanged. +strategy DailyLossLimit { + + agent RiskDesk + + every 1m { + + if DAY_LOSS_PCT >= 2 { + + pause() + + } + + } + +} diff --git a/nano/library/risk/daily_loss_limit_ir.json b/nano/library/risk/daily_loss_limit_ir.json new file mode 100644 index 0000000..988908a --- /dev/null +++ b/nano/library/risk/daily_loss_limit_ir.json @@ -0,0 +1,29 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "DailyLossLimit", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "1m" + }, + { + "type": "Condition", + "signal": "DAY_LOSS_PCT", + "operator": ">=", + "value": 2 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/nano/library/risk/leverage_ceiling.nano b/nano/library/risk/leverage_ceiling.nano new file mode 100644 index 0000000..92ba0b3 --- /dev/null +++ b/nano/library/risk/leverage_ceiling.nano @@ -0,0 +1,28 @@ +// Leverage ceiling: pause when gross exposure reaches 3x equity. +// REGIME: all of them. Leverage is a constraint, not a forecast. +// CONDITIONS: none. Armed whenever the book is open. +// INVALIDATION: none. It is reset by reducing size, deliberately, outside this +// rule. +// SHAPE: 5m; gross exposure moves on fills and on marks, and 5m is fast enough +// to catch both without re-checking a number that has not changed. +// NOT position_concentration_cap: that measures how the book is distributed, +// this measures how large it is in total. A perfectly diversified book at 5x +// gross passes the concentration cap and belongs to this rule. +// CALIBRATED ON: nothing instrument-specific, but 3x is a house figure rather +// than a universal one - a futures book and a cash equity book do not mean the +// same thing by "gross". +strategy LeverageCeiling { + + agent RiskDesk + + every 5m { + + if GROSS_LEVERAGE >= 3 { + + pause() + + } + + } + +} diff --git a/nano/library/risk/leverage_ceiling_ir.json b/nano/library/risk/leverage_ceiling_ir.json new file mode 100644 index 0000000..baea8f0 --- /dev/null +++ b/nano/library/risk/leverage_ceiling_ir.json @@ -0,0 +1,29 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "LeverageCeiling", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "5m" + }, + { + "type": "Condition", + "signal": "GROSS_LEVERAGE", + "operator": ">=", + "value": 3 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/nano/library/risk/position_concentration_cap.nano b/nano/library/risk/position_concentration_cap.nano new file mode 100644 index 0000000..4f9e0d1 --- /dev/null +++ b/nano/library/risk/position_concentration_cap.nano @@ -0,0 +1,28 @@ +// Position concentration cap: pause when any single position exceeds 25 +// percent of the book. +// REGIME: all of them. Concentration risk does not care about trend. +// CONDITIONS: none. Armed whenever the book is open. +// INVALIDATION: none. It is reset by the book changing shape, not by a signal. +// SHAPE: 5m; position weights move on fills, not on ticks, so a faster cadence +// would add checks without adding information. +// NOT leverage_ceiling: that measures total size against equity, this measures +// distribution. A book at 1x gross with everything in one name is fully inside +// the leverage limit and is exactly the situation this rule exists for. +// CALIBRATED ON: nothing instrument-specific. 25 percent is a book-level +// figure. A concentrated book by mandate should raise it deliberately rather +// than disarm the rule. +strategy PositionConcentrationCap { + + agent RiskDesk + + every 5m { + + if MAX_POSITION_PCT >= 25 { + + pause() + + } + + } + +} diff --git a/nano/library/risk/position_concentration_cap_ir.json b/nano/library/risk/position_concentration_cap_ir.json new file mode 100644 index 0000000..6474c00 --- /dev/null +++ b/nano/library/risk/position_concentration_cap_ir.json @@ -0,0 +1,29 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "PositionConcentrationCap", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "5m" + }, + { + "type": "Condition", + "signal": "MAX_POSITION_PCT", + "operator": ">=", + "value": 25 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/nano/library/risk/stale_data_halt.nano b/nano/library/risk/stale_data_halt.nano new file mode 100644 index 0000000..972c5d9 --- /dev/null +++ b/nano/library/risk/stale_data_halt.nano @@ -0,0 +1,32 @@ +// Stale data halt: pause when the feed has not delivered an accepted tick for +// 30 seconds, and flag the gap for review. +// REGIME: all of them, and it outranks every directional rule. A signal +// computed from a stale tape is not a weak signal, it is a fabricated one. +// CONDITIONS: none. Armed whenever anything downstream is consuming the feed. +// INVALIDATION: none. Freshness is not a view. +// SHAPE: 1m. The cadence bounds how long a stale tape can go unnoticed, so it +// is set to the tightest interval the library uses. +// NOT event_liquidity_halt: that asks whether the book can be traded, this asks +// whether the numbers describing it are real at all. A liquid market with a +// dead feed passes that rule and fails this one. +// CALIBRATED ON: liquid instruments on a continuous session. A thin or +// session-bound instrument has legitimate 30-second gaps and needs a wider +// bound, or the rule fires all day and gets disarmed - which is worse than +// never arming it. +strategy StaleDataHalt { + + agent RiskDesk + + every 1m { + + if FEED_AGE_SEC >= 30 { + + pause() + + observe() + + } + + } + +} diff --git a/nano/library/risk/stale_data_halt_ir.json b/nano/library/risk/stale_data_halt_ir.json new file mode 100644 index 0000000..86f453e --- /dev/null +++ b/nano/library/risk/stale_data_halt_ir.json @@ -0,0 +1,33 @@ +{ + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "StaleDataHalt", + "effects": [ + "intent.emit", + "log.append" + ], + "nodes": [ + { + "type": "Schedule", + "interval": "1m" + }, + { + "type": "Condition", + "signal": "FEED_AGE_SEC", + "operator": ">=", + "value": 30 + }, + { + "type": "Intent", + "action": "PAUSE" + }, + { + "type": "Intent", + "action": "OBSERVE" + }, + { + "type": "Agent", + "name": "RiskDesk" + } + ] +} diff --git a/tests/test_library.py b/tests/test_library.py index 1e110aa..3c34856 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -211,6 +211,83 @@ def test_event_release_integrity_halt_pauses_on_conflict(): ] +def test_daily_loss_limit_fires_at_the_boundary_not_below_it(): + # 2.0 is >= 2, so the boundary tick fires. This is where a control differs + # from an entry: a limit that only trips *past* its number lets the book sit + # exactly on the limit indefinitely. + graph = _load("risk/daily_loss_limit.nano") + frame = MarketFrame( + timestamps=(0, 60, 120), + signals={"DAY_LOSS_PCT": (0.4, 2.0, 3.1)}, + ) + result = execute(graph, frame) + assert [(i.action, i.timestamp) for i in result.intents] == [ + ("PAUSE", 60), + ("PAUSE", 120), + ] + assert [a.name for a in graph.agents] == ["RiskDesk"] + + +def test_stale_data_halt_emits_pause_then_observe_in_order(): + # The ordered run log is the product here: a host reading it needs to know + # the halt came first and the review flag second, not merely that both fired. + graph = _load("risk/stale_data_halt.nano") + frame = MarketFrame( + timestamps=(0, 60), + signals={"FEED_AGE_SEC": (2.0, 45.0)}, + ) + result = execute(graph, frame) + assert [(i.action, i.timestamp) for i in result.intents] == [ + ("PAUSE", 60), + ("OBSERVE", 60), + ] + + +def test_concentration_and_leverage_are_independent_controls(): + # The distinction the two rules exist to make: a book can be perfectly + # diversified and over-levered, or unlevered and entirely in one name. + # Neither rule catches the other's failure, which is why both ship. + concentration = _load("risk/position_concentration_cap.nano") + leverage = _load("risk/leverage_ceiling.nano") + + # 1x gross, everything in one position -> concentration only. + lopsided = MarketFrame( + timestamps=(0, 300), + signals={"MAX_POSITION_PCT": (80.0, 80.0), "GROSS_LEVERAGE": (1.0, 1.0)}, + ) + assert [i.action for i in execute(concentration, lopsided).intents] == ["PAUSE", "PAUSE"] + assert execute(leverage, lopsided).intents == () + + # 5x gross spread thinly -> leverage only. + spread_thin = MarketFrame( + timestamps=(0, 300), + signals={"MAX_POSITION_PCT": (4.0, 4.0), "GROSS_LEVERAGE": (5.0, 5.0)}, + ) + assert execute(concentration, spread_thin).intents == () + assert [i.action for i in execute(leverage, spread_thin).intents] == ["PAUSE", "PAUSE"] + + +def test_consecutive_loss_circuit_ignores_a_streak_that_resets(): + # Three losses, a win (host resets the count), then three more. The streak + # never reaches four, so a rule counting losses rather than tracking runs + # would fire here and this one must not. + graph = _load("risk/consecutive_loss_circuit.nano") + frame = MarketFrame( + timestamps=(0, 300, 600, 900, 1200, 1500, 1800), + signals={"CONSECUTIVE_LOSSES": (1.0, 2.0, 3.0, 0.0, 1.0, 2.0, 3.0)}, + ) + assert execute(graph, frame).intents == () + + +def test_correlation_cluster_guard_ignores_a_diversified_book(): + graph = _load("risk/correlation_cluster_guard.nano") + frame = MarketFrame( + timestamps=(0, 900, 1800), + signals={"CLUSTER_EXPOSURE_PCT": (12.0, 28.0, 39.9)}, # never >= 40 + ) + assert execute(graph, frame).intents == () + + def test_atr_halt_emits_no_intent_in_calm_regime(): graph = _load("volatility/atr_volatility_halt.nano") frame = MarketFrame(