diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:37:11 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:37:11 +0900 |
| commit | 8b0cf4e574390738ee33f7ff334dd5f5109b7819 (patch) | |
| tree | 371f4d99581e5aad26658a2ff5f7792efcb9f6ab | |
| parent | b23aef3a9947d4d3d8e87b595ecf547159df7289 (diff) | |
feat(strategy): add technical indicators library (ATR, ADX, RSI, MACD, Bollinger, Stochastic, OBV)
6 files changed, 357 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/indicators/__init__.py b/services/strategy-engine/strategies/indicators/__init__.py new file mode 100644 index 0000000..1a54d59 --- /dev/null +++ b/services/strategy-engine/strategies/indicators/__init__.py @@ -0,0 +1,12 @@ +"""Reusable technical indicator functions.""" +from strategies.indicators.trend import ema, sma, macd, adx +from strategies.indicators.volatility import atr, bollinger_bands, keltner_channels +from strategies.indicators.momentum import rsi, stochastic +from strategies.indicators.volume import volume_sma, volume_ratio, obv + +__all__ = [ + "ema", "sma", "macd", "adx", + "atr", "bollinger_bands", "keltner_channels", + "rsi", "stochastic", + "volume_sma", "volume_ratio", "obv", +] diff --git a/services/strategy-engine/strategies/indicators/momentum.py b/services/strategy-engine/strategies/indicators/momentum.py new file mode 100644 index 0000000..395c52d --- /dev/null +++ b/services/strategy-engine/strategies/indicators/momentum.py @@ -0,0 +1,36 @@ +"""Momentum indicators: RSI, Stochastic.""" +import pandas as pd +import numpy as np + + +def rsi(closes: pd.Series, period: int = 14) -> pd.Series: + """RSI using Wilder's smoothing (EMA-based).""" + delta = closes.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(com=period - 1, min_periods=period).mean() + avg_loss = loss.ewm(com=period - 1, min_periods=period).mean() + rs = avg_gain / avg_loss + rsi_vals = pd.Series(np.where(avg_loss == 0, 100.0, 100 - (100 / (1 + rs))), index=closes.index) + # Keep NaN where we don't have enough data + rsi_vals[:period] = np.nan + return rsi_vals + + +def stochastic( + highs: pd.Series, + lows: pd.Series, + closes: pd.Series, + k_period: int = 14, + d_period: int = 3, +) -> tuple[pd.Series, pd.Series]: + """Stochastic Oscillator. + + Returns: (%K, %D) + """ + lowest_low = lows.rolling(window=k_period).min() + highest_high = highs.rolling(window=k_period).max() + denom = highest_high - lowest_low + k = 100 * (closes - lowest_low) / denom.replace(0, float("nan")) + d = k.rolling(window=d_period).mean() + return k, d diff --git a/services/strategy-engine/strategies/indicators/trend.py b/services/strategy-engine/strategies/indicators/trend.py new file mode 100644 index 0000000..10b69fa --- /dev/null +++ b/services/strategy-engine/strategies/indicators/trend.py @@ -0,0 +1,104 @@ +"""Trend indicators: EMA, SMA, MACD, ADX.""" +import pandas as pd +import numpy as np + + +def sma(series: pd.Series, period: int) -> pd.Series: + """Simple Moving Average.""" + return series.rolling(window=period).mean() + + +def ema(series: pd.Series, period: int) -> pd.Series: + """Exponential Moving Average.""" + return series.ewm(span=period, adjust=False).mean() + + +def macd( + closes: pd.Series, + fast: int = 12, + slow: int = 26, + signal: int = 9, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """MACD indicator. + + Returns: (macd_line, signal_line, histogram) + """ + fast_ema = ema(closes, fast) + slow_ema = ema(closes, slow) + macd_line = fast_ema - slow_ema + signal_line = ema(macd_line, signal) + histogram = macd_line - signal_line + return macd_line, signal_line, histogram + + +def adx( + highs: pd.Series, + lows: pd.Series, + closes: pd.Series, + period: int = 14, +) -> pd.Series: + """Average Directional Index (ADX). + + Returns ADX series. Values > 25 indicate strong trend, < 20 indicate ranging. + """ + high = highs.values + low = lows.values + close = closes.values + n = len(close) + + if n < period + 1: + return pd.Series([np.nan] * n) + + # True Range + tr = np.zeros(n) + tr[0] = high[0] - low[0] + for i in range(1, n): + tr[i] = max( + high[i] - low[i], + abs(high[i] - close[i - 1]), + abs(low[i] - close[i - 1]), + ) + + # Directional Movement + plus_dm = np.zeros(n) + minus_dm = np.zeros(n) + for i in range(1, n): + up = high[i] - high[i - 1] + down = low[i - 1] - low[i] + plus_dm[i] = up if up > down and up > 0 else 0.0 + minus_dm[i] = down if down > up and down > 0 else 0.0 + + # Smoothed with Wilder's method + atr_vals = np.zeros(n) + plus_di = np.zeros(n) + minus_di = np.zeros(n) + + atr_vals[period] = np.mean(tr[1 : period + 1]) + plus_smooth = np.mean(plus_dm[1 : period + 1]) + minus_smooth = np.mean(minus_dm[1 : period + 1]) + + if atr_vals[period] > 0: + plus_di[period] = 100 * plus_smooth / atr_vals[period] + minus_di[period] = 100 * minus_smooth / atr_vals[period] + + for i in range(period + 1, n): + atr_vals[i] = (atr_vals[i - 1] * (period - 1) + tr[i]) / period + plus_smooth = (plus_smooth * (period - 1) + plus_dm[i]) / period + minus_smooth = (minus_smooth * (period - 1) + minus_dm[i]) / period + if atr_vals[i] > 0: + plus_di[i] = 100 * plus_smooth / atr_vals[i] + minus_di[i] = 100 * minus_smooth / atr_vals[i] + + # DX and ADX + dx = np.zeros(n) + for i in range(period, n): + di_sum = plus_di[i] + minus_di[i] + dx[i] = 100 * abs(plus_di[i] - minus_di[i]) / di_sum if di_sum > 0 else 0.0 + + adx_vals = np.full(n, np.nan) + if 2 * period < n: + adx_vals[2 * period] = np.mean(dx[period : 2 * period + 1]) + for i in range(2 * period + 1, n): + adx_vals[i] = (adx_vals[i - 1] * (period - 1) + dx[i]) / period + + return pd.Series(adx_vals, index=closes.index if hasattr(closes, 'index') else None) diff --git a/services/strategy-engine/strategies/indicators/volatility.py b/services/strategy-engine/strategies/indicators/volatility.py new file mode 100644 index 0000000..d47eb86 --- /dev/null +++ b/services/strategy-engine/strategies/indicators/volatility.py @@ -0,0 +1,69 @@ +"""Volatility indicators: ATR, Bollinger Bands, Keltner Channels.""" +import pandas as pd +import numpy as np + + +def atr( + highs: pd.Series, + lows: pd.Series, + closes: pd.Series, + period: int = 14, +) -> pd.Series: + """Average True Range using Wilder's smoothing.""" + high = highs.values + low = lows.values + close = closes.values + n = len(close) + + tr = np.zeros(n) + tr[0] = high[0] - low[0] + for i in range(1, n): + tr[i] = max( + high[i] - low[i], + abs(high[i] - close[i - 1]), + abs(low[i] - close[i - 1]), + ) + + atr_vals = np.full(n, np.nan) + if n >= period: + atr_vals[period - 1] = np.mean(tr[:period]) + for i in range(period, n): + atr_vals[i] = (atr_vals[i - 1] * (period - 1) + tr[i]) / period + + return pd.Series(atr_vals, index=closes.index if hasattr(closes, 'index') else None) + + +def bollinger_bands( + closes: pd.Series, + period: int = 20, + num_std: float = 2.0, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """Bollinger Bands. + + Returns: (upper_band, middle_band, lower_band) + """ + middle = closes.rolling(window=period).mean() + std = closes.rolling(window=period).std() + upper = middle + num_std * std + lower = middle - num_std * std + return upper, middle, lower + + +def keltner_channels( + highs: pd.Series, + lows: pd.Series, + closes: pd.Series, + ema_period: int = 20, + atr_period: int = 14, + atr_multiplier: float = 2.0, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """Keltner Channels. + + Returns: (upper_channel, middle_ema, lower_channel) + """ + from strategies.indicators.trend import ema as calc_ema + middle = calc_ema(closes, ema_period) + atr_vals = atr(highs, lows, closes, atr_period) + upper = middle + atr_multiplier * atr_vals + lower = middle - atr_multiplier * atr_vals + return upper, middle, lower diff --git a/services/strategy-engine/strategies/indicators/volume.py b/services/strategy-engine/strategies/indicators/volume.py new file mode 100644 index 0000000..323d427 --- /dev/null +++ b/services/strategy-engine/strategies/indicators/volume.py @@ -0,0 +1,21 @@ +"""Volume indicators: Volume SMA, Volume Ratio, OBV.""" +import pandas as pd +import numpy as np + + +def volume_sma(volumes: pd.Series, period: int = 20) -> pd.Series: + """Simple moving average of volume.""" + return volumes.rolling(window=period).mean() + + +def volume_ratio(volumes: pd.Series, period: int = 20) -> pd.Series: + """Current volume as ratio of average volume. >1 = above average.""" + avg = volume_sma(volumes, period) + return volumes / avg.replace(0, float("nan")) + + +def obv(closes: pd.Series, volumes: pd.Series) -> pd.Series: + """On-Balance Volume.""" + direction = np.sign(closes.diff()) + direction.iloc[0] = 0 + return (direction * volumes).cumsum() diff --git a/services/strategy-engine/tests/test_indicators.py b/services/strategy-engine/tests/test_indicators.py new file mode 100644 index 0000000..ac5b505 --- /dev/null +++ b/services/strategy-engine/tests/test_indicators.py @@ -0,0 +1,115 @@ +"""Tests for technical indicator library.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pandas as pd +import numpy as np +import pytest + +from strategies.indicators.trend import sma, ema, macd, adx +from strategies.indicators.volatility import atr, bollinger_bands +from strategies.indicators.momentum import rsi, stochastic +from strategies.indicators.volume import volume_sma, volume_ratio, obv + + +class TestTrend: + def test_sma_basic(self): + s = pd.Series([1, 2, 3, 4, 5]) + result = sma(s, 3) + assert result.iloc[-1] == pytest.approx(4.0) + + def test_ema_basic(self): + s = pd.Series([1, 2, 3, 4, 5]) + result = ema(s, 3) + assert not np.isnan(result.iloc[-1]) + + def test_macd_returns_three_series(self): + s = pd.Series(range(50), dtype=float) + macd_line, signal_line, hist = macd(s) + assert len(macd_line) == 50 + assert len(signal_line) == 50 + assert len(hist) == 50 + + def test_adx_returns_series(self): + n = 60 + highs = pd.Series(np.random.uniform(100, 110, n)) + lows = pd.Series(np.random.uniform(90, 100, n)) + closes = pd.Series(np.random.uniform(95, 105, n)) + result = adx(highs, lows, closes, period=14) + assert len(result) == n + # Last value should be a number (not NaN for 60 bars with period 14) + assert not np.isnan(result.iloc[-1]) + + def test_adx_trending_market(self): + # Strong uptrend should have high ADX + n = 100 + closes = pd.Series([100 + i * 2 for i in range(n)], dtype=float) + highs = closes + 3 + lows = closes - 3 + result = adx(highs, lows, closes) + assert result.iloc[-1] > 25 # Strong trend + + def test_adx_ranging_market(self): + # Sideways market should have low ADX + n = 100 + closes = pd.Series([100 + (i % 5) - 2 for i in range(n)], dtype=float) + highs = closes + 1 + lows = closes - 1 + result = adx(highs, lows, closes) + assert result.iloc[-1] < 25 + + +class TestVolatility: + def test_atr_basic(self): + n = 30 + highs = pd.Series([110] * n, dtype=float) + lows = pd.Series([90] * n, dtype=float) + closes = pd.Series([100] * n, dtype=float) + result = atr(highs, lows, closes, period=14) + assert result.iloc[-1] == pytest.approx(20.0, rel=0.01) + + def test_bollinger_bands_width(self): + s = pd.Series([100] * 20 + [110, 90] * 5, dtype=float) + upper, mid, lower = bollinger_bands(s, period=20) + assert upper.iloc[-1] > mid.iloc[-1] > lower.iloc[-1] + + +class TestMomentum: + def test_rsi_oversold(self): + # Declining prices should give low RSI + s = pd.Series([100 - i for i in range(30)], dtype=float) + result = rsi(s, period=14) + assert result.iloc[-1] < 30 + + def test_rsi_overbought(self): + s = pd.Series([100 + i for i in range(30)], dtype=float) + result = rsi(s, period=14) + assert result.iloc[-1] > 70 + + def test_stochastic_returns_k_and_d(self): + n = 30 + highs = pd.Series(np.random.uniform(100, 110, n)) + lows = pd.Series(np.random.uniform(90, 100, n)) + closes = pd.Series(np.random.uniform(95, 105, n)) + k, d = stochastic(highs, lows, closes) + assert len(k) == n + assert len(d) == n + + +class TestVolume: + def test_volume_sma(self): + v = pd.Series([100] * 20 + [200] * 5, dtype=float) + result = volume_sma(v, period=20) + assert result.iloc[-1] > 100 + + def test_volume_ratio_above_average(self): + v = pd.Series([100] * 20 + [300], dtype=float) + result = volume_ratio(v, period=20) + assert result.iloc[-1] > 2.0 + + def test_obv_up(self): + closes = pd.Series([100, 101, 102, 103], dtype=float) + volumes = pd.Series([10, 10, 10, 10], dtype=float) + result = obv(closes, volumes) + assert result.iloc[-1] > 0 # All up moves = positive OBV |
