alphaengine 0.1.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.
@@ -0,0 +1,108 @@
1
+ """
2
+ Performance, risk-adjusted metrics over a supplied return stream.
3
+
4
+ Pure math, no data layer, no FRED. The risk-free rate is an explicit parameter
5
+ (default 0.0) so results are reproducible and the no-fetch invariant holds, the
6
+ upstream backend performance.py fetched the 3-month T-bill from FRED, which is
7
+ exactly the landmine this copy removes.
8
+
9
+ Sharpe, Sortino, Calmar, max drawdown, total/annualized return, VaR/CVaR(95),
10
+ and alpha/beta/information-ratio when a benchmark return series is supplied.
11
+ Loss metrics (max_drawdown, var, cvar) are reported as POSITIVE magnitudes in
12
+ both decimal and percent; the sign convention is documented per field.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+
19
+ import numpy as np
20
+
21
+ _PPY = 252 # trading periods per year
22
+
23
+
24
+ def _clean(v):
25
+ if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
26
+ return None
27
+ return v
28
+
29
+
30
+ def performance_report(
31
+ returns: list[float],
32
+ *,
33
+ equity_curve: list[float] | None = None,
34
+ benchmark_returns: list[float] | None = None,
35
+ risk_free_rate: float = 0.0,
36
+ periods_per_year: int = _PPY,
37
+ ) -> dict:
38
+ """Risk-adjusted performance metrics for a daily return series (decimals).
39
+
40
+ `risk_free_rate` is ANNUAL (e.g. 0.04); supply it explicitly, the engine
41
+ never fetches it. Loss metrics are positive magnitudes.
42
+ """
43
+ arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
44
+ n = arr.size
45
+ if n < 30:
46
+ return {"error": "need >= 30 observations", "n_obs": int(n)}
47
+
48
+ ppy = int(periods_per_year)
49
+ rf_per = float(risk_free_rate) / ppy
50
+ excess = arr - rf_per
51
+ sd = float(arr.std(ddof=1))
52
+
53
+ sharpe = float(excess.mean() / sd) if sd > 0 else 0.0
54
+ sharpe_ann = sharpe * math.sqrt(ppy)
55
+
56
+ # Downside deviation is the TARGET SEMIDEVIATION: RMS of below-target
57
+ # excess around the target (0), averaged over ALL N, not the std of the
58
+ # negative subset (which measures spread around the negatives' own mean).
59
+ # Math-identical to backend/quant/performance.sortino_ratio (M1 fix).
60
+ downside = np.minimum(excess, 0.0)
61
+ dsd = float(np.sqrt(np.mean(downside**2))) if np.count_nonzero(downside) else 0.0
62
+ sortino_ann = (float(excess.mean()) / dsd) * math.sqrt(ppy) if dsd > 0 else 0.0
63
+
64
+ eq = np.asarray(equity_curve, dtype=float) if equity_curve else np.cumprod(1.0 + arr)
65
+ peak = np.maximum.accumulate(eq)
66
+ drawdown = eq / peak - 1.0
67
+ max_dd = float(drawdown.min()) if drawdown.size else 0.0
68
+
69
+ total_return = float(eq[-1] - 1.0) if eq.size else 0.0
70
+ ann_return = float((1.0 + total_return) ** (ppy / n) - 1.0) if total_return > -1 else -1.0
71
+ calmar = float(ann_return / abs(max_dd)) if max_dd < 0 else 0.0
72
+
73
+ cut = float(np.percentile(arr, 5))
74
+ var95 = abs(cut)
75
+ tail = arr[arr <= cut]
76
+ cvar95 = abs(float(tail.mean())) if tail.size else var95
77
+
78
+ out = {
79
+ "n_obs": int(n),
80
+ "sharpe_ratio": _clean(round(sharpe, 4)), # per-period
81
+ "sharpe_annualized": _clean(round(sharpe_ann, 4)),
82
+ "sortino_ratio": _clean(round(sortino_ann, 4)), # annualized
83
+ "calmar_ratio": _clean(round(calmar, 4)),
84
+ "max_drawdown_pct": _clean(round(abs(max_dd) * 100, 2)), # positive magnitude
85
+ "total_return_pct": _clean(round(total_return * 100, 2)),
86
+ "annualized_return_pct": _clean(round(ann_return * 100, 2)),
87
+ "var_95": _clean(round(var95, 4)), # positive daily loss fraction
88
+ "cvar_95": _clean(round(cvar95, 4)),
89
+ "volatility_annualized_pct": _clean(round(sd * math.sqrt(ppy) * 100, 2)),
90
+ "risk_free_rate": round(float(risk_free_rate), 4),
91
+ }
92
+
93
+ if benchmark_returns:
94
+ b = np.asarray([float(x) for x in benchmark_returns], dtype=float)
95
+ m = min(len(b), n)
96
+ if m >= 30:
97
+ a2, b2 = arr[-m:] - rf_per, b[-m:] - rf_per
98
+ var_b = float(np.var(b2, ddof=1))
99
+ beta = float(np.cov(a2, b2, ddof=1)[0, 1] / var_b) if var_b > 0 else 0.0
100
+ alpha_per = float(a2.mean() - beta * b2.mean())
101
+ active = arr[-m:] - b[-m:]
102
+ te = float(active.std(ddof=1))
103
+ ir = (float(active.mean()) / te) * math.sqrt(ppy) if te > 0 else 0.0
104
+ out["beta"] = _clean(round(beta, 4))
105
+ out["alpha_annualized_pct"] = _clean(round(alpha_per * ppy * 100, 2))
106
+ out["information_ratio"] = _clean(round(ir, 4))
107
+
108
+ return out
@@ -0,0 +1,127 @@
1
+ """
2
+ Risk, VaR / CVaR over a supplied portfolio return stream.
3
+
4
+ Lifted (math-identical) from backend/quant/risk.py's returns-based paths, with
5
+ the data/limits coupling removed: the caller supplies a daily portfolio return
6
+ series; nothing is fetched. Three layers of VaR rigor plus Expected Shortfall:
7
+
8
+ 1. Parametric Gaussian VaR, z·σ·√horizon.
9
+ 2. Cornish-Fisher VaR, expands z by skew/kurtosis (observed non-normality).
10
+ 3. Historical-percentile VaR + bootstrap CI (deterministic seed).
11
+ 4. CVaR (Expected Shortfall), mean of the tail beyond the percentile VaR.
12
+
13
+ z comes from scipy's inverse-normal so non-{0.95,0.99} confidences are exact.
14
+ Pure numpy/scipy. Deterministic given inputs on the pinned stack.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+
21
+ import numpy as np
22
+ from scipy import stats
23
+
24
+
25
+ def _clean(val):
26
+ if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
27
+ return None
28
+ return val
29
+
30
+
31
+ def compute_var_cvar(
32
+ portfolio_returns: list[float],
33
+ *,
34
+ confidence: float = 0.95,
35
+ horizon_days: int = 1,
36
+ portfolio_value: float = 100_000.0,
37
+ bootstrap_samples: int = 1000,
38
+ ) -> dict:
39
+ """Portfolio VaR (parametric + Cornish-Fisher + historical) and CVaR.
40
+
41
+ `portfolio_returns`: historical daily portfolio returns as decimals. VaR is
42
+ reported as a positive loss fraction (and dollars at `portfolio_value`).
43
+ """
44
+ arr = np.array(
45
+ [
46
+ r
47
+ for r in (portfolio_returns or [])
48
+ if r is not None and not (isinstance(r, float) and np.isnan(r))
49
+ ],
50
+ dtype=float,
51
+ )
52
+ n_obs = arr.size
53
+ if n_obs < 20:
54
+ return {"error": "need >= 20 observations", "n_obs": int(n_obs)}
55
+
56
+ z = float(stats.norm.ppf(confidence))
57
+ mean = float(np.mean(arr))
58
+ std = float(np.std(arr, ddof=1)) if n_obs > 1 else 0.0
59
+
60
+ # 1. Parametric Gaussian VaR (per-period vol scaled to horizon).
61
+ parametric_daily = z * std * math.sqrt(horizon_days)
62
+ result = {
63
+ "n_obs": int(n_obs),
64
+ "confidence": confidence,
65
+ "horizon_days": int(horizon_days),
66
+ "low_sample": bool(n_obs < 60),
67
+ "parametric": {
68
+ "var_pct": _clean(round(parametric_daily * 100, 2)),
69
+ "var_dollars": _clean(round(parametric_daily * portfolio_value, 2)),
70
+ "daily_vol_pct": _clean(round(std * 100, 2)),
71
+ },
72
+ "method": "parametric_gaussian+cornish_fisher+historical",
73
+ }
74
+
75
+ # 2. Cornish-Fisher adjusted VaR.
76
+ if std > 0:
77
+ skew = float(np.mean(((arr - mean) / std) ** 3))
78
+ kurt = float(np.mean(((arr - mean) / std) ** 4) - 3.0) # excess
79
+ z_cf = (
80
+ z + (z**2 - 1) * skew / 6.0 + (z**3 - 3 * z) * kurt / 24.0 - (2 * z**3 - 5 * z) * (skew**2) / 36.0
81
+ )
82
+ # The CF expansion is only valid near-normal; extreme skew/kurtosis
83
+ # can drive z_cf <= 0, which would report a NEGATIVE loss (violating
84
+ # the positive-loss convention). Outside the validity domain, fall
85
+ # back to the Gaussian z and say so, never fabricate from a broken
86
+ # expansion.
87
+ cf_invalid = bool(z_cf <= 0)
88
+ z_eff = z if cf_invalid else z_cf
89
+ cf_daily = z_eff * std * math.sqrt(horizon_days)
90
+ result["cornish_fisher"] = {
91
+ "var_pct": _clean(round(cf_daily * 100, 2)),
92
+ "var_dollars": _clean(round(cf_daily * portfolio_value, 2)),
93
+ "skewness": _clean(round(skew, 3)),
94
+ "excess_kurtosis": _clean(round(kurt, 3)),
95
+ "z_adjusted": _clean(round(float(z_eff), 3)),
96
+ "cf_fallback_gaussian": cf_invalid,
97
+ }
98
+
99
+ # 3. Historical-percentile VaR + bootstrap CI (deterministic seed).
100
+ rng = np.random.default_rng(42)
101
+ tail_pct = (1 - confidence) * 100
102
+ samples = rng.choice(arr, size=(bootstrap_samples, n_obs), replace=True)
103
+ boot_vars = np.percentile(samples, tail_pct, axis=1) * math.sqrt(horizon_days)
104
+ lo, hi = np.percentile(boot_vars, [2.5, 97.5])
105
+ point = float(np.percentile(arr, tail_pct)) * math.sqrt(horizon_days)
106
+ # Report VaR as a positive loss magnitude. The tail percentiles are
107
+ # negative returns, so abs() flips their order, sort so low <= high.
108
+ ci_low, ci_high = sorted((abs(float(lo)) * 100, abs(float(hi)) * 100))
109
+ result["historical"] = {
110
+ "var_pct": _clean(round(abs(point) * 100, 2)),
111
+ "ci_95_low_pct": _clean(round(ci_low, 2)),
112
+ "ci_95_high_pct": _clean(round(ci_high, 2)),
113
+ "bootstrap_samples": bootstrap_samples,
114
+ }
115
+
116
+ # 4. CVaR (Expected Shortfall), mean of the tail beyond the percentile VaR.
117
+ var_cutoff = np.percentile(arr, tail_pct)
118
+ tail = arr[arr <= var_cutoff]
119
+ cvar = float(np.mean(tail)) if tail.size > 0 else float(var_cutoff)
120
+ result["cvar"] = {
121
+ "cvar_pct": _clean(round(abs(cvar) * 100, 2)),
122
+ "cvar_dollars": _clean(round(abs(cvar) * portfolio_value, 2)),
123
+ "var_pct": _clean(round(abs(float(var_cutoff)) * 100, 2)),
124
+ "tail_observations": int(tail.size),
125
+ }
126
+
127
+ return result
@@ -0,0 +1,213 @@
1
+ """
2
+ technical.py, price-derived technical features on a SUPPLIED price series.
3
+
4
+ Pure deterministic math (no fetch, no LLM, no randomness): Wilder RSI, SMA/EMA,
5
+ distance-from-MA, and ATR, with interpretive flags. Prices are caller-supplied
6
+ (model "b": the engine never fetches prices). A feature that needs more data than
7
+ supplied (RSI/EMA below their window, ATR with no high/low) is reported as
8
+ absent-with-a-reason, never guessed. Coverage is a first-class output.
9
+
10
+ Math conventions (so a caller can audit):
11
+ - SMA(w) = mean of the trailing w closes.
12
+ - EMA(w) = seeded with SMA of the first w closes, then alpha=2/(w+1) recursion.
13
+ - RSI(w) = Wilder: seed avg gain/loss = simple mean of the first w deltas, then
14
+ avg = (prev*(w-1) + current)/w. RS = avg_gain/avg_loss;
15
+ RSI = 100 - 100/(1+RS). All-up -> 100, all-down -> 0, flat -> 50.
16
+ - ATR(w) = Wilder smoothing of True Range; needs high/low (OHLC rows).
17
+ - distance_pct = (last_close - MA) / MA * 100.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ DEFAULT_SMA_WINDOWS = [50, 150, 200]
27
+ RSI_OVERBOUGHT = 70.0
28
+ RSI_OVERSOLD = 30.0
29
+ _RND = 6
30
+
31
+
32
+ def _unavailable(reason: str, **details) -> dict:
33
+ """Local capability-error shape (kept identical in the backend copy for parity)."""
34
+ return {"available": False, "reason": reason, **details}
35
+
36
+
37
+ def _extract(series: Any) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]:
38
+ """Return (closes, highs, lows). Accepts bare closes, {date: close}, or a list
39
+ of OHLC row dicts. highs/lows are None unless OHLC rows carry them."""
40
+ if isinstance(series, dict):
41
+ dates = sorted(series.keys())
42
+ return np.asarray([float(series[d]) for d in dates], dtype=float), None, None
43
+ if isinstance(series, list) and series and isinstance(series[0], dict):
44
+ rows = series
45
+ if all("date" in r for r in rows):
46
+ rows = sorted(rows, key=lambda r: r["date"])
47
+ closes = np.asarray([float(r["close"]) for r in rows], dtype=float)
48
+ if all(("high" in r and "low" in r) for r in rows):
49
+ highs = np.asarray([float(r["high"]) for r in rows], dtype=float)
50
+ lows = np.asarray([float(r["low"]) for r in rows], dtype=float)
51
+ return closes, highs, lows
52
+ return closes, None, None
53
+ closes = np.asarray([float(x) for x in (series or []) if x is not None], dtype=float)
54
+ return closes, None, None
55
+
56
+
57
+ def _sma(closes: np.ndarray, window: int) -> float | None:
58
+ if len(closes) < window:
59
+ return None
60
+ return float(closes[-window:].mean())
61
+
62
+
63
+ def _ema(closes: np.ndarray, window: int) -> float | None:
64
+ if len(closes) < window:
65
+ return None
66
+ alpha = 2.0 / (window + 1)
67
+ ema = float(closes[:window].mean())
68
+ for c in closes[window:]:
69
+ ema = alpha * float(c) + (1 - alpha) * ema
70
+ return ema
71
+
72
+
73
+ def _wilder_rsi(closes: np.ndarray, window: int) -> float | None:
74
+ if len(closes) < window + 1:
75
+ return None
76
+ deltas = np.diff(closes)
77
+ gains = np.where(deltas > 0, deltas, 0.0)
78
+ losses = np.where(deltas < 0, -deltas, 0.0)
79
+ avg_gain = float(gains[:window].mean())
80
+ avg_loss = float(losses[:window].mean())
81
+ for i in range(window, len(deltas)):
82
+ avg_gain = (avg_gain * (window - 1) + float(gains[i])) / window
83
+ avg_loss = (avg_loss * (window - 1) + float(losses[i])) / window
84
+ if avg_loss == 0 and avg_gain == 0:
85
+ return 50.0
86
+ if avg_loss == 0:
87
+ return 100.0
88
+ rs = avg_gain / avg_loss
89
+ return 100.0 - 100.0 / (1.0 + rs)
90
+
91
+
92
+ def _atr(highs: np.ndarray | None, lows: np.ndarray | None, closes: np.ndarray, window: int) -> float | None:
93
+ if highs is None or lows is None or len(closes) < window + 1:
94
+ return None
95
+ n = len(closes)
96
+ tr = np.empty(n - 1)
97
+ for i in range(1, n):
98
+ tr[i - 1] = max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]), abs(lows[i] - closes[i - 1]))
99
+ atr = float(tr[:window].mean())
100
+ for i in range(window, len(tr)):
101
+ atr = (atr * (window - 1) + float(tr[i])) / window
102
+ return atr
103
+
104
+
105
+ def _ma_block(last: float, value: float | None) -> dict | None:
106
+ if value is None:
107
+ return None
108
+ return {
109
+ "value": round(value, _RND),
110
+ "above": bool(last > value),
111
+ "distance_pct": round((last - value) / value * 100.0, 4) if value != 0 else None,
112
+ }
113
+
114
+
115
+ def technical_features(
116
+ prices: dict,
117
+ *,
118
+ sma_windows: list[int] | None = None,
119
+ ema_windows: list[int] | None = None,
120
+ rsi_window: int = 14,
121
+ atr_window: int = 14,
122
+ ) -> dict:
123
+ """Per-symbol technical features over supplied prices. See module docstring for
124
+ the math. Returns per-symbol values + interpretive flags, plus coverage."""
125
+ sma_windows = list(sma_windows) if sma_windows else list(DEFAULT_SMA_WINDOWS)
126
+ ema_windows = list(ema_windows) if ema_windows else []
127
+
128
+ features: dict[str, dict] = {}
129
+ coverage_n = 0
130
+ for sym, series in prices.items():
131
+ closes, highs, lows = _extract(series)
132
+ n = len(closes)
133
+ if n == 0:
134
+ features[sym] = {
135
+ "n_observations": 0,
136
+ "last_close": None,
137
+ "insufficient": ["no_data"],
138
+ "flags": [],
139
+ }
140
+ continue
141
+ coverage_n += 1
142
+ last = float(closes[-1])
143
+ res: dict[str, Any] = {
144
+ "n_observations": n,
145
+ "last_close": round(last, _RND),
146
+ "sma": {},
147
+ "ema": {},
148
+ "flags": [],
149
+ "insufficient": [],
150
+ }
151
+
152
+ for w in sma_windows:
153
+ block = _ma_block(last, _sma(closes, w))
154
+ res["sma"][str(w)] = block
155
+ if block is None:
156
+ res["insufficient"].append(f"sma_{w}")
157
+ else:
158
+ res["flags"].append(f"{'above' if block['above'] else 'below'}_sma_{w}")
159
+ for w in ema_windows:
160
+ block = _ma_block(last, _ema(closes, w))
161
+ res["ema"][str(w)] = block
162
+ if block is None:
163
+ res["insufficient"].append(f"ema_{w}")
164
+ else:
165
+ res["flags"].append(f"{'above' if block['above'] else 'below'}_ema_{w}")
166
+
167
+ rsi = _wilder_rsi(closes, rsi_window)
168
+ if rsi is None:
169
+ res["rsi"] = None
170
+ res["insufficient"].append(f"rsi_{rsi_window}")
171
+ else:
172
+ ob, osd = rsi > RSI_OVERBOUGHT, rsi < RSI_OVERSOLD
173
+ res["rsi"] = {
174
+ "window": rsi_window,
175
+ "value": round(rsi, 4),
176
+ "overbought": bool(ob),
177
+ "oversold": bool(osd),
178
+ }
179
+ if ob:
180
+ res["flags"].append("rsi_overbought")
181
+ if osd:
182
+ res["flags"].append("rsi_oversold")
183
+
184
+ atr = _atr(highs, lows, closes, atr_window)
185
+ if atr is None:
186
+ res["atr"] = (
187
+ _unavailable("ATR needs high/low; supply OHLC rows", atr_window=atr_window)
188
+ if highs is None
189
+ else _unavailable(f"need >= {atr_window + 1} bars", atr_window=atr_window)
190
+ )
191
+ else:
192
+ res["atr"] = {
193
+ "window": atr_window,
194
+ "value": round(atr, _RND),
195
+ "pct_of_close": round(atr / last * 100.0, 4) if last != 0 else None,
196
+ }
197
+
198
+ features[sym] = res
199
+
200
+ return {
201
+ "features": features,
202
+ "params": {
203
+ "sma_windows": sma_windows,
204
+ "ema_windows": ema_windows,
205
+ "rsi_window": rsi_window,
206
+ "atr_window": atr_window,
207
+ },
208
+ "universe_size": len(prices),
209
+ "coverage_n": coverage_n,
210
+ }
211
+
212
+
213
+ __all__ = ["technical_features"]