mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-09 16:40:58 +00:00
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>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user