quantmacro-india / src /models /predictor.py
kingbros919's picture
Upload folder using huggingface_hub
292b308 verified
Raw
History Blame Contribute Delete
16.3 kB
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
from sklearn.metrics import accuracy_score, mean_squared_error, precision_score, recall_score, f1_score
import logging
logger = logging.getLogger(__name__)
class PricePredictor:
def __init__(self):
self.trend_model = None
self.return_model = None
self.metrics = {}
self.trained = False
self.feature_names = []
def prepare_data(self, df: pd.DataFrame, sector_index: str = None) -> tuple:
"""
Calculates advanced technical indicators and lags them to prevent lookahead bias.
"""
df = df.copy()
df = df.sort_values('date')
df['date'] = pd.to_datetime(df['date'])
if 'daily_return_pct' not in df.columns:
df['daily_return_pct'] = df['close_price'].pct_change() * 100
# --- TECHNICAL INDICATORS ---
# 1. RSI (14-day)
delta = df['close_price'].diff()
gain = (delta.where(delta > 0, 0.0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0.0)).rolling(window=14).mean()
rs = gain / (loss + 1e-9)
df['RSI'] = 100 - (100 / (1 + rs))
df['RSI'] = df['RSI'].fillna(50)
# 2. MACD
ema12 = df['close_price'].ewm(span=12, adjust=False).mean()
ema26 = df['close_price'].ewm(span=26, adjust=False).mean()
df['MACD_line'] = ema12 - ema26
df['MACD_signal'] = df['MACD_line'].ewm(span=9, adjust=False).mean()
df['MACD_hist'] = df['MACD_line'] - df['MACD_signal']
# 3. Bollinger Bands
df['BB_middle'] = df['close_price'].rolling(window=20).mean()
df['BB_std'] = df['close_price'].rolling(window=20).std()
df['BB_upper'] = df['BB_middle'] + (2 * df['BB_std'])
df['BB_lower'] = df['BB_middle'] - (2 * df['BB_std'])
df['BB_width'] = (df['BB_upper'] - df['BB_lower']) / (df['BB_middle'] + 1e-9)
df['BB_width'] = df['BB_width'].fillna(0)
# 4. ATR (Average True Range)
if 'high_price' in df.columns and 'low_price' in df.columns:
tr1 = df['high_price'] - df['low_price']
tr2 = (df['high_price'] - df['close_price'].shift(1)).abs()
tr3 = (df['low_price'] - df['close_price'].shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
df['ATR'] = tr.rolling(window=14).mean()
else:
df['ATR'] = df['close_price'].rolling(window=14).std()
df['ATR'] = df['ATR'].fillna(0)
# 5. Moving Average Crossover (SMA20/SMA50)
df['SMA20'] = df['close_price'].rolling(window=20).mean()
df['SMA50'] = df['close_price'].rolling(window=50).mean()
df['MA_crossover'] = np.where(df['SMA20'] > df['SMA50'], 1, -1)
# 6. Sector Momentum
df['momentum_5d'] = df['close_price'].pct_change(5) * 100
df['momentum_21d'] = df['close_price'].pct_change(21) * 100
# 7. Volume Z-score
if 'volume' in df.columns and df['volume'].std() > 0:
df['volume_zscore'] = (df['volume'] - df['volume'].rolling(window=10).mean()) / (df['volume'].rolling(window=10).std() + 1e-9)
else:
df['volume_zscore'] = 0.0
df['volume_zscore'] = df['volume_zscore'].fillna(0)
# 8. Drawdown
rolling_max = df['close_price'].cummax()
df['drawdown'] = (df['close_price'] - rolling_max) / (rolling_max + 1e-9)
# 9. Rolling Sharpe (20-day)
rolling_mean_ret = df['daily_return_pct'].rolling(window=20).mean()
rolling_std_ret = df['daily_return_pct'].rolling(window=20).std()
df['rolling_sharpe'] = (rolling_mean_ret / (rolling_std_ret + 1e-9)) * np.sqrt(252)
df['rolling_sharpe'] = df['rolling_sharpe'].fillna(0)
# --- NEW TECHNICAL INDICATORS (TASK 2) ---
# 10. Stochastic Oscillator
low_14 = df['low_price'].rolling(window=14).min()
high_14 = df['high_price'].rolling(window=14).max()
df['stochastic_k'] = ((df['close_price'] - low_14) / (high_14 - low_14 + 1e-9)) * 100
df['stochastic_d'] = df['stochastic_k'].rolling(window=3).mean()
df['stochastic_k'] = df['stochastic_k'].fillna(50)
df['stochastic_d'] = df['stochastic_d'].fillna(50)
# 11. VWAP (rolling 20-day proxy)
typical_price = (df['high_price'] + df['low_price'] + df['close_price']) / 3
pv = typical_price * df['volume']
df['vwap'] = pv.rolling(window=20).sum() / (df['volume'].rolling(window=20).sum() + 1e-9)
df['vwap'] = df['vwap'].fillna(df['close_price'])
# 12. Sector Relative Strength (to SENSEX) and Rolling Beta (60-day)
from src.database.connection import get_connection
try:
conn = get_connection()
sensex_df = pd.read_sql("""
SELECT date(date) as date, close_price as sensex_close
FROM bse_sector_prices
WHERE sector_index = 'BSE_SENSEX'
""", conn)
conn.close()
if not sensex_df.empty:
sensex_df['date'] = pd.to_datetime(sensex_df['date'])
df = pd.merge(df, sensex_df, on='date', how='left')
df['sensex_close'] = df['sensex_close'].ffill().bfill()
df['relative_strength'] = df['close_price'] / (df['sensex_close'] + 1e-9)
df['sensex_return'] = df['sensex_close'].pct_change() * 100
ret_cov = df['daily_return_pct'].rolling(window=60).cov(df['sensex_return'])
mkt_var = df['sensex_return'].rolling(window=60).var()
df['rolling_beta'] = ret_cov / (mkt_var + 1e-9)
else:
df['relative_strength'] = 1.0
df['rolling_beta'] = 1.0
except Exception as e:
logger.warning(f"Failed to calculate beta or relative strength: {e}")
df['relative_strength'] = 1.0
df['rolling_beta'] = 1.0
df['relative_strength'] = df['relative_strength'].fillna(1.0)
df['rolling_beta'] = df['rolling_beta'].fillna(1.0)
# 13. Rolling Sortino Ratio (20-day)
rolling_mean_ret = df['daily_return_pct'].rolling(window=20).mean()
downside_diff = df['daily_return_pct'].clip(upper=0)
rolling_downside_std = downside_diff.rolling(window=20).std()
df['rolling_sortino'] = (rolling_mean_ret / (rolling_downside_std + 1e-9)) * np.sqrt(252)
df['rolling_sortino'] = df['rolling_sortino'].fillna(0.0)
# 14. Realized Volatility (20-day)
df['realized_volatility'] = df['daily_return_pct'].rolling(window=20).std() * np.sqrt(252)
df['realized_volatility'] = df['realized_volatility'].fillna(0.0)
# 15. Skewness & Kurtosis (20-day)
df['rolling_skew'] = df['daily_return_pct'].rolling(window=20).skew().fillna(0.0)
df['rolling_kurt'] = df['daily_return_pct'].rolling(window=20).kurt().fillna(0.0)
# 16. Momentum Factors
df['momentum_10d'] = df['close_price'].pct_change(10) * 100
df['momentum_10d'] = df['momentum_10d'].fillna(0.0)
# 17. Volatility Regime
vol_threshold = df['realized_volatility'].rolling(window=100, min_periods=20).quantile(0.7)
df['vol_regime'] = np.where(df['realized_volatility'] > vol_threshold.fillna(999.0), 1, 0)
# --- MACRO INDICATORS ---
try:
conn = get_connection()
macro_dfs = []
for macro_idx in ['USD_INR', 'CRUDE_OIL', 'INDIA_VIX', 'BOND_YIELD_10Y', 'INFLATION_CPI', 'REPO_RATE']:
m_df = pd.read_sql("""
SELECT date(date) as date, close_price as {col}
FROM bse_sector_prices
WHERE sector_index = ?
""".format(col=macro_idx.lower()), conn, params=(macro_idx,))
if not m_df.empty:
m_df['date'] = pd.to_datetime(m_df['date'])
macro_dfs.append(m_df)
conn.close()
for m_df in macro_dfs:
df = pd.merge(df, m_df, on='date', how='left')
df[m_df.columns[1]] = df[m_df.columns[1]].ffill().bfill()
except Exception as e:
logger.warning(f"Failed to fetch macro features: {e}")
# Ensure macro columns exist
for col in ['usd_inr', 'crude_oil', 'india_vix', 'bond_yield_10y', 'inflation_cpi', 'repo_rate']:
if col not in df.columns:
df[col] = 0.0
else:
df[col] = df[col].fillna(0.0)
# --- NLP SENTIMENT INTEGRATION ---
if sector_index:
try:
conn = get_connection()
sent_df = pd.read_sql("""
SELECT date(published_at) as date, AVG(sentiment_score) as avg_sentiment
FROM raw_news r
JOIN news_sector_mapping m ON r.id = m.news_id
WHERE m.sector_index = ?
GROUP BY date(published_at)
""", conn, params=(sector_index,))
conn.close()
if not sent_df.empty:
sent_df['date'] = pd.to_datetime(sent_df['date'])
df = pd.merge(df, sent_df, on='date', how='left')
df['avg_sentiment'] = df['avg_sentiment'].fillna(0.0)
else:
df['avg_sentiment'] = 0.0
except Exception as e:
logger.warning(f"Failed to fetch daily sentiment feature: {e}")
df['avg_sentiment'] = 0.0
else:
df['avg_sentiment'] = 0.0
# --- LAG FEATURES BY 1 DAY (No Lookahead Bias) ---
feature_cols = [
'daily_return_pct', 'RSI', 'MACD_line', 'MACD_signal', 'MACD_hist',
'BB_width', 'ATR', 'MA_crossover', 'momentum_5d', 'momentum_21d',
'volume_zscore', 'drawdown', 'rolling_sharpe', 'avg_sentiment',
'stochastic_k', 'stochastic_d', 'vwap', 'relative_strength',
'rolling_beta', 'rolling_sortino', 'realized_volatility',
'rolling_skew', 'rolling_kurt', 'momentum_10d', 'vol_regime',
'usd_inr', 'crude_oil', 'india_vix', 'bond_yield_10y', 'inflation_cpi', 'repo_rate'
]
for col in feature_cols:
df[f'{col}_lag1'] = df[col].shift(1)
lagged_features = [f'{col}_lag1' for col in feature_cols]
# Target: t+1 daily return and binary trend direction
df['target_return'] = df['daily_return_pct'].shift(-1)
df['target_trend'] = np.where(df['target_return'] > 0, 1, 0)
df = df.dropna(subset=lagged_features)
return df, lagged_features
def train_and_evaluate(self, df: pd.DataFrame, sector_index: str = None) -> tuple:
"""
Performs walk-forward time-series splitting to train and validate model performance.
"""
if len(df) < 50:
logger.warning("Insufficient historical data for robust ML model training.")
return False, None
df_clean, features = self.prepare_data(df, sector_index)
self.feature_names = features
df_train_eval = df_clean.iloc[:-1]
if len(df_train_eval) < 20:
return False, None
X = df_train_eval[features]
y_trend = df_train_eval['target_trend']
y_return = df_train_eval['target_return']
# Time-based split (80% train, 20% test)
split_idx = int(len(X) * 0.8)
X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
yt_train, yt_test = y_trend.iloc[:split_idx], y_trend.iloc[split_idx:]
yr_train, yr_test = y_return.iloc[:split_idx], y_return.iloc[split_idx:]
# --- MODEL INITIALIZATION & STACKED ENSEMBLE (TASK 3) ---
from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor, VotingClassifier, VotingRegressor
from sklearn.calibration import CalibratedClassifierCV
# Ensemble Classification Setup
rf_clf = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
et_clf = ExtraTreesClassifier(n_estimators=100, max_depth=5, random_state=42)
estimators = [('rf', rf_clf), ('et', et_clf)]
try:
import xgboost as xgb
estimators.append(('xgb', xgb.XGBClassifier(n_estimators=50, max_depth=3, random_state=42, eval_metric='logloss')))
except ImportError:
pass
try:
import lightgbm as lgb
estimators.append(('lgb', lgb.LGBMClassifier(n_estimators=50, max_depth=3, random_state=42, verbose=-1)))
except ImportError:
pass
ensemble_clf = VotingClassifier(estimators=estimators, voting='soft')
# Platt Scaling / Probability Calibration via CalibratedClassifierCV
self.trend_model = CalibratedClassifierCV(estimator=ensemble_clf, method='sigmoid', cv=3)
# Ensemble Regression Setup
rf_reg = RandomForestRegressor(n_estimators=100, max_depth=5, random_state=42)
et_reg = ExtraTreesRegressor(n_estimators=100, max_depth=5, random_state=42)
reg_estimators = [('rf', rf_reg), ('et', et_reg)]
try:
import xgboost as xgb
reg_estimators.append(('xgb', xgb.XGBRegressor(n_estimators=50, max_depth=3, random_state=42)))
except ImportError:
pass
try:
import lightgbm as lgb
reg_estimators.append(('lgb', lgb.LGBMRegressor(n_estimators=50, max_depth=3, random_state=42, verbose=-1)))
except ImportError:
pass
self.return_model = VotingRegressor(estimators=reg_estimators)
# Train models
self.trend_model.fit(X_train, yt_train)
self.return_model.fit(X_train, yr_train)
# Generate predictions
trend_preds = self.trend_model.predict(X_test)
trend_probs = self.trend_model.predict_proba(X_test)[:, 1]
return_preds = self.return_model.predict(X_test)
self.trained = True
# Calculate standard ML classification & regression metrics
self.metrics = {
"accuracy": accuracy_score(yt_test, trend_preds),
"precision": precision_score(yt_test, trend_preds, zero_division=0),
"recall": recall_score(yt_test, trend_preds, zero_division=0),
"f1": f1_score(yt_test, trend_preds, zero_division=0),
"rmse": np.sqrt(mean_squared_error(yr_test, return_preds))
}
# Build test results DataFrame
test_results = df_train_eval.iloc[split_idx:].copy()
test_results['predicted_trend'] = trend_preds
test_results['predicted_prob'] = trend_probs
test_results['predicted_return'] = return_preds
return True, test_results
def predict_next_day(self, df: pd.DataFrame, sector_index: str = None) -> tuple:
"""
Uses the trained model to predict the market direction and expected return for t+1.
"""
if not self.trained:
logger.error("Predictor model has not been trained yet.")
return None, None, None
df_clean, features = self.prepare_data(df, sector_index)
if df_clean.empty:
return None, None, None
# Predict based on the latest available row (which contains feature values shifted to t)
last_row = df_clean.iloc[-1]
X_latest = pd.DataFrame([last_row[features]])
pred_trend = self.trend_model.predict(X_latest)[0]
trend_probs = self.trend_model.predict_proba(X_latest)[0]
confidence = max(trend_probs) * 100
pred_return = self.return_model.predict(X_latest)[0]
pred_price = last_row['close_price'] * (1 + pred_return / 100)
return int(pred_trend), float(pred_price), float(confidence)