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 +86 -0
- riskkit/adapters/__init__.py +7 -0
- riskkit/adapters/backtesting.py +165 -0
- riskkit/adapters/freqtrade.py +144 -0
- riskkit/adapters/vectorbt.py +129 -0
- riskkit/correlation.py +119 -0
- riskkit/drawdown.py +171 -0
- riskkit/manager.py +619 -0
- riskkit/metrics.py +41 -0
- riskkit/py.typed +0 -0
- riskkit/session.py +173 -0
- riskkit/sizing.py +263 -0
- riskkit/stops.py +228 -0
- riskkit/validator.py +197 -0
- riskkit_quant-0.4.0.dist-info/METADATA +260 -0
- riskkit_quant-0.4.0.dist-info/RECORD +18 -0
- riskkit_quant-0.4.0.dist-info/WHEEL +4 -0
- riskkit_quant-0.4.0.dist-info/licenses/LICENSE +21 -0
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)
|