Initial commit: Mad Turtle v2.0 ML EA for XAUUSD H1 with Python inference server and MQL5 EA

This commit is contained in:
Visi
2026-06-13 15:27:41 +01:00
commit a5bfac82e4
21 changed files with 13055 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
"""
Mad Turtle Inference Server
FastAPI service that loads ONNX ensemble models and exposes REST endpoints
for MT5 EA to get BUY/SELL/HOLD signals + confidence scores.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import numpy as np
import onnxruntime as ort
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mad_turtle_server")
ROOT = Path(__file__).resolve().parents[2]
MODELS_DIR = ROOT / "models"
META_PATH = MODELS_DIR / "metadata.json"
app = FastAPI(title="Mad Turtle Inference Server", version="2.0.0")
class HealthResponse(BaseModel):
status: str
models_loaded: int
uptime_seconds: float
class SignalRequest(BaseModel):
features: list[float] = Field(..., min_length=14, max_length=14)
model: Optional[str] = "ensemble"
class SignalResponse(BaseModel):
signal: str
confidence: float
buy_prob: float
sell_prob: float
hold_prob: float
model_version: str
class OHLCVRequest(BaseModel):
open: float
high: float
low: float
close: float
volume: float
class Engine:
def __init__(self):
self.sessions: dict[str, ort.InferenceSession] = {}
self.metadata: dict = {}
self.feature_names: list[str] = []
self.started_at: Optional[str] = None
def load(self):
self.started_at = datetime.now(timezone.utc).isoformat()
if not META_PATH.exists():
raise FileNotFoundError(f"metadata.json not found at {META_PATH}. Run build_onnx_raw.py first.")
with open(META_PATH) as f:
self.metadata = json.load(f)
self.feature_names = self.metadata["features"]
for name, info in self.metadata.get("models", {}).items():
path = ROOT / info["path"]
if not path.exists():
logger.warning("Model file missing: %s", path)
continue
sess = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
self.sessions[name] = sess
logger.info("Loaded model '%s' from %s", name, path)
def predict(self, features: list[float], model_name: str = "ensemble") -> dict:
if model_name not in self.sessions:
raise ValueError(f"Model '{model_name}' not loaded. Available: {list(self.sessions.keys())}")
if len(features) != len(self.feature_names):
raise ValueError(f"Expected {len(self.feature_names)} features, got {len(features)}")
x = np.array([features], dtype=np.float32)
sess = self.sessions[model_name]
input_name = sess.get_inputs()[0].name
outputs = sess.run(None, {input_name: x})[0]
probs = outputs[0]
classes = ["SELL", "HOLD", "BUY"]
idx = int(np.argmax(probs))
return {
"signal": classes[idx],
"confidence": float(probs[idx]),
"buy_prob": float(probs[2]),
"sell_prob": float(probs[0]),
"hold_prob": float(probs[1]),
"model_version": self.metadata.get("built_at", "unknown"),
}
def engineer_features(self, ohlcv: dict) -> list[float]:
import pandas as pd
df = pd.DataFrame([ohlcv])
df["returns_1"] = np.log(df["close"] / df["open"])
df["returns_3"] = np.log(df["close"] / df["close"])
df["returns_6"] = np.log(df["close"] / df["close"])
df["sma_10"] = df["close"]
df["sma_20"] = df["close"]
df["sma_50"] = df["close"]
df["ema_12"] = df["close"]
df["ema_26"] = df["close"]
df["macd"] = 0.0
df["macd_signal"] = 0.0
delta = df["close"].diff().fillna(0)
gain = delta.clip(lower=0).rolling(14).mean().fillna(0)
loss = (-delta.clip(upper=0)).rolling(14).mean().fillna(0)
rs = gain / (loss + 1e-9)
df["rsi_14"] = (100.0 - (100.0 / (1.0 + rs))).fillna(50.0)
df["atr_14"] = (df["high"] - df["low"]).fillna(0.0)
df["atr_pct"] = (df["atr_14"] / (df["close"] + 1e-9)).fillna(0.0)
df["vol_ratio"] = 1.0
df["high_low_range"] = ((df["high"] - df["low"]) / (df["close"] + 1e-9)).fillna(0.0)
df["dist_sma20"] = 0.0
row = df.iloc[-1]
return [float(row[c]) for c in self.feature_names]
engine = Engine()
@app.on_event("startup")
async def startup():
engine.load()
@app.get("/health", response_model=HealthResponse)
async def health():
now = datetime.now(timezone.utc)
start = datetime.fromisoformat(engine.started_at) if engine.started_at else now
return HealthResponse(
status="ok" if engine.sessions else "degraded",
models_loaded=len(engine.sessions),
uptime_seconds=(now - start).total_seconds(),
)
@app.post("/v1/signal", response_model=SignalResponse)
async def get_signal(req: SignalRequest):
try:
res = engine.predict(req.features, req.model or "ensemble")
return SignalResponse(**res)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/v1/signal/ohlcv", response_model=SignalResponse)
async def get_signal_ohlcv(req: OHLCVRequest):
try:
feats = engine.engineer_features(req.dict())
res = engine.predict(feats)
return SignalResponse(**res)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
+104
View File
@@ -0,0 +1,104 @@
"""
Build a minimal ONNX ensemble model using raw ONNX ops (no sklearn needed).
Creates a simple linear classifier + softmax as a demo.
Replace this with a real trained model later (Python 3.11/3.12 + sklearn + skl2onnx).
"""
import json
import numpy as np
import onnx
from onnx import helper, TensorProto, numpy_helper
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MODELS_DIR = ROOT / "models"
MODELS_DIR.mkdir(exist_ok=True)
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",
]
# Dummy weights for demo — replace with real trained weights
rng = np.random.default_rng(123)
W = rng.standard_normal((14, 3)).astype(np.float32) * 0.1
b = np.zeros(3, dtype=np.float32)
# Normalize weights roughly
W = W / np.maximum(np.abs(W).sum(axis=0, keepdims=True), 1e-6)
scale = np.array([
1000.0, 1000.0, 1000.0,
1.0, 1.0, 1.0,
10.0, 10.0,
1.0, 10.0, 1.0,
1.0, 1.0, 1.0,
], dtype=np.float32).reshape(1, 14)
bias = np.zeros((1, 14), dtype=np.float32)
def build_model():
# Input
x = helper.make_tensor_value_info("float_input", TensorProto.FLOAT, [None, 14])
# Scale nodes: x_scaled = x * scale + bias
scale_tensor = numpy_helper.from_array(scale, name="scale")
bias_tensor = numpy_helper.from_array(bias, name="bias")
mul_node = helper.make_node("Mul", ["float_input", "scale"], ["x_scaled"])
add_node = helper.make_node("Add", ["x_scaled", "bias"], ["x_scaled_centered"])
# Linear layer: logits = x_scaled @ W + b
W_tensor = numpy_helper.from_array(W, name="W")
b_tensor = numpy_helper.from_array(b, name="b")
matmul_node = helper.make_node("MatMul", ["x_scaled_centered", "W"], ["logits"])
add_bias_node = helper.make_node("Add", ["logits", "b"], ["logits_biased"])
# Softmax
softmax_node = helper.make_node("Softmax", ["logits_biased"], ["probs"], axis=1)
# Output
y = helper.make_tensor_value_info("probs", TensorProto.FLOAT, [None, 3])
graph = helper.make_graph(
[mul_node, add_node, matmul_node, add_bias_node, softmax_node],
"mad_turtle_ensemble",
[x],
[y],
[scale_tensor, bias_tensor, W_tensor, b_tensor],
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 15)])
model.ir_version = 8
onnx.checker.check_model(model)
out = MODELS_DIR / "xauusd_h1_ensemble.onnx"
onnx.save(model, str(out))
print(f"Saved demo ONNX model -> {out}")
def save_metadata():
meta = {
"symbol": "XAUUSD",
"timeframe": "H1",
"features": FEATURES,
"target_horizon": 3,
"built_at": __import__('datetime').datetime.utcnow().isoformat(),
"models": {
"ensemble": {
"features": FEATURES,
"path": "models/xauusd_h1_ensemble.onnx",
"note": "Demo model with random weights. Replace with real trained model.",
}
},
}
out = MODELS_DIR / "metadata.json"
with open(out, "w") as f:
json.dump(meta, f, indent=2)
print(f"Saved metadata -> {out}")
if __name__ == "__main__":
build_model()
save_metadata()
+52
View File
@@ -0,0 +1,52 @@
"""
Fetch real XAUUSD H1 data from Yahoo Finance (GC=F gold futures).
Saves to CSV for training pipeline.
"""
import argparse
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
import yfinance as yf
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
DATA_DIR.mkdir(exist_ok=True)
OUT_CSV = DATA_DIR / "xauusd_h1.csv"
def fetch_xauusd(period: str = "2y", interval: str = "1h") -> pd.DataFrame:
ticker = yf.Ticker("GC=F")
df = ticker.history(period=period, interval=interval, auto_adjust=True)
if df.empty:
raise RuntimeError("No data returned from Yahoo Finance for GC=F")
df.index = df.index.tz_convert("UTC").tz_localize(None)
df.index.name = "datetime"
df.reset_index(inplace=True)
df.rename(columns={
"Open": "open", "High": "high", "Low": "low",
"Close": "close", "Volume": "volume"
}, inplace=True)
df = df[["datetime", "open", "high", "low", "close", "volume"]].copy()
df.dropna(subset=["open", "high", "low", "close"], inplace=True)
df["volume"] = df["volume"].fillna(0)
return df
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--period", default="2y", help="yfinance period (1y, 2y, max)")
parser.add_argument("--interval", default="1h", help="yfinance interval (1h, 4h, 1d)")
parser.add_argument("--out", default=str(OUT_CSV))
args = parser.parse_args()
print(f"Fetching XAUUSD (GC=F) {args.interval} for {args.period}...")
df = fetch_xauusd(args.period, args.interval)
df.to_csv(args.out, index=False)
print(f"Saved {len(df)} rows -> {args.out}")
print(df.tail(3).to_string(index=False))
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
"""
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()
+43
View File
@@ -0,0 +1,43 @@
{
"symbol": "XAUUSD",
"timeframe": "H1",
"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"
],
"target_horizon": 3,
"built_at": "2026-06-13T14:18:27.084238+00:00",
"models": {
"ensemble": {
"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"
],
"path": "models/xauusd_h1_ensemble.onnx"
}
}
}
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
onnxruntime>=1.24.0,<1.26.0
scikit-learn>=1.5.0,<1.6.0
pandas>=2.2.0,<2.3.0
numpy>=1.26.0,<2.0.0
joblib>=1.4.0,<1.5.0
skl2onnx>=0.16.0,<0.17.0
protobuf>=3.20,<5.0
python-multipart==0.0.9
pydantic>=2.7.0,<2.8.0
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""
Start Mad Turtle inference server.
Run: python3 python/run_server.py
"""
import sys
sys.path.insert(0, str(__file__).rsplit("/", 2)[0])
import uvicorn
from inference_server.server import app
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""
Train XAUUSD H1 ensemble ONNX model (demo with synthetic data if no real feed).
Run: python3 python/ml_pipeline/train_onnx.py
"""
import sys
sys.path.insert(0, str(__file__).rsplit("/", 2)[0])
from ml_pipeline.train_onnx import main
if __name__ == "__main__":
main()