TradeMind / ml /explain.py
KSS-1227's picture
some more new changes
49382d9
Raw History Blame Contribute Delete
8.09 kB
# ml/explain.py
import shap
import joblib
import numpy as np
import pandas as pd
import os
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data.fetch_prices import fetch_prices, STOCKS
from ml.technical import add_technical_indicators
from ml.market_relative import compute_live_relative_features, MARKET_RELATIVE_FEATURES
from ml.rf_model import FEATURES # single source of truth β€” see the same
# note in agents/signal_agent.py. This
# file previously had its own stale
# 12-feature copy, causing a feature-count
# mismatch at scaler.transform() time.
MODEL_PATH = "ml/rf_model.pkl"
SCALER_PATH = "ml/scaler.pkl"
# Plain English templates for each feature
EXPLANATIONS = {
"RSI": lambda v: (
f"RSI at {round(v, 1)} β€” stock is oversold, bounce likely ↑"
if v < 35 else
f"RSI at {round(v, 1)} β€” stock is overbought, pullback likely ↓"
if v > 65 else
f"RSI at {round(v, 1)} β€” momentum is neutral"
),
"MACD": lambda v: (
f"MACD positive β€” bullish trend building ↑"
if v > 0 else
f"MACD negative β€” bearish pressure present ↓"
),
"MACD_signal": lambda v: (
f"MACD signal line suggests upward crossover ↑"
if v < 0 else
f"MACD signal line suggests downward crossover ↓"
),
"MACD_hist": lambda v: (
f"MACD histogram expanding β€” momentum increasing ↑"
if v > 0 else
f"MACD histogram contracting β€” momentum weakening ↓"
),
"BB_upper": lambda v: f"Upper Bollinger Band at β‚Ή{round(v, 1)} β€” resistance level to watch",
"BB_lower": lambda v: f"Lower Bollinger Band at β‚Ή{round(v, 1)} β€” current support zone",
"EMA_20": lambda v: f"20-day EMA at β‚Ή{round(v, 1)} β€” short-term trend reference",
"EMA_50": lambda v: f"50-day EMA at β‚Ή{round(v, 1)} β€” medium-term trend reference",
"Volume_MA20": lambda v: f"Average volume {int(v):,} β€” liquidity reference",
"Returns": lambda v: (
f"Today's return: +{round(v*100, 2)}% β€” positive momentum"
if v > 0 else
f"Today's return: {round(v*100, 2)}% β€” negative momentum"
),
"Returns_5d": lambda v: (
f"5-day return: +{round(v*100, 2)}% β€” short-term uptrend"
if v > 0 else
f"5-day return: {round(v*100, 2)}% β€” short-term downtrend"
),
"Volume": lambda v: f"Current volume: {int(v):,} shares traded",
# Market-relative / cross-sectional β€” added alongside the FEATURES fix.
# Without these, a market-relative feature ranking in the SHAP top-3
# would silently produce fewer than 3 reasons instead of an error β€”
# not a crash, but a quiet quality gap against this project's own
# "every signal fully explained" standard.
"Rel_Return": lambda v: (
f"Outperforming Nifty by {round(v*100, 2)}% today ↑"
if v > 0 else
f"Underperforming Nifty by {round(abs(v)*100, 2)}% today ↓"
),
"Rel_Return_5d": lambda v: (
f"Outperforming Nifty by {round(v*100, 2)}% over 5 days ↑"
if v > 0 else
f"Underperforming Nifty by {round(abs(v)*100, 2)}% over 5 days ↓"
),
"Beta_20d": lambda v: (
f"Beta {round(v, 2)} β€” amplifying market moves more than usual"
if v > 1.2 else
f"Beta {round(v, 2)} β€” moving roughly in line with the market"
if v > 0.8 else
f"Beta {round(v, 2)} β€” dampening market moves, more defensive"
),
"RS_line_ROC_10": lambda v: (
f"Relative strength vs. Nifty rising ({round(v*100, 2)}% over 10 days) ↑"
if v > 0 else
f"Relative strength vs. Nifty falling ({round(v*100, 2)}% over 10 days) ↓"
),
"RSI_rel_class": lambda v: (
f"RSI {round(v, 1)} points above its peer group average"
if v > 0 else
f"RSI {round(abs(v), 1)} points below its peer group average"
),
"Returns_rel_class": lambda v: (
f"Outperforming peer stocks by {round(v*100, 2)}% today ↑"
if v > 0 else
f"Underperforming peer stocks by {round(abs(v)*100, 2)}% today ↓"
),
}
def explain_signal(symbol: str, signal: str) -> dict:
"""
Generate SHAP-based plain English explanation for a signal
"""
# Load model and scaler
model = joblib.load(MODEL_PATH)
scaler = joblib.load(SCALER_PATH)
# Get latest data
df = fetch_prices(symbol, period="1y")
df = add_technical_indicators(df)
latest = df.iloc[-1].to_dict()
# Market-relative / cross-sectional features β€” same computation used in
# agents/research_agent.py and ml/rf_model.py's predict_signal(), needed
# here too since this function does its own independent data fetch
# rather than reusing research_agent's result.
try:
relative_feats = compute_live_relative_features(symbol, df, STOCKS, period="1y")
latest.update(relative_feats)
except Exception as e:
print(f"[Explain] Market-relative features failed ({e}) β€” "
f"falling back to 0 for those fields.")
latest.update({f: 0.0 for f in MARKET_RELATIVE_FEATURES})
# Build feature vector
feature_values = {f: float(latest.get(f, 0)) for f in FEATURES}
X = np.array([list(feature_values.values())])
X_scaled = scaler.transform(X)
# SHAP explanation
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_scaled)
# Map signal to class index
signal_idx = {"SELL": 0, "HOLD": 1, "BUY": 2}.get(signal, 1)
# Handle both 2D and 3D SHAP output formats
if isinstance(shap_values, list):
# list of arrays β€” one per class
shap_for_signal = shap_values[signal_idx][0]
else:
# 3D array β€” shape (n_samples, n_features, n_classes)
if shap_values.ndim == 3:
shap_for_signal = shap_values[0, :, signal_idx]
else:
shap_for_signal = shap_values[0]
# Get top 3 most impactful features
feature_impact = list(zip(FEATURES, shap_for_signal))
feature_impact.sort(key=lambda x: abs(x[1]), reverse=True)
top_3 = feature_impact[:3]
# Convert to plain English
reasons = []
for feature, shap_val in top_3:
raw_value = feature_values[feature]
if feature in EXPLANATIONS:
reason = EXPLANATIONS[feature](raw_value)
direction = "supports" if shap_val > 0 else "cautions against"
reasons.append(f"{reason}")
# Risk context
rsi_val = feature_values.get("RSI", 50)
ret_5d = feature_values.get("Returns_5d", 0)
risk_note = build_risk_note(signal, rsi_val, ret_5d)
return {
"symbol": symbol,
"signal": signal,
"reasons": reasons,
"risk_note": risk_note,
"top_features": [(f, round(float(v), 4)) for f, v in top_3],
}
def build_risk_note(signal: str, rsi: float, ret_5d: float) -> str:
"""Plain English risk warning"""
if signal == "BUY":
if rsi > 65:
return "⚠️ Caution: RSI is high β€” stock may be overbought, consider smaller position"
return "βœ… Risk level moderate β€” set stop-loss 3-5% below entry price"
elif signal == "SELL":
if rsi < 35:
return "⚠️ Caution: RSI is low β€” stock may be oversold, short-selling risky"
return "βœ… Risk level moderate β€” confirm with volume before acting"
else:
return "ℹ️ Hold β€” no strong directional signal, wait for confirmation"
if __name__ == "__main__":
print("Generating explanation for RELIANCE.NS...")
result = explain_signal("RELIANCE.NS", "SELL")
print(f"\nSignal: {result['signal']} β€” {result['symbol']}")
print("\nWhy this signal?")
for i, reason in enumerate(result["reasons"], 1):
print(f" {i}. {reason}")
print(f"\nRisk note: {result['risk_note']}")
print(f"\nTop SHAP features: {result['top_features']}")