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/drawdown.py ADDED
@@ -0,0 +1,171 @@
1
+ """Drawdown manager.
2
+
3
+ Tracks high-water-mark equity and the resulting drawdown, then maps it onto a
4
+ tier ladder. Each tier dials position size down, raises the bar for taking new
5
+ trades, and eventually halts entirely. A recovery ramp prevents the manager
6
+ from snapping straight back to full size the moment equity ticks up — it has to
7
+ earn its way back one tier at a time.
8
+
9
+ Default tiers::
10
+
11
+ 0 dd <= tier1 normal
12
+ 1 tier1 < dd <= tier2 0.75x size
13
+ 2 tier2 < dd <= tier3 0.50x size, min_score 80
14
+ 3 tier3 < dd <= halt 0.25x size, min_score 85, max 1 concurrent
15
+ 4 dd > halt HALT — no new entries
16
+
17
+ A separate weekly-loss guard pauses new entries for 24h if equity falls more
18
+ than ``weekly_loss_pause_pct`` within a rolling 7-day window.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timedelta, timezone
24
+
25
+
26
+ @dataclass
27
+ class DrawdownState:
28
+ """The current risk posture implied by drawdown.
29
+
30
+ ``size_multiplier`` scales position size; ``min_score_override`` and
31
+ ``max_concurrent_override`` are ``None`` when the tier imposes no override.
32
+ When ``halted`` is True, no new entries should be opened.
33
+ """
34
+
35
+ tier: int
36
+ drawdown_pct: float
37
+ peak_equity: float
38
+ halted: bool
39
+ size_multiplier: float
40
+ min_score_override: int | None
41
+ max_concurrent_override: int | None
42
+ reason: str = ""
43
+
44
+
45
+ @dataclass
46
+ class _WeeklyWindow:
47
+ start_equity: float
48
+ start_ts: datetime
49
+
50
+
51
+ class DrawdownManager:
52
+ """Stateful drawdown tracker. Call :meth:`update` once per equity refresh.
53
+
54
+ All threshold arguments are human percentages (``3.0`` == 3% drawdown).
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ tier1_pct: float = 3.0,
60
+ tier2_pct: float = 5.0,
61
+ tier3_pct: float = 7.0,
62
+ halt_pct: float = 10.0,
63
+ weekly_loss_pause_pct: float = 3.0,
64
+ recovery_ramp: bool = True,
65
+ ) -> None:
66
+ self.tier1 = tier1_pct
67
+ self.tier2 = tier2_pct
68
+ self.tier3 = tier3_pct
69
+ self.halt = halt_pct
70
+ self.weekly_loss = weekly_loss_pause_pct
71
+ self.recovery_ramp = recovery_ramp
72
+
73
+ self.peak_equity: float = 0.0
74
+ self.current_tier: int = 0
75
+ self.weekly_window: _WeeklyWindow | None = None
76
+ self.weekly_pause_until: datetime | None = None
77
+
78
+ def update(self, equity: float, now: datetime | None = None) -> DrawdownState:
79
+ """Feed the latest equity and get back the current :class:`DrawdownState`."""
80
+ now = now or datetime.now(timezone.utc)
81
+ if equity <= 0:
82
+ return DrawdownState(
83
+ 4, 100.0, self.peak_equity, True, 0.0, None, None,
84
+ reason="non-positive equity",
85
+ )
86
+
87
+ if equity > self.peak_equity:
88
+ self.peak_equity = equity
89
+
90
+ dd_pct = (
91
+ (self.peak_equity - equity) / self.peak_equity * 100.0
92
+ if self.peak_equity else 0.0
93
+ )
94
+
95
+ target = self._target_tier(dd_pct)
96
+
97
+ # Recovery ramp: a tier can only step DOWN one level at a time, and only
98
+ # once drawdown has recovered to half of the lower tier's threshold.
99
+ if self.recovery_ramp and target < self.current_tier:
100
+ lower_threshold = [self.tier1, self.tier2, self.tier3, self.halt][
101
+ max(0, self.current_tier - 1)
102
+ ]
103
+ if dd_pct < lower_threshold * 0.5:
104
+ self.current_tier = max(target, self.current_tier - 1)
105
+ # otherwise hold at the current tier
106
+ else:
107
+ self.current_tier = target
108
+
109
+ weekly_loss = self._update_weekly_window(equity, now)
110
+
111
+ size_mult, min_score, max_conc, halted, reason = self._tier_actions(
112
+ self.current_tier, weekly_pause=self.weekly_pause_until is not None
113
+ )
114
+
115
+ return DrawdownState(
116
+ tier=self.current_tier,
117
+ drawdown_pct=dd_pct,
118
+ peak_equity=self.peak_equity,
119
+ halted=halted,
120
+ size_multiplier=size_mult,
121
+ min_score_override=min_score,
122
+ max_concurrent_override=max_conc,
123
+ reason=reason,
124
+ )
125
+
126
+ # ------------------------------------------------------------------ helpers
127
+
128
+ def _target_tier(self, dd_pct: float) -> int:
129
+ if dd_pct > self.halt:
130
+ return 4
131
+ if dd_pct > self.tier3:
132
+ return 3
133
+ if dd_pct > self.tier2:
134
+ return 2
135
+ if dd_pct > self.tier1:
136
+ return 1
137
+ return 0
138
+
139
+ def _update_weekly_window(self, equity: float, now: datetime) -> float:
140
+ """Maintain the rolling 7-day window and arm/clear the weekly pause."""
141
+ if self.weekly_window is None or (now - self.weekly_window.start_ts).days >= 7:
142
+ self.weekly_window = _WeeklyWindow(start_equity=equity, start_ts=now)
143
+
144
+ weekly_loss = (
145
+ (self.weekly_window.start_equity - equity)
146
+ / self.weekly_window.start_equity * 100.0
147
+ )
148
+
149
+ if weekly_loss > self.weekly_loss and self.weekly_pause_until is None:
150
+ self.weekly_pause_until = now + timedelta(hours=24)
151
+
152
+ if self.weekly_pause_until and now >= self.weekly_pause_until:
153
+ self.weekly_pause_until = None
154
+
155
+ return weekly_loss
156
+
157
+ @staticmethod
158
+ def _tier_actions(
159
+ tier: int, weekly_pause: bool
160
+ ) -> tuple[float, int | None, int | None, bool, str]:
161
+ if weekly_pause:
162
+ return 0.0, None, 0, True, "weekly loss limit — 24h pause"
163
+ if tier == 0:
164
+ return 1.0, None, None, False, "normal"
165
+ if tier == 1:
166
+ return 0.75, None, None, False, "tier1 — size 0.75x"
167
+ if tier == 2:
168
+ return 0.5, 80, None, False, "tier2 — size 0.5x, min_score 80"
169
+ if tier == 3:
170
+ return 0.25, 85, 1, False, "tier3 — size 0.25x, min_score 85, max 1 position"
171
+ return 0.0, None, 0, True, "HALT — drawdown exceeded halt threshold"