357 lines
14 KiB
Python
357 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GENESIS — Mt5Bridge Unified Adapter
|
|
|
|
Provides a bridge() function with the SAME call interface as the old API2TRADE version,
|
|
but internally routes all calls to the Mt5Bridge REST API.
|
|
|
|
Usage:
|
|
from core.mt5_bridge import bridge, get_bars, pip_size
|
|
|
|
acc = bridge("/balance")
|
|
pos = bridge("/positions")
|
|
q = bridge("/quote?symbol=EURUSD")
|
|
ord = bridge("/market", "POST", {"symbol":"EURUSD","type":"Buy","volume":0.1,
|
|
"stop_loss":1.08,"take_profit":1.09,"comment":"TEST"})
|
|
bridge("/close", "POST", {"ticket": 12345})
|
|
bridge("/modify", "POST", {"ticket": 12345, "stop_loss": 1.07})
|
|
|
|
Environment variables:
|
|
MT5_BRIDGE_URL — Base URL (default: http://61.164.252.86:13485)
|
|
MT5_BRIDGE_KEY — API Key for X-API-Key header
|
|
MT5_SYMBOL_MAP — JSON string mapping xx-suffix symbols to broker symbols
|
|
e.g. '{"EURUSDxx":"EURUSD","XAUUSDxx":"XAUUSDc"}'
|
|
|
|
Mt5Bridge API docs: see Mt5Bridge使用指南.md
|
|
"""
|
|
import os, json, logging, math
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
BRIDGE_URL = os.getenv("MT5_BRIDGE_URL", "http://61.164.252.86:13485")
|
|
BRIDGE_KEY = os.getenv("MT5_BRIDGE_KEY", "")
|
|
|
|
_SYMBOL_MAP_RAW = os.getenv("MT5_SYMBOL_MAP", "")
|
|
if _SYMBOL_MAP_RAW:
|
|
try:
|
|
SYMBOL_MAP = json.loads(_SYMBOL_MAP_RAW)
|
|
except json.JSONDecodeError:
|
|
SYMBOL_MAP = {}
|
|
else:
|
|
SYMBOL_MAP = {}
|
|
|
|
_HEADERS = {"X-API-Key": BRIDGE_KEY, "Content-Type": "application/json"}
|
|
|
|
|
|
def resolve_symbol(sym: str) -> str:
|
|
if sym in SYMBOL_MAP:
|
|
resolved = SYMBOL_MAP[sym]
|
|
if resolved != sym:
|
|
log.debug(f"resolve_symbol: {sym} → {resolved} (MAP)")
|
|
return resolved
|
|
if sym.endswith("xx"):
|
|
base = sym[:-2]
|
|
if base in SYMBOL_MAP:
|
|
resolved = SYMBOL_MAP[base]
|
|
log.debug(f"resolve_symbol: {sym} → {resolved} (MAP via base)")
|
|
return resolved
|
|
log.debug(f"resolve_symbol: {sym} → {base} (strip xx)")
|
|
return base
|
|
return sym
|
|
|
|
|
|
def _api_get(path: str, params=None) -> dict:
|
|
try:
|
|
r = requests.get(f"{BRIDGE_URL}{path}", params=params,
|
|
headers=_HEADERS, timeout=15)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
log.error(f"Mt5Bridge GET {path}: {e}")
|
|
return {}
|
|
|
|
|
|
def _api_post(path: str, data: dict) -> dict:
|
|
try:
|
|
r = requests.post(f"{BRIDGE_URL}{path}", json=data,
|
|
headers=_HEADERS, timeout=15)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
log.error(f"Mt5Bridge POST {path}: {e}")
|
|
return {}
|
|
|
|
|
|
def bridge(path, method="GET", data=None) -> dict:
|
|
"""
|
|
Unified bridge interface — same signature as the old API2TRADE version.
|
|
|
|
Supported paths:
|
|
/balance → GET /account
|
|
/positions → GET /positions
|
|
/history → GET /history/deals (today)
|
|
/quote?symbol=X → GET /symbols/{sym}/tick
|
|
/market (POST) → POST /order/send
|
|
/close (POST) → POST /position/close
|
|
/modify (POST) → POST /position/modify
|
|
/symbols/{sym} → GET /symbols/{sym}
|
|
/rates?symbol=X&tf=M5&count=100 → GET /rates/from-pos
|
|
"""
|
|
# ── Quote ──────────────────────────────────────────────────────────────
|
|
if path.startswith("/quote"):
|
|
sym = path.split("symbol=")[-1] if "symbol=" in path else ""
|
|
if not sym and data:
|
|
sym = data.get("symbol", "")
|
|
sym = resolve_symbol(sym)
|
|
raw = _api_get(f"/symbols/{sym}/tick")
|
|
items = raw.get("data", [])
|
|
if items:
|
|
t = items[0]
|
|
return {
|
|
"bid": float(t.get("bid", 0)),
|
|
"ask": float(t.get("ask", 0)),
|
|
"symbol": sym,
|
|
}
|
|
return {"bid": 0, "ask": 0, "symbol": sym}
|
|
|
|
# ── Balance / Account ──────────────────────────────────────────────────
|
|
if path == "/balance":
|
|
raw = _api_get("/account")
|
|
items = raw.get("data", [])
|
|
if not items:
|
|
raw = _api_get("/account")
|
|
items = raw.get("data", [])
|
|
if items:
|
|
a = items[0]
|
|
return {
|
|
"balance": float(a.get("balance", 0)),
|
|
"equity": float(a.get("equity", 0)),
|
|
"margin": float(a.get("margin", 0)),
|
|
"profit": float(a.get("profit", 0)),
|
|
"margin_free": float(a.get("margin_free", 0)),
|
|
"margin_level": float(a.get("margin_level", 0)),
|
|
"leverage": int(a.get("leverage", 0)),
|
|
"currency": a.get("currency", "USD"),
|
|
}
|
|
return {"balance": 0, "equity": 0, "margin": 0, "profit": 0}
|
|
|
|
# ── Positions ──────────────────────────────────────────────────────────
|
|
if path == "/positions":
|
|
sym_filter = None
|
|
if data and data.get("symbol"):
|
|
sym_filter = resolve_symbol(data["symbol"])
|
|
raw = _api_get("/positions", params={"symbol": sym_filter} if sym_filter else None)
|
|
items = raw.get("data", [])
|
|
return [{
|
|
"ticket": p.get("ticket", 0),
|
|
"symbol": p.get("symbol", ""),
|
|
"orderType": "BUY" if p.get("type", 0) == 0 else "SELL",
|
|
"type": p.get("type", 0),
|
|
"lots": float(p.get("volume", 0)),
|
|
"volume": float(p.get("volume", 0)),
|
|
"openPrice": float(p.get("price_open", 0)),
|
|
"price_open": float(p.get("price_open", 0)),
|
|
"price_current": float(p.get("price_current", 0)),
|
|
"sl": float(p.get("sl", 0)),
|
|
"tp": float(p.get("tp", 0)),
|
|
"profit": float(p.get("profit", 0)),
|
|
"swap": float(p.get("swap", 0)),
|
|
"comment": p.get("comment", ""),
|
|
"magic": p.get("magic", 0),
|
|
} for p in items]
|
|
|
|
# ── History ────────────────────────────────────────────────────────────
|
|
if path == "/history":
|
|
now = datetime.now(timezone.utc)
|
|
date_from = now.strftime("%Y-%m-%d")
|
|
date_to = (now + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
raw = _api_get("/history/deals", params={
|
|
"date_from": date_from,
|
|
"date_to": date_to,
|
|
})
|
|
items = raw.get("data", [])
|
|
return [{
|
|
"ticket": d.get("ticket", 0),
|
|
"symbol": d.get("symbol", ""),
|
|
"type": d.get("type", 0),
|
|
"entry": d.get("entry", 0),
|
|
"volume": float(d.get("volume", 0)),
|
|
"price": float(d.get("price", 0)),
|
|
"profit": float(d.get("profit", 0)),
|
|
"commission": float(d.get("commission", 0)),
|
|
"swap": float(d.get("swap", 0)),
|
|
"comment": d.get("comment", ""),
|
|
"magic": d.get("magic", 0),
|
|
"time": d.get("time", ""),
|
|
} for d in items]
|
|
|
|
# ── Place order ────────────────────────────────────────────────────────
|
|
if path == "/market" and data:
|
|
sym = resolve_symbol(data.get("symbol", ""))
|
|
direction = data.get("type", "Buy")
|
|
order_type = 0 if direction.lower() in ("buy", "long") else 1
|
|
tick_data = _api_get(f"/symbols/{sym}/tick")
|
|
tick_items = tick_data.get("data", [])
|
|
price = 0
|
|
if tick_items:
|
|
price = float(tick_items[0].get("ask" if order_type == 0 else "bid", 0))
|
|
request_obj = {
|
|
"action": 1,
|
|
"symbol": sym,
|
|
"volume": float(data.get("volume", 0.01)),
|
|
"order_type": order_type,
|
|
"price": price,
|
|
"sl": 0,
|
|
"tp": 0,
|
|
"magic": int(data.get("magic", 88001)),
|
|
"comment": data.get("comment", "GENESIS"),
|
|
"deviation": 10,
|
|
"type_filling": 0,
|
|
}
|
|
sl_val = data.get("stop_loss")
|
|
tp_val = data.get("take_profit")
|
|
if sl_val is not None and float(sl_val) != 0:
|
|
request_obj["sl"] = float(sl_val)
|
|
if tp_val is not None and float(tp_val) != 0:
|
|
request_obj["tp"] = float(tp_val)
|
|
payload = {"request": request_obj}
|
|
raw = _api_post("/order/send", payload)
|
|
resp_data = raw.get("data", raw)
|
|
ticket = resp_data.get("order") or resp_data.get("ticket")
|
|
retcode = resp_data.get("retcode", 0)
|
|
if retcode == 10009 and ticket:
|
|
return {"ticket": ticket}
|
|
return {"ticket": ticket, "retcode": retcode,
|
|
"comment": resp_data.get("comment", "")}
|
|
|
|
# ── Close position ─────────────────────────────────────────────────────
|
|
if path == "/close" and data:
|
|
ticket = data.get("ticket")
|
|
payload = {"ticket": int(ticket)}
|
|
if data.get("volume"):
|
|
payload["volume"] = float(data["volume"])
|
|
raw = _api_post("/position/close", payload)
|
|
resp_data = raw.get("data", raw)
|
|
retcode = resp_data.get("retcode", 0)
|
|
if retcode == 10009:
|
|
return {"message": "ok"}
|
|
return {"retcode": retcode, "comment": resp_data.get("comment", "")}
|
|
|
|
# ── Modify position ────────────────────────────────────────────────────
|
|
if path == "/modify" and data:
|
|
ticket = data.get("ticket")
|
|
payload = {"ticket": int(ticket)}
|
|
if data.get("stop_loss") is not None:
|
|
payload["sl"] = float(data["stop_loss"])
|
|
if data.get("take_profit") is not None:
|
|
payload["tp"] = float(data["take_profit"])
|
|
raw = _api_post("/position/modify", payload)
|
|
resp_data = raw.get("data", raw)
|
|
retcode = resp_data.get("retcode", 0)
|
|
if retcode == 10009:
|
|
return {"ok": True}
|
|
return {"retcode": retcode, "comment": resp_data.get("comment", "")}
|
|
|
|
# ── Symbol info ────────────────────────────────────────────────────────
|
|
if path.startswith("/symbols/"):
|
|
sym = path.split("/symbols/")[-1].split("?")[0]
|
|
sym = resolve_symbol(sym)
|
|
raw = _api_get(f"/symbols/{sym}")
|
|
items = raw.get("data", [])
|
|
return items[0] if items else {}
|
|
|
|
# ── K-line rates ───────────────────────────────────────────────────────
|
|
if path == "/rates" and data:
|
|
sym = resolve_symbol(data.get("symbol", ""))
|
|
tf = data.get("timeframe", "M5")
|
|
count = data.get("count", 100)
|
|
raw = _api_get("/rates/from-pos", params={
|
|
"symbol": sym,
|
|
"timeframe": f"TIMEFRAME_{tf}",
|
|
"start_pos": 0,
|
|
"count": count,
|
|
})
|
|
return raw.get("data", [])
|
|
|
|
# ── Health check ───────────────────────────────────────────────────────
|
|
if path == "/health":
|
|
return _api_get("/health")
|
|
|
|
# ── Fallback: pass through to bridge ───────────────────────────────────
|
|
if method == "POST" and data:
|
|
return _api_post(path, data)
|
|
return _api_get(path, params=data if isinstance(data, dict) else None)
|
|
|
|
|
|
def pip_size(symbol: str) -> float:
|
|
s = symbol.upper()
|
|
if "JPY" in s:
|
|
return 0.01
|
|
if "XAU" in s or "GOLD" in s:
|
|
return 0.1
|
|
return 0.0001
|
|
|
|
|
|
def get_bars(symbol: str, tf: str = "M5", count: int = 100) -> list:
|
|
"""
|
|
Fetch OHLCV bars from Mt5Bridge /rates/from-pos.
|
|
tf: M1, M5, M15, M30, H1, H4, D1
|
|
Returns list of dicts with: time, open, high, low, close, tick_volume
|
|
"""
|
|
sym = resolve_symbol(symbol)
|
|
raw = _api_get("/rates/from-pos", params={
|
|
"symbol": sym,
|
|
"timeframe": f"TIMEFRAME_{tf}",
|
|
"start_pos": 0,
|
|
"count": count,
|
|
})
|
|
items = raw.get("data", [])
|
|
if not items:
|
|
return []
|
|
import pandas as pd
|
|
df = pd.DataFrame(items)
|
|
if "time" in df.columns:
|
|
df["time"] = pd.to_datetime(df["time"])
|
|
df.columns = [c.lower().replace("tick_volume", "volume") for c in df.columns]
|
|
return df.to_dict("records")
|
|
|
|
|
|
def get_bars_by_date(symbol: str, tf: str = "H1",
|
|
date_from: str = "", date_to: str = "") -> list:
|
|
sym = resolve_symbol(symbol)
|
|
params = {
|
|
"symbol": sym,
|
|
"timeframe": f"TIMEFRAME_{tf}",
|
|
}
|
|
if date_from:
|
|
params["date_from"] = date_from
|
|
if date_to:
|
|
params["date_to"] = date_to
|
|
raw = _api_get("/rates/from-date", params=params)
|
|
items = raw.get("data", [])
|
|
if not items:
|
|
return []
|
|
import pandas as pd
|
|
df = pd.DataFrame(items)
|
|
if "time" in df.columns:
|
|
df["time"] = pd.to_datetime(df["time"])
|
|
df.columns = [c.lower().replace("tick_volume", "volume") for c in df.columns]
|
|
return df.to_dict("records")
|
|
|
|
|
|
def calc_lot(equity: float, sl_pips: float, symbol: str, risk_pct: float = 0.01) -> float:
|
|
pip_val_per_lot = 10.0
|
|
s = symbol.upper()
|
|
if "JPY" in s:
|
|
pip_val_per_lot = 9.0
|
|
if "GBP" in s:
|
|
pip_val_per_lot = 12.5
|
|
if "XAU" in s or "GOLD" in s:
|
|
pip_val_per_lot = 1.0
|
|
raw_lot = (equity * risk_pct) / (sl_pips * pip_val_per_lot) if sl_pips > 0 else 0.01
|
|
raw_lot = max(0.01, min(raw_lot, 5.0))
|
|
return round(round(raw_lot / 0.01) * 0.01, 2) |