416 lines
15 KiB
Python
416 lines
15 KiB
Python
|
|
#!/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()
|