#!/usr/bin/env python3 """ Mt5Bridge end-to-end test. Usage: python test_all.py # default remote, read-only + check python test_all.py --trades # enable trade test (open/close) python test_all.py --base http://host:p # custom base URL python test_all.py --symbol XAUUSD # custom symbol """ import argparse import json import sys import time from typing import Callable try: import requests except ImportError: print("missing requests: pip install requests") sys.exit(1) try: import websocket except ImportError: print("missing websocket-client: pip install websocket-client") sys.exit(1) # ─────────── Colors ─────────── C_G = "\033[92m" C_R = "\033[91m" C_Y = "\033[93m" C_B = "\033[96m" C_X = "\033[0m" C_D = "\033[2m" # ─────────── Stats ─────────── PASS = 0 FAIL = 0 ERRORS: list[str] = [] def case(name: str, fn: Callable[[], bool]) -> None: global PASS, FAIL sys.stdout.write(f"{C_B}- {name}{C_X} ") try: ok = fn() # lambda with walrus expressions returns tuple; take last element if isinstance(ok, tuple): ok = ok[-1] if ok: print(f"{C_G}PASS{C_X}") PASS += 1 else: print(f"{C_R}FAIL{C_X}") FAIL += 1 ERRORS.append(name) except AssertionError as e: print(f"{C_R}FAIL{C_X} {C_D}{e}{C_X}") FAIL += 1 ERRORS.append(f"{name}: {e}") except Exception as e: msg = f"{type(e).__name__}: {e}" print(f"{C_R}ERROR{C_X} {C_D}{msg}{C_X}") FAIL += 1 ERRORS.append(f"{name}: {msg}") # ─────────── HTTP client ─────────── class Bridge: def __init__(self, base: str, key: str, timeout: float = 15.0): self.base = base.rstrip("/") self.key = key self.timeout = timeout self.s = requests.Session() self.s.headers.update({"X-API-Key": key}) def get(self, path: str, params: dict | None = None) -> dict: r = self.s.get(f"{self.base}{path}", params=params, timeout=self.timeout) r.raise_for_status() return r.json() def post(self, path: str, body: dict) -> dict: r = self.s.post(f"{self.base}{path}", json=body, timeout=self.timeout) r.raise_for_status() return r.json() def delete(self, path: str) -> dict: r = self.s.delete(f"{self.base}{path}", timeout=self.timeout) r.raise_for_status() return r.json() def expect_status(self, method: str, path: str, expected: int, **kw) -> int: r = self.s.request(method, f"{self.base}{path}", timeout=self.timeout, **kw) assert r.status_code == expected, f"HTTP {r.status_code} != {expected}: {r.text[:200]}" return r.status_code # ─────────── SSE ─────────── def sse_first(base: str, key: str, symbol: str, timeout_sec: float) -> tuple[bool, str]: """Open SSE, wait for first data: line or timeout.""" import urllib.request url = f"{base}/stream/ticks-sse/{symbol}?key={key}" req = urllib.request.Request(url, headers={"X-API-Key": key, "Accept": "text/event-stream"}) with urllib.request.urlopen(req, timeout=timeout_sec + 5) as resp: deadline = time.time() + timeout_sec while time.time() < deadline: line = resp.readline() if not line: break line = line.decode("utf-8", "ignore").rstrip("\r\n") if line.startswith("data: "): return True, line[6:80] return False, "" # ─────────── WebSocket ─────────── def ws_first(base: str, key: str, symbol: str, timeout_sec: float) -> tuple[bool, str]: ws_url = "ws" + base[4:] if base.startswith("http") else base ws_url = f"{ws_url}/stream/ticks/{symbol}?key={key}" deadline = time.time() + timeout_sec ws = websocket.create_connection(ws_url, timeout=timeout_sec + 5, header=[f"X-API-Key: {key}"]) try: ws.settimeout(1.0) last_type = None while time.time() < deadline: try: opcode, data = ws.recv_data() except (websocket.WebSocketTimeoutException, TimeoutError): continue if opcode in (websocket.ABNF.OPCODE_TEXT,): text = data.decode("utf-8", "ignore") if '"type":"tick"' in text: return True, text[:80] last_type = "hb" elif opcode == websocket.ABNF.OPCODE_CLOSE: break # fall back: got heartbeat means transport works, but MT5 may need time return last_type == "hb", f"heartbeat (no tick yet)" finally: try: ws.close() except Exception: pass return False, "" # ─────────── Warm-up ─────────── def warmup(b: Bridge, symbol: str, timeout_sec: float = 15.0) -> None: try: ok, detail = ws_first(b.base, b.key, symbol, timeout_sec) if "tick" in detail: print(f"{C_D} [WS warm-up ok, tick received]{C_X}") elif "heartbeat" in detail: print(f"{C_D} [WS warm-up: got heartbeat, waiting for MT5 to initialize quote]{C_X}") time.sleep(3) # give MT5 time to populate symbol data else: print(f"{C_D} [WS warm-up failed (no data)]{C_X}") except Exception as e: print(f"{C_D} [WS warm-up exception: {e}]{C_X}") # ════════════════════════════════════════════════════════════════════ def run(args) -> int: b = Bridge(args.base, args.key) sym = args.symbol print() print(f"{C_Y}=== Mt5Bridge E2E test ==={C_X}") print(f"Base : {args.base}") print(f"Symbol : {sym}") print(f"Trades : {'enabled (will place real orders)' if args.trades else 'disabled (--trades to enable)'}") print() # ── system / account ── case("1. GET /health", lambda: ( r := b.get("/health"), r["status"] == "healthy" and r["mt5_connected"] is True )) case("2. GET /account", lambda: ( r := b.get("/account"), a := r["data"][0], print(f" login={a['login']} server={a['server']} balance={a['balance']} currency={a['currency']}"), a["login"] > 0 and a["balance"] >= 0 )) # ── warm-up to add symbol to Market Watch ── print(f"{C_D}-- warm-up (WS) --{C_X}") warmup(b, sym, timeout_sec=6) # ── quote ── case("3. GET /symbols/" + sym, lambda: ( r := b.get(f"/symbols/{sym}"), s := r["data"][0], print(f" bid={s['bid']} ask={s['ask']} digits={s['digits']} point={s['point']}"), s["bid"] > 0 and s["digits"] > 0 )) case("4. GET /symbols/" + sym + "/tick", lambda: ( r := b.get(f"/symbols/{sym}/tick"), t := r["data"][0], print(f" bid={t['bid']} ask={t['ask']} time={t['time']}"), t["bid"] > 0 and t["ask"] >= t["bid"] )) # ── rates ── case("5. GET /rates/from-pos (H1, count=5)", lambda: ( r := b.get("/rates/from-pos", {"symbol": sym, "timeframe": "TIMEFRAME_H1", "start_pos": 0, "count": 5}), print(f" count={r['count']} first={r['data'][0]['time']} last={r['data'][-1]['time']}"), r["count"] == 5 )) case("6. GET /rates/from-date (cross-day 7-7~7-8, H1)", lambda: ( r := b.get("/rates/from-date", {"symbol": sym, "timeframe": "TIMEFRAME_H1", "date_from": "2026-07-07", "date_to": "2026-07-08"}), print(f" count={r['count']} first={r['data'][0]['time']}"), r["count"] > 0 )) case("6b. GET /rates/from-date (same day 7-7~7-7, M1)", lambda: ( r := b.get("/rates/from-date", {"symbol": sym, "timeframe": "TIMEFRAME_M1", "date_from": "2026-07-07", "date_to": "2026-07-07"}), print(f" count={r['count']} (bug fix: should be > 0)"), r["count"] > 0 )) case("6c. GET /rates/from-date (date_from > date_to, expect 400)", lambda: ( b.expect_status("GET", "/rates/from-date", 400, params={"symbol": sym, "timeframe": "TIMEFRAME_H1", "date_from": "2026-07-08", "date_to": "2026-07-01"}), print(" status=400 ok"), True )) # ── positions / orders / history ── case("7. GET /positions", lambda: ( r := b.get("/positions"), print(f" count={r['count']}"), True )) case("7b. GET /positions?symbol=__NOPE__", lambda: ( r := b.get("/positions", {"symbol": "__NOPE__"}), print(f" count={r['count']}"), r["count"] == 0 )) case("8. GET /orders", lambda: ( r := b.get("/orders"), print(f" count={r['count']}"), True )) case("9. GET /history/deals (7-1~7-8)", lambda: ( r := b.get("/history/deals", {"date_from": "2026-07-01", "date_to": "2026-07-08", "symbol": sym}), print(f" count={r['count']}"), True )) # ── gvar ── import uuid gvar = f"TEST_{uuid.uuid4().hex[:8]}" case(f"10a. POST /gvar/{gvar}", lambda: ( r := b.post(f"/gvar/{gvar}", {"value": 42.5}), print(f" value={r['value']}"), r["value"] == 42.5 )) case(f"10b. GET /gvar/{gvar}", lambda: ( r := b.get(f"/gvar/{gvar}"), print(f" value={r['value']}"), r["value"] == 42.5 )) case("10c. GET /gvar (expect at least 1 TEST_*)", lambda: ( r := b.get("/gvar"), found := sum(1 for g in r["data"] if g["name"].startswith("TEST_")), print(f" total={r['count']} TEST_*={found}"), found >= 1 )) case(f"10d. DELETE /gvar/{gvar}", lambda: ( r := b.delete(f"/gvar/{gvar}"), print(f" deleted={r['deleted']}"), r["deleted"] == gvar )) # ── order check ── case("11. POST /order/check (buy 0.01 " + sym + ")", lambda: ( bid := b.get(f"/symbols/{sym}/tick")["data"][0]["bid"], r := b.post("/order/check", { "action": 1, "symbol": sym, "volume": 0.01, "order_type": 0, "price": bid, "sl": 0, "tp": 0, "magic": 0, "comment": "test", "deviation": 10, "type_filling": 0, # ORDER_FILLING_FOK - required by ICMarkets }), rc := r["data"]["retcode"], print(f" retcode={rc} comment={r['data']['comment']}"), rc == 0 # with type_filling set, should be 0 )) # ── SSE ── case(f"12. SSE /stream/ticks-sse/{sym} (<=35s for tick or heartbeat)", lambda: ( result := sse_first(b.base, b.key, sym, timeout_sec=35), ok := result[0], snippet := result[1], print(f" {snippet}"), ok )) # ── WebSocket ── case(f"13. WS /stream/ticks/{sym} (<=35s for tick or heartbeat)", lambda: ( result := ws_first(b.base, b.key, sym, timeout_sec=35), ok := result[0], snippet := result[1], print(f" {snippet}"), ok )) # ── trades (opt-in) ── if args.trades: print() print(f"{C_Y}[!] TRADES enabled - will place real orders{C_X}") print() case("14. POST /order/send -> /position/close (open 0.01 then close)", lambda: ( bid := b.get(f"/symbols/{sym}/tick")["data"][0]["bid"], r := b.post("/order/send", {"request": { "action": 1, "symbol": sym, "volume": 0.01, "order_type": 0, "price": bid, "sl": 0, "tp": 0, "magic": 99001, "comment": "selftest", "deviation": 10, "type_filling": 0, }}), print(f" send retcode={r['data']['retcode']} order={r['data']['order']}"), r["data"]["retcode"] == 10009, time.sleep(0.5), r2 := b.post("/position/close", {"ticket": r["data"]["order"]}), print(f" close retcode={r2['data']['retcode']}"), r2["data"]["retcode"] == 10009 )) case("15. POST /position/modify (change SL/TP, then restore)", lambda: ( r := b.get("/positions"), print(f" positions count={r['count']}"), (r["count"] == 0) and (print(" [no positions, skip]"), True)[-1] or ( p := r["data"][0], orig_sl := p["sl"], orig_tp := p["tp"], new_sl := round(p["price_current"] - 50, 2), new_tp := round(p["price_current"] + 50, 2), r2 := b.post("/position/modify", {"ticket": p["ticket"], "sl": new_sl, "tp": new_tp}), print(f" modify retcode={r2['data']['retcode']}"), r2["data"]["retcode"] == 10009, r3 := b.post("/position/modify", {"ticket": p["ticket"], "sl": orig_sl, "tp": orig_tp}), print(f" restore retcode={r3['data']['retcode']}"), r3["data"]["retcode"] == 10009, )[-1] )) case("16. POST /positions/close-batch (magic=99001)", lambda: ( r := b.post("/positions/close-batch", {"magic": 99001}), print(f" closed={r['closed']} failed={r['failed']}"), True )) case("17. POST /order/cancel (place pending then cancel)", lambda: ( bid := b.get(f"/symbols/{sym}/tick")["data"][0]["bid"], pend_price := int(bid * 0.9), r := b.post("/order/send", {"request": { "action": 5, "symbol": sym, "volume": 0.01, "order_type": 2, "price": pend_price, "sl": 0, "tp": 0, "magic": 99002, "comment": "selftest-pending", "deviation": 0, "type_filling": 0, }}), print(f" place retcode={r['data']['retcode']} order={r['data']['order']}"), r["data"]["retcode"] == 10009, time.sleep(0.5), r2 := b.post("/order/cancel", {"ticket": r["data"]["order"]}), print(f" cancel retcode={r2['data']['retcode']}"), r2["data"]["retcode"] == 10009 )) else: print() print(f"{C_D}[skip trades -- use --trades to enable]{C_X}") # ── summary ── print() print(f"{C_Y}=== Summary ==={C_X}") print(f"Pass: {C_G}{PASS}{C_X}") color = C_R if FAIL else C_G print(f"Fail: {color}{FAIL}{C_X}") if ERRORS: print() print(f"{C_R}Failed cases:{C_X}") for e in ERRORS: print(f" - {e}") print() return 0 if FAIL == 0 else 1 def main(): p = argparse.ArgumentParser(description="Mt5Bridge end-to-end test") p.add_argument("--base", default="http://61.164.252.86:13485", help="Bridge base URL") p.add_argument("--key", default="UiHMqtaYLZzwBdcuS4RFmEGhgDO8N2eI", help="API Key") p.add_argument("--symbol", default="XAUUSD", help="Test symbol") p.add_argument("--trades", action="store_true", help="Enable trade test (places real orders)") args = p.parse_args() sys.exit(run(args)) if __name__ == "__main__": main()