Trading-Bot-M20 / signals /technical.py
raghava4u's picture
Upload folder using huggingface_hub
d53dc44 verified
Raw History Blame Contribute Delete
16 kB
"""
signals/technical.py β€” Technical signal engine.
RSI, MACD, EMA200, Bollinger Bands, VWAP (session-aware), ATR percentile filter.
Correlation guard for portfolio-level risk.
Returns TechnicalScore per contracts.py.
"""
from __future__ import annotations
import datetime
import logging
import zoneinfo
from typing import Literal
import numpy as np
import pandas as pd
import config
from contracts import TechnicalScore, PriceTarget
logger = logging.getLogger("trading_system.technical")
# ── Indicator Functions ──────────────────────────────────────────────────────
def compute_rsi(series: pd.Series, period: int = 14) -> pd.Series:
"""Relative Strength Index."""
delta = series.diff()
gain = delta.where(delta > 0, 0.0)
loss = (-delta).where(delta < 0, 0.0)
avg_gain = gain.ewm(alpha=1 / period, min_periods=period).mean()
avg_loss = loss.ewm(alpha=1 / period, min_periods=period).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))
return rsi
def compute_macd(
series: pd.Series,
fast: int = 12,
slow: int = 26,
signal: int = 9,
) -> tuple[pd.Series, pd.Series, pd.Series]:
"""MACD line, signal line, histogram."""
ema_fast = series.ewm(span=fast, min_periods=fast).mean()
ema_slow = series.ewm(span=slow, min_periods=slow).mean()
macd_line = ema_fast - ema_slow
signal_line = macd_line.ewm(span=signal, min_periods=signal).mean()
histogram = macd_line - signal_line
return macd_line, signal_line, histogram
def compute_bollinger_bands(
series: pd.Series,
period: int = 20,
std_dev: float = 2.0,
) -> tuple[pd.Series, pd.Series, pd.Series]:
"""Upper, middle, lower Bollinger Bands."""
middle = series.rolling(window=period).mean()
std = series.rolling(window=period).std()
upper = middle + std_dev * std
lower = middle - std_dev * std
return upper, middle, lower
def compute_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Average True Range on OHLC data."""
high = df["high"]
low = df["low"]
close = df["close"].shift(1)
tr1 = high - low
tr2 = (high - close).abs()
tr3 = (low - close).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = tr.ewm(alpha=1 / period, min_periods=period).mean()
return atr
def compute_ema(series: pd.Series, period: int) -> pd.Series:
"""Exponential Moving Average."""
return series.ewm(span=period, min_periods=period).mean()
def compute_vwap(df: pd.DataFrame) -> pd.Series:
"""Session-aware VWAP. Resets at 09:30 ET every trading day.
Partitions by trading session date in US/Eastern timezone.
VWAP = cumsum(typical_price * volume) / cumsum(volume)
"""
if df.empty:
return pd.Series(dtype=float)
work = df.copy()
# Get timestamps β€” handle both index-based and column-based
if isinstance(work.index, pd.DatetimeIndex):
idx = work.index
elif "timestamp" in work.columns:
idx = pd.DatetimeIndex(work["timestamp"])
else:
return pd.Series(np.nan, index=work.index)
if idx.tz is None:
idx = idx.tz_localize("UTC")
eastern = idx.tz_convert(zoneinfo.ZoneInfo("US/Eastern"))
mask = (
((eastern.hour > 9) | ((eastern.hour == 9) & (eastern.minute >= 30))) &
((eastern.hour < 16) | ((eastern.hour == 16) & (eastern.minute == 0)))
)
work = work[mask].copy()
if work.empty:
return pd.Series(np.nan, index=df.index)
eastern = eastern[mask]
work["session_date"] = eastern.date
typical_price = (work["high"] + work["low"] + work["close"]) / 3
work["tp_vol"] = typical_price * work["volume"]
vwap_values = []
for _, group in work.groupby("session_date"):
cum_tp_vol = group["tp_vol"].cumsum()
cum_vol = group["volume"].cumsum().replace(0, np.nan)
session_vwap = cum_tp_vol / cum_vol
vwap_values.append(session_vwap)
if vwap_values:
result = pd.concat(vwap_values).reindex(df.index)
else:
result = pd.Series(np.nan, index=df.index)
return result
# ── ATR Percentile Filter ───────────────────────────────────────────────────
def atr_percentile_filter(
df_5m: pd.DataFrame,
window_days: int | None = None,
) -> tuple[bool, float, float]:
"""Check if current ATR is above the 20th percentile of the rolling window.
Returns: (passes_filter, current_atr, threshold_20pct)
"""
window_days = window_days or config.ATR_PERCENTILE_WINDOW_DAYS
atr_series = compute_atr(df_5m, period=14)
if atr_series.dropna().empty:
return True, 0.0, 0.0
bars_per_day = 78 # ~6.5 hours of 5m bars
window_bars = window_days * bars_per_day
window = atr_series.dropna().tail(window_bars)
if len(window) < bars_per_day:
return True, float(atr_series.iloc[-1]), 0.0
current_atr = float(atr_series.iloc[-1])
threshold = float(np.percentile(window.values, 20))
passes = current_atr >= threshold
return passes, current_atr, threshold
# ── EMA200 Trend Filter ─────────────────────────────────────────────────────
def ema200_trend_bias(daily_df: pd.DataFrame) -> int:
"""Compute EMA200 trend bias on daily bars.
Returns: +1 (bullish), -1 (bearish), 0 (disabled/neutral)
"""
if daily_df.empty or len(daily_df) < 200:
if not daily_df.empty:
logger.warning(
"Daily bars < 200 (%d), EMA200 filter disabled.", len(daily_df)
)
return 0
ema200 = compute_ema(daily_df["close"], 200)
last_close = float(daily_df["close"].iloc[-1])
last_ema = float(ema200.iloc[-1])
if np.isnan(last_ema):
return 0
if last_close > last_ema:
return 1
elif last_close < last_ema:
return -1
return 0
# ── Correlation Guard ────────────────────────────────────────────────────────
def build_correlation_matrix(
symbols: list[str],
get_daily_bars_fn,
window: int = 60,
) -> pd.DataFrame:
"""Build correlation matrix on 60-day rolling returns."""
returns = {}
for sym in symbols:
try:
df = get_daily_bars_fn(sym)
if df is not None and len(df) >= window:
returns[sym] = df["close"].pct_change().tail(window)
except Exception:
continue
if not returns:
return pd.DataFrame()
return pd.DataFrame(returns).corr()
def correlation_guard(
symbol: str,
direction: Literal["buy", "sell"],
open_positions: dict[str, str],
corr_matrix: pd.DataFrame,
threshold: float = 0.85,
) -> tuple[bool, str | None]:
"""Check if a proposed trade is blocked by correlation with open positions.
Args:
symbol: Proposed trade symbol
direction: "buy" or "sell"
open_positions: {symbol: "buy"|"sell"} for currently open positions
corr_matrix: Correlation matrix from build_correlation_matrix
threshold: Correlation threshold
Returns: (allowed, skip_reason)
"""
if corr_matrix.empty or symbol not in corr_matrix.columns:
return True, None
for pos_sym, pos_dir in open_positions.items():
if pos_sym not in corr_matrix.columns:
continue
corr = corr_matrix.loc[symbol, pos_sym]
if abs(corr) > threshold and direction == pos_dir:
reason = f"correlated_position_same_direction: corr({symbol},{pos_sym})={corr:.3f}"
return False, reason
return True, None
# ── Signal Score Composition ─────────────────────────────────────────────────
#
# Weights (must sum to 1.0):
# RSI(14) divergence from 50: 0.20
# MACD histogram sign + slope: 0.20
# BB position (% from mean): 0.15
# VWAP deviation: 0.20
# EMA200 trend bias: 0.15
# ATR percentile (pass/fail): 0.10 (0 if below threshold)
SIGNAL_WEIGHTS = {
"rsi": 0.20,
"macd": 0.20,
"bb": 0.15,
"vwap": 0.20,
"ema200": 0.15,
"atr": 0.10,
}
assert abs(sum(SIGNAL_WEIGHTS.values()) - 1.0) < 0.001, "Signal weights must sum to 1.0"
def compute_technical(
symbol: str,
df_5m: pd.DataFrame,
daily_df: pd.DataFrame | None = None,
open_positions: dict[str, str] | None = None,
corr_matrix: pd.DataFrame | None = None,
) -> tuple[TechnicalScore, PriceTarget]:
"""Compute full technical score and price target for a symbol.
Args:
symbol: Stock symbol
df_5m: 5-minute OHLCV DataFrame (needs ~100+ bars)
daily_df: Daily OHLCV DataFrame (needs 200+ bars for EMA200)
open_positions: Currently open positions {symbol: direction}
corr_matrix: Correlation matrix for guard
Returns: (TechnicalScore, PriceTarget)
"""
now = datetime.datetime.now(datetime.timezone.utc)
sub_signals: dict[str, float] = {}
n_computed = 0
skip_reason = None
# Minimum data check
if len(df_5m) < 30:
return (
TechnicalScore(
symbol=symbol, timestamp=now, score=0.0,
sub_signals={}, skip_reason="insufficient_data",
n_signals_computed=0,
),
PriceTarget(symbol=symbol, current_price=0.0, predicted_direction="NEUTRAL"),
)
close = df_5m["close"]
current_price = float(close.iloc[-1])
# ── 1. RSI (weight 0.20) ──
try:
rsi = compute_rsi(close, 14)
last_rsi = float(rsi.iloc[-1])
if not np.isnan(last_rsi):
# Normalize: RSI < 30 β†’ +1 (oversold = bullish), RSI > 70 β†’ -1 (overbought = bearish)
rsi_score = (50 - last_rsi) / 50 # range roughly -1 to +1
rsi_score = max(-1.0, min(1.0, rsi_score))
sub_signals["rsi"] = rsi_score
n_computed += 1
except Exception as e:
logger.debug("RSI computation failed for %s: %s", symbol, e)
# ── 2. MACD (weight 0.20) ──
try:
_, _, histogram = compute_macd(close)
hist_vals = histogram.dropna()
if len(hist_vals) >= 2:
last_hist = float(hist_vals.iloc[-1])
prev_hist = float(hist_vals.iloc[-2])
# Sign gives direction, slope gives momentum
sign = 1.0 if last_hist > 0 else -1.0
slope = 1.0 if last_hist > prev_hist else -1.0
macd_score = 0.5 * sign + 0.5 * slope
macd_score = max(-1.0, min(1.0, macd_score))
sub_signals["macd"] = macd_score
n_computed += 1
except Exception as e:
logger.debug("MACD computation failed for %s: %s", symbol, e)
# ── 3. Bollinger Bands (weight 0.15) ──
try:
upper, middle, lower = compute_bollinger_bands(close)
last_close = current_price
last_upper = float(upper.iloc[-1])
last_lower = float(lower.iloc[-1])
last_middle = float(middle.iloc[-1])
if not any(np.isnan([last_upper, last_lower, last_middle])):
band_width = last_upper - last_lower
if band_width > 0:
# Position within bands: -1 at upper (sell), +1 at lower (buy)
bb_pos = (last_middle - last_close) / (band_width / 2)
bb_score = max(-1.0, min(1.0, bb_pos))
sub_signals["bb"] = bb_score
n_computed += 1
except Exception as e:
logger.debug("BB computation failed for %s: %s", symbol, e)
# ── 4. VWAP (weight 0.20) ──
try:
vwap = compute_vwap(df_5m)
last_vwap = float(vwap.iloc[-1])
if not np.isnan(last_vwap) and last_vwap > 0:
# Price below VWAP = bullish (buy opportunity), above = bearish
deviation = (last_vwap - current_price) / last_vwap
vwap_score = max(-1.0, min(1.0, deviation * 20)) # Scale
sub_signals["vwap"] = vwap_score
n_computed += 1
except Exception as e:
logger.debug("VWAP computation failed for %s: %s", symbol, e)
# ── 5. EMA200 Trend Bias (weight 0.15) ──
try:
if daily_df is not None and not daily_df.empty:
trend = ema200_trend_bias(daily_df)
sub_signals["ema200"] = float(trend)
n_computed += 1
else:
sub_signals["ema200"] = 0.0
except Exception as e:
logger.debug("EMA200 computation failed for %s: %s", symbol, e)
# ── 6. ATR Percentile Filter (weight 0.10) ──
try:
atr_pass, current_atr, atr_threshold = atr_percentile_filter(df_5m)
if atr_pass:
sub_signals["atr"] = 1.0
else:
sub_signals["atr"] = 0.0
skip_reason = "atr_below_20th_percentile"
n_computed += 1
except Exception as e:
logger.debug("ATR computation failed for %s: %s", symbol, e)
current_atr = 0.0
# ── Correlation Guard ──
if skip_reason is None and open_positions and corr_matrix is not None:
weighted_score = sum(
sub_signals.get(k, 0.0) * SIGNAL_WEIGHTS[k] for k in SIGNAL_WEIGHTS
)
proposed_dir = "buy" if weighted_score > 0 else "sell"
allowed, corr_reason = correlation_guard(
symbol, proposed_dir, open_positions, corr_matrix
)
if not allowed:
skip_reason = corr_reason
# ── Compute weighted score ──
if n_computed < 3:
score = 0.0
if skip_reason is None:
skip_reason = "insufficient_data"
else:
score = sum(
sub_signals.get(k, 0.0) * SIGNAL_WEIGHTS[k] for k in SIGNAL_WEIGHTS
)
score = max(-1.0, min(1.0, score))
# ── Build Price Target ──
atr_val = current_atr if current_atr > 0 else current_price * 0.005
if score > 0:
predicted_dir = "UP"
entry = current_price
stop = current_price - 1.0 * atr_val
tp = current_price + 2.0 * atr_val
elif score < 0:
predicted_dir = "DOWN"
entry = current_price
stop = current_price + 1.0 * atr_val
tp = current_price - 2.0 * atr_val
else:
predicted_dir = "NEUTRAL"
entry = None
stop = None
tp = None
expected_profit = 0.0
if entry and tp:
qty_estimate = (config.ALLOCATED_CAPITAL * config.RISK_PER_TRADE_PCT / 100) / atr_val if atr_val > 0 else 0
expected_profit = abs(tp - entry) * qty_estimate
price_target = PriceTarget(
symbol=symbol,
current_price=current_price,
predicted_direction=predicted_dir,
entry_price=entry,
stop_loss_price=stop,
take_profit_price=tp,
expected_profit_usd=round(expected_profit, 2),
confidence=abs(score),
)
tech_score = TechnicalScore(
symbol=symbol,
timestamp=now,
score=score,
sub_signals=sub_signals,
skip_reason=skip_reason,
n_signals_computed=n_computed,
)
return tech_score, price_target