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/manager.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""RiskManager — the one-object façade over all of riskkit.
|
|
2
|
+
|
|
3
|
+
The individual components (:class:`PositionSizer`, :class:`DrawdownManager`,
|
|
4
|
+
:class:`StopEngine`, :class:`CorrelationGuard`, :class:`SessionManager`,
|
|
5
|
+
:class:`PreTradeValidator`) are deliberately small and independent so you can
|
|
6
|
+
reach for exactly one. But most systems want all of them, wired together, with
|
|
7
|
+
state flowing between them — the drawdown tier should shrink the position the
|
|
8
|
+
sizer hands back; the session's losing streak should feed the sizer's reduction
|
|
9
|
+
ladder; the open book should drive the correlation and exposure checks.
|
|
10
|
+
|
|
11
|
+
``RiskManager`` does that wiring. You build it once from a single
|
|
12
|
+
:class:`RiskConfig`, push equity into it as your account moves, and then ask one
|
|
13
|
+
question per trade::
|
|
14
|
+
|
|
15
|
+
from riskkit import RiskManager, RiskConfig, TradeIntent
|
|
16
|
+
|
|
17
|
+
risk = RiskManager(RiskConfig(
|
|
18
|
+
base_risk_pct=1.0, max_notional_pct=4.0,
|
|
19
|
+
drawdown=dict(tier1_pct=3, halt_pct=10),
|
|
20
|
+
session=dict(max_trades_per_day=5),
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
risk.on_equity(equity) # refresh drawdown/session state
|
|
24
|
+
decision = risk.evaluate(TradeIntent(
|
|
25
|
+
symbol="BTC/USDT", side="long",
|
|
26
|
+
entry_price=100.0, stop_price=98.0, target_price=104.0,
|
|
27
|
+
score=82, atr=2.0, atr_baseline=2.0,
|
|
28
|
+
))
|
|
29
|
+
if decision.ok:
|
|
30
|
+
place(decision.units, decision.stop) # your execution layer
|
|
31
|
+
risk.on_fill(decision) # tell riskkit it filled
|
|
32
|
+
...
|
|
33
|
+
risk.on_close(trade_record) # when the position closes
|
|
34
|
+
|
|
35
|
+
Nothing here talks to an exchange or a backtester. You feed it numbers and it
|
|
36
|
+
hands back an auditable :class:`RiskDecision`: how big, where the stop sits, and
|
|
37
|
+
— when it says no — every reason why.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from dataclasses import asdict, dataclass, field, fields, replace
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from math import inf
|
|
44
|
+
from typing import Any, Mapping
|
|
45
|
+
|
|
46
|
+
from .correlation import CorrelationDecision, CorrelationGuard
|
|
47
|
+
from .drawdown import DrawdownManager, DrawdownState
|
|
48
|
+
from .session import SessionDecision, SessionManager, TradeRecord
|
|
49
|
+
from .sizing import PositionSizer, SizingInputs, SizingResult
|
|
50
|
+
from .stops import StopEngine
|
|
51
|
+
from .validator import PreTradeValidator, TradeProposal, ValidationResult
|
|
52
|
+
|
|
53
|
+
# Mirrors PreTradeValidator's own default; also the façade's multi-position
|
|
54
|
+
# baseline for the total-exposure cap.
|
|
55
|
+
_DEFAULT_TOTAL_EXPOSURE_PCT = 10.0
|
|
56
|
+
|
|
57
|
+
# RiskConfig fields that are per-component keyword-argument dicts.
|
|
58
|
+
_COMPONENT_FIELDS = ("sizing", "drawdown", "stops", "correlation", "session", "validator")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RiskConfig:
|
|
63
|
+
"""One config for the whole stack.
|
|
64
|
+
|
|
65
|
+
The two knobs people change most often — ``base_risk_pct`` (risk per trade)
|
|
66
|
+
and ``max_notional_pct`` (the hard size ceiling) — are promoted to the top
|
|
67
|
+
level and flow into both the sizer and the validator so the two never
|
|
68
|
+
disagree. Everything else is configured per component: each of the dict
|
|
69
|
+
fields below is passed straight through as keyword arguments to the matching
|
|
70
|
+
component's constructor, so any argument that component accepts works here.
|
|
71
|
+
|
|
72
|
+
Example::
|
|
73
|
+
|
|
74
|
+
RiskConfig(
|
|
75
|
+
base_risk_pct=0.75,
|
|
76
|
+
max_notional_pct=4.0,
|
|
77
|
+
max_concurrent=3,
|
|
78
|
+
drawdown=dict(tier1_pct=3, halt_pct=10),
|
|
79
|
+
session=dict(max_trades_per_day=5, min_minutes_between_trades=30),
|
|
80
|
+
correlation=dict(static_groups={"majors": {"BTC/USDT", "ETH/USDT"}}),
|
|
81
|
+
)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# Promoted convenience knobs (feed sizer + validator).
|
|
85
|
+
base_risk_pct: float = 1.0
|
|
86
|
+
max_notional_pct: float = 4.0
|
|
87
|
+
# A baseline cap on concurrently open positions (None = unlimited). The
|
|
88
|
+
# drawdown manager can tighten this further at deep tiers.
|
|
89
|
+
max_concurrent: int | None = None
|
|
90
|
+
# Cap on total open risk-at-stop ("heat") as a % of equity (None = unlimited).
|
|
91
|
+
max_portfolio_heat_pct: float | None = None
|
|
92
|
+
# Cap on open notional in any one sector / asset-class as a % of equity, so no
|
|
93
|
+
# single sector can dominate the book. Only bites on trades tagged with a
|
|
94
|
+
# ``TradeIntent.sector`` (None = unlimited).
|
|
95
|
+
max_exposure_per_sector_pct: float | None = None
|
|
96
|
+
|
|
97
|
+
# Per-component overrides — passed verbatim to each constructor.
|
|
98
|
+
sizing: dict = field(default_factory=dict)
|
|
99
|
+
drawdown: dict = field(default_factory=dict)
|
|
100
|
+
stops: dict = field(default_factory=dict)
|
|
101
|
+
correlation: dict = field(default_factory=dict)
|
|
102
|
+
session: dict = field(default_factory=dict)
|
|
103
|
+
validator: dict = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
# ----------------------------------------------------------- serialization
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, Any]:
|
|
108
|
+
"""Return this config as a plain nested dict (round-trips with :meth:`from_dict`)."""
|
|
109
|
+
return asdict(self)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "RiskConfig":
|
|
113
|
+
"""Build a config from a plain mapping (e.g. parsed JSON/TOML/YAML).
|
|
114
|
+
|
|
115
|
+
Unknown top-level keys and non-dict component sections raise rather than
|
|
116
|
+
being silently dropped, so a typo'd config fails loudly.
|
|
117
|
+
"""
|
|
118
|
+
valid = {f.name for f in fields(cls)}
|
|
119
|
+
unknown = set(data) - valid
|
|
120
|
+
if unknown:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"unknown RiskConfig fields {sorted(unknown)}; valid keys are {sorted(valid)}"
|
|
123
|
+
)
|
|
124
|
+
for key in _COMPONENT_FIELDS:
|
|
125
|
+
if key in data and not isinstance(data[key], Mapping):
|
|
126
|
+
raise TypeError(
|
|
127
|
+
f"RiskConfig.{key} must be a mapping, got {type(data[key]).__name__}"
|
|
128
|
+
)
|
|
129
|
+
return cls(**{k: dict(v) if k in _COMPONENT_FIELDS else v
|
|
130
|
+
for k, v in data.items()})
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_yaml(cls, path: str) -> "RiskConfig":
|
|
134
|
+
"""Load a config from a YAML file. Requires PyYAML (``pip install riskkit[yaml]``)."""
|
|
135
|
+
try:
|
|
136
|
+
import yaml
|
|
137
|
+
except ImportError as exc: # pragma: no cover - exercised without PyYAML
|
|
138
|
+
raise ImportError(
|
|
139
|
+
"from_yaml requires PyYAML. Install it with `pip install riskkit[yaml]`."
|
|
140
|
+
) from exc
|
|
141
|
+
with open(path) as fh:
|
|
142
|
+
return cls.from_dict(yaml.safe_load(fh) or {})
|
|
143
|
+
|
|
144
|
+
# ----------------------------------------------------------------- presets
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def preset(cls, name: str) -> "RiskConfig":
|
|
148
|
+
"""Return a named preset: ``"conservative"``, ``"balanced"``, or ``"aggressive"``."""
|
|
149
|
+
builders = {
|
|
150
|
+
"conservative": cls.conservative,
|
|
151
|
+
"balanced": cls.balanced,
|
|
152
|
+
"aggressive": cls.aggressive,
|
|
153
|
+
}
|
|
154
|
+
try:
|
|
155
|
+
return builders[name]()
|
|
156
|
+
except KeyError:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"unknown preset {name!r}; choose from {sorted(builders)}"
|
|
159
|
+
) from None
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def conservative(cls) -> "RiskConfig":
|
|
163
|
+
"""Capital-preservation first: small risk, tight halts, a high quality bar."""
|
|
164
|
+
return cls(
|
|
165
|
+
base_risk_pct=0.5,
|
|
166
|
+
max_notional_pct=3.0,
|
|
167
|
+
max_concurrent=2,
|
|
168
|
+
max_portfolio_heat_pct=4.0,
|
|
169
|
+
max_exposure_per_sector_pct=4.0,
|
|
170
|
+
sizing=dict(max_risk_pct=1.0, min_risk_pct=0.2, high_conviction_size_mult=1.25),
|
|
171
|
+
drawdown=dict(tier1_pct=2, tier2_pct=4, tier3_pct=6, halt_pct=8,
|
|
172
|
+
weekly_loss_pause_pct=2),
|
|
173
|
+
stops=dict(breakeven_at_r=1.0, trailing_start_at_r=1.0, trailing_atr_multiplier=1.0),
|
|
174
|
+
correlation=dict(dynamic_threshold=0.6),
|
|
175
|
+
session=dict(max_trades_per_day=3, max_daily_loss_pct=1.0,
|
|
176
|
+
min_minutes_between_trades=30, min_score=75),
|
|
177
|
+
validator=dict(min_score=75, min_rr_ratio=2.5, max_total_exposure_pct=6.0),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def balanced(cls) -> "RiskConfig":
|
|
182
|
+
"""A sensible middle ground — close to the library defaults, made explicit."""
|
|
183
|
+
return cls(
|
|
184
|
+
base_risk_pct=1.0,
|
|
185
|
+
max_notional_pct=5.0,
|
|
186
|
+
max_concurrent=4,
|
|
187
|
+
max_portfolio_heat_pct=8.0,
|
|
188
|
+
max_exposure_per_sector_pct=10.0,
|
|
189
|
+
sizing=dict(max_risk_pct=1.5, min_risk_pct=0.25, high_conviction_size_mult=1.5),
|
|
190
|
+
drawdown=dict(tier1_pct=3, tier2_pct=5, tier3_pct=7, halt_pct=10,
|
|
191
|
+
weekly_loss_pause_pct=3),
|
|
192
|
+
stops=dict(breakeven_at_r=1.0, trailing_start_at_r=1.5, trailing_atr_multiplier=1.5),
|
|
193
|
+
correlation=dict(dynamic_threshold=0.75),
|
|
194
|
+
session=dict(max_trades_per_day=5, max_daily_loss_pct=1.5,
|
|
195
|
+
min_minutes_between_trades=15, min_score=65),
|
|
196
|
+
validator=dict(min_score=70, min_rr_ratio=2.0, max_total_exposure_pct=20.0),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def aggressive(cls) -> "RiskConfig":
|
|
201
|
+
"""More risk per trade and deeper drawdown tolerance — still anti-martingale."""
|
|
202
|
+
return cls(
|
|
203
|
+
base_risk_pct=2.0,
|
|
204
|
+
max_notional_pct=10.0,
|
|
205
|
+
max_concurrent=8,
|
|
206
|
+
max_portfolio_heat_pct=15.0,
|
|
207
|
+
max_exposure_per_sector_pct=25.0,
|
|
208
|
+
sizing=dict(max_risk_pct=3.0, min_risk_pct=0.5, high_conviction_size_mult=2.0),
|
|
209
|
+
drawdown=dict(tier1_pct=5, tier2_pct=8, tier3_pct=12, halt_pct=18,
|
|
210
|
+
weekly_loss_pause_pct=6),
|
|
211
|
+
stops=dict(breakeven_at_r=1.5, trailing_start_at_r=2.0, trailing_atr_multiplier=2.5),
|
|
212
|
+
correlation=dict(dynamic_threshold=0.85),
|
|
213
|
+
session=dict(max_trades_per_day=12, max_daily_loss_pct=3.0,
|
|
214
|
+
min_minutes_between_trades=5, min_score=55),
|
|
215
|
+
validator=dict(min_score=60, min_rr_ratio=1.5, max_total_exposure_pct=50.0),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class TradeIntent:
|
|
221
|
+
"""A trade you are considering, *before* riskkit has sized or vetted it.
|
|
222
|
+
|
|
223
|
+
You fill in what your strategy knows — the instrument, direction, the entry,
|
|
224
|
+
where the stop and target sit, and a 0–100 signal ``score`` — plus whatever
|
|
225
|
+
market-quality and (optional) edge statistics you have. riskkit supplies the
|
|
226
|
+
rest: how big the position should be, and whether it clears every gate.
|
|
227
|
+
|
|
228
|
+
The per-trade risk factors the sizer's reduction ladder needs (drawdown,
|
|
229
|
+
losing streak, daily loss) are **not** asked for here — the manager derives
|
|
230
|
+
them from the equity you push in and the trades you record, so they can never
|
|
231
|
+
drift out of sync with reality.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
symbol: str
|
|
235
|
+
side: str # "long" | "short"
|
|
236
|
+
entry_price: float
|
|
237
|
+
stop_price: float
|
|
238
|
+
target_price: float
|
|
239
|
+
score: int = 100 # signal-quality score (0-100)
|
|
240
|
+
strategy: str = "default"
|
|
241
|
+
regime: str = ""
|
|
242
|
+
sector: str = "" # sector / asset-class tag (enables the per-sector cap)
|
|
243
|
+
|
|
244
|
+
# Volatility scaling for the sizer (optional; left at 0 → no vol scaling).
|
|
245
|
+
atr: float = 0.0
|
|
246
|
+
atr_baseline: float = 0.0
|
|
247
|
+
# Defaults to ``score`` when None; drives the sizer's conviction bonus.
|
|
248
|
+
confluence_score: int | None = None
|
|
249
|
+
|
|
250
|
+
# Market-quality snapshot (fed to the validator).
|
|
251
|
+
spread_pct: float = 0.0
|
|
252
|
+
orderbook_depth: float = inf
|
|
253
|
+
recent_atr_spike_x: float = 1.0
|
|
254
|
+
last_quote_age_sec: float = 0.0
|
|
255
|
+
free_balance: float = inf
|
|
256
|
+
|
|
257
|
+
# Optional historical edge → enables the sizer's half-Kelly ceiling.
|
|
258
|
+
win_rate: float | None = None
|
|
259
|
+
avg_win: float | None = None
|
|
260
|
+
avg_loss: float | None = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class OpenPosition:
|
|
265
|
+
"""A position the manager believes is currently open.
|
|
266
|
+
|
|
267
|
+
Tracked so the correlation guard, exposure cap, and concurrency cap have
|
|
268
|
+
something to reason about. Registered via :meth:`RiskManager.on_fill` and
|
|
269
|
+
cleared by :meth:`RiskManager.on_close`.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
symbol: str
|
|
273
|
+
side: str
|
|
274
|
+
units: float
|
|
275
|
+
notional: float
|
|
276
|
+
entry_price: float
|
|
277
|
+
stop_price: float
|
|
278
|
+
strategy: str = "default"
|
|
279
|
+
sector: str = ""
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def risk_amount(self) -> float:
|
|
283
|
+
"""Capital at risk if the stop is hit (units × distance to stop)."""
|
|
284
|
+
return abs(self.entry_price - self.stop_price) * self.units
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class RiskDecision:
|
|
289
|
+
"""The answer to "should I take this trade, and how big?".
|
|
290
|
+
|
|
291
|
+
``ok`` is the bottom line. When it is ``True``, ``units`` and ``stop`` are
|
|
292
|
+
ready to send to your execution layer. When it is ``False``, ``reasons`` lists
|
|
293
|
+
every gate that blocked it, and the component results below let you inspect
|
|
294
|
+
exactly what happened.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
ok: bool
|
|
298
|
+
symbol: str
|
|
299
|
+
side: str
|
|
300
|
+
units: float
|
|
301
|
+
notional: float
|
|
302
|
+
entry: float
|
|
303
|
+
stop: float
|
|
304
|
+
target: float
|
|
305
|
+
risk_pct: float
|
|
306
|
+
risk_amount: float
|
|
307
|
+
reasons: list[str] = field(default_factory=list)
|
|
308
|
+
sector: str = ""
|
|
309
|
+
|
|
310
|
+
# Full component results, for auditing.
|
|
311
|
+
sizing: SizingResult | None = None
|
|
312
|
+
validation: ValidationResult | None = None
|
|
313
|
+
drawdown: DrawdownState | None = None
|
|
314
|
+
session: SessionDecision | None = None
|
|
315
|
+
correlation: CorrelationDecision | None = None
|
|
316
|
+
|
|
317
|
+
def __bool__(self) -> bool: # `if decision:` reads the same as `decision.ok`
|
|
318
|
+
return self.ok
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class RiskManager:
|
|
322
|
+
"""Wires all six riskkit components together behind one config and one call.
|
|
323
|
+
|
|
324
|
+
Build it from a :class:`RiskConfig`, call :meth:`on_equity` whenever your
|
|
325
|
+
account value changes, and :meth:`evaluate` for each trade you are
|
|
326
|
+
considering. Tell it about fills and closes (:meth:`on_fill` /
|
|
327
|
+
:meth:`on_close`) so the open book, drawdown, and session state stay current.
|
|
328
|
+
|
|
329
|
+
The underlying components are exposed as attributes (``.sizer``, ``.drawdown``,
|
|
330
|
+
``.stops``, ``.correlation``, ``.session``, ``.validator``) for when you need
|
|
331
|
+
to reach past the façade.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(self, config: RiskConfig | None = None) -> None:
|
|
335
|
+
self.config = config or RiskConfig()
|
|
336
|
+
|
|
337
|
+
# Promote the headline knobs, letting a per-component dict override them.
|
|
338
|
+
sizing_kwargs = {
|
|
339
|
+
"base_risk_pct": self.config.base_risk_pct,
|
|
340
|
+
"max_notional_pct": self.config.max_notional_pct,
|
|
341
|
+
**self.config.sizing,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
self.sizer = PositionSizer(**sizing_kwargs)
|
|
345
|
+
self.drawdown = DrawdownManager(**self.config.drawdown)
|
|
346
|
+
self.stops = StopEngine(**self.config.stops)
|
|
347
|
+
self.correlation = CorrelationGuard(**self.config.correlation)
|
|
348
|
+
self.session = SessionManager(**self.config.session)
|
|
349
|
+
|
|
350
|
+
# Seed the validator from the headline knob and the session's effective
|
|
351
|
+
# limits, so the two enforcers never silently disagree on the same
|
|
352
|
+
# threshold. Anything in ``config.validator`` still wins if set.
|
|
353
|
+
validator_kwargs = {
|
|
354
|
+
"max_notional_pct": self.config.max_notional_pct,
|
|
355
|
+
# A single full-size position must fit inside the total-exposure cap,
|
|
356
|
+
# so the cap tracks max_notional_pct when that is raised above the
|
|
357
|
+
# multi-position baseline. An explicit validator override still wins.
|
|
358
|
+
"max_total_exposure_pct": max(
|
|
359
|
+
_DEFAULT_TOTAL_EXPOSURE_PCT, self.config.max_notional_pct
|
|
360
|
+
),
|
|
361
|
+
"max_daily_trades": self.session.max_trades,
|
|
362
|
+
"max_daily_loss_pct": self.session.max_loss_pct,
|
|
363
|
+
"min_seconds_between_trades": self.session.min_minutes_between * 60,
|
|
364
|
+
}
|
|
365
|
+
if self.config.max_portfolio_heat_pct is not None:
|
|
366
|
+
validator_kwargs["max_portfolio_heat_pct"] = self.config.max_portfolio_heat_pct
|
|
367
|
+
if self.config.max_exposure_per_sector_pct is not None:
|
|
368
|
+
validator_kwargs["max_exposure_per_sector_pct"] = self.config.max_exposure_per_sector_pct
|
|
369
|
+
validator_kwargs.update(self.config.validator) # explicit overrides win
|
|
370
|
+
self.validator = PreTradeValidator(**validator_kwargs)
|
|
371
|
+
|
|
372
|
+
self._equity: float | None = None
|
|
373
|
+
self._dd_state: DrawdownState | None = None
|
|
374
|
+
self._open: dict[str, OpenPosition] = {}
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------ state
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def equity(self) -> float | None:
|
|
380
|
+
"""The most recent equity pushed in via :meth:`on_equity`."""
|
|
381
|
+
return self._equity
|
|
382
|
+
|
|
383
|
+
def on_equity(self, equity: float, now: datetime | None = None) -> DrawdownState:
|
|
384
|
+
"""Push the latest account equity. Refreshes drawdown state and returns it.
|
|
385
|
+
|
|
386
|
+
Call this whenever equity moves (at least once before :meth:`evaluate`).
|
|
387
|
+
"""
|
|
388
|
+
self._equity = equity
|
|
389
|
+
self._dd_state = self.drawdown.update(equity, now=now)
|
|
390
|
+
return self._dd_state
|
|
391
|
+
|
|
392
|
+
def open_symbols(self) -> set[str]:
|
|
393
|
+
"""Symbols the manager currently considers open."""
|
|
394
|
+
return set(self._open)
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def open_positions(self) -> dict[str, OpenPosition]:
|
|
398
|
+
"""A copy of the open book, keyed by symbol."""
|
|
399
|
+
return dict(self._open)
|
|
400
|
+
|
|
401
|
+
def exposure_pct(self) -> float:
|
|
402
|
+
"""Total open notional as a percentage of current equity."""
|
|
403
|
+
if not self._equity:
|
|
404
|
+
return 0.0
|
|
405
|
+
return sum(p.notional for p in self._open.values()) / self._equity * 100.0
|
|
406
|
+
|
|
407
|
+
def portfolio_heat_pct(self) -> float:
|
|
408
|
+
"""Total open risk-at-stop ("heat") as a percentage of current equity."""
|
|
409
|
+
if not self._equity:
|
|
410
|
+
return 0.0
|
|
411
|
+
return sum(p.risk_amount for p in self._open.values()) / self._equity * 100.0
|
|
412
|
+
|
|
413
|
+
def sector_exposure_pct(self, sector: str) -> float:
|
|
414
|
+
"""Open notional in one sector / asset-class as a percentage of equity.
|
|
415
|
+
|
|
416
|
+
Untagged positions (``sector == ""``) never count toward any sector, so an
|
|
417
|
+
empty ``sector`` always returns ``0.0``.
|
|
418
|
+
"""
|
|
419
|
+
if not self._equity or not sector:
|
|
420
|
+
return 0.0
|
|
421
|
+
return sum(
|
|
422
|
+
p.notional for p in self._open.values() if p.sector == sector
|
|
423
|
+
) / self._equity * 100.0
|
|
424
|
+
|
|
425
|
+
def sector_exposure(self) -> dict[str, float]:
|
|
426
|
+
"""Per-sector open notional as a percentage of equity, for tagged positions."""
|
|
427
|
+
if not self._equity:
|
|
428
|
+
return {}
|
|
429
|
+
out: dict[str, float] = {}
|
|
430
|
+
for p in self._open.values():
|
|
431
|
+
if p.sector:
|
|
432
|
+
out[p.sector] = out.get(p.sector, 0.0) + p.notional / self._equity * 100.0
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
# ------------------------------------------------------------------ evaluate
|
|
436
|
+
|
|
437
|
+
def evaluate(self, intent: TradeIntent, now: datetime | None = None) -> RiskDecision:
|
|
438
|
+
"""Size and validate a single trade. The heart of the façade.
|
|
439
|
+
|
|
440
|
+
Returns a :class:`RiskDecision`. ``decision.ok`` is the AND of the
|
|
441
|
+
pre-trade validator passing *and* the session manager permitting a new
|
|
442
|
+
entry — the latter catches behavioural blocks (tilt, cooldowns, profit
|
|
443
|
+
target hit, strategy halt) that have no dedicated validator flag.
|
|
444
|
+
"""
|
|
445
|
+
if self._equity is None or self._dd_state is None:
|
|
446
|
+
raise RuntimeError(
|
|
447
|
+
"call on_equity(...) at least once before evaluate(...)"
|
|
448
|
+
)
|
|
449
|
+
now = now or datetime.now(timezone.utc)
|
|
450
|
+
equity = self._equity
|
|
451
|
+
dd = self._dd_state
|
|
452
|
+
|
|
453
|
+
# ---- gather stateful gates ----
|
|
454
|
+
sess = self.session.can_open(intent.strategy, now=now, score=intent.score)
|
|
455
|
+
corr = self.correlation.can_open(intent.symbol, self.open_symbols())
|
|
456
|
+
|
|
457
|
+
# ---- size the trade ----
|
|
458
|
+
# The drawdown dimension is applied once, via the manager's size
|
|
459
|
+
# multiplier below — so we leave drawdown_pct out of the sizer to avoid
|
|
460
|
+
# double-counting its own drawdown ladder. The losing-streak and
|
|
461
|
+
# daily-loss factors come straight from recorded session state.
|
|
462
|
+
daily_loss_pct = max(0.0, -self.session.day_pnl_pct)
|
|
463
|
+
sized = self.sizer.size(SizingInputs(
|
|
464
|
+
equity=equity,
|
|
465
|
+
entry_price=intent.entry_price,
|
|
466
|
+
stop_price=intent.stop_price,
|
|
467
|
+
atr=intent.atr,
|
|
468
|
+
atr_baseline=intent.atr_baseline,
|
|
469
|
+
confluence_score=(
|
|
470
|
+
intent.confluence_score if intent.confluence_score is not None
|
|
471
|
+
else intent.score
|
|
472
|
+
),
|
|
473
|
+
consecutive_losses=self.session.consecutive_losses,
|
|
474
|
+
drawdown_pct=0.0,
|
|
475
|
+
daily_loss_pct=daily_loss_pct,
|
|
476
|
+
win_rate=intent.win_rate,
|
|
477
|
+
avg_win=intent.avg_win,
|
|
478
|
+
avg_loss=intent.avg_loss,
|
|
479
|
+
))
|
|
480
|
+
|
|
481
|
+
dd_mult = dd.size_multiplier
|
|
482
|
+
units = sized.units * dd_mult
|
|
483
|
+
notional = units * intent.entry_price
|
|
484
|
+
risk_per_unit = abs(intent.entry_price - intent.stop_price)
|
|
485
|
+
risk_amount = units * risk_per_unit
|
|
486
|
+
risk_pct = sized.risk_pct * dd_mult
|
|
487
|
+
|
|
488
|
+
# Fold the drawdown reduction into the size audit trail.
|
|
489
|
+
applied = dict(sized.multipliers_applied)
|
|
490
|
+
if dd_mult != 1.0:
|
|
491
|
+
applied["drawdown_manager"] = dd_mult
|
|
492
|
+
sized_audit = replace(
|
|
493
|
+
sized, units=units, notional=notional,
|
|
494
|
+
risk_amount=risk_amount, risk_pct=risk_pct,
|
|
495
|
+
multipliers_applied=applied,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# ---- concurrency ceiling (config baseline tightened by drawdown tier) ----
|
|
499
|
+
# The drawdown manager uses max_concurrent_override == 0 as a "no
|
|
500
|
+
# positions" sentinel during a halt/pause; that case is already reported
|
|
501
|
+
# via the halt flag, so only treat an override of >= 1 as a real cap.
|
|
502
|
+
dd_concurrent = (
|
|
503
|
+
dd.max_concurrent_override
|
|
504
|
+
if (dd.max_concurrent_override or 0) >= 1 else None
|
|
505
|
+
)
|
|
506
|
+
caps = [
|
|
507
|
+
c for c in (self.config.max_concurrent, dd_concurrent)
|
|
508
|
+
if c is not None
|
|
509
|
+
]
|
|
510
|
+
effective_max = min(caps) if caps else None
|
|
511
|
+
at_max_concurrent = (
|
|
512
|
+
effective_max is not None and len(self._open) >= effective_max
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
seconds_since_last = (
|
|
516
|
+
(now - self.session.last_trade_ts).total_seconds()
|
|
517
|
+
if self.session.last_trade_ts else inf
|
|
518
|
+
)
|
|
519
|
+
cooldown_active = (
|
|
520
|
+
bool(sess.cooldown_until is not None and now < sess.cooldown_until)
|
|
521
|
+
or sess.on_tilt
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# ---- final validation gate ----
|
|
525
|
+
proposal = TradeProposal(
|
|
526
|
+
symbol=intent.symbol,
|
|
527
|
+
side=intent.side,
|
|
528
|
+
entry_price=intent.entry_price,
|
|
529
|
+
stop_price=intent.stop_price,
|
|
530
|
+
target_price=intent.target_price,
|
|
531
|
+
size_units=units,
|
|
532
|
+
notional=notional,
|
|
533
|
+
strategy=intent.strategy,
|
|
534
|
+
score=intent.score,
|
|
535
|
+
regime=intent.regime,
|
|
536
|
+
spread_pct=intent.spread_pct,
|
|
537
|
+
orderbook_depth=intent.orderbook_depth,
|
|
538
|
+
recent_atr_spike_x=intent.recent_atr_spike_x,
|
|
539
|
+
last_quote_age_sec=intent.last_quote_age_sec,
|
|
540
|
+
equity=equity,
|
|
541
|
+
free_balance=intent.free_balance,
|
|
542
|
+
current_total_exposure_pct=self.exposure_pct(),
|
|
543
|
+
current_portfolio_heat_pct=self.portfolio_heat_pct(),
|
|
544
|
+
sector=intent.sector,
|
|
545
|
+
current_sector_exposure_pct=self.sector_exposure_pct(intent.sector),
|
|
546
|
+
open_concurrent_positions=len(self._open),
|
|
547
|
+
daily_loss_pct=daily_loss_pct,
|
|
548
|
+
daily_trade_count=self.session.day_trades,
|
|
549
|
+
drawdown_halted=dd.halted,
|
|
550
|
+
cooldown_active=cooldown_active,
|
|
551
|
+
correlation_blocked=not corr.allowed,
|
|
552
|
+
at_max_concurrent=at_max_concurrent,
|
|
553
|
+
seconds_since_last_trade=seconds_since_last,
|
|
554
|
+
)
|
|
555
|
+
validation = self.validator.validate(
|
|
556
|
+
proposal, min_score_override=dd.min_score_override
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
ok = validation.passed and sess.allowed
|
|
560
|
+
reasons: list[str] = [f"{f.name}: {f.details}" for f in validation.failures]
|
|
561
|
+
if not sess.allowed:
|
|
562
|
+
reasons.append(f"session: {sess.reason}")
|
|
563
|
+
|
|
564
|
+
return RiskDecision(
|
|
565
|
+
ok=ok,
|
|
566
|
+
symbol=intent.symbol,
|
|
567
|
+
side=intent.side,
|
|
568
|
+
units=units,
|
|
569
|
+
notional=notional,
|
|
570
|
+
entry=intent.entry_price,
|
|
571
|
+
stop=intent.stop_price,
|
|
572
|
+
target=intent.target_price,
|
|
573
|
+
risk_pct=risk_pct,
|
|
574
|
+
risk_amount=risk_amount,
|
|
575
|
+
reasons=reasons,
|
|
576
|
+
sector=intent.sector,
|
|
577
|
+
sizing=sized_audit,
|
|
578
|
+
validation=validation,
|
|
579
|
+
drawdown=dd,
|
|
580
|
+
session=sess,
|
|
581
|
+
correlation=corr,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------------ book
|
|
585
|
+
|
|
586
|
+
def on_fill(self, decision: RiskDecision, strategy: str = "default") -> OpenPosition:
|
|
587
|
+
"""Record that an evaluated trade was actually filled.
|
|
588
|
+
|
|
589
|
+
Adds it to the open book so subsequent correlation, exposure, and
|
|
590
|
+
concurrency checks account for it. Pass the :class:`RiskDecision` you got
|
|
591
|
+
back from :meth:`evaluate`.
|
|
592
|
+
"""
|
|
593
|
+
pos = OpenPosition(
|
|
594
|
+
symbol=decision.symbol,
|
|
595
|
+
side=decision.side,
|
|
596
|
+
units=decision.units,
|
|
597
|
+
notional=decision.notional,
|
|
598
|
+
entry_price=decision.entry,
|
|
599
|
+
stop_price=decision.stop,
|
|
600
|
+
strategy=strategy,
|
|
601
|
+
sector=decision.sector,
|
|
602
|
+
)
|
|
603
|
+
self._open[pos.symbol] = pos
|
|
604
|
+
return pos
|
|
605
|
+
|
|
606
|
+
def on_close(
|
|
607
|
+
self,
|
|
608
|
+
trade: TradeRecord,
|
|
609
|
+
equity_before: float | None = None,
|
|
610
|
+
) -> None:
|
|
611
|
+
"""Record a closed trade: feed the session manager and free the slot.
|
|
612
|
+
|
|
613
|
+
``equity_before`` defaults to the manager's last-known equity; it scales
|
|
614
|
+
the trade's PnL into the session's daily-loss percentage.
|
|
615
|
+
"""
|
|
616
|
+
self.session.record_trade(
|
|
617
|
+
trade, equity_before if equity_before is not None else (self._equity or 0.0)
|
|
618
|
+
)
|
|
619
|
+
self._open.pop(trade.symbol, None)
|
riskkit/metrics.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Risk metrics.
|
|
2
|
+
|
|
3
|
+
Historical Value-at-Risk and Conditional Value-at-Risk (expected shortfall) over
|
|
4
|
+
a series of returns. Both are reported as **positive loss magnitudes** — a VaR of
|
|
5
|
+
``0.04`` means "at this confidence, losses are not expected to exceed 4%."
|
|
6
|
+
|
|
7
|
+
Pure standard library; feed it a sequence of period returns (as fractions, e.g.
|
|
8
|
+
``-0.02`` for −2%) and it hands back auditable numbers. By construction
|
|
9
|
+
``conditional_value_at_risk >= value_at_risk`` (the average tail loss is at least
|
|
10
|
+
the threshold loss).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tail(returns: Sequence[float], confidence: float) -> list[float]:
|
|
18
|
+
"""The worst ``(1 - confidence)`` fraction of returns, ascending."""
|
|
19
|
+
if not returns:
|
|
20
|
+
raise ValueError("returns must be non-empty")
|
|
21
|
+
if not 0.0 < confidence < 1.0:
|
|
22
|
+
raise ValueError(f"confidence must be in (0, 1), got {confidence}")
|
|
23
|
+
ordered = sorted(returns)
|
|
24
|
+
k = max(1, int(round((1.0 - confidence) * len(ordered))))
|
|
25
|
+
return ordered[:k]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def value_at_risk(returns: Sequence[float], confidence: float = 0.95) -> float:
|
|
29
|
+
"""Historical VaR — the threshold loss at ``confidence`` (positive = loss).
|
|
30
|
+
|
|
31
|
+
Example: with daily returns and ``confidence=0.95``, the return is the loss
|
|
32
|
+
that the worst ~5% of days breach.
|
|
33
|
+
"""
|
|
34
|
+
return -_tail(returns, confidence)[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def conditional_value_at_risk(returns: Sequence[float], confidence: float = 0.95) -> float:
|
|
38
|
+
"""Historical CVaR / expected shortfall — the *average* loss in the tail beyond
|
|
39
|
+
:func:`value_at_risk` (positive = loss). Always ``>= value_at_risk``."""
|
|
40
|
+
tail = _tail(returns, confidence)
|
|
41
|
+
return -sum(tail) / len(tail)
|
riskkit/py.typed
ADDED
|
File without changes
|