Initial commit: forex quant dashboard with momentum strategy + TA-Lib free impl

This commit is contained in:
addychai355-create
2026-05-12 21:42:26 +08:00
commit 24d2f4feed
15 changed files with 1643 additions and 0 deletions
+271
View File
@@ -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}")