2025-08-10 17:43:21 -04:00 | | | import os
|
| | | from datetime import datetime, timedelta
|
| | | from typing import Tuple, List, Optional
|
| | |
|
| | | import numpy as np
|
| | | import pandas as pd
|
2025-09-20 02:15:03 -04:00 | | | try:
|
| | | import yfinance as yf
|
| | | except Exception:
|
| | | yf = None
|
2025-08-10 17:43:21 -04:00 | | | from sklearn.preprocessing import StandardScaler
|
| | |
|
| | |
|
| | | FX_SUFFIX = "=X"
|
| | | SYMBOL_MAP = {
|
| | | # Indices
|
| | | "US30": "^DJI",
|
| | | "DJI": "^DJI",
|
| | | "US500": "^GSPC",
|
| | | "SPX500": "^GSPC",
|
| | | "SPX": "^GSPC",
|
| | | "NAS100": "^IXIC",
|
| | | "NDX": "^NDX",
|
| | | "GER40": "^GDAXI",
|
| | | "DE40": "^GDAXI",
|
| | | "UK100": "^FTSE",
|
| | | "FR40": "^FCHI",
|
| | | "JP225": "^N225",
|
| | | # Crypto
|
| | | "BTCUSD": "BTC-USD",
|
| | | "ETHUSD": "ETH-USD",
|
| | | # Metals FX-style still use =X on Yahoo
|
| | | "XAUUSD": "XAUUSD=X",
|
| | | "XAGUSD": "XAGUSD=X",
|
| | | }
|
| | |
|
| | |
|
| | | def map_symbol_to_yahoo(symbol: str) -> str:
|
| | | # Map common indices/crypto, then fallback to FX mapping (EURUSD -> EURUSD=X)
|
| | | sym = symbol.replace("/", "").upper()
|
| | | if sym in SYMBOL_MAP:
|
| | | return SYMBOL_MAP[sym]
|
| | | if sym.endswith(FX_SUFFIX):
|
| | | return sym
|
| | | return f"{sym}{FX_SUFFIX}"
|
| | |
|
| | |
|
| | | def _tf_to_interval_and_freq(tf_minutes: Optional[int]) -> Tuple[str, str]:
|
| | | """Map MT5 timeframe (minutes) to yfinance interval and pandas freq."""
|
| | | if tf_minutes is None or tf_minutes <= 0:
|
| | | return "1d", "1D"
|
| | | if tf_minutes <= 1:
|
| | | return "1m", "1min"
|
| | | if tf_minutes <= 5:
|
| | | return "5m", "5min"
|
| | | if tf_minutes <= 15:
|
| | | return "15m", "15min"
|
| | | if tf_minutes <= 30:
|
| | | return "30m", "30min"
|
| | | if tf_minutes <= 60:
|
| | | return "60m", "60min"
|
| | | if tf_minutes <= 240:
|
| | | return "60m", f"{tf_minutes}min"
|
| | | return "1d", "1D"
|
| | |
|
| | |
|
| | | def enrich_with_yahoo(df: pd.DataFrame,
|
| | | symbol_col: str = "symbol",
|
| | | time_col: str = "close_time",
|
| | | target_timeframe_minutes: Optional[int] = None,
|
| | | tz: str = "UTC",
|
| | | fill_holidays: bool = True) -> pd.DataFrame:
|
| | | if symbol_col not in df.columns:
|
| | | # fallback: try 'entry_symbol' or assume single symbol
|
| | | return df
|
| | | if time_col not in df.columns:
|
| | | # try alternate column names
|
| | | for alt in ("time", "timestamp", "entry_time"):
|
| | | if alt in df.columns:
|
| | | time_col = alt
|
| | | break
|
| | | if time_col not in df.columns:
|
| | | return df
|
| | |
|
2025-09-20 02:15:03 -04:00 | | | # If yfinance is unavailable, skip enrichment gracefully
|
| | | if yf is None:
|
| | | return df
|
2025-08-10 17:43:21 -04:00 | | | df = df.copy()
|
| | | df[time_col] = pd.to_datetime(df[time_col], utc=True, errors="coerce")
|
| | | if tz:
|
| | | # Normalize to UTC for stable asof joins
|
| | | try:
|
| | | df[time_col] = df[time_col].dt.tz_convert("UTC")
|
| | | except Exception:
|
| | | # If tz-naive after parsing, localize then convert
|
| | | try:
|
| | | df[time_col] = df[time_col].dt.tz_localize("UTC")
|
| | | except Exception:
|
| | | pass
|
| | |
|
| | | # For each symbol, fetch yahoo OHLCV over covered date range
|
| | | enriched_frames: List[pd.DataFrame] = []
|
| | | for sym, g in df.groupby(symbol_col):
|
| | | start = g[time_col].min() - timedelta(days=5)
|
| | | end = g[time_col].max() + timedelta(days=2)
|
| | | ysym = map_symbol_to_yahoo(sym)
|
| | | interval, freq = _tf_to_interval_and_freq(target_timeframe_minutes)
|
| | | try:
|
| | | y = yf.download(ysym, start=start.tz_convert("UTC").to_pydatetime(), end=end.tz_convert("UTC").to_pydatetime(), interval=interval)
|
| | | except Exception:
|
| | | y = pd.DataFrame()
|
| | | if y is None or y.empty:
|
| | | enriched_frames.append(g)
|
| | | continue
|
| | | # Flatten potential MultiIndex columns from yfinance
|
| | | if isinstance(y.columns, pd.MultiIndex):
|
| | | y.columns = ["_".join([str(c) for c in tup if c is not None and str(c) != ""]).strip("_") for tup in y.columns]
|
| | | # Standardize column names (case-insensitive)
|
| | | rename_map = {}
|
| | | for c in list(y.columns):
|
| | | cl = str(c).strip().lower()
|
| | | if cl == "open":
|
| | | rename_map[c] = "y_open"
|
| | | elif cl == "high":
|
| | | rename_map[c] = "y_high"
|
| | | elif cl == "low":
|
| | | rename_map[c] = "y_low"
|
| | | elif cl == "close":
|
| | | rename_map[c] = "y_close"
|
| | | elif cl in ("adj close", "adjclose", "adj_close"):
|
| | | rename_map[c] = "y_adj_close"
|
| | | elif cl == "volume":
|
| | | rename_map[c] = "y_volume"
|
| | | if rename_map:
|
| | | y = y.rename(columns=rename_map)
|
| | | # Ensure datetime index and then expose as a normal column in UTC
|
| | | if not isinstance(y.index, pd.DatetimeIndex):
|
| | | try:
|
| | | y.index = pd.to_datetime(y.index, utc=True)
|
| | | except Exception:
|
| | | y.index = pd.to_datetime(y.index, utc=True, errors="coerce")
|
| | | else:
|
| | | if y.index.tz is None:
|
| | | y.index = y.index.tz_localize("UTC")
|
| | | else:
|
| | | y.index = y.index.tz_convert("UTC")
|
| | | if fill_holidays and freq:
|
| | | # Reindex to continuous timeline at target frequency and ffill to bridge holidays/weekends
|
| | | try:
|
| | | full_idx = pd.date_range(start=y.index.min(), end=y.index.max(), freq=freq, tz="UTC")
|
| | | y = y.reindex(full_idx).ffill()
|
| | | except Exception:
|
| | | pass
|
| | | y = y.reset_index()
|
| | | # The reset index column is commonly named 'Date' or 'Datetime' for yfinance
|
| | | if "Date" in y.columns:
|
| | | y = y.rename(columns={"Date": time_col})
|
| | | elif "Datetime" in y.columns:
|
| | | y = y.rename(columns={"Datetime": time_col})
|
| | | else:
|
| | | # If the datetime column got a different name, coerce to target name
|
| | | y.columns = [time_col if str(c).lower() in ("date", "datetime", "index") else c for c in y.columns]
|
| | |
|
| | | # Ensure y_close exists (robust, case-insensitive)
|
| | | if "y_close" not in y.columns:
|
| | | lower_map = {str(c).lower(): c for c in y.columns}
|
| | | for cand in ("y_close", "close", "adj close", "adj_close", "y_adj_close"):
|
| | | if cand in lower_map:
|
| | | y["y_close"] = y[lower_map[cand]]
|
| | | break
|
| | | if "y_close" not in y.columns:
|
| | | # If still missing, create NaNs to keep pipeline running
|
| | | y["y_close"] = np.nan
|
| | |
|
| | | # Derived features
|
| | | y["y_ret_1d"] = y["y_close"].pct_change(1)
|
| | | y["y_vol_5"] = y["y_ret_1d"].rolling(5).std().fillna(0.0)
|
| | | y["y_vol_20"] = y["y_ret_1d"].rolling(20).std().fillna(0.0)
|
| | |
|
| | | # Ensure left frame doesn't carry a time index level
|
| | | gg = g.copy()
|
| | | if isinstance(gg.index, pd.MultiIndex) or (gg.index.name == time_col):
|
| | | gg = gg.reset_index()
|
| | | # Make sure no MultiIndex columns remain
|
| | | if isinstance(gg.columns, pd.MultiIndex):
|
| | | gg.columns = ["_".join([str(c) for c in tup if c is not None and str(c) != ""]).strip("_") for tup in gg.columns]
|
| | | if isinstance(y.columns, pd.MultiIndex):
|
| | | y.columns = ["_".join([str(c) for c in tup if c is not None and str(c) != ""]).strip("_") for tup in y.columns]
|
| | | merged = pd.merge_asof(
|
| | | gg.sort_values(time_col),
|
| | | y.sort_values(time_col),
|
| | | on=time_col,
|
| | | direction="backward",
|
| | | tolerance=pd.Timedelta(minutes=target_timeframe_minutes) if target_timeframe_minutes else None,
|
| | | )
|
| | | enriched_frames.append(merged)
|
| | |
|
| | | return pd.concat(enriched_frames, ignore_index=True)
|
| | |
|
| | |
|
| | | def build_feature_matrix(df: pd.DataFrame,
|
| | | drop_cols: Tuple[str, ...] = ("r_multiple", "close_event", "strategy", "order_id", "deal_id")) -> Tuple[np.ndarray, List[str]]:
|
| | | x = df.copy()
|
| | | for c in drop_cols:
|
| | | if c in x.columns:
|
| | | x = x.drop(columns=[c])
|
| | | # Remove non-numeric columns
|
| | | num = x.select_dtypes(include=[np.number]).copy()
|
| | | features = num.columns.tolist()
|
| | | return num.values.astype(float), features
|
| | |
|
| | |
|
| | | def scale_features(x: np.ndarray) -> Tuple[np.ndarray, StandardScaler]:
|
| | | scaler = StandardScaler()
|
| | | xs = scaler.fit_transform(x)
|
| | | return xs, scaler
|