Aggregate risk across strategies¶
Per-strategy daily-loss limits are common in trading frameworks. Portfolio-level caps that span strategies and accounts are commercial-only or homegrown. lrvx ships an open-source reference implementation in the C++ engine: a single-process, in-memory aggregator that combines PnL and exposure across N registered strategies, applies portfolio-level rules, and trips a kill switch when any rule is breached. Every binding (Python, Node, Codon, QuickJS) drives the same engine state through the C ABI.
Multi-process aggregation through shared state is a Phase 2 concern. The public API stays the same when that backend lands.
Quick start¶
from lrvx.portfolio_risk import (
PortfolioRiskAggregator, RiskRules,
)
aggregator = PortfolioRiskAggregator(
rules=RiskRules(
max_drawdown_pct=0.20,
max_daily_loss=10_000,
max_gross_exposure=500_000,
max_concentration_pct=0.40,
),
initial_equity=100_000,
on_breach=my_kill_switch.activate,
)
aggregator.update(
"ema-trend",
realized_pnl=120.0, unrealized_pnl=-30.0, fees=-2.50,
gross_exposure=5_000.0, net_exposure=4_500.0, trade_count=12,
)
breach = aggregator.check_order(
strategy="ema-trend", notional=2_500.0, side="buy",
)
if breach is not None:
reject_order(breach.detail)
const lrvx = require('@lrvx/lrvx');
const aggregator = new lrvx.PortfolioRiskAggregator({
rules: {
maxDrawdownPct: 0.20,
maxDailyLoss: 10_000,
maxGrossExposure: 500_000,
maxConcentrationPct: 0.40,
},
initialEquity: 100_000,
});
aggregator.update('ema-trend', {
realizedPnl: 120, unrealizedPnl: -30, fees: -2.50,
grossExposure: 5_000, netExposure: 4_500, tradeCount: 12,
});
const breach = aggregator.checkOrder('ema-trend', 2_500, 'buy');
if (breach !== null) rejectOrder(breach.detail);
from lrvx.portfolio_risk import (
PortfolioRiskAggregator, RiskRules, StrategyFields,
)
rules = RiskRules(0.20, 10000.0, 500000.0, 0.40)
agg = PortfolioRiskAggregator(rules, True, True, True, True, 100000.0)
f = StrategyFields(120.0, -30.0, -2.5, 5000.0, 4500.0, 12)
agg.update("ema-trend", f, 0x3F)
summary = agg.summary()
The four rules¶
| Rule | Trips when |
|---|---|
max_drawdown_pct |
(peak_equity - current_equity) / peak_equity exceeds the threshold. Reported as a fraction between 0 and 1. |
max_daily_loss |
Combined daily PnL across every strategy goes more negative than this. The sign you pass carries no meaning: -10_000 and 10_000 both cap the loss at 10,000. |
max_gross_exposure |
Sum of gross_exposure across all strategies exceeds this cap. |
max_concentration_pct |
Any single strategy holds more than this share of total gross exposure. Only fires when two or more strategies actually carry gross exposure. A registered row sitting at zero does not count, so one of a pair flattening out does not turn the other into "100% of gross". |
Each rule is independent. Setting any to None disables that rule. A breach on any rule trips the kill switch and fires the optional on_breach callback once.
max_drawdown_pct needs a capital base¶
A drawdown is a fraction of something. initial_equity is that something, and
a max_drawdown_pct rule without a positive one raises ValueError in Python,
throws in Node and QuickJS, and returns a null handle through the C ABI. Pass
the account's starting equity alongside the rule:
from lrvx.portfolio_risk import PortfolioRiskAggregator, RiskRules
aggregator = PortfolioRiskAggregator(
rules=RiskRules(max_drawdown_pct=0.20),
initial_equity=100_000,
)
Pre-trade gating¶
check_order(strategy, notional, side) inspects a candidate order against the current aggregate without mutating state. It returns either None (allowed) or a Breach describing why the order should be rejected. Use it from your RiskManager hook; it is cheap to call and thread-safe.
An active kill switch refuses everything. Below that, the gate applies all four
rules to any order that would grow the position, and lets risk-reducing orders
through untouched. Every rejection it hands back names a rule snapshot() also
reports, so an operator reading the snapshot can see why an order was refused.
How the gate decides an order reduces risk¶
side is "buy", "sell" or "reduce":
"reduce"always counts as risk-reducing, whatever the book looks like. Use it when you know the order closes something and do not want to reason about direction."buy"and"sell"are read against the strategy'snet_exposure. An order that shrinks|net|reduces risk.- If you have never reported a
net_exposurefor that strategy, its gross exposure stands in: a"sell"against a non-zero gross counts as reducing.
Feed net_exposure through update if you want direction handled precisely.
Without it the gate falls back to the gross heuristic above, which is right for
a one-directional book and wrong for one that is long and short at once.
Reading the aggregate¶
snapshot() returns a PortfolioSnapshot with totals, every per-strategy row (deep-copied so the caller can hold it past the snapshot lock), the current peak equity, the active drawdown, and the list of currently breached rules.
snap = aggregator.snapshot()
print(snap.to_dict())
# {
# "total_realized_pnl": ...,
# "total_daily_pnl": ...,
# "total_gross_exposure": ...,
# "drawdown_pct": ...,
# "kill_switch_active": ...,
# "breaches": [{"rule": "...", "value": ..., "limit": ..., "detail": "..."}, ...],
# "accounts": [...],
# }
The to_dict() method is JSON-serializable, so you can drop it straight into the runtime-state snapshot file the MCP analytics tools read. If you do, get_pnl and get_kill_switch over MCP automatically reflect portfolio-level state.
Retiring a strategy¶
remove(name) drops a strategy's row from the aggregate and
re-evaluates immediately. Use it when a strategy is stopped for the
session rather than leaving a stale row inflating gross exposure.
Re-evaluation is one-directional: it can raise peak equity and it can
trip the kill switch, but it never lowers peak equity and never
clears an already-active switch. Dropping the row that caused a
breach removes the breach from the next snapshot(), but trading
stays halted until reset_kill_switch().
After a breach¶
reset_kill_switch() is the explicit operator action that re-enables trading. The aggregator does not auto-reset; that is intentional. The next update re-evaluates and re-trips the switch if the underlying cause (drawdown, gross exposure, concentration) is still present, so resetting without addressing the cause buys you nothing. Read the breach detail, decide whether to flatten manually, and only then reset.
Thread safety¶
Every public method takes the aggregator's internal lock. Update from inside on_fill callbacks, check_order from inside RiskManager.preTradeCheck, and snapshot from a background reporter thread; none of those need extra synchronization on the call site.
The optional on_breach callback fires inside the aggregator's lock to keep the breach state consistent across observers. Keep your callback cheap. If you want to do heavy work on a breach (page the operator, open a ticket), enqueue and process on a separate thread.
What's not here¶
- Multi-process aggregation. Phase 1 is single-process. Phase 2 will plug in shared state (Redis, file-locked JSON, or a small RPC) without changing the public API.
- Per-symbol concentration limits. The current concentration rule is per-strategy. Per-symbol is a sensible follow-up when a real user hits the limitation.
- Time-window PnL. The aggregator carries the values you push in. If you want rolling 1-hour or week-to-date PnL, compute it in your update loop and feed it through one of the existing fields.
See also¶
- Inspect a running engine over MCP. Pair the aggregator's
snapshot().to_dict()with the MCP analytics tools to expose portfolio-level state to an AI agent. - Live engine analytics over MCP. When a breach fires,
explain_decisionagainst the event log answers "what triggered the kill switch".