Initial commit: Forex Indicator Telegram Bot MVP
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>
This commit is contained in:
+128
@@ -0,0 +1,128 @@
|
||||
"""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]),
|
||||
}
|
||||
Reference in New Issue
Block a user