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/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