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/session.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Session manager.
|
|
2
|
+
|
|
3
|
+
Enforces the behavioural guardrails that keep a system (and its operator) out of
|
|
4
|
+
trouble: daily trade/loss caps, profit-taking stops, minimum spacing between
|
|
5
|
+
trades, escalating cooldowns after losing streaks, and tilt detection.
|
|
6
|
+
|
|
7
|
+
Pure standard library. Feed it closed trades via :meth:`record_trade` and ask
|
|
8
|
+
:meth:`can_open` before each new entry.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, time, timedelta, timezone
|
|
14
|
+
from statistics import mean
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TradeRecord:
|
|
19
|
+
ts_open: datetime
|
|
20
|
+
ts_close: datetime
|
|
21
|
+
pnl: float
|
|
22
|
+
pnl_pct: float
|
|
23
|
+
score: int # signal-quality score for the trade (0-100)
|
|
24
|
+
position_size_units: float
|
|
25
|
+
duration_minutes: float
|
|
26
|
+
side: str
|
|
27
|
+
symbol: str
|
|
28
|
+
strategy: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SessionDecision:
|
|
33
|
+
allowed: bool
|
|
34
|
+
reason: str = ""
|
|
35
|
+
cooldown_until: datetime | None = None
|
|
36
|
+
on_tilt: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionManager:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
max_trades_per_day: int = 5,
|
|
43
|
+
max_losses_per_day: int = 3,
|
|
44
|
+
max_daily_loss_pct: float = 1.5,
|
|
45
|
+
max_daily_profit_pct: float = 5.0,
|
|
46
|
+
min_minutes_between_trades: int = 15,
|
|
47
|
+
consecutive_loss_cooldowns: dict[int, int] | None = None,
|
|
48
|
+
consecutive_loss_halt: int = 5,
|
|
49
|
+
min_score: int = 65,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.max_trades = max_trades_per_day
|
|
52
|
+
self.max_losses = max_losses_per_day
|
|
53
|
+
self.max_loss_pct = max_daily_loss_pct
|
|
54
|
+
self.max_profit_pct = max_daily_profit_pct
|
|
55
|
+
self.min_minutes_between = min_minutes_between_trades
|
|
56
|
+
# consecutive-loss count -> cooldown minutes
|
|
57
|
+
self.cooldowns = consecutive_loss_cooldowns or {2: 30, 3: 120, 4: 1440}
|
|
58
|
+
self.cl_halt = consecutive_loss_halt
|
|
59
|
+
self.min_score = min_score
|
|
60
|
+
|
|
61
|
+
self.day_pnl: float = 0.0
|
|
62
|
+
self.day_pnl_pct: float = 0.0
|
|
63
|
+
self.day_trades: int = 0
|
|
64
|
+
self.day_losses: int = 0
|
|
65
|
+
self.day_anchor: datetime | None = None
|
|
66
|
+
self.consecutive_losses: int = 0
|
|
67
|
+
self.cooldown_until: datetime | None = None
|
|
68
|
+
self.strategy_halts: dict[str, datetime] = {}
|
|
69
|
+
self.last_trade_ts: datetime | None = None
|
|
70
|
+
self.recent_trades: list[TradeRecord] = []
|
|
71
|
+
|
|
72
|
+
# ----------------------------------------------------------------- day roll
|
|
73
|
+
|
|
74
|
+
def _roll_day(self, now: datetime) -> None:
|
|
75
|
+
if self.day_anchor is None or now.date() != self.day_anchor.date():
|
|
76
|
+
self.day_anchor = datetime.combine(now.date(), time(0, 0, tzinfo=timezone.utc))
|
|
77
|
+
self.day_pnl = 0.0
|
|
78
|
+
self.day_pnl_pct = 0.0
|
|
79
|
+
self.day_trades = 0
|
|
80
|
+
self.day_losses = 0
|
|
81
|
+
|
|
82
|
+
# ----------------------------------------------------------------- record
|
|
83
|
+
|
|
84
|
+
def record_trade(self, trade: TradeRecord, equity_before: float) -> None:
|
|
85
|
+
self._roll_day(trade.ts_close)
|
|
86
|
+
self.day_pnl += trade.pnl
|
|
87
|
+
self.day_pnl_pct = (self.day_pnl / equity_before * 100.0) if equity_before else 0.0
|
|
88
|
+
self.day_trades += 1
|
|
89
|
+
self.last_trade_ts = trade.ts_close
|
|
90
|
+
if trade.pnl < 0:
|
|
91
|
+
self.day_losses += 1
|
|
92
|
+
self.consecutive_losses += 1
|
|
93
|
+
mins = self.cooldowns.get(self.consecutive_losses)
|
|
94
|
+
if mins:
|
|
95
|
+
self.cooldown_until = trade.ts_close + timedelta(minutes=mins)
|
|
96
|
+
if self.consecutive_losses >= self.cl_halt:
|
|
97
|
+
self.strategy_halts[trade.strategy] = trade.ts_close + timedelta(hours=24)
|
|
98
|
+
else:
|
|
99
|
+
self.consecutive_losses = 0
|
|
100
|
+
self.cooldown_until = None
|
|
101
|
+
self.recent_trades.append(trade)
|
|
102
|
+
if len(self.recent_trades) > 50:
|
|
103
|
+
self.recent_trades = self.recent_trades[-50:]
|
|
104
|
+
|
|
105
|
+
# ----------------------------------------------------------------- can_open
|
|
106
|
+
|
|
107
|
+
def can_open(
|
|
108
|
+
self,
|
|
109
|
+
strategy: str,
|
|
110
|
+
now: datetime | None = None,
|
|
111
|
+
score: int = 100,
|
|
112
|
+
) -> SessionDecision:
|
|
113
|
+
now = now or datetime.now(timezone.utc)
|
|
114
|
+
self._roll_day(now)
|
|
115
|
+
|
|
116
|
+
if self.day_trades >= self.max_trades:
|
|
117
|
+
return SessionDecision(False, "daily trade cap reached")
|
|
118
|
+
if self.day_losses >= self.max_losses:
|
|
119
|
+
return SessionDecision(False, "daily loss-count cap reached")
|
|
120
|
+
if self.day_pnl_pct <= -self.max_loss_pct:
|
|
121
|
+
return SessionDecision(False, f"daily loss limit {self.max_loss_pct}% reached")
|
|
122
|
+
if self.day_pnl_pct >= self.max_profit_pct:
|
|
123
|
+
return SessionDecision(False, f"daily profit limit {self.max_profit_pct}% reached")
|
|
124
|
+
if self.cooldown_until and now < self.cooldown_until:
|
|
125
|
+
return SessionDecision(False, "consecutive-loss cooldown", cooldown_until=self.cooldown_until)
|
|
126
|
+
if self.last_trade_ts and (now - self.last_trade_ts).total_seconds() < self.min_minutes_between * 60:
|
|
127
|
+
return SessionDecision(False, "min-time-between-trades not elapsed")
|
|
128
|
+
halt_until = self.strategy_halts.get(strategy)
|
|
129
|
+
if halt_until and now < halt_until:
|
|
130
|
+
return SessionDecision(False, f"strategy '{strategy}' halted until {halt_until.isoformat()}")
|
|
131
|
+
if self.detect_tilt(score):
|
|
132
|
+
return SessionDecision(False, "tilt detected", on_tilt=True, cooldown_until=now + timedelta(hours=4))
|
|
133
|
+
return SessionDecision(True)
|
|
134
|
+
|
|
135
|
+
# ----------------------------------------------------------------- tilt
|
|
136
|
+
|
|
137
|
+
def detect_tilt(self, latest_score: int = 100) -> bool:
|
|
138
|
+
"""Flag tilt if recent behaviour matches any of these patterns over the
|
|
139
|
+
last 5 trades:
|
|
140
|
+
|
|
141
|
+
- average hold time shrank > 50% vs the prior 20-trade baseline
|
|
142
|
+
- position size increased right after a loss
|
|
143
|
+
- any two trades were spaced less than 5 minutes apart
|
|
144
|
+
- any recent trade was taken on a weak (< ``min_score``) signal
|
|
145
|
+
"""
|
|
146
|
+
if len(self.recent_trades) < 5:
|
|
147
|
+
return False
|
|
148
|
+
last5 = self.recent_trades[-5:]
|
|
149
|
+
baseline = self.recent_trades[-25:-5] if len(self.recent_trades) >= 25 else self.recent_trades
|
|
150
|
+
baseline_hold = mean(t.duration_minutes for t in baseline) if baseline else 0
|
|
151
|
+
last5_hold = mean(t.duration_minutes for t in last5)
|
|
152
|
+
if baseline_hold > 0 and last5_hold < 0.5 * baseline_hold:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
sizes = [t.position_size_units for t in last5]
|
|
156
|
+
for i in range(1, len(sizes)):
|
|
157
|
+
if last5[i - 1].pnl < 0 and sizes[i] > sizes[i - 1] * 1.1:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
gaps = [
|
|
161
|
+
(last5[i].ts_open - last5[i - 1].ts_close).total_seconds()
|
|
162
|
+
for i in range(1, len(last5))
|
|
163
|
+
]
|
|
164
|
+
if gaps and min(gaps) < 5 * 60:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
if any(t.score < self.min_score for t in last5):
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
if latest_score and latest_score < self.min_score:
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
return False
|
riskkit/sizing.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Position sizing.
|
|
2
|
+
|
|
3
|
+
Volatility-adjusted fixed-fractional sizing with an optional Kelly ceiling and
|
|
4
|
+
a reduction ladder that cuts size after losing streaks and during drawdowns.
|
|
5
|
+
|
|
6
|
+
The core idea: you decide how much *risk* (distance to your stop) you are
|
|
7
|
+
willing to put on per trade, expressed as a fraction of equity. From that, the
|
|
8
|
+
number of units follows directly. Everything else — volatility scaling, the
|
|
9
|
+
Kelly cap, the reduction ladder, the high-conviction bonus — only ever moves
|
|
10
|
+
that risk fraction up or down within hard floors and ceilings.
|
|
11
|
+
|
|
12
|
+
The notional cap is absolute: a position's notional can never exceed
|
|
13
|
+
``max_notional_pct`` of equity, regardless of what the risk math produces.
|
|
14
|
+
|
|
15
|
+
Alongside the class, this module offers three small, composable sizing helpers
|
|
16
|
+
you can reach for on their own: :func:`kelly_fraction` (the edge-optimal risk
|
|
17
|
+
fraction), :func:`volatility_target_size` (size a position to a target
|
|
18
|
+
volatility), and :func:`inverse_vol_weights` (naive risk-parity weights across a
|
|
19
|
+
basket). Each is a pure function with no dependencies.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Mapping
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SizingInputs:
|
|
29
|
+
"""Everything the sizer needs to size a single trade.
|
|
30
|
+
|
|
31
|
+
Prices are in quote currency; ``atr`` is the current Average True Range and
|
|
32
|
+
``atr_baseline`` is a longer-run ATR used to scale risk down when the market
|
|
33
|
+
is more volatile than usual. ``drawdown_pct`` and ``daily_loss_pct`` are
|
|
34
|
+
positive numbers (e.g. ``4.2`` means down 4.2%).
|
|
35
|
+
|
|
36
|
+
The Kelly inputs (``win_rate``, ``avg_win``, ``avg_loss``) are optional. When
|
|
37
|
+
all three are supplied the sizer applies a half-Kelly ceiling.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
equity: float
|
|
41
|
+
entry_price: float
|
|
42
|
+
stop_price: float
|
|
43
|
+
atr: float
|
|
44
|
+
atr_baseline: float
|
|
45
|
+
confluence_score: int = 100
|
|
46
|
+
consecutive_losses: int = 0
|
|
47
|
+
drawdown_pct: float = 0.0
|
|
48
|
+
daily_loss_pct: float = 0.0
|
|
49
|
+
win_rate: float | None = None
|
|
50
|
+
avg_win: float | None = None
|
|
51
|
+
avg_loss: float | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SizingResult:
|
|
56
|
+
"""The sized position.
|
|
57
|
+
|
|
58
|
+
``units`` is the position size in base units (0 means *do not trade*).
|
|
59
|
+
``multipliers_applied`` records every adjustment that fired, so the decision
|
|
60
|
+
is fully auditable. When ``units`` is 0, ``reason_for_zero`` explains why.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
units: float
|
|
64
|
+
notional: float
|
|
65
|
+
risk_amount: float
|
|
66
|
+
risk_pct: float
|
|
67
|
+
multipliers_applied: dict[str, float] = field(default_factory=dict)
|
|
68
|
+
reason_for_zero: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PositionSizer:
|
|
72
|
+
"""Volatility-adjusted fixed-fractional position sizer.
|
|
73
|
+
|
|
74
|
+
All percentage arguments are given as human percentages (``1.0`` == 1%) and
|
|
75
|
+
stored internally as fractions.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
base_risk_pct: float = 1.0,
|
|
81
|
+
max_risk_pct: float = 1.5,
|
|
82
|
+
min_risk_pct: float = 0.25,
|
|
83
|
+
max_notional_pct: float = 4.0,
|
|
84
|
+
high_conviction_score: int = 85,
|
|
85
|
+
high_conviction_size_mult: float = 1.5,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.base_risk = base_risk_pct / 100.0
|
|
88
|
+
self.max_risk = max_risk_pct / 100.0
|
|
89
|
+
self.min_risk = min_risk_pct / 100.0
|
|
90
|
+
self.max_notional = max_notional_pct / 100.0
|
|
91
|
+
self.high_conviction = high_conviction_score
|
|
92
|
+
self.hc_size_mult = high_conviction_size_mult
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ helpers
|
|
95
|
+
|
|
96
|
+
def _reduction_multiplier(self, inputs: SizingInputs) -> tuple[float, dict[str, float]]:
|
|
97
|
+
"""Combine every size adjustment into a single multiplier (audited)."""
|
|
98
|
+
applied: dict[str, float] = {}
|
|
99
|
+
m = 1.0
|
|
100
|
+
|
|
101
|
+
if inputs.consecutive_losses >= 3:
|
|
102
|
+
applied["consecutive_losses>=3"] = 0.5
|
|
103
|
+
m *= 0.5
|
|
104
|
+
elif inputs.consecutive_losses == 2:
|
|
105
|
+
applied["consecutive_losses==2"] = 0.75
|
|
106
|
+
m *= 0.75
|
|
107
|
+
|
|
108
|
+
dd = inputs.drawdown_pct
|
|
109
|
+
if dd > 7:
|
|
110
|
+
applied["drawdown>7"] = 0.25
|
|
111
|
+
m *= 0.25
|
|
112
|
+
elif dd > 5:
|
|
113
|
+
applied["drawdown>5"] = 0.5
|
|
114
|
+
m *= 0.5
|
|
115
|
+
elif dd > 3:
|
|
116
|
+
applied["drawdown>3"] = 0.75
|
|
117
|
+
m *= 0.75
|
|
118
|
+
|
|
119
|
+
if inputs.daily_loss_pct > 1.0:
|
|
120
|
+
applied["daily_loss>1"] = 0.5
|
|
121
|
+
m *= 0.5
|
|
122
|
+
|
|
123
|
+
if 70 <= inputs.confluence_score < 75:
|
|
124
|
+
applied["confluence_70_74"] = 0.75
|
|
125
|
+
m *= 0.75
|
|
126
|
+
|
|
127
|
+
if inputs.confluence_score >= self.high_conviction:
|
|
128
|
+
applied["high_conviction"] = self.hc_size_mult
|
|
129
|
+
m *= self.hc_size_mult
|
|
130
|
+
|
|
131
|
+
return m, applied
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------ public
|
|
134
|
+
|
|
135
|
+
def size(self, inputs: SizingInputs) -> SizingResult:
|
|
136
|
+
"""Size one trade. Returns a :class:`SizingResult`; ``units == 0`` means skip."""
|
|
137
|
+
risk_per_unit = abs(inputs.entry_price - inputs.stop_price)
|
|
138
|
+
if risk_per_unit <= 0 or inputs.equity <= 0:
|
|
139
|
+
return SizingResult(0, 0, 0, 0, reason_for_zero="zero-distance stop or equity")
|
|
140
|
+
|
|
141
|
+
# Volatility scaling: more vol than baseline -> smaller risk fraction.
|
|
142
|
+
ratio = (inputs.atr / inputs.atr_baseline) if inputs.atr_baseline > 0 else 1.0
|
|
143
|
+
ratio = max(0.2, min(5.0, ratio))
|
|
144
|
+
vol_adjusted_risk = self.base_risk / ratio
|
|
145
|
+
|
|
146
|
+
# Kelly ceiling (only if we have all three stats). A non-positive Kelly
|
|
147
|
+
# fraction means no historical edge -> risk clamps toward 0 and the
|
|
148
|
+
# min-risk floor below skips the trade.
|
|
149
|
+
if (
|
|
150
|
+
inputs.win_rate is not None
|
|
151
|
+
and inputs.avg_win is not None
|
|
152
|
+
and inputs.avg_loss is not None
|
|
153
|
+
):
|
|
154
|
+
kelly = kelly_fraction(
|
|
155
|
+
inputs.win_rate, inputs.avg_win, inputs.avg_loss, fraction=0.5
|
|
156
|
+
)
|
|
157
|
+
vol_adjusted_risk = min(vol_adjusted_risk, kelly)
|
|
158
|
+
|
|
159
|
+
# Reduction ladder + ceiling.
|
|
160
|
+
red_mult, applied = self._reduction_multiplier(inputs)
|
|
161
|
+
risk_pct = vol_adjusted_risk * red_mult
|
|
162
|
+
risk_pct = max(0.0, min(self.max_risk, risk_pct))
|
|
163
|
+
|
|
164
|
+
if risk_pct < self.min_risk:
|
|
165
|
+
return SizingResult(
|
|
166
|
+
0, 0, 0, risk_pct, applied,
|
|
167
|
+
reason_for_zero=f"risk {risk_pct * 100:.3f}% below floor",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
risk_amount = inputs.equity * risk_pct
|
|
171
|
+
units = risk_amount / risk_per_unit
|
|
172
|
+
|
|
173
|
+
# Absolute notional cap.
|
|
174
|
+
max_units_by_notional = (inputs.equity * self.max_notional) / inputs.entry_price
|
|
175
|
+
if units > max_units_by_notional:
|
|
176
|
+
applied["notional_cap"] = max_units_by_notional / units
|
|
177
|
+
units = max_units_by_notional
|
|
178
|
+
# The cap bound the size, so realized risk is now below target —
|
|
179
|
+
# keep risk_pct consistent with risk_amount (risk_amount / equity).
|
|
180
|
+
risk_pct = (units * risk_per_unit) / inputs.equity
|
|
181
|
+
|
|
182
|
+
return SizingResult(
|
|
183
|
+
units=units,
|
|
184
|
+
notional=units * inputs.entry_price,
|
|
185
|
+
risk_amount=units * risk_per_unit,
|
|
186
|
+
risk_pct=risk_pct,
|
|
187
|
+
multipliers_applied=applied,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Standalone, composable sizers — pure functions, no dependencies. Use them on
|
|
193
|
+
# their own when the stop-distance model of PositionSizer is not what you want.
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def kelly_fraction(
|
|
198
|
+
win_rate: float,
|
|
199
|
+
avg_win: float,
|
|
200
|
+
avg_loss: float,
|
|
201
|
+
fraction: float = 1.0,
|
|
202
|
+
) -> float:
|
|
203
|
+
"""The Kelly-optimal fraction of capital to risk, given a historical edge.
|
|
204
|
+
|
|
205
|
+
``win_rate`` is the win probability (0–1); ``avg_win`` and ``avg_loss`` are the
|
|
206
|
+
mean win and mean loss as positive magnitudes in the same unit (e.g. R, or
|
|
207
|
+
currency). Returns ``kelly · fraction`` clamped at 0 — a non-positive edge
|
|
208
|
+
sizes to nothing. Pass ``fraction=0.5`` for the common, less-aggressive
|
|
209
|
+
half-Kelly. Returns 0 for degenerate inputs (any of the three ≤ 0).
|
|
210
|
+
|
|
211
|
+
kelly = win_rate − (1 − win_rate) / (avg_win / avg_loss)
|
|
212
|
+
"""
|
|
213
|
+
if win_rate <= 0 or avg_win <= 0 or avg_loss <= 0:
|
|
214
|
+
return 0.0
|
|
215
|
+
payoff = avg_win / avg_loss
|
|
216
|
+
kelly = win_rate - ((1.0 - win_rate) / payoff)
|
|
217
|
+
return max(0.0, kelly * fraction)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def volatility_target_size(
|
|
221
|
+
equity: float,
|
|
222
|
+
price: float,
|
|
223
|
+
return_volatility: float,
|
|
224
|
+
target_volatility_pct: float,
|
|
225
|
+
max_notional_pct: float = 100.0,
|
|
226
|
+
) -> float:
|
|
227
|
+
"""Units sized so a position's expected volatility ≈ a target % of equity.
|
|
228
|
+
|
|
229
|
+
Volatility targeting sizes off an instrument's *return volatility* rather than
|
|
230
|
+
a stop distance: a calmer instrument earns a larger position and a wilder one
|
|
231
|
+
a smaller position, so each contributes a similar amount of risk. Supply the
|
|
232
|
+
per-period return volatility as a fraction (``0.02`` == 2% σ of returns) and
|
|
233
|
+
the volatility you want the position to carry, as a % of equity.
|
|
234
|
+
|
|
235
|
+
units = (target_volatility_pct/100 · equity) / (return_volatility · price)
|
|
236
|
+
|
|
237
|
+
The result is capped so notional never exceeds ``max_notional_pct`` of equity.
|
|
238
|
+
Returns 0 for degenerate inputs (non-positive equity, price, volatility, or
|
|
239
|
+
target).
|
|
240
|
+
"""
|
|
241
|
+
if (equity <= 0 or price <= 0 or return_volatility <= 0
|
|
242
|
+
or target_volatility_pct <= 0):
|
|
243
|
+
return 0.0
|
|
244
|
+
target_dollar_vol = (target_volatility_pct / 100.0) * equity
|
|
245
|
+
per_unit_vol = return_volatility * price
|
|
246
|
+
units = target_dollar_vol / per_unit_vol
|
|
247
|
+
max_units = (max_notional_pct / 100.0) * equity / price
|
|
248
|
+
return min(units, max_units)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def inverse_vol_weights(volatilities: Mapping[str, float]) -> dict[str, float]:
|
|
252
|
+
"""Risk-parity weights inversely proportional to each asset's volatility.
|
|
253
|
+
|
|
254
|
+
The simplest form of risk parity: ignoring correlations, weighting each asset
|
|
255
|
+
by 1/σ makes every position contribute the same risk. Returns weights that sum
|
|
256
|
+
to 1.0. Assets with non-positive volatility are dropped; an empty or all-zero
|
|
257
|
+
input returns ``{}``.
|
|
258
|
+
"""
|
|
259
|
+
inv = {k: 1.0 / v for k, v in volatilities.items() if v > 0}
|
|
260
|
+
total = sum(inv.values())
|
|
261
|
+
if total <= 0:
|
|
262
|
+
return {}
|
|
263
|
+
return {k: w / total for k, w in inv.items()}
|
riskkit/stops.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Stop engine.
|
|
2
|
+
|
|
3
|
+
Every open position carries a *stack* of stops at once; the tightest one
|
|
4
|
+
relative to current price is active. Stops only ever move closer to price,
|
|
5
|
+
never further away.
|
|
6
|
+
|
|
7
|
+
Stop types in the stack:
|
|
8
|
+
initial set at entry, never widens
|
|
9
|
+
breakeven activated at 1R profit (with a fee buffer)
|
|
10
|
+
trailing_atr activated at ``trailing_start_at_r``, trails N*ATR behind price
|
|
11
|
+
trailing_ema optional, trails a slow EMA (useful for trend trades)
|
|
12
|
+
chandelier optional, trails N*ATR from the highest high / lowest low since entry
|
|
13
|
+
structure optional, ratchets to a swing level you supply (tighten-only)
|
|
14
|
+
psar optional, ratchets to a Parabolic SAR value you supply (tighten-only)
|
|
15
|
+
time exit after N bars if the trade hasn't reached 1R
|
|
16
|
+
volatility exit if ATR spikes past ``volatility_exit_threshold`` x baseline
|
|
17
|
+
rsi for mean-reversion: exit if a momentum signal re-extremes
|
|
18
|
+
|
|
19
|
+
The chandelier, structure, and psar stops follow riskkit's "you feed it numbers"
|
|
20
|
+
rule: structure and psar simply trail to the level you pass in each bar, and the
|
|
21
|
+
chandelier anchors to the running high/low the engine tracks for you. All three
|
|
22
|
+
are tighten-only, like every other stop here.
|
|
23
|
+
|
|
24
|
+
The engine is pure arithmetic — it takes prices and indicator values you supply
|
|
25
|
+
and returns an exit reason (or ``None``). It never talks to an exchange.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
32
|
+
Side = Literal["long", "short"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StopStack:
|
|
37
|
+
"""Per-position stop state. Create one at entry, then feed it to
|
|
38
|
+
:meth:`StopEngine.update` once per bar."""
|
|
39
|
+
|
|
40
|
+
side: Side
|
|
41
|
+
entry_price: float
|
|
42
|
+
initial: float
|
|
43
|
+
breakeven: float | None = None
|
|
44
|
+
trailing_atr: float | None = None
|
|
45
|
+
trailing_ema: float | None = None
|
|
46
|
+
chandelier: float | None = None
|
|
47
|
+
structure: float | None = None
|
|
48
|
+
psar: float | None = None
|
|
49
|
+
time_stop_bars: int | None = None
|
|
50
|
+
rsi_stop: bool = False
|
|
51
|
+
use_chandelier: bool = False
|
|
52
|
+
volatility_baseline: float | None = None
|
|
53
|
+
volatility_threshold: float = 2.0
|
|
54
|
+
bars_held: int = 0
|
|
55
|
+
realized_r: float = 0.0
|
|
56
|
+
# Running extremes since entry, maintained for the chandelier stop.
|
|
57
|
+
highest_since_entry: float | None = None
|
|
58
|
+
lowest_since_entry: float | None = None
|
|
59
|
+
history: list[tuple[str, float, str]] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def initial_risk(self) -> float:
|
|
63
|
+
return abs(self.entry_price - self.initial)
|
|
64
|
+
|
|
65
|
+
def active_stop(self) -> float:
|
|
66
|
+
"""The tightest (closest-to-price) stop currently in the stack."""
|
|
67
|
+
candidates = [self.initial]
|
|
68
|
+
for level in (self.breakeven, self.trailing_atr, self.trailing_ema,
|
|
69
|
+
self.chandelier, self.structure, self.psar):
|
|
70
|
+
if level is not None:
|
|
71
|
+
candidates.append(level)
|
|
72
|
+
return max(candidates) if self.side == "long" else min(candidates)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StopEngine:
|
|
76
|
+
"""Advances stop stacks each bar and signals exits."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
breakeven_at_r: float = 1.0,
|
|
81
|
+
trailing_start_at_r: float = 1.5,
|
|
82
|
+
trailing_atr_multiplier: float = 1.5,
|
|
83
|
+
volatility_exit_threshold: float = 2.0,
|
|
84
|
+
fees_round_trip_pct: float = 0.001,
|
|
85
|
+
chandelier_atr_multiplier: float = 3.0,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.breakeven_at_r = breakeven_at_r
|
|
88
|
+
self.trailing_start_r = trailing_start_at_r
|
|
89
|
+
self.trailing_atr_mult = trailing_atr_multiplier
|
|
90
|
+
self.vol_exit_th = volatility_exit_threshold
|
|
91
|
+
self.fees_rt = fees_round_trip_pct
|
|
92
|
+
self.chandelier_atr_mult = chandelier_atr_multiplier
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _r_multiple(stack: StopStack, price: float) -> float:
|
|
96
|
+
if stack.initial_risk <= 0:
|
|
97
|
+
return 0.0
|
|
98
|
+
delta = (
|
|
99
|
+
(price - stack.entry_price)
|
|
100
|
+
if stack.side == "long"
|
|
101
|
+
else (stack.entry_price - price)
|
|
102
|
+
)
|
|
103
|
+
return delta / stack.initial_risk
|
|
104
|
+
|
|
105
|
+
def update(
|
|
106
|
+
self,
|
|
107
|
+
stack: StopStack,
|
|
108
|
+
current_price: float,
|
|
109
|
+
current_atr: float,
|
|
110
|
+
current_ema_slow: float | None = None,
|
|
111
|
+
rsi_at_extreme_again: bool = False,
|
|
112
|
+
current_high: float | None = None,
|
|
113
|
+
current_low: float | None = None,
|
|
114
|
+
structure_level: float | None = None,
|
|
115
|
+
psar_value: float | None = None,
|
|
116
|
+
) -> tuple[StopStack, str | None]:
|
|
117
|
+
"""Advance the stack one bar. Returns ``(stack, exit_reason)``;
|
|
118
|
+
``exit_reason`` is ``None`` unless an exit fired.
|
|
119
|
+
|
|
120
|
+
``current_high`` / ``current_low`` feed the chandelier stop's running
|
|
121
|
+
extreme (they default to ``current_price`` if you only have closes).
|
|
122
|
+
``structure_level`` and ``psar_value`` activate the structure and PSAR
|
|
123
|
+
stops when supplied — each ratchets toward the level, tighten-only."""
|
|
124
|
+
stack.bars_held += 1
|
|
125
|
+
r_now = self._r_multiple(stack, current_price)
|
|
126
|
+
stack.realized_r = max(stack.realized_r, r_now)
|
|
127
|
+
|
|
128
|
+
# Volatility spike -> bail.
|
|
129
|
+
if stack.volatility_baseline and current_atr > self.vol_exit_th * stack.volatility_baseline:
|
|
130
|
+
return stack, f"volatility spike ({current_atr / stack.volatility_baseline:.2f}x baseline)"
|
|
131
|
+
|
|
132
|
+
# Momentum re-extreme (mean reversion only).
|
|
133
|
+
if stack.rsi_stop and rsi_at_extreme_again:
|
|
134
|
+
return stack, "momentum returned to extreme"
|
|
135
|
+
|
|
136
|
+
# Time stop: hasn't reached 1R within the bar limit.
|
|
137
|
+
if (
|
|
138
|
+
stack.time_stop_bars is not None
|
|
139
|
+
and stack.bars_held >= stack.time_stop_bars
|
|
140
|
+
and stack.realized_r < 1.0
|
|
141
|
+
):
|
|
142
|
+
return stack, f"time stop after {stack.bars_held} bars without 1R"
|
|
143
|
+
|
|
144
|
+
# Breakeven at 1R (parked just past entry to clear fees).
|
|
145
|
+
if stack.breakeven is None and r_now >= self.breakeven_at_r:
|
|
146
|
+
fees_buffer = stack.entry_price * self.fees_rt
|
|
147
|
+
be = stack.entry_price + (fees_buffer if stack.side == "long" else -fees_buffer)
|
|
148
|
+
stack.breakeven = be
|
|
149
|
+
stack.history.append(("breakeven_activated", be, "1R reached"))
|
|
150
|
+
|
|
151
|
+
# Trailing ATR stop (tighten-only).
|
|
152
|
+
if r_now >= self.trailing_start_r:
|
|
153
|
+
new_trail = (
|
|
154
|
+
current_price - self.trailing_atr_mult * current_atr
|
|
155
|
+
if stack.side == "long"
|
|
156
|
+
else current_price + self.trailing_atr_mult * current_atr
|
|
157
|
+
)
|
|
158
|
+
if stack.trailing_atr is None:
|
|
159
|
+
stack.trailing_atr = new_trail
|
|
160
|
+
stack.history.append(("trail_atr_activated", new_trail, f"{self.trailing_start_r}R reached"))
|
|
161
|
+
elif (stack.side == "long" and new_trail > stack.trailing_atr) or (
|
|
162
|
+
stack.side == "short" and new_trail < stack.trailing_atr
|
|
163
|
+
):
|
|
164
|
+
stack.trailing_atr = new_trail
|
|
165
|
+
stack.history.append(("trail_atr_tightened", new_trail, "trail"))
|
|
166
|
+
|
|
167
|
+
# Trailing EMA stop (tighten-only), for trend trades.
|
|
168
|
+
if current_ema_slow is not None and r_now >= self.trailing_start_r:
|
|
169
|
+
if stack.trailing_ema is None:
|
|
170
|
+
stack.trailing_ema = current_ema_slow
|
|
171
|
+
stack.history.append(("trail_ema_activated", current_ema_slow, ""))
|
|
172
|
+
elif (stack.side == "long" and current_ema_slow > stack.trailing_ema) or (
|
|
173
|
+
stack.side == "short" and current_ema_slow < stack.trailing_ema
|
|
174
|
+
):
|
|
175
|
+
stack.trailing_ema = current_ema_slow
|
|
176
|
+
|
|
177
|
+
# Chandelier stop (tighten-only): trail ATR from the highest high (long) /
|
|
178
|
+
# lowest low (short) *since entry*, not from the current bar.
|
|
179
|
+
if stack.use_chandelier:
|
|
180
|
+
high = current_high if current_high is not None else current_price
|
|
181
|
+
low = current_low if current_low is not None else current_price
|
|
182
|
+
if stack.side == "long":
|
|
183
|
+
stack.highest_since_entry = (
|
|
184
|
+
high if stack.highest_since_entry is None
|
|
185
|
+
else max(stack.highest_since_entry, high)
|
|
186
|
+
)
|
|
187
|
+
level = stack.highest_since_entry - self.chandelier_atr_mult * current_atr
|
|
188
|
+
if stack.chandelier is None or level > stack.chandelier:
|
|
189
|
+
stack.chandelier = level
|
|
190
|
+
stack.history.append(("chandelier", level, "trail"))
|
|
191
|
+
else:
|
|
192
|
+
stack.lowest_since_entry = (
|
|
193
|
+
low if stack.lowest_since_entry is None
|
|
194
|
+
else min(stack.lowest_since_entry, low)
|
|
195
|
+
)
|
|
196
|
+
level = stack.lowest_since_entry + self.chandelier_atr_mult * current_atr
|
|
197
|
+
if stack.chandelier is None or level < stack.chandelier:
|
|
198
|
+
stack.chandelier = level
|
|
199
|
+
stack.history.append(("chandelier", level, "trail"))
|
|
200
|
+
|
|
201
|
+
# Structure stop (tighten-only): ratchet to a swing level you supply —
|
|
202
|
+
# a swing low for longs, a swing high for shorts. Never loosens.
|
|
203
|
+
if structure_level is not None:
|
|
204
|
+
if stack.structure is None or (
|
|
205
|
+
(stack.side == "long" and structure_level > stack.structure)
|
|
206
|
+
or (stack.side == "short" and structure_level < stack.structure)
|
|
207
|
+
):
|
|
208
|
+
stack.structure = structure_level
|
|
209
|
+
stack.history.append(("structure", structure_level, "swing"))
|
|
210
|
+
|
|
211
|
+
# Parabolic SAR stop (tighten-only): ratchet to a PSAR value you supply.
|
|
212
|
+
# When PSAR flips past price it tightens onto it and triggers the exit.
|
|
213
|
+
if psar_value is not None:
|
|
214
|
+
if stack.psar is None or (
|
|
215
|
+
(stack.side == "long" and psar_value > stack.psar)
|
|
216
|
+
or (stack.side == "short" and psar_value < stack.psar)
|
|
217
|
+
):
|
|
218
|
+
stack.psar = psar_value
|
|
219
|
+
stack.history.append(("psar", psar_value, ""))
|
|
220
|
+
|
|
221
|
+
# Stopped out?
|
|
222
|
+
active = stack.active_stop()
|
|
223
|
+
if stack.side == "long" and current_price <= active:
|
|
224
|
+
return stack, f"stopped out at {active:.4f}"
|
|
225
|
+
if stack.side == "short" and current_price >= active:
|
|
226
|
+
return stack, f"stopped out at {active:.4f}"
|
|
227
|
+
|
|
228
|
+
return stack, None
|