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