Initial commit: forex quant dashboard with momentum strategy + TA-Lib free impl
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Momentum + Volatility Filter Strategy
|
||||
|
||||
Core logic:
|
||||
- Buy when short-term MA crosses above medium-term MA
|
||||
- MACD momentum confirmation
|
||||
- Volatility filter via ATR
|
||||
- RSI overbought/oversold exit
|
||||
|
||||
Works with OR without TA-Lib (uses pandas rolling if TA-Lib unavailable).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Try TA-Lib, fall back to pandas implementation
|
||||
try:
|
||||
import talib
|
||||
HAS_TALIB = True
|
||||
except ImportError:
|
||||
HAS_TALIB = False
|
||||
|
||||
|
||||
def _sma(values, period):
|
||||
"""Simple Moving Average."""
|
||||
if HAS_TALIB:
|
||||
return talib.SMA(values, timeperiod=period)
|
||||
return pd.Series(values).rolling(period).mean().values
|
||||
|
||||
|
||||
def _ema(values, period):
|
||||
"""Exponential Moving Average."""
|
||||
if HAS_TALIB:
|
||||
return talib.EMA(values, timeperiod=period)
|
||||
return pd.Series(values).ewm(span=period, adjust=False).mean().values
|
||||
|
||||
|
||||
def _rsi(values, period=14):
|
||||
"""Relative Strength Index."""
|
||||
if HAS_TALIB:
|
||||
return talib.RSI(values, timeperiod=period)
|
||||
series = pd.Series(values)
|
||||
delta = series.diff()
|
||||
gain = delta.where(delta > 0, 0).rolling(period).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(period).mean()
|
||||
rs = gain / loss.replace(0, np.nan)
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return rsi.values
|
||||
|
||||
|
||||
def _macd(values, fast=12, slow=26, signal=9):
|
||||
"""MACD."""
|
||||
if HAS_TALIB:
|
||||
macd, macd_signal, macd_hist = talib.MACD(values, fast, slow, signal)
|
||||
return macd, macd_signal, macd_hist
|
||||
ema_fast = _ema(values, fast)
|
||||
ema_slow = _ema(values, slow)
|
||||
macd = ema_fast - ema_slow
|
||||
signal_line = _ema(macd, signal)
|
||||
hist = macd - signal_line
|
||||
return macd, signal_line, hist
|
||||
|
||||
|
||||
def _atr(high, low, close, period=14):
|
||||
"""Average True Range."""
|
||||
if HAS_TALIB:
|
||||
return talib.ATR(high, low, close, timeperiod=period)
|
||||
high, low, close = pd.Series(high), pd.Series(low), pd.Series(close)
|
||||
tr = pd.concat([
|
||||
high - low,
|
||||
(high - close.shift()).abs(),
|
||||
(low - close.shift()).abs(),
|
||||
], axis=1).max(axis=1)
|
||||
return tr.rolling(period).mean().values
|
||||
|
||||
|
||||
def _bbands(values, period=20, nbdev=2):
|
||||
"""Bollinger Bands."""
|
||||
if HAS_TALIB:
|
||||
return talib.BBANDS(values, timeperiod=period, nbdevup=nbdev, nbdevdn=nbdev)
|
||||
series = pd.Series(values)
|
||||
sma = series.rolling(period).mean()
|
||||
std = series.rolling(period).std()
|
||||
upper = sma + nbdev * std
|
||||
lower = sma - nbdev * std
|
||||
return upper.values, sma.values, lower.values
|
||||
|
||||
|
||||
def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add technical indicators to OHLC DataFrame."""
|
||||
df = df.copy()
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values.astype(np.float64)
|
||||
|
||||
# Moving averages
|
||||
df["ma_fast"] = _sma(close, 8)
|
||||
df["ma_mid"] = _sma(close, 21)
|
||||
df["ma_slow"] = _sma(close, 50)
|
||||
|
||||
# EMA
|
||||
df["ema_fast"] = _ema(close, 12)
|
||||
df["ema_slow"] = _ema(close, 26)
|
||||
|
||||
# MACD
|
||||
macd, macd_signal, macd_hist = _macd(close, 12, 26, 9)
|
||||
df["macd"] = macd
|
||||
df["macd_signal"] = macd_signal
|
||||
df["macd_hist"] = macd_hist
|
||||
|
||||
# RSI
|
||||
df["rsi"] = _rsi(close, 14)
|
||||
|
||||
# ATR
|
||||
df["atr"] = _atr(high, low, close, 14)
|
||||
df["atr_pct"] = df["atr"] / close * 100
|
||||
|
||||
# Bollinger Bands
|
||||
upper, mid, lower = _bbands(close, 20, 2)
|
||||
df["bb_upper"] = upper
|
||||
df["bb_mid"] = mid
|
||||
df["bb_lower"] = lower
|
||||
df["bb_width"] = (upper - lower) / mid * 100
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def generate_signals(
|
||||
df: pd.DataFrame,
|
||||
atr_min_pct: float = 0.05,
|
||||
atr_max_pct: float = 1.0,
|
||||
rsi_oversold: float = 30.0,
|
||||
rsi_overbought: float = 70.0,
|
||||
use_macd_filter: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Generate trading signals from indicators.
|
||||
Returns df with added 'signal' column: 1 = long, -1 = short, 0 = flat.
|
||||
"""
|
||||
df = df.copy()
|
||||
df["signal"] = 0
|
||||
|
||||
if len(df) < 60:
|
||||
return df
|
||||
|
||||
# Core momentum entry conditions
|
||||
bull_trend = df["ma_fast"] > df["ma_mid"]
|
||||
macd_bull = (df["macd_hist"] > 0) & (df["macd_hist"].shift(1) <= 0)
|
||||
price_strong = (
|
||||
(df["close"] > df["ma_fast"]) &
|
||||
(df["close"] > df["ma_mid"]) &
|
||||
(df["close"] > df["ma_slow"])
|
||||
)
|
||||
|
||||
# Core momentum exit
|
||||
bear_trend = df["ma_fast"] < df["ma_mid"]
|
||||
macd_bear = (df["macd_hist"] < 0) & (df["macd_hist"].shift(1) >= 0)
|
||||
|
||||
# Volatility filter
|
||||
valid_vol = (df["atr_pct"] >= atr_min_pct) & (df["atr_pct"] <= atr_max_pct)
|
||||
|
||||
# RSI filter
|
||||
rsi_not_overbought = df["rsi"] < rsi_overbought
|
||||
|
||||
# Long entry
|
||||
long_entry = bull_trend & valid_vol & price_strong & rsi_not_overbought
|
||||
if use_macd_filter:
|
||||
long_entry = long_entry & macd_bull
|
||||
|
||||
# Long exit
|
||||
long_exit = bear_trend | macd_bear | (df["rsi"] > rsi_overbought + 10)
|
||||
|
||||
# Apply signals
|
||||
df.loc[long_entry, "signal"] = 1
|
||||
df.loc[long_exit & (df["signal"].shift(1) == 1), "signal"] = 0
|
||||
|
||||
# Forward-fill (hold between entries/exits)
|
||||
df["position"] = df["signal"].replace(0, np.nan).ffill().fillna(0)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def calculate_performance(df: pd.DataFrame, spread_cost: float = 0.0001) -> dict:
|
||||
"""
|
||||
Calculate basic strategy metrics.
|
||||
spread_cost = estimated spread + commission in price units (~1 pip for EUR/USD)
|
||||
"""
|
||||
df = df.copy()
|
||||
df["returns"] = df["close"].pct_change()
|
||||
df["strategy_returns"] = df["position"].shift(1) * df["returns"]
|
||||
|
||||
# Subtract transaction costs on signal changes
|
||||
df["trades"] = df["position"].diff().abs().clip(0)
|
||||
df["strategy_returns"] -= df["trades"] * spread_cost / df["close"]
|
||||
|
||||
total_return = (1 + df["strategy_returns"]).prod() - 1
|
||||
buy_hold_return = (1 + df["returns"]).prod() - 1
|
||||
|
||||
sharpe = np.nan
|
||||
if df["strategy_returns"].std() > 0:
|
||||
sharpe = (
|
||||
df["strategy_returns"].mean()
|
||||
/ df["strategy_returns"].std()
|
||||
* np.sqrt(252)
|
||||
)
|
||||
|
||||
max_drawdown = _max_drawdown((1 + df["strategy_returns"]).cumprod())
|
||||
|
||||
# Count actual trades (entry = position change from 0 to 1)
|
||||
pos = df["position"].values
|
||||
entries = np.where((pos[1:] == 1) & (pos[:-1] == 0))[0]
|
||||
num_trades = len(entries)
|
||||
|
||||
win_rate = np.nan
|
||||
if num_trades > 0:
|
||||
trade_returns = []
|
||||
for entry_idx in entries:
|
||||
# Find exit after this entry
|
||||
exit_idx = np.where((pos[entry_idx + 1:] == 0))[0]
|
||||
if len(exit_idx) > 0:
|
||||
exit_idx = entry_idx + 1 + exit_idx[0]
|
||||
trade_return = df["close"].iloc[exit_idx] / df["close"].iloc[entry_idx] - 1
|
||||
trade_returns.append(trade_return)
|
||||
else:
|
||||
trade_returns.append(df["close"].iloc[-1] / df["close"].iloc[entry_idx] - 1)
|
||||
if trade_returns:
|
||||
win_rate = sum(1 for r in trade_returns if r > 0) / len(trade_returns)
|
||||
|
||||
return {
|
||||
"total_return_pct": total_return * 100,
|
||||
"buy_hold_return_pct": buy_hold_return * 100,
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"max_drawdown_pct": max_drawdown * 100,
|
||||
"win_rate_pct": win_rate * 100 if not np.isnan(win_rate) else 0,
|
||||
"num_trades": num_trades,
|
||||
"exposure_pct": (df["position"] != 0).mean() * 100,
|
||||
}
|
||||
|
||||
|
||||
def _max_drawdown(equity_curve: pd.Series) -> float:
|
||||
peak = equity_curve.expanding().max()
|
||||
dd = (equity_curve - peak) / peak
|
||||
return min(dd.min(), 0)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Quick test
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
from data.fx_data import get_forex_data
|
||||
|
||||
print(f"TA-Lib: {'✅ enabled' if HAS_TALIB else '❌ not available (using pandas fallback)'}")
|
||||
print("Loading EUR/USD daily data...")
|
||||
df = get_forex_data("EUR_USD", "1d", years_back=2)
|
||||
if df.empty:
|
||||
print("No data loaded.")
|
||||
exit(1)
|
||||
|
||||
df = add_indicators(df)
|
||||
df = generate_signals(df)
|
||||
|
||||
perf = calculate_performance(df)
|
||||
print("\n📊 Strategy Performance (EUR/USD Daily, 2yr)")
|
||||
for k, v in perf.items():
|
||||
print(f" {k}: {v}")
|
||||
Reference in New Issue
Block a user