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(