Initial commit: Mad Turtle v2.0 ML EA for XAUUSD H1 with Python inference server and MQL5 EA
This commit is contained in:
Binary file not shown.
@@ -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")
|
||||
Reference in New Issue
Block a user