summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:37:11 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:37:11 +0900
commit8b0cf4e574390738ee33f7ff334dd5f5109b7819 (patch)
tree371f4d99581e5aad26658a2ff5f7792efcb9f6ab
parentb23aef3a9947d4d3d8e87b595ecf547159df7289 (diff)
feat(strategy): add technical indicators library (ATR, ADX, RSI, MACD, Bollinger, Stochastic, OBV)
-rw-r--r--services/strategy-engine/strategies/indicators/__init__.py12
-rw-r--r--services/strategy-engine/strategies/indicators/momentum.py36
-rw-r--r--services/strategy-engine/strategies/indicators/trend.py104
-rw-r--r--services/strategy-engine/strategies/indicators/volatility.py69
-rw-r--r--services/strategy-engine/strategies/indicators/volume.py21
-rw-r--r--services/strategy-engine/tests/test_indicators.py115
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