Files
fx-quant/src/indicators/technical.py
T
Brent Neale dce54845c2 Phase 1: Event-driven backtester, 5 strategies, and baseline results
- Built event-driven backtesting engine with spread/slippage modeling,
  3-TP partial closes, trailing stops, and rich trade logging (20+ features)
- Implemented 5 strategy signal generators (MA Breakout, VWAP Reversal,
  Key Level Breakout, EMA Ribbon Scalp, Momentum Exhaustion)
- Full indicator library (EMA, SMA, RSI, ATR, MACD, ADX, Stochastic,
  Session VWAP bands, swing points, key levels, RSI divergence)
- Data pipeline: Dukascopy download, validation, 70/30 train/test split
- Baseline results: all 5 strategies generate 200+ trades on training data
  (Jan 2021 - Aug 2023), best profit factors 0.82-0.96 on select pairs
- Trade logs and reports saved for Phase 3 ML feature engineering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:04:40 +10:00

332 lines
12 KiB
Python

"""
Technical indicator library for fx-quant Phase 1.
All indicators are computed on the full dataframe upfront, but the backtester
only exposes data up to the current candle index (preventing lookahead).
Using .shift(1) where noted to ensure signals only use closed-candle data.
"""
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Moving Averages
# ---------------------------------------------------------------------------
def ema(series: pd.Series, period: int) -> pd.Series:
return series.ewm(span=period, adjust=False).mean()
def sma(series: pd.Series, period: int) -> pd.Series:
return series.rolling(window=period, min_periods=period).mean()
# ---------------------------------------------------------------------------
# RSI
# ---------------------------------------------------------------------------
def rsi(series: pd.Series, period: int = 14) -> pd.Series:
delta = series.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
return 100 - (100 / (1 + rs))
# ---------------------------------------------------------------------------
# ATR
# ---------------------------------------------------------------------------
def atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
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 tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
# ---------------------------------------------------------------------------
# MACD
# ---------------------------------------------------------------------------
def macd(series: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9):
ema_fast = ema(series, fast)
ema_slow = ema(series, slow)
macd_line = ema_fast - ema_slow
signal_line = ema(macd_line, signal)
histogram = macd_line - signal_line
return macd_line, signal_line, histogram
# ---------------------------------------------------------------------------
# ADX
# ---------------------------------------------------------------------------
def adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
high, low, close = df["high"], df["low"], df["close"]
plus_dm = high.diff().clip(lower=0)
minus_dm = (-low.diff()).clip(lower=0)
# When both are positive, keep only the larger
both_pos = (plus_dm > 0) & (minus_dm > 0)
plus_bigger = plus_dm >= minus_dm
plus_dm = plus_dm.where(~both_pos | plus_bigger, 0)
minus_dm = minus_dm.where(~both_pos | ~plus_bigger, 0)
atr_vals = atr(df, period)
plus_di = 100 * ema(plus_dm, period) / atr_vals.replace(0, np.nan)
minus_di = 100 * ema(minus_dm, period) / atr_vals.replace(0, np.nan)
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
return ema(dx, period)
# ---------------------------------------------------------------------------
# Stochastic Oscillator
# ---------------------------------------------------------------------------
def stochastic(df: pd.DataFrame, k_period: int = 5, k_smooth: int = 3,
d_smooth: int = 3):
low_min = df["low"].rolling(k_period, min_periods=k_period).min()
high_max = df["high"].rolling(k_period, min_periods=k_period).max()
raw_k = 100 * (df["close"] - low_min) / (high_max - low_min).replace(0, np.nan)
k = raw_k.rolling(k_smooth, min_periods=1).mean()
d = k.rolling(d_smooth, min_periods=1).mean()
return k, d
# ---------------------------------------------------------------------------
# Session VWAP with Bands (reset at London open 08:00 UTC)
# ---------------------------------------------------------------------------
def session_vwap_bands(df: pd.DataFrame, session_start_hour: int = 8):
"""Compute intra-session VWAP with standard deviation bands."""
typical_price = (df["high"] + df["low"] + df["close"]) / 3
volume = df["volume"].replace(0, 1) # avoid division by zero
# Identify session boundaries
hours = pd.Series(df.index.hour, index=df.index)
prev_hours = hours.shift(1)
session_start = (hours == session_start_hour) & (prev_hours != session_start_hour)
session_start.iloc[0] = True
# Assign session IDs
session_id = session_start.astype(int).cumsum()
# Cumulative VWAP per session
tp_vol = typical_price * volume
cum_tp_vol = tp_vol.groupby(session_id).cumsum()
cum_vol = volume.groupby(session_id).cumsum()
vwap = cum_tp_vol / cum_vol
# Rolling std dev of typical price from VWAP within session
deviation = typical_price - vwap
cum_dev_sq = (deviation ** 2).groupby(session_id).cumsum()
cum_count = deviation.groupby(session_id).cumcount() + 1
std_dev = np.sqrt(cum_dev_sq / cum_count)
return pd.DataFrame({
"session_vwap": vwap,
"vwap_std": std_dev,
"vwap_upper_1_5": vwap + 1.5 * std_dev,
"vwap_lower_1_5": vwap - 1.5 * std_dev,
"vwap_upper_2": vwap + 2.0 * std_dev,
"vwap_lower_2": vwap - 2.0 * std_dev,
"vwap_upper_2_5": vwap + 2.5 * std_dev,
"vwap_lower_2_5": vwap - 2.5 * std_dev,
}, index=df.index)
# ---------------------------------------------------------------------------
# Swing High / Low Detection
# ---------------------------------------------------------------------------
def swing_highs(df: pd.DataFrame, lookback: int = 5) -> pd.Series:
"""True where high[i] is highest in [i-lookback, i+lookback] window."""
high = df["high"]
roll_max = high.rolling(2 * lookback + 1, center=True, min_periods=lookback + 1).max()
return high == roll_max
def swing_lows(df: pd.DataFrame, lookback: int = 5) -> pd.Series:
"""True where low[i] is lowest in [i-lookback, i+lookback] window."""
low = df["low"]
roll_min = low.rolling(2 * lookback + 1, center=True, min_periods=lookback + 1).min()
return low == roll_min
# ---------------------------------------------------------------------------
# Key Level Identification (horizontal S/R)
# ---------------------------------------------------------------------------
def identify_key_levels(df: pd.DataFrame, lookback: int = 5,
tolerance_atr_mult: float = 0.5,
min_touches: int = 3) -> list:
"""
Find horizontal S/R levels by clustering swing highs/lows.
Returns list of (price_level, touch_count) tuples.
"""
atr_val = atr(df).iloc[-1] if len(df) > 14 else None
if atr_val is None or np.isnan(atr_val):
return []
tolerance = atr_val * tolerance_atr_mult
# Collect swing points
sh = swing_highs(df, lookback)
sl = swing_lows(df, lookback)
swing_prices = pd.concat([
df.loc[sh, "high"],
df.loc[sl, "low"],
]).sort_values()
if len(swing_prices) < min_touches:
return []
# Cluster swing points
levels = []
used = set()
for i, price in enumerate(swing_prices):
if i in used:
continue
cluster = [price]
used.add(i)
for j in range(i + 1, len(swing_prices)):
if j in used:
continue
if abs(swing_prices.iloc[j] - price) <= tolerance:
cluster.append(swing_prices.iloc[j])
used.add(j)
if len(cluster) >= min_touches:
levels.append((np.mean(cluster), len(cluster)))
return sorted(levels, key=lambda x: -x[1])
# ---------------------------------------------------------------------------
# Engulfing Candle Detection
# ---------------------------------------------------------------------------
def is_bullish_engulfing(df: pd.DataFrame, i: int) -> bool:
if i < 1:
return False
prev = df.iloc[i - 1]
curr = df.iloc[i]
prev_body = abs(prev["close"] - prev["open"])
curr_body = abs(curr["close"] - curr["open"])
return (prev["close"] < prev["open"] and # prev bearish
curr["close"] > curr["open"] and # curr bullish
curr_body > prev_body and # engulfs
curr["close"] > prev["open"] and
curr["open"] <= prev["close"])
def is_bearish_engulfing(df: pd.DataFrame, i: int) -> bool:
if i < 1:
return False
prev = df.iloc[i - 1]
curr = df.iloc[i]
prev_body = abs(prev["close"] - prev["open"])
curr_body = abs(curr["close"] - curr["open"])
return (prev["close"] > prev["open"] and # prev bullish
curr["close"] < curr["open"] and # curr bearish
curr_body > prev_body and # engulfs
curr["close"] < prev["open"] and
curr["open"] >= prev["close"])
# ---------------------------------------------------------------------------
# RSI Divergence Detection
# ---------------------------------------------------------------------------
def detect_rsi_divergence(df: pd.DataFrame, rsi_col: str, lookback: int = 20,
i: int = None) -> str:
"""
Detect regular RSI divergence at index i.
Returns 'bullish', 'bearish', or None.
"""
if i is None:
i = len(df) - 1
if i < lookback:
return None
window = df.iloc[i - lookback:i + 1]
rsi_vals = window[rsi_col]
lows = window["low"]
highs = window["high"]
# Bullish divergence: price makes lower low, RSI makes higher low
recent_low_idx = lows.idxmin()
if recent_low_idx == window.index[-1]:
# Current bar is the low
prev_window = window.iloc[:-3] # exclude last 3 bars
if len(prev_window) > 3:
prev_low_idx = prev_window["low"].idxmin()
if (lows.loc[recent_low_idx] < prev_window["low"].loc[prev_low_idx] and
rsi_vals.loc[recent_low_idx] > rsi_vals.loc[prev_low_idx]):
return "bullish"
# Bearish divergence: price makes higher high, RSI makes lower high
recent_high_idx = highs.idxmax()
if recent_high_idx == window.index[-1]:
prev_window = window.iloc[:-3]
if len(prev_window) > 3:
prev_high_idx = prev_window["high"].idxmax()
if (highs.loc[recent_high_idx] > prev_window["high"].loc[prev_high_idx] and
rsi_vals.loc[recent_high_idx] < rsi_vals.loc[prev_high_idx]):
return "bearish"
return None
# ---------------------------------------------------------------------------
# Master Function: Compute All Indicators on a DataFrame
# ---------------------------------------------------------------------------
def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
Add all technical indicators to a candle dataframe.
The caller must ensure df has columns: open, high, low, close, volume.
"""
df = df.copy()
# Moving Averages
for period in [20, 50, 100, 200]:
df[f"ema_{period}"] = ema(df["close"], period)
df["sma_200"] = sma(df["close"], 200)
# RSI
df["rsi_14"] = rsi(df["close"], 14)
# ATR
df["atr_14"] = atr(df, 14)
# MACD
df["macd"], df["macd_signal"], df["macd_hist"] = macd(df["close"])
# ADX
df["adx_14"] = adx(df, 14)
# Stochastic
df["stoch_k"], df["stoch_d"] = stochastic(df)
# Session VWAP (only meaningful for intraday timeframes)
if len(df) > 0 and hasattr(df.index, "hour"):
try:
vwap_df = session_vwap_bands(df)
for col in vwap_df.columns:
df[col] = vwap_df[col]
except Exception:
pass
# Swing points
df["is_swing_high"] = swing_highs(df)
df["is_swing_low"] = swing_lows(df)
return df