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.
- alphaengine/__init__.py +47 -0
- alphaengine/_version.py +15 -0
- alphaengine/core/__init__.py +63 -0
- alphaengine/core/backtest.py +676 -0
- alphaengine/core/factors.py +136 -0
- alphaengine/core/pairs.py +514 -0
- alphaengine/core/performance.py +108 -0
- alphaengine/core/risk.py +127 -0
- alphaengine/core/technical.py +213 -0
- alphaengine/core/validation.py +361 -0
- alphaengine/py.typed +0 -0
- alphaengine/study/__init__.py +32 -0
- alphaengine/study/schema.py +146 -0
- alphaengine/sweep/__init__.py +18 -0
- alphaengine/sweep/runner.py +321 -0
- alphaengine-0.1.0.dist-info/METADATA +157 -0
- alphaengine-0.1.0.dist-info/RECORD +19 -0
- alphaengine-0.1.0.dist-info/WHEEL +4 -0
- alphaengine-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
alphaengine/core/risk.py
ADDED
|
@@ -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"]
|