Files

220 lines
7.8 KiB
Python

"""
Mad Turtle ML Pipeline
- Generates/loads OHLCV features for XAUUSD H1
- Trains ensemble models (BUY/SELL sub-models)
- Exports to ONNX for MT5 inference via REST bridge
"""
import os
import numpy as np
import pandas as pd
from pathlib import Path
from datetime import datetime, timedelta, timezone
import json
import onnx
import onnxruntime as ort
try:
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, VotingClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import joblib
HAS_SKLEARN = True
except Exception as e:
HAS_SKLEARN = False
SKLEARN_IMPORT_ERROR = str(e)
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
MODELS_DIR = ROOT / "models"
DATA_DIR.mkdir(exist_ok=True)
MODELS_DIR.mkdir(exist_ok=True)
def generate_synthetic_gold_data(days: int = 2000, seed: int = 42) -> pd.DataFrame:
"""Generate realistic synthetic XAUUSD H1 data when no real feed is available."""
rng = np.random.default_rng(seed)
n = days * 24
base = 1800.0
returns = rng.normal(loc=0.00002, scale=0.0008, size=n)
prices = base * np.exp(np.cumsum(returns))
df = pd.DataFrame({"close": prices})
df["open"] = df["close"].shift(1).fillna(base)
df["high"] = df[["open", "close"]].max(axis=1) * (1 + np.abs(rng.normal(0, 0.0003, n)))
df["low"] = df[["open", "close"]].min(axis=1) * (1 - np.abs(rng.normal(0, 0.0003, n)))
df["volume"] = rng.lognormal(mean=10, sigma=1.0, size=n)
df.index = pd.date_range(end=datetime.now(timezone.utc), periods=n, freq="h")
return df
def load_real_data(csv_path: Path) -> pd.DataFrame:
"""Load OHLCV from CSV (datetime,open,high,low,close,volume)."""
if not csv_path.exists():
raise FileNotFoundError(f"Real data CSV not found: {csv_path}")
df = pd.read_csv(csv_path, parse_dates=["datetime"])
df.sort_values("datetime", inplace=True)
df.reset_index(drop=True, inplace=True)
df.set_index("datetime", inplace=True)
df.dropna(subset=["open", "high", "low", "close"], inplace=True)
if "volume" not in df.columns:
df["volume"] = 0.0
df["volume"] = df["volume"].fillna(0)
return df
def engineer_features(df: pd.DataFrame) -> pd.DataFrame:
"""Feature set inspired by price-action + momentum + volatility."""
out = df.copy()
out["returns_1"] = np.log(out["close"] / out["close"].shift(1))
out["returns_3"] = np.log(out["close"] / out["close"].shift(3))
out["returns_6"] = np.log(out["close"] / out["close"].shift(6))
out["sma_10"] = out["close"].rolling(10).mean()
out["sma_20"] = out["close"].rolling(20).mean()
out["sma_50"] = out["close"].rolling(50).mean()
out["ema_12"] = out["close"].ewm(span=12, adjust=False).mean()
out["ema_26"] = out["close"].ewm(span=26, adjust=False).mean()
out["macd"] = out["ema_12"] - out["ema_26"]
out["macd_signal"] = out["macd"].ewm(span=9, adjust=False).mean()
delta = out["close"].diff()
gain = delta.clip(lower=0).rolling(14).mean()
loss = (-delta.clip(upper=0)).rolling(14).mean()
rs = gain / (loss + 1e-9)
out["rsi_14"] = 100.0 - (100.0 / (1.0 + rs))
tr1 = out["high"] - out["low"]
tr2 = (out["high"] - out["close"].shift(1)).abs()
tr3 = (out["low"] - out["close"].shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
out["atr_14"] = tr.rolling(14).mean()
out["atr_pct"] = out["atr_14"] / (out["close"] + 1e-9)
out["vol_ratio"] = out["volume"] / (out["volume"].rolling(20).mean() + 1e-9)
out["high_low_range"] = (out["high"] - out["low"]) / (out["close"] + 1e-9)
out["dist_sma20"] = (out["close"] - out["sma_20"]) / (out["close"] + 1e-9)
out.dropna(inplace=True)
return out
def make_target(df: pd.DataFrame, horizon: int = 3) -> pd.DataFrame:
"""Multi-class target:
0 = SELL (return < -threshold)
1 = HOLD (return within threshold)
2 = BUY (return > +threshold)
"""
fwd = np.log(df["close"].shift(-horizon) / df["close"])
thr = fwd.std() * 0.3
target = pd.cut(fwd, bins=[-np.inf, -thr, thr, np.inf], labels=[0, 1, 2])
df["target"] = target
df.dropna(subset=["target"], inplace=True)
df["target"] = df["target"].astype(int)
return df
FEATURES = [
"returns_1", "returns_3", "returns_6",
"sma_10", "sma_20", "sma_50",
"macd", "macd_signal",
"rsi_14", "atr_14", "atr_pct",
"vol_ratio", "high_low_range", "dist_sma20",
]
def build_pipeline():
return Pipeline([
("scaler", StandardScaler()),
("clf", RandomForestClassifier(n_estimators=300, max_depth=12, random_state=42, n_jobs=-1, class_weight="balanced")),
])
def train_models(df: pd.DataFrame):
buy = df[df["target"] == 2].copy()
hold = df[df["target"] == 1].copy()
sell = df[df["target"] == 0].copy()
min_n = min(len(buy), len(hold), len(sell))
if min_n < 200:
raise ValueError(f"Not enough samples per class (min={min_n}). Provide more data or reduce horizon.")
buy = buy.sample(len(buy), random_state=42) if len(buy) > min_n else buy
hold = hold.sample(len(hold), random_state=42) if len(hold) > min_n else hold
sell = sell.sample(len(sell), random_state=42) if len(sell) > min_n else sell
balanced = pd.concat([buy, hold, sell]).sample(frac=1, random_state=42).reset_index(drop=True)
X = balanced[FEATURES].values
y = balanced["target"].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
pipe = build_pipeline()
pipe.fit(X_train, y_train)
preds = pipe.predict(X_test)
print("Accuracy:", accuracy_score(y_test, preds))
print(classification_report(y_test, preds, target_names=["SELL", "HOLD", "BUY"]))
return pipe, FEATURES
def export_onnx(model: Pipeline, features: list, path: Path):
initial_types = [("float_input", FloatTensorType([None, len(features)]))]
onnx_model = convert_sklearn(model, initial_types=initial_types, target_opset=15)
onnx.save(onnx_model, str(path))
print(f"Saved ONNX model -> {path}")
def save_metadata(meta: dict, path: Path):
with open(path, "w") as f:
json.dump(meta, f, indent=2)
def main():
meta = {
"symbol": "XAUUSD",
"timeframe": "H1",
"features": FEATURES,
"target_horizon": 3,
"built_at": datetime.now(timezone.utc).isoformat(),
"models": {},
}
real_csv = DATA_DIR / "xauusd_h1.csv"
if HAS_SKLEARN:
if real_csv.exists():
print(f"Loading real data from {real_csv}")
df = load_real_data(real_csv)
else:
print("Real data CSV not found, generating synthetic data")
df = generate_synthetic_gold_data(days=1500)
df = engineer_features(df)
df = make_target(df, horizon=3)
buy_pipe, feats = train_models(pd.concat([df[df["target"] == 2], df[df["target"] != 2]]))
export_onnx(buy_pipe, feats, MODELS_DIR / "xauusd_h1_ensemble.onnx")
meta["models"]["ensemble"] = {"features": feats, "path": "models/xauusd_h1_ensemble.onnx"}
else:
print("sklearn/skl2onnx not available (", SKLEARN_IMPORT_ERROR, ")")
print("Falling back to demo ONNX model via build_onnx_raw.py")
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from build_onnx_raw import build_model, save_metadata as save_meta
build_model()
save_meta()
with open(MODELS_DIR / "metadata.json") as f:
meta = json.load(f)
save_metadata(meta, MODELS_DIR / "metadata.json")
print("Done.")
if __name__ == "__main__":
main()