riskkit-quant 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
riskkit/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """riskkit — a framework-agnostic risk-management toolkit for systematic traders.
2
+
3
+ riskkit is the layer most backtesters and trading bots leave thin: how big a
4
+ position to take, where stops live, when to cut size, what not to stack, and
5
+ when to stop trading altogether. Every component is plain Python that knows
6
+ nothing about any exchange, data provider, or backtesting framework — you feed
7
+ it numbers and it hands back auditable decisions.
8
+
9
+ Components
10
+ ----------
11
+ - :class:`PositionSizer` — volatility-adjusted sizing with a Kelly ceiling
12
+ - :class:`DrawdownManager` — drawdown-tiered size reduction + halt
13
+ - :class:`StopEngine` — composable stop stack (initial/BE/trail/time/vol)
14
+ - :class:`CorrelationGuard` — one open position per correlation group
15
+ - :class:`SessionManager` — daily limits, cooldowns, tilt detection
16
+ - :class:`PreTradeValidator` — the composable final gate before an order
17
+
18
+ Quick start::
19
+
20
+ from riskkit import PositionSizer, SizingInputs
21
+
22
+ sizer = PositionSizer(base_risk_pct=1.0, max_notional_pct=4.0)
23
+ result = sizer.size(SizingInputs(
24
+ equity=10_000, entry_price=100, stop_price=98,
25
+ atr=2.0, atr_baseline=2.0,
26
+ ))
27
+ print(result.units, result.risk_pct)
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from .correlation import CorrelationDecision, CorrelationGuard
32
+ from .drawdown import DrawdownManager, DrawdownState
33
+ from .manager import OpenPosition, RiskConfig, RiskDecision, RiskManager, TradeIntent
34
+ from .metrics import conditional_value_at_risk, value_at_risk
35
+ from .session import SessionDecision, SessionManager, TradeRecord
36
+ from .sizing import (
37
+ PositionSizer,
38
+ SizingInputs,
39
+ SizingResult,
40
+ inverse_vol_weights,
41
+ kelly_fraction,
42
+ volatility_target_size,
43
+ )
44
+ from .stops import Side, StopEngine, StopStack
45
+ from .validator import CheckResult, PreTradeValidator, TradeProposal, ValidationResult
46
+
47
+ __version__ = "0.4.0"
48
+
49
+ __all__ = [
50
+ # façade
51
+ "RiskManager",
52
+ "RiskConfig",
53
+ "TradeIntent",
54
+ "RiskDecision",
55
+ "OpenPosition",
56
+ # sizing
57
+ "PositionSizer",
58
+ "SizingInputs",
59
+ "SizingResult",
60
+ "kelly_fraction",
61
+ "volatility_target_size",
62
+ "inverse_vol_weights",
63
+ # drawdown
64
+ "DrawdownManager",
65
+ "DrawdownState",
66
+ # stops
67
+ "StopEngine",
68
+ "StopStack",
69
+ "Side",
70
+ # correlation
71
+ "CorrelationGuard",
72
+ "CorrelationDecision",
73
+ # session
74
+ "SessionManager",
75
+ "SessionDecision",
76
+ "TradeRecord",
77
+ # validator
78
+ "PreTradeValidator",
79
+ "TradeProposal",
80
+ "ValidationResult",
81
+ "CheckResult",
82
+ # metrics
83
+ "value_at_risk",
84
+ "conditional_value_at_risk",
85
+ "__version__",
86
+ ]
@@ -0,0 +1,7 @@
1
+ """First-class adapters that drop riskkit into popular backtesting frameworks.
2
+
3
+ Each adapter lives in its own module and imports its framework lazily, so the
4
+ riskkit core stays dependency-free. Import the one you need directly::
5
+
6
+ from riskkit.adapters.backtesting import RiskkitStrategy
7
+ """
@@ -0,0 +1,165 @@
1
+ """backtesting.py adapter — risk-managed entries via a Strategy mixin.
2
+
3
+ `RiskkitStrategy` wires a :class:`riskkit.RiskManager` into a
4
+ `backtesting.py <https://kernc.github.io/backtesting.py/>`_ strategy. Subclass it,
5
+ write your signal logic in ``next()`` as usual, and call :meth:`risk_long` /
6
+ :meth:`risk_short` instead of ``self.buy`` / ``self.sell``. Every entry is then
7
+ sized and validated by your single ``RiskConfig`` — drawdown laddering, session
8
+ caps, correlation/exposure limits, and the pre-trade checklist all apply, and the
9
+ result is converted into a backtesting.py order for you.
10
+
11
+ Closed trades are fed back into the session manager automatically, so streaks,
12
+ cooldowns, and the daily-loss state are live during the backtest — a drawdown
13
+ that crosses your halt threshold will stop new entries, exactly as it would in
14
+ production.
15
+
16
+ Requires backtesting.py: ``pip install "riskkit[backtesting]"``
17
+
18
+ Example::
19
+
20
+ from riskkit import RiskConfig
21
+ from riskkit.adapters.backtesting import RiskkitStrategy
22
+ from backtesting import Backtest
23
+ from backtesting.lib import crossover
24
+ from backtesting.test import GOOG, SMA
25
+
26
+ class SmaCross(RiskkitStrategy):
27
+ risk_config = RiskConfig(base_risk_pct=2.0, max_notional_pct=15.0)
28
+
29
+ def init(self):
30
+ self.fast = self.I(SMA, self.data.Close, 10)
31
+ self.slow = self.I(SMA, self.data.Close, 30)
32
+
33
+ def next(self):
34
+ price = self.data.Close[-1]
35
+ if crossover(self.fast, self.slow) and not self.position:
36
+ self.risk_long(stop_price=price * 0.97, target_price=price * 1.06)
37
+ elif crossover(self.slow, self.fast) and self.position:
38
+ self.position.close()
39
+
40
+ Backtest(GOOG, SmaCross, cash=100_000, commission=0.002).run()
41
+ """
42
+ from __future__ import annotations
43
+
44
+ from datetime import datetime
45
+
46
+ from backtesting import Strategy
47
+
48
+ from ..manager import RiskConfig, RiskDecision, RiskManager, TradeIntent
49
+ from ..session import TradeRecord
50
+
51
+
52
+ class RiskkitStrategy(Strategy):
53
+ """A backtesting.py ``Strategy`` mixin that routes entries through riskkit.
54
+
55
+ Override these class attributes to configure it:
56
+
57
+ - ``risk_config`` — the :class:`~riskkit.RiskConfig` for this strategy.
58
+ - ``risk_symbol`` — symbol used for correlation grouping (default ``"ASSET"``).
59
+ - ``risk_strategy`` — strategy name recorded on trades (default ``"default"``).
60
+ - ``risk_max_fraction`` — cap on the equity fraction sent to one order
61
+ (default ``0.99``; backtesting.py cannot invest more than available cash).
62
+ """
63
+
64
+ risk_config: RiskConfig | None = None
65
+ risk_symbol: str = "ASSET"
66
+ risk_strategy: str = "default"
67
+ risk_max_fraction: float = 0.99
68
+
69
+ def init(self) -> None:
70
+ """No-op by default — override to declare your indicators."""
71
+
72
+ # ------------------------------------------------------------------ internals
73
+
74
+ @property
75
+ def risk(self) -> RiskManager:
76
+ """The :class:`~riskkit.RiskManager`, created lazily on first use."""
77
+ mgr = getattr(self, "_risk_mgr", None)
78
+ if mgr is None:
79
+ mgr = self._risk_mgr = RiskManager(self.risk_config or RiskConfig())
80
+ self._closed_seen = 0
81
+ return mgr
82
+
83
+ def _now(self) -> datetime | None:
84
+ ts = self.data.index[-1]
85
+ return ts if isinstance(ts, datetime) else None
86
+
87
+ def _ingest_closed(self) -> None:
88
+ """Feed any newly-closed backtesting.py trades into the session manager."""
89
+ mgr = self.risk
90
+ trades = self.closed_trades
91
+ equity = float(self.equity)
92
+ while self._closed_seen < len(trades):
93
+ t = trades[self._closed_seen]
94
+ self._closed_seen += 1
95
+ duration = 0.0
96
+ if isinstance(t.entry_time, datetime) and isinstance(t.exit_time, datetime):
97
+ duration = (t.exit_time - t.entry_time).total_seconds() / 60.0
98
+ mgr.on_close(
99
+ TradeRecord(
100
+ ts_open=t.entry_time, ts_close=t.exit_time,
101
+ pnl=float(t.pl), pnl_pct=float(t.pl_pct) * 100.0,
102
+ score=100, position_size_units=abs(float(t.size)),
103
+ duration_minutes=duration,
104
+ side="long" if t.is_long else "short",
105
+ symbol=self.risk_symbol, strategy=self.risk_strategy,
106
+ ),
107
+ equity_before=equity,
108
+ )
109
+
110
+ def _enter(
111
+ self, side: str, entry_price, stop_price, target_price,
112
+ score: int, atr: float, atr_baseline: float, intent_kwargs: dict,
113
+ ) -> RiskDecision:
114
+ now = self._now()
115
+ self._ingest_closed() # reflect any closes before we size
116
+ equity = float(self.equity)
117
+ self.risk.on_equity(equity, now=now)
118
+ price = float(self.data.Close[-1] if entry_price is None else entry_price)
119
+
120
+ decision = self.risk.evaluate(
121
+ TradeIntent(
122
+ symbol=self.risk_symbol, side=side,
123
+ entry_price=price, stop_price=float(stop_price),
124
+ target_price=float(target_price), score=score,
125
+ atr=atr, atr_baseline=atr_baseline, **intent_kwargs,
126
+ ),
127
+ now=now,
128
+ )
129
+
130
+ if decision.ok and equity > 0:
131
+ fraction = min(self.risk_max_fraction, decision.notional / equity)
132
+ if fraction > 0:
133
+ place = self.buy if side == "long" else self.sell
134
+ place(size=fraction, sl=float(stop_price), tp=float(target_price))
135
+ self.risk.on_fill(decision, strategy=self.risk_strategy)
136
+ return decision
137
+
138
+ # ------------------------------------------------------------------ public API
139
+
140
+ def risk_long(
141
+ self, *, stop_price, target_price, entry_price=None,
142
+ score: int = 100, atr: float = 0.0, atr_baseline: float = 0.0,
143
+ **intent_kwargs,
144
+ ) -> RiskDecision:
145
+ """Attempt a risk-sized, validated long. Returns the :class:`RiskDecision`.
146
+
147
+ ``entry_price`` defaults to the current close. Extra keyword arguments are
148
+ forwarded to :class:`~riskkit.TradeIntent` (e.g. ``win_rate`` for Kelly,
149
+ ``spread_pct`` for the market-quality gate).
150
+ """
151
+ return self._enter(
152
+ "long", entry_price, stop_price, target_price,
153
+ score, atr, atr_baseline, intent_kwargs,
154
+ )
155
+
156
+ def risk_short(
157
+ self, *, stop_price, target_price, entry_price=None,
158
+ score: int = 100, atr: float = 0.0, atr_baseline: float = 0.0,
159
+ **intent_kwargs,
160
+ ) -> RiskDecision:
161
+ """Attempt a risk-sized, validated short. Returns the :class:`RiskDecision`."""
162
+ return self._enter(
163
+ "short", entry_price, stop_price, target_price,
164
+ score, atr, atr_baseline, intent_kwargs,
165
+ )
@@ -0,0 +1,144 @@
1
+ """freqtrade adapter — risk-managed staking and entry vetoes.
2
+
3
+ freqtrade strategies are composition-friendly: keep your signals in
4
+ ``populate_*`` and let this helper drive sizing and the entry gate from the
5
+ strategy callbacks. ``FreqtradeRiskManager`` imports **nothing** from freqtrade —
6
+ you pass it the values freqtrade hands your callbacks, and it returns what those
7
+ callbacks should return. That keeps the riskkit core dependency-free and means
8
+ the adapter is fully unit-testable without installing freqtrade.
9
+
10
+ Wire it into your ``IStrategy`` like this::
11
+
12
+ from riskkit import RiskConfig
13
+ from riskkit.adapters.freqtrade import FreqtradeRiskManager
14
+
15
+ class MyStrategy(IStrategy):
16
+ def bot_start(self):
17
+ self.risk = FreqtradeRiskManager(RiskConfig.balanced())
18
+
19
+ def custom_stake_amount(self, pair, current_time, current_rate,
20
+ proposed_stake, min_stake, max_stake,
21
+ leverage, entry_tag, side, **kwargs):
22
+ info = self.custom_info[pair]
23
+ return self.risk.stake_amount(
24
+ pair=pair, side=side,
25
+ equity=self.wallets.get_total_stake_amount(),
26
+ current_rate=current_rate,
27
+ stop_price=info["stop_price"],
28
+ max_stake=max_stake, min_stake=min_stake,
29
+ score=info.get("score", 100),
30
+ atr=info.get("atr", 0.0), atr_baseline=info.get("atr_baseline", 0.0),
31
+ now=current_time,
32
+ )
33
+
34
+ def confirm_trade_entry(self, pair, *args, **kwargs):
35
+ allowed = self.risk.confirm_entry(pair)
36
+ if allowed:
37
+ self.risk.on_fill(pair) # register it in the open book
38
+ return allowed
39
+
40
+ A returned stake of ``0.0`` tells freqtrade to skip the entry, so sizing and the
41
+ veto can both live in ``custom_stake_amount`` if you prefer a single callback.
42
+ """
43
+ from __future__ import annotations
44
+
45
+ from datetime import datetime
46
+
47
+ from ..manager import RiskConfig, RiskDecision, RiskManager, TradeIntent
48
+ from ..session import TradeRecord
49
+
50
+
51
+ class FreqtradeRiskManager:
52
+ """Drives a :class:`~riskkit.RiskManager` from freqtrade's callback signatures.
53
+
54
+ For cross-pair features (correlation, total exposure, concurrency) to work,
55
+ call :meth:`on_fill` once an entry is confirmed and :meth:`on_exit` when a
56
+ trade closes, so the open book and session state stay current.
57
+ """
58
+
59
+ def __init__(self, config: RiskConfig | None = None) -> None:
60
+ self.risk = RiskManager(config or RiskConfig())
61
+ self._last: dict[str, RiskDecision] = {}
62
+
63
+ # ------------------------------------------------------------------ entry
64
+
65
+ def stake_amount(
66
+ self, *, pair: str, equity: float, current_rate: float, stop_price: float,
67
+ max_stake: float, min_stake: float = 0.0, target_price: float | None = None,
68
+ side: str = "long", score: int = 100, now: datetime | None = None,
69
+ **intent_kwargs,
70
+ ) -> float:
71
+ """Riskkit-sized stake (quote currency) for ``custom_stake_amount``.
72
+
73
+ Returns ``0.0`` when riskkit vetoes the entry or the size would fall below
74
+ ``min_stake`` — both of which make freqtrade skip the trade. The result is
75
+ also cached so :meth:`confirm_entry` can mirror the same decision.
76
+
77
+ ``target_price`` is optional: freqtrade trades are usually exited by ROI /
78
+ trailing stops rather than a fixed target, so when it is omitted a target
79
+ that exactly meets the configured minimum reward:risk is assumed (the R:R
80
+ gate stays neutral). Extra keyword arguments flow to :class:`TradeIntent`.
81
+ """
82
+ if target_price is None:
83
+ risk = abs(current_rate - stop_price)
84
+ rr = self.risk.validator.min_rr
85
+ target_price = (
86
+ current_rate + rr * risk if side == "long" else current_rate - rr * risk
87
+ )
88
+
89
+ self.risk.on_equity(equity, now=now)
90
+ decision = self.risk.evaluate(
91
+ TradeIntent(
92
+ symbol=pair, side=side, entry_price=current_rate,
93
+ stop_price=stop_price, target_price=target_price,
94
+ score=score, **intent_kwargs,
95
+ ),
96
+ now=now,
97
+ )
98
+ self._last[pair] = decision
99
+ if not decision.ok:
100
+ return 0.0
101
+
102
+ stake = decision.notional
103
+ if max_stake:
104
+ stake = min(stake, float(max_stake))
105
+ if min_stake and stake < float(min_stake):
106
+ return 0.0
107
+ return stake
108
+
109
+ def confirm_entry(self, pair: str) -> bool:
110
+ """Mirror the cached decision in ``confirm_trade_entry``. ``True`` allows it."""
111
+ decision = self._last.get(pair)
112
+ return bool(decision and decision.ok)
113
+
114
+ def last_decision(self, pair: str) -> RiskDecision | None:
115
+ """The most recent :class:`RiskDecision` for ``pair`` (e.g. to log veto reasons)."""
116
+ return self._last.get(pair)
117
+
118
+ # ------------------------------------------------------------------ book
119
+
120
+ def on_fill(self, pair: str) -> None:
121
+ """Register a confirmed entry in the open book (call after confirmation)."""
122
+ decision = self._last.get(pair)
123
+ if decision and decision.ok:
124
+ self.risk.on_fill(decision)
125
+
126
+ def on_exit(
127
+ self, *, pair: str, pnl: float, pnl_pct: float,
128
+ open_time: datetime, close_time: datetime, amount: float = 0.0,
129
+ side: str = "long", strategy: str = "default",
130
+ equity_before: float | None = None,
131
+ ) -> None:
132
+ """Feed a closed trade back to the session manager (streaks, cooldowns, day P&L)."""
133
+ duration = 0.0
134
+ if isinstance(open_time, datetime) and isinstance(close_time, datetime):
135
+ duration = (close_time - open_time).total_seconds() / 60.0
136
+ self.risk.on_close(
137
+ TradeRecord(
138
+ ts_open=open_time, ts_close=close_time,
139
+ pnl=pnl, pnl_pct=pnl_pct, score=100,
140
+ position_size_units=abs(amount), duration_minutes=duration,
141
+ side=side, symbol=pair, strategy=strategy,
142
+ ),
143
+ equity_before=equity_before,
144
+ )
@@ -0,0 +1,129 @@
1
+ """vectorbt adapter — size an array of signals with riskkit.
2
+
3
+ vectorbt is vectorized; riskkit's drawdown and session guards are inherently
4
+ sequential, so they don't map onto a single vectorized pass. What *does* map
5
+ cleanly is **sizing**: given equity and per-bar entry / stop / volatility, produce
6
+ an array of position sizes to feed vectorbt as ``size=``.
7
+
8
+ :func:`size_signals` does exactly that. It imports nothing from vectorbt and
9
+ returns a plain list, so it works with ``vbt.Portfolio.from_signals``, your own
10
+ vectorized loop, or pandas. Per-trade drawdown control is supported by passing a
11
+ ``drawdown_pct`` series (from whatever equity proxy you have) — it feeds the
12
+ sizer's reduction ladder. The *stateful* guards (drawdown halting, session caps)
13
+ need the sequential :class:`~riskkit.RiskManager`; see the backtesting.py adapter.
14
+
15
+ Example::
16
+
17
+ import vectorbt as vbt
18
+ from riskkit.adapters.vectorbt import size_signals
19
+
20
+ sizes = size_signals(
21
+ equity=10_000,
22
+ entry_prices=close.where(entries), # price where entering, else NaN
23
+ stop_prices=close * 0.97,
24
+ atr=atr, atr_baseline=atr.rolling(100).mean(),
25
+ )
26
+ pf = vbt.Portfolio.from_signals(close, entries, exits,
27
+ size=sizes, size_type="value")
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from math import isnan
32
+ from typing import Sequence
33
+
34
+ from ..sizing import PositionSizer, SizingInputs
35
+
36
+
37
+ def _as_list(value, n: int, name: str) -> list:
38
+ """Normalize a scalar or sequence to a length-``n`` list (pandas/numpy safe)."""
39
+ if value is None:
40
+ return [None] * n
41
+ if hasattr(value, "__len__") and not isinstance(value, (str, bytes)):
42
+ seq = list(value)
43
+ if len(seq) != n:
44
+ raise ValueError(f"{name} has length {len(seq)}, expected {n}")
45
+ return seq
46
+ return [value] * n
47
+
48
+
49
+ def _is_entry(price) -> bool:
50
+ """A bar is an entry where the entry price is present and positive."""
51
+ if price is None:
52
+ return False
53
+ try:
54
+ if isnan(price):
55
+ return False
56
+ except TypeError:
57
+ return False
58
+ return price > 0
59
+
60
+
61
+ def size_signals(
62
+ *,
63
+ equity,
64
+ entry_prices: Sequence[float],
65
+ stop_prices: Sequence[float],
66
+ atr=None,
67
+ atr_baseline=None,
68
+ drawdown_pct=None,
69
+ sizer: PositionSizer | None = None,
70
+ return_fraction: bool = True,
71
+ ) -> list[float]:
72
+ """Size each entry signal with riskkit's :class:`PositionSizer`.
73
+
74
+ Parameters
75
+ ----------
76
+ equity:
77
+ Account equity — a scalar (fixed-fractional on a constant base) or a
78
+ per-bar sequence (e.g. a rolling equity estimate).
79
+ entry_prices:
80
+ Entry price at each bar; use ``NaN`` / ``0`` / ``None`` on bars with no
81
+ entry. This array drives where a non-zero size is produced.
82
+ stop_prices:
83
+ Stop price at each bar (defines risk-per-unit).
84
+ atr, atr_baseline:
85
+ Optional volatility and its baseline for the sizer's vol scaling
86
+ (scalar or per-bar). Omitted ⇒ no vol scaling.
87
+ drawdown_pct:
88
+ Optional per-bar drawdown percentage fed to the sizer's reduction ladder.
89
+ sizer:
90
+ A configured :class:`PositionSizer` (defaults to ``PositionSizer()``).
91
+ return_fraction:
92
+ When ``True`` (default) each element is ``notional / equity`` (use vectorbt
93
+ ``size_type="value"``/percent); when ``False`` it is units (``"amount"``).
94
+
95
+ Returns
96
+ -------
97
+ A list the same length as ``entry_prices``: the size on entry bars, ``0.0``
98
+ elsewhere (and where the sizer vetoes the trade).
99
+ """
100
+ sizer = sizer or PositionSizer()
101
+ n = len(entry_prices)
102
+ eq = _as_list(equity, n, "equity")
103
+ entries = list(entry_prices)
104
+ stops = _as_list(stop_prices, n, "stop_prices")
105
+ atrs = _as_list(atr, n, "atr")
106
+ bases = _as_list(atr_baseline, n, "atr_baseline")
107
+ dds = _as_list(drawdown_pct, n, "drawdown_pct")
108
+
109
+ sizes: list[float] = []
110
+ for i in range(n):
111
+ entry = entries[i]
112
+ if not _is_entry(entry) or eq[i] is None or stops[i] is None:
113
+ sizes.append(0.0)
114
+ continue
115
+ result = sizer.size(SizingInputs(
116
+ equity=float(eq[i]),
117
+ entry_price=float(entry),
118
+ stop_price=float(stops[i]),
119
+ atr=float(atrs[i]) if atrs[i] is not None else 0.0,
120
+ atr_baseline=float(bases[i]) if bases[i] is not None else 0.0,
121
+ drawdown_pct=float(dds[i]) if dds[i] is not None else 0.0,
122
+ ))
123
+ if result.units <= 0:
124
+ sizes.append(0.0)
125
+ elif return_fraction:
126
+ sizes.append(result.notional / float(eq[i]) if eq[i] else 0.0)
127
+ else:
128
+ sizes.append(result.units)
129
+ return sizes
riskkit/correlation.py ADDED
@@ -0,0 +1,119 @@
1
+ """Correlation guard.
2
+
3
+ Stops you from stacking the same risk under different names — opening three
4
+ "different" positions that are really one correlated bet. It works from two
5
+ sources:
6
+
7
+ 1. **Static groups** you define (e.g. instruments you know move together).
8
+ 2. **Dynamic groups** computed from a rolling return correlation matrix.
9
+
10
+ Rule: at most one open position per correlation group at a time.
11
+
12
+ The static-group logic is pure Python with no dependencies. The dynamic
13
+ recompute uses pandas, which is an optional extra (``pip install riskkit[pandas]``)
14
+ — import it only if you call :meth:`CorrelationGuard.recompute_dynamic`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, Mapping
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - typing only
22
+ import pandas as pd
23
+
24
+
25
+ @dataclass
26
+ class CorrelationDecision:
27
+ allowed: bool
28
+ reason: str = ""
29
+ blocking_symbol: str | None = None
30
+ group: str | None = None
31
+
32
+
33
+ class CorrelationGuard:
34
+ """Limit concurrent exposure across correlated instruments.
35
+
36
+ Parameters
37
+ ----------
38
+ static_groups:
39
+ Named groups of symbols known to move together. Optional.
40
+ dynamic_threshold:
41
+ Absolute return-correlation above which two symbols are grouped
42
+ dynamically.
43
+ lookback_days:
44
+ Rolling window used by :meth:`recompute_dynamic`.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ static_groups: Mapping[str, set[str]] | None = None,
50
+ dynamic_threshold: float = 0.75,
51
+ lookback_days: int = 30,
52
+ ) -> None:
53
+ self.static_groups: dict[str, set[str]] = dict(static_groups or {})
54
+ self.dynamic_threshold = dynamic_threshold
55
+ self.lookback_days = lookback_days
56
+ self._dynamic_groups: list[set[str]] = []
57
+
58
+ # ----------------------------------------------------------------- dynamic
59
+
60
+ def recompute_dynamic(self, daily_closes: "Mapping[str, pd.Series]") -> None:
61
+ """Rebuild dynamic groups from a mapping ``symbol -> daily-close Series``.
62
+
63
+ Requires pandas (``pip install riskkit[pandas]``).
64
+ """
65
+ try:
66
+ import pandas as pd
67
+ except ImportError as exc: # pragma: no cover - exercised without pandas
68
+ raise ImportError(
69
+ "recompute_dynamic requires pandas. Install it with "
70
+ "`pip install riskkit[pandas]`."
71
+ ) from exc
72
+
73
+ symbols = list(daily_closes.keys())
74
+ if len(symbols) < 2:
75
+ self._dynamic_groups = []
76
+ return
77
+
78
+ df = pd.DataFrame(
79
+ {s: daily_closes[s].tail(self.lookback_days * 2) for s in symbols}
80
+ ).dropna()
81
+ if len(df) < self.lookback_days // 2:
82
+ self._dynamic_groups = []
83
+ return
84
+
85
+ corr = df.pct_change().dropna().corr().abs()
86
+
87
+ groups: list[set[str]] = []
88
+ for i, a in enumerate(symbols):
89
+ for b in symbols[i + 1:]:
90
+ if a == b or pd.isna(corr.loc[a, b]):
91
+ continue
92
+ if corr.loc[a, b] > self.dynamic_threshold:
93
+ for g in groups:
94
+ if a in g or b in g:
95
+ g.update({a, b})
96
+ break
97
+ else:
98
+ groups.append({a, b})
99
+ self._dynamic_groups = groups
100
+
101
+ # ----------------------------------------------------------------- check
102
+
103
+ def can_open(self, symbol: str, open_symbols: set[str]) -> CorrelationDecision:
104
+ """Return whether ``symbol`` may be opened given the currently open set."""
105
+ all_groups = list(self.static_groups.items()) + [
106
+ (f"dynamic_{i}", g) for i, g in enumerate(self._dynamic_groups)
107
+ ]
108
+ for name, members in all_groups:
109
+ if symbol not in members:
110
+ continue
111
+ conflict = open_symbols & members
112
+ if conflict:
113
+ return CorrelationDecision(
114
+ allowed=False,
115
+ reason=f"correlated with open position in group '{name}'",
116
+ blocking_symbol=sorted(conflict)[0],
117
+ group=name,
118
+ )
119
+ return CorrelationDecision(allowed=True)