b1687582ec
Multi-indicator signal engine with RSI, MACD, Bollinger Bands, EMA trend filter, ADX strength gate, Stochastic, ATR, and higher-timeframe trend confirmation. Twelve Data API client with in-memory cache and rate limiting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""Technical indicator functions.
|
|
|
|
Pure pandas implementations — no third-party TA library. Each public function
|
|
takes a DataFrame with columns [open, high, low, close] indexed by datetime
|
|
ascending, and returns either a float or a dict of latest values.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
|
|
|
|
def _require(df: pd.DataFrame, n: int) -> None:
|
|
if len(df) < n:
|
|
raise ValueError(f"Not enough data: need at least {n} rows, got {len(df)}")
|
|
|
|
|
|
def _ema(series: pd.Series, period: int) -> pd.Series:
|
|
return series.ewm(span=period, adjust=False).mean()
|
|
|
|
|
|
def _wilder(series: pd.Series, period: int) -> pd.Series:
|
|
"""Wilder's smoothing (RMA) — same as EMA with alpha = 1/period."""
|
|
return series.ewm(alpha=1.0 / period, adjust=False).mean()
|
|
|
|
|
|
def rsi(df: pd.DataFrame, period: int = 14) -> float:
|
|
"""Latest RSI using Wilder's smoothing."""
|
|
_require(df, period + 1)
|
|
delta = df["close"].diff()
|
|
gain = delta.clip(lower=0.0)
|
|
loss = -delta.clip(upper=0.0)
|
|
avg_gain = _wilder(gain, period)
|
|
avg_loss = _wilder(loss, period)
|
|
rs = avg_gain / avg_loss.replace(0.0, pd.NA)
|
|
rsi_series = (100.0 - (100.0 / (1.0 + rs))).fillna(100.0)
|
|
return float(rsi_series.iloc[-1])
|
|
|
|
|
|
def macd(df: pd.DataFrame) -> dict[str, float]:
|
|
"""Latest MACD line, signal, histogram (12/26/9), plus prior histogram for direction."""
|
|
_require(df, 35)
|
|
fast = _ema(df["close"], 12)
|
|
slow = _ema(df["close"], 26)
|
|
macd_line = fast - slow
|
|
signal_line = _ema(macd_line, 9)
|
|
hist = macd_line - signal_line
|
|
return {
|
|
"macd": float(macd_line.iloc[-1]),
|
|
"signal": float(signal_line.iloc[-1]),
|
|
"hist": float(hist.iloc[-1]),
|
|
"hist_prev": float(hist.iloc[-2]),
|
|
}
|
|
|
|
|
|
def bollinger(df: pd.DataFrame, period: int = 20) -> dict[str, float]:
|
|
"""Latest Bollinger Bands (upper/mid/lower, 2 stdev) and current price."""
|
|
_require(df, period + 1)
|
|
close = df["close"]
|
|
mid = close.rolling(period).mean()
|
|
std = close.rolling(period).std(ddof=0)
|
|
return {
|
|
"upper": float((mid + 2.0 * std).iloc[-1]),
|
|
"mid": float(mid.iloc[-1]),
|
|
"lower": float((mid - 2.0 * std).iloc[-1]),
|
|
"price": float(close.iloc[-1]),
|
|
}
|
|
|
|
|
|
def ema_pair(df: pd.DataFrame, fast: int = 20, slow: int = 50) -> dict[str, float]:
|
|
"""Latest fast/slow EMA values + current close."""
|
|
_require(df, slow + 1)
|
|
return {
|
|
"fast": float(_ema(df["close"], fast).iloc[-1]),
|
|
"slow": float(_ema(df["close"], slow).iloc[-1]),
|
|
"price": float(df["close"].iloc[-1]),
|
|
}
|
|
|
|
|
|
def atr(df: pd.DataFrame, period: int = 14) -> float:
|
|
"""Latest Average True Range (Wilder)."""
|
|
_require(df, period + 1)
|
|
high, low, close = df["high"], df["low"], df["close"]
|
|
prev_close = close.shift(1)
|
|
tr = pd.concat(
|
|
[(high - low), (high - prev_close).abs(), (low - prev_close).abs()],
|
|
axis=1,
|
|
).max(axis=1)
|
|
return float(_wilder(tr, period).iloc[-1])
|
|
|
|
|
|
def adx(df: pd.DataFrame, period: int = 14) -> float:
|
|
"""Latest ADX(14) — trend strength, range 0-100."""
|
|
_require(df, period * 2 + 1)
|
|
high, low, close = df["high"], df["low"], df["close"]
|
|
up = high.diff()
|
|
down = -low.diff()
|
|
plus_dm = pd.Series(((up > down) & (up > 0)) * up, index=df.index).clip(lower=0.0)
|
|
minus_dm = pd.Series(((down > up) & (down > 0)) * down, index=df.index).clip(lower=0.0)
|
|
|
|
prev_close = close.shift(1)
|
|
tr = pd.concat(
|
|
[(high - low), (high - prev_close).abs(), (low - prev_close).abs()],
|
|
axis=1,
|
|
).max(axis=1)
|
|
|
|
atr_s = _wilder(tr, period)
|
|
plus_di = 100.0 * _wilder(plus_dm, period) / atr_s.replace(0.0, pd.NA)
|
|
minus_di = 100.0 * _wilder(minus_dm, period) / atr_s.replace(0.0, pd.NA)
|
|
dx = 100.0 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0.0, pd.NA)
|
|
adx_series = _wilder(dx.fillna(0.0), period)
|
|
return float(adx_series.iloc[-1])
|
|
|
|
|
|
def stochastic(df: pd.DataFrame, k_period: int = 14, d_period: int = 3) -> dict[str, float]:
|
|
"""Latest Stochastic %K (smoothed) and %D, plus prior values for cross detection."""
|
|
_require(df, k_period + d_period + 1)
|
|
high, low, close = df["high"], df["low"], df["close"]
|
|
lowest = low.rolling(k_period).min()
|
|
highest = high.rolling(k_period).max()
|
|
raw_k = 100.0 * (close - lowest) / (highest - lowest).replace(0.0, pd.NA)
|
|
k = raw_k.rolling(d_period).mean()
|
|
d = k.rolling(d_period).mean()
|
|
return {
|
|
"k": float(k.iloc[-1]),
|
|
"d": float(d.iloc[-1]),
|
|
"k_prev": float(k.iloc[-2]),
|
|
"d_prev": float(d.iloc[-2]),
|
|
}
|