diff --git a/API使用指南.md b/API使用指南.md index 9de8e07..8f14bef 100644 --- a/API使用指南.md +++ b/API使用指南.md @@ -11,6 +11,10 @@ | 地址 | `http://61.164.252.86:13485` | | 认证 | `X-API-Key` Header 或 `?key=` URL 参数 | | 格式 | 所有返回均为 JSON | +| WebSocket | `ws://61.164.252.86:13485`,握手时用 Header `X-API-Key` | +| SSE | `http://.../stream/ticks-sse/{symbol}`,Header 或 `?key=` 均可 | + +> **浏览器注意**:原生 `WebSocket` 不支持自定义 Header,须用 `?key=` URL 参数;SSE 用 `new EventSource(url + '?key=...')` 同理。Node/Python/Java 客户端用 Header 更干净。 --- @@ -42,7 +46,34 @@ print(api("/health")) ## API 接口速查 -### 1. 健康检查 +| 分类 | 方法 | 端点 | 用途 | +|------|------|------|------| +| **系统** | `GET` | `/health` | 健康检查 + MT5 连接状态 | +| **账户** | `GET` | `/account` | 余额/净值/保证金/杠杆 | +| **行情** | `GET` | `/symbols/{symbol}` | 品种信息(点值/手数/合约) | +| | `GET` | `/symbols/{symbol}/tick` | 拉取一次 tick | +| | `WS` | `/stream/ticks/{symbol}` | **实时 tick 推送**(推荐) | +| | `GET` | `/stream/ticks-sse/{symbol}` | SSE 推送(浏览器/内网代理友好) | +| **K 线** | `GET` | `/rates/from-pos` | K 线(按偏移量) | +| | `GET` | `/rates/from-date` | **K 线(按时间范围)⭐** | +| **持仓** | `GET` | `/positions[?symbol=]` | 当前持仓列表 | +| | `POST` | `/position/close` | 平仓(全/部分) | +| | `POST` | `/position/modify` | 改 SL/TP | +| | `POST` | `/position/close-by` | 对冲平仓(节省点差) | +| | `POST` | `/positions/close-batch` | 批量平仓(按 magic/symbol) | +| **挂单** | `GET` | `/orders[?symbol=]` | 挂单列表 | +| | `POST` | `/order/cancel` | 撤单 | +| | `POST` | `/order/modify` | 改挂单价/SL/TP | +| **下单** | `POST` | `/order/check` | 预检(不成交) | +| | `POST` | `/order/send` | 实际下单 | +| **历史** | `GET` | `/history/deals` | 历史成交 | +| **信号** | `GET/POST/DELETE` | `/gvar[/{name}]` | MQL5 全局变量(指标信号桥) | + +> 推送类端点(WS/SSE):每品种最多 50 个订阅者,超出返回 `429`;每 30 秒发一条心跳包用于穿透 NAT 保持连接。 + +--- + +## 1. 健康检查 ``` GET /health @@ -55,7 +86,7 @@ status = api("/health") --- -### 2. 账户信息 +## 2. 账户信息 ``` GET /account @@ -86,12 +117,16 @@ print(f"杠杆: 1:{acc['leverage']}, 币种: {acc['currency']}") --- -### 3. 实时行情 +## 3. 实时行情 + +#### 3-1. 主动拉取(一次性) ``` GET /symbols/{symbol}/tick ``` +自动将品种加入 MT5 Market Watch,并等待最多 3 秒获取真实 tick 数据(解决品种未订阅时返回空值的问题)。 + ```python def get_tick(symbol): data = api(f"/symbols/{symbol}/tick")["data"][0] @@ -111,14 +146,145 @@ print(f"XAUUSD Bid: {bid} Ask: {ask} Spread: {ask - bid}") | volume | 成交量 | | time | 时间 | +#### 3-2. 订阅推送(WebSocket 流式)⭐ 推荐 + +``` +WS /stream/ticks/{symbol} +``` + +MT5 每收到一个 tick 就立即推给所有订阅者,省去轮询。 + +- 走 `X-API-Key` 认证(Header `X-API-Key: your-key`,WebSocket 客户端在握手 Header 里带) +- 连上时自动把品种加入 MT5 Market Watch;最后一个订阅者断开时自动移除 +- 每个品种最多 50 个订阅者(含 WS + SSE 总数),超出返回 `429` +- 每 30 秒发一条心跳(无 tick 时也发),客户端可用于保活与断线检测 + +**推送格式(每条 tick 一帧 JSON 文本):** + +```json +{ + "type": "tick", + "symbol": "XAUUSDc", + "time": "2026-07-08T10:30:45", + "bid": 4180.0, + "ask": 4180.5, + "last": 4180.2, + "volume": 100, + "time_msc": "2026-07-08T10:30:45.123000", + "flags": 6 +} +``` + +**心跳包:** + +```json +{"type":"heartbeat","time":"2026-07-08T10:31:15"} +``` + +#### 3-3. SSE 推送(不能用 WebSocket 的环境) + +``` +GET /stream/ticks-sse/{symbol} → Content-Type: text/event-stream +``` + +面向无法建 WebSocket 的客户端(部分老浏览器、内网代理、curl 测试等)。语义同 3-2,每条 tick 一帧 SSE: + +``` +data: {"type":"tick","symbol":"XAUUSDc","bid":4180.0,...} + +data: {"type":"tick","symbol":"XAUUSDc","bid":4181.0,...} + +``` + +**curl 测试:** + +```bash +curl -N -H "X-API-Key: your-api-key" \ + http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc +``` + +**Python SSE 客户端(`sseclient-py`):** + +```python +from sseclient import SSEClient +import json + +messages = SSEClient("http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc", + headers={"X-API-Key": "your-api-key"}) +for msg in messages: + data = json.loads(msg.data) + if data.get("type") == "heartbeat": + continue + print(data["symbol"], data["bid"], data["ask"]) +``` + +**Python 示例(需安装 `websocket-client`):** + +```python +import websocket +import threading + +def on_message(ws, msg): + tick = eval(msg) # 或 json.loads(msg) + print(f"{tick['symbol']} Bid:{tick['bid']} Ask:{tick['ask']}") + +def on_open(ws): + print("connected") + +def on_close(ws, code, reason): + print(f"disconnected: {code} {reason}") + +ws = websocket.WebSocketApp( + f"ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + header=[f"X-API-Key: your-api-key"], + on_message=on_message, + on_open=on_open, + on_close=on_close, +) +ws.run_forever() +``` + +**`websockets` 库(asyncio 版):** + +```python +import asyncio +import websockets +import json + +async def watch_ticks(): + headers = {"X-API-Key": "your-api-key"} + async with websockets.connect( + "ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + additional_headers=headers, + ) as ws: + async for raw in ws: + tick = json.loads(raw) + print(tick["symbol"], tick["bid"], tick["ask"]) + +asyncio.run(watch_ticks()) +``` + +**浏览器控制台测试:** + +```js +const ws = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc", { + headers: { "X-API-Key": "your-api-key" } // 浏览器原生 WS 不支持自定义 Header,需走 ?key= 参数,见下 +}); +// 浏览器场景:用 query 参数传 key +const ws2 = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc?key=your-api-key"); +ws2.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + --- -### 4. 品种信息 +## 4. 品种信息 ``` GET /symbols/{symbol} ``` +bid/ask 从实时 tick 数据获取(自动等待最多 3 秒),避免品种刚加入 Market Watch 时返回 0 的问题。 + ```python def get_symbol_info(symbol): info = api(f"/symbols/{symbol}")["data"][0] @@ -131,7 +297,7 @@ def get_symbol_info(symbol): --- -### 5. 历史 K 线(按偏移量) +## 5. 历史 K 线(按偏移量) ``` GET /rates/from-pos?symbol={symbol}&timeframe={timeframe}&start_pos={start}&count={count} @@ -203,15 +369,17 @@ print(df.head()) --- -### 6. 当前持仓 +## 6. 当前持仓 ``` -GET /positions +GET /positions[?symbol={symbol}] ``` +symbol 可选过滤。 + ```python -def get_positions(): - return api("/positions")["data"] +def get_positions(symbol=None): + return api("/positions", params={"symbol": symbol} if symbol else None)["data"] positions = get_positions() for pos in positions: @@ -222,9 +390,132 @@ for pos in positions: **返回字段:** `ticket`, `symbol`, `type`(0=买,1=卖), `volume`, `price_open`, `sl`, `tp`, `price_current`, `swap`, `profit`, `comment`, `magic` +### 6-1. 平仓 + +``` +POST /position/close +``` + +```json +// 全平 +{ "ticket": 12345678 } +// 部分平仓 +{ "ticket": 12345678, "volume": 0.05 } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| ticket | ✅ | 持仓编号 | +| volume | ❌ | 平仓手数;不传/0 = 全平;>0 = 部分平仓 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_position(ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return api_post("/position/close", body)["data"] + +close_position(12345678) # 全平 +close_position(12345678, volume=0.05) # 部分平 +``` + +### 6-2. 改持仓 SL/TP + +``` +POST /position/modify +``` + +```json +{ "ticket": 12345678, "sl": 4170.0, "tp": 4190.0 } +``` + +SL/TP 设为 0 表示清除对应止损/止盈。 + +```python +def modify_position(ticket, sl=0, tp=0): + return api_post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] +``` + +### 6-3. 对冲平仓(节省点差) + +``` +POST /position/close-by +``` + +用一张反向持仓对冲平仓,只收一次点差(MT5 净额结算),适合双向网格 / 锁仓策略快速离场。 + +```json +{ "position": 111, "position_by": 222 } +``` + +要求:两张持仓 **同品种 + 反向**。 + +```python +def close_by(ticket_a, ticket_b): + return api_post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] +``` + +### 6-4. 移动止损(客户端轮询模式) + +Bridge 不在服务端跑轮询,暴露 `/position/modify` 由客户端自己做: + +```python +def trail_stop(ticket, distance, step): + """distance: 跟踪距离(如 50 点);step: 最小推进步长(如 10 点)""" + pos = next((p for p in bridge.positions() if p["ticket"] == ticket), None) + if not pos: + return + current = pos["price_current"] + if pos["type"] == 0: # 多单 + new_sl = current - distance + if new_sl - pos["sl"] >= step: + bridge.modify_position(ticket, sl=new_sl) + else: # 空单 + new_sl = current + distance + if pos["sl"] - new_sl >= step: + bridge.modify_position(ticket, sl=new_sl) + +# 每秒跑一次 +while True: + for p in bridge.positions(): + trail_stop(p["ticket"], distance=50, step=10) + time.sleep(1) +``` + +也可以用 WebSocket tick 流推送驱动,把 `time.sleep(1)` 换成 tick 回调,反应更快。 + +### 6-5. 批量平仓 + +``` +POST /positions/close-batch +``` + +按 `symbol` 和/或 `magic` 批量平仓。**至少传一个**过滤条件,避免误清整个账户。 + +```json +{ "magic": 123456 } +{ "symbol": "XAUUSDc", "magic": 123456 } +{ "symbol": "XAUUSDc" } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| symbol | 一 | 品种过滤 | +| magic | 一 | Magic Number 过滤 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_by_magic(magic): + return api_post("/positions/close-batch", {"magic": magic})["data"] + +result = close_by_magic(123456) +print(f"已平 {result['closed']} 单,失败 {result['failed']} 单") +# data 数组里有每张单的 ticket / symbol / retcode / comment +``` + --- -### 7. 挂单 +## 7. 挂单 ``` GET /orders?symbol={symbol} @@ -238,9 +529,43 @@ for o in orders: print(f"{o['ticket']} {o['symbol']} 类型:{o['type']} 手数:{o['volume_initial']}") ``` +### 7-1. 撤单 + +``` +POST /order/cancel +``` + +```json +{ "ticket": 87654321 } +``` + +```python +def cancel_order(ticket): + return api_post("/order/cancel", {"ticket": ticket})["data"] + +cancel_order(87654321) +``` + +### 7-2. 改挂单 + +``` +POST /order/modify +``` + +```json +{ "ticket": 87654321, "price": 4180.0, "sl": 4170.0, "tp": 4190.0 } +``` + +sl/tp 设为 0 表示清除。 + +```python +def modify_order(ticket, price, sl=0, tp=0): + return api_post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] +``` + --- -### 8. 订单预检 +## 8. 订单预检 ``` POST /order/check @@ -248,6 +573,8 @@ POST /order/check 下单前验证,不会真正执行。检查保证金是否足够、价格是否有效等。 +**填充模式自动适配**:服务端会根据品种的 `SYMBOL_FILLING_MODE` 自动选择经纪商支持的填充模式。如果请求的 `type_filling` 不被支持,会按 IOC(1) → FOK(0) → RETURN(2) 顺序降级,无需客户端手动判断。 + ```python def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): """预检订单""" @@ -261,7 +588,8 @@ def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, co "tp": tp or 0, "magic": magic, "comment": comment, - "deviation": 10 + "deviation": 10, + "type_filling": 0 # 0=FOK, 1=IOC, 2=RETURN — 服务端自动适配,不传也行 } result = api_post("/order/check", data)["data"] print(f"预检结果: retcode={result['retcode']}, comment={result['comment']}") @@ -274,14 +602,26 @@ def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, co check_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) ``` +**type_filling 说明:** + +| 值 | 含义 | 说明 | +|------|------|------| +| 0 | FOK (Fill or Kill) | 必须全部成交,否则取消 | +| 1 | IOC (Immediate or Cancel) | 能成交多少成交多少 | +| 2 | RETURN | 剩余部分留在订单簿 | + +> 不同经纪商支持的填充模式不同(如 ICMarkets 只支持 IOC),服务端会自动降级,客户端无需关心。 + --- -### 9. 下单 +## 9. 下单 ``` POST /order/send ``` +与 `/order/check` 相同,`type_filling` 会自动适配经纪商支持的填充模式。 + ```python def send_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): """下单""" @@ -296,7 +636,8 @@ def send_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, com "tp": tp or 0, "magic": magic, "comment": comment, - "deviation": 10 + "deviation": 10, + "type_filling": 0 # 自动适配,不传也行 } } result = api_post("/order/send", data)["data"] @@ -320,7 +661,7 @@ result = send_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) --- -### 10. 历史成交 +## 10. 历史成交 ``` GET /history/deals?date_from={from}&date_to={to}&symbol={symbol} @@ -340,7 +681,7 @@ for d in deals: --- -### 11. 全局变量(MQL5 信号桥) +## 11. 全局变量(MQL5 信号桥) > 让 MQL5 指标/EA 把信号写出来,Python 通过 Bridge 读取。不用翻译 MQL5 代码。 @@ -468,6 +809,25 @@ class Mt5Bridge: r.raise_for_status() return r.json() + def stream_ticks(self, symbol, on_tick): + """订阅 tick 推送,on_tick 回调收到 dict,阻塞运行。需 pip install websocket-client""" + import websocket + url = f"{BRIDGE.replace('http', 'ws', 1)}/stream/ticks/{symbol}" + ws = websocket.WebSocketApp( + url, + header=[f"X-API-Key: {KEY}"], + on_message=lambda ws, msg: on_tick(eval(msg)), + on_error=lambda ws, err: print(f"ws error: {err}"), + ) + ws.run_forever() + + def stream_ticks_async(self, symbol, on_tick): + """后台线程订阅 tick,不阻塞主线程""" + import threading + t = threading.Thread(target=self.stream_ticks, args=(symbol, on_tick), daemon=True) + t.start() + return t + # ── 行情 ── def tick(self, symbol): d = self._get(f"/symbols/{symbol}/tick")["data"][0] @@ -490,12 +850,37 @@ class Mt5Bridge: return self._get("/account")["data"][0] # ── 持仓 ── - def positions(self): - return self._get("/positions")["data"] + def positions(self, symbol=None): + return self._get("/positions", params={"symbol": symbol} if symbol else None)["data"] def has_position(self, symbol): return any(p["symbol"] == symbol for p in self.positions()) + def close(self, ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return self._post("/position/close", body)["data"] + + def modify_position(self, ticket, sl=0, tp=0): + return self._post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] + + def close_by(self, ticket_a, ticket_b): + return self._post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] + + def close_batch(self, magic=None, symbol=None, deviation=None): + body = {k: v for k, v in {"magic": magic, "symbol": symbol, "deviation": deviation}.items() if v is not None} + return self._post("/positions/close-batch", body) + + # ── 挂单 ── + def orders(self, symbol=None): + return self._get("/orders", params={"symbol": symbol} if symbol else None)["data"] + + def cancel_order(self, ticket): + return self._post("/order/cancel", {"ticket": ticket})["data"] + + def modify_order(self, ticket, price, sl=0, tp=0): + return self._post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] + # ── 下单 ── def buy(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): return self._send(symbol, volume, 0, price, sl, tp, magic, comment) diff --git a/Program.cs b/Program.cs index 36e6db9..5ad31c4 100644 --- a/Program.cs +++ b/Program.cs @@ -2,6 +2,10 @@ using MtApi5; using Microsoft.AspNetCore.Mvc; using System.Reflection; using System.Globalization; +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls("http://0.0.0.0:8080"); @@ -42,6 +46,7 @@ builder.Services.AddCors(options => }); var app = builder.Build(); app.UseCors(); +app.UseWebSockets(); app.UseExceptionHandler(errorApp => { @@ -78,71 +83,59 @@ app.Use(async (context, next) => await next(); }); -app.MapGet("/health", async (MtApi5Client mt, SemaphoreSlim gate) => +app.MapGet("/health", (MtApi5Client mt) => { - await gate.WaitAsync(); - try - { - var connected = mt.ConnectionState == Mt5ConnectionState.Connected; - return connected - ? Results.Ok(new { status = "healthy", mt5_connected = true, mt5_version = "unknown", api_version = "1.0.0" }) - : Results.Json(new { status = "disconnected", mt5_connected = false, mt5_version = "unknown", api_version = "1.0.0" }, statusCode: StatusCodes.Status503ServiceUnavailable); - } - finally - { - gate.Release(); - } + var connected = mt.ConnectionState == Mt5ConnectionState.Connected; + return connected + ? Results.Ok(new { status = "healthy", mt5_connected = true, mt5_version = "unknown", api_version = "1.0.0" }) + : Results.Json(new { status = "disconnected", mt5_connected = false, mt5_version = "unknown", api_version = "1.0.0" }, statusCode: StatusCodes.Status503ServiceUnavailable); }); -app.MapGet("/account", async (MtApi5Client mt, SemaphoreSlim gate) => +app.MapGet("/account", (MtApi5Client mt) => { - await gate.WaitAsync(); - try - { - var login = (ulong)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LOGIN); - var leverage = (uint)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LEVERAGE); - var tradeAllowed = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_ALLOWED) != 0; - var tradeExpert = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_EXPERT) != 0; - var currency = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_CURRENCY) ?? "USD"; - var server = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_SERVER) ?? ""; - var name = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_NAME) ?? ""; - var company = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_COMPANY) ?? ""; - var balance = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_BALANCE); - var equity = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_EQUITY); - var profit = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_PROFIT); - var margin = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN); - var marginFree = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_FREE); - var marginLevel = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_LEVEL); + var login = (ulong)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LOGIN); + var leverage = (uint)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LEVERAGE); + var tradeAllowed = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_ALLOWED) != 0; + var tradeExpert = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_EXPERT) != 0; + var currency = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_CURRENCY) ?? "USD"; + var server = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_SERVER) ?? ""; + var name = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_NAME) ?? ""; + var company = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_COMPANY) ?? ""; + var balance = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_BALANCE); + var equity = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_EQUITY); + var profit = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_PROFIT); + var margin = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN); + var marginFree = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_FREE); + var marginLevel = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_LEVEL); - return Results.Ok(new + return Results.Ok(new + { + data = new[] { - data = new[] - { - new { login, leverage, trade_allowed = tradeAllowed, trade_expert = tradeExpert, currency, currency_digits = 2, server, name, company, balance, equity, profit, margin, margin_free = marginFree, margin_level = marginLevel } - }, - count = 1, - format = "json" - }); - } - finally - { - gate.Release(); - } + new { login, leverage, trade_allowed = tradeAllowed, trade_expert = tradeExpert, currency, currency_digits = 2, server, name, company, balance, equity, profit, margin, margin_free = marginFree, margin_level = marginLevel } + }, + count = 1, + format = "json" + }); }); -app.MapGet("/symbols/{symbol}", async (string symbol, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) => +app.MapGet("/symbols/{symbol}", (string symbol, MtApi5Client mt, ILoggerFactory loggerFactory) => { if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." }); - - await gate.WaitAsync(); try { + // ponytail: SymbolSelect(true) subscribes to MT5 quotes for this symbol — without it + // SymbolInfoDouble returns 0 for bid/ask until a tick arrives naturally. + mt.SymbolSelect(symbol, true); var digits = (int)mt.SymbolInfoInteger(symbol, ENUM_SYMBOL_INFO_INTEGER.SYMBOL_DIGITS); var spreadFloat = mt.SymbolInfoInteger(symbol, ENUM_SYMBOL_INFO_INTEGER.SYMBOL_SPREAD_FLOAT) != 0; var spread = (int)mt.SymbolInfoInteger(symbol, ENUM_SYMBOL_INFO_INTEGER.SYMBOL_SPREAD); var point = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_POINT); - var bid = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_BID); - var ask = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_ASK); + double bid = 0, ask = 0; + var tickInfo = WaitForTick(mt, symbol); + if (tickInfo != null) { bid = tickInfo.bid; ask = tickInfo.ask; } + if (bid == 0) bid = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_BID); + if (ask == 0) ask = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_ASK); var volumeMin = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_VOLUME_MIN); var volumeMax = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_VOLUME_MAX); var volumeStep = mt.SymbolInfoDouble(symbol, ENUM_SYMBOL_INFO_DOUBLE.SYMBOL_VOLUME_STEP); @@ -169,56 +162,218 @@ app.MapGet("/symbols/{symbol}", async (string symbol, MtApi5Client mt, Semaphore loggerFactory.CreateLogger("SymbolsEndpoint").LogError(ex, "Failed to read symbol info for {Symbol}", symbol); return Results.Problem("Failed to read symbol info.", statusCode: StatusCodes.Status502BadGateway); } - finally - { - gate.Release(); - } }); -app.MapGet("/symbols/{symbol}/tick", async (string symbol, MtApi5Client mt, SemaphoreSlim gate) => +app.MapGet("/symbols/{symbol}/tick", (string symbol, MtApi5Client mt) => { if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." }); - await gate.WaitAsync(); + mt.SymbolSelect(symbol, true); + var tick = WaitForTick(mt, symbol); + if (tick == null) return Results.NotFound(new { detail = $"Tick for {symbol} not found" }); + + return Results.Json(new + { + data = new[] + { + new + { + time = tick.time.ToString("yyyy-MM-ddTHH:mm:ss"), + bid = tick.bid, + ask = tick.ask, + last = tick.last, + volume = tick.volume, + time_msc = tick.time.ToString("yyyy-MM-ddTHH:mm:ss.fff000"), + flags = 6, + volume_real = tick.volume_real + } + }, + count = 1, + format = "json" + }); +}); + +// Tick stream via WebSocket. Connect: ws://host:8080/stream/ticks/{symbol} +// MT5 must be in Market Watch for the symbol; this endpoint auto-adds it on connect +// and removes it when the last subscriber disconnects. +app.Map("/stream/ticks/{symbol}", async (string symbol, HttpContext ctx, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) => +{ + var logger = loggerFactory.CreateLogger("TickStream"); + if (string.IsNullOrWhiteSpace(symbol)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { detail = "Symbol is required." }); + return; + } + if (!ctx.WebSockets.IsWebSocketRequest) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { detail = "WebSocket required. Use ws://.../stream/ticks/{symbol}." }); + return; + } + if (mt.ConnectionState != Mt5ConnectionState.Connected) + { + ctx.Response.StatusCode = 503; + await ctx.Response.WriteAsJsonAsync(new { detail = "MT5 is not connected." }); + return; + } + + await gate.WaitAsync(ctx.RequestAborted); try { - var tick = mt.SymbolInfoTick(symbol); - if (tick == null) return Results.NotFound(new { detail = $"Tick for {symbol} not found" }); - - return Results.Json(new + if (!mt.SymbolSelect(symbol, true)) { - data = new[] - { - new - { - time = tick.time.ToString("yyyy-MM-ddTHH:mm:ss"), - bid = tick.bid, - ask = tick.ask, - last = tick.last, - volume = tick.volume, - time_msc = tick.time.ToString("yyyy-MM-ddTHH:mm:ss.fff000"), - flags = 6, - volume_real = tick.volume_real - } - }, - count = 1, - format = "json" - }); + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { detail = $"Symbol '{symbol}' not found." }); + return; + } } finally { gate.Release(); } + + var subs = MtConnectionService.TickSubscribers.GetOrAdd(symbol, _ => new ConcurrentDictionary()); + if (subs.Count >= MtConnectionService.MaxSubscribersPerSymbol) + { + ctx.Response.StatusCode = 429; + await ctx.Response.WriteAsJsonAsync(new { detail = $"Too many subscribers for {symbol} (limit={MtConnectionService.MaxSubscribersPerSymbol})." }); + return; + } + + using var ws = await ctx.WebSockets.AcceptWebSocketAsync(); + var subscriber = new WsSubscriber(ws); + subs[subscriber] = 1; + logger.LogInformation("Tick stream subscribed for {Symbol} via WS (subscribers={Count})", symbol, subs.Count); + + var buf = new byte[1024]; + try + { + while (ws.State == WebSocketState.Open && !ctx.RequestAborted.IsCancellationRequested) + { + var result = await ws.ReceiveAsync(buf, ctx.RequestAborted); + if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + break; + } + } + } + catch (OperationCanceledException) { } + catch (WebSocketException) { } + finally + { + subs.TryRemove(subscriber, out _); + MtConnectionService.TickSubscribers.TryRemove(symbol, out _); + if (subs.IsEmpty) + { + try + { + if (mt.ConnectionState == Mt5ConnectionState.Connected) + { + await gate.WaitAsync(); + try { mt.SymbolSelect(symbol, false); } + finally { gate.Release(); } + } + } + catch (Exception ex) { logger.LogWarning(ex, "Failed to remove {Symbol} from Market Watch", symbol); } + } + logger.LogInformation("Tick stream disconnected for {Symbol} via WS (remaining={Count})", symbol, subs.Count); + } }); -app.MapGet("/rates/from-pos", async (string symbol, string timeframe, int start_pos, int count, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) => +// Tick stream via Server-Sent Events. Same semantics as /stream/ticks/{symbol}, +// one-way push over HTTP for clients that can't open a WebSocket. +// ponytail: SSE = no receive loop, just keep the response open and let the broadcast +// loop (OnQuoteUpdate + heartbeat timer) push data frames. +app.MapGet("/stream/ticks-sse/{symbol}", async (string symbol, HttpContext ctx, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) => +{ + var logger = loggerFactory.CreateLogger("TickStream"); + if (string.IsNullOrWhiteSpace(symbol)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { detail = "Symbol is required." }); + return; + } + if (mt.ConnectionState != Mt5ConnectionState.Connected) + { + ctx.Response.StatusCode = 503; + await ctx.Response.WriteAsJsonAsync(new { detail = "MT5 is not connected." }); + return; + } + + await gate.WaitAsync(ctx.RequestAborted); + try + { + if (!mt.SymbolSelect(symbol, true)) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { detail = $"Symbol '{symbol}' not found." }); + return; + } + } + finally + { + gate.Release(); + } + + var subs = MtConnectionService.TickSubscribers.GetOrAdd(symbol, _ => new ConcurrentDictionary()); + if (subs.Count >= MtConnectionService.MaxSubscribersPerSymbol) + { + ctx.Response.StatusCode = 429; + await ctx.Response.WriteAsJsonAsync(new { detail = $"Too many subscribers for {symbol} (limit={MtConnectionService.MaxSubscribersPerSymbol})." }); + return; + } + + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx buffering + + var subscriber = new SseSubscriber(ctx.Response.Body); + subs[subscriber] = 1; + logger.LogInformation("Tick stream subscribed for {Symbol} via SSE (subscribers={Count})", symbol, subs.Count); + + try + { + // Initial comment to flush headers and confirm connection + await ctx.Response.WriteAsync(": connected\n\n", ctx.RequestAborted); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); + + // Hold the request open until client disconnects; ticks/heartbeats arrive via OnQuoteUpdate + timer. + await Task.Delay(Timeout.Infinite, ctx.RequestAborted); + } + catch (OperationCanceledException) { } + catch (Exception ex) { logger.LogDebug(ex, "SSE stream ended for {Symbol}", symbol); } + finally + { + subs.TryRemove(subscriber, out _); + MtConnectionService.TickSubscribers.TryRemove(symbol, out _); + if (subs.IsEmpty) + { + try + { + if (mt.ConnectionState == Mt5ConnectionState.Connected) + { + await gate.WaitAsync(); + try { mt.SymbolSelect(symbol, false); } + finally { gate.Release(); } + } + } + catch (Exception ex) { logger.LogWarning(ex, "Failed to remove {Symbol} from Market Watch", symbol); } + } + logger.LogInformation("Tick stream disconnected for {Symbol} via SSE (remaining={Count})", symbol, subs.Count); + } +}); + +app.MapGet("/rates/from-pos", (string symbol, string timeframe, int start_pos, int count, MtApi5Client mt, ILoggerFactory loggerFactory) => { if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." }); if (!TryParseTimeframe(timeframe, out var tf)) return Results.BadRequest(new { detail = $"Unsupported timeframe '{timeframe}'." }); if (start_pos < 0) return Results.BadRequest(new { detail = "start_pos must be greater than or equal to 0." }); if (count <= 0 || count > 10000) return Results.BadRequest(new { detail = "count must be between 1 and 10000." }); - await gate.WaitAsync(); try { const int chunkSize = 1000; @@ -230,7 +385,6 @@ app.MapGet("/rates/from-pos", async (string symbol, string timeframe, int start_ { int chunk = Math.Min(remaining, chunkSize); var result = mt.CopyRates(symbol, tf, currentPos, chunk, out MqlRates[]? rates); - Console.WriteLine($"CopyRates pos={currentPos} chunk={chunk} result={result}, rates={(rates != null ? rates.Length.ToString() : "null")}"); if (rates != null && rates.Length > 0) { @@ -264,79 +418,102 @@ app.MapGet("/rates/from-pos", async (string symbol, string timeframe, int start_ loggerFactory.CreateLogger("RatesEndpoint").LogError(ex, "Failed to read rates for {Symbol} {Timeframe}", symbol, timeframe); return Results.Problem("Failed to read rates.", statusCode: StatusCodes.Status502BadGateway); } - finally +}); + +app.MapGet("/rates/from-date", (string symbol, string timeframe, string date_from, string date_to, MtApi5Client mt, ILoggerFactory loggerFactory) => +{ + if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." }); + if (!TryParseTimeframe(timeframe, out var tf)) return Results.BadRequest(new { detail = $"Unsupported timeframe '{timeframe}'." }); + if (!TryParseClientDate(date_from, out var fromDate)) return Results.BadRequest(new { detail = "date_from must be a valid ISO-8601 or yyyy-MM-dd value." }); + if (!TryParseClientDate(date_to, out var toDate)) return Results.BadRequest(new { detail = "date_to must be a valid ISO-8601 or yyyy-MM-dd value." }); + if (fromDate > toDate) return Results.BadRequest(new { detail = "date_from must be earlier than or equal to date_to." }); + + try { - gate.Release(); + // ponytail: Bars(from, to) is half-open [from, to). Same-day query would yield 0 + // without this nudge — bump toDate by one day to include the entire end day. + var effectiveTo = (toDate.Date == fromDate.Date) ? toDate.Date.AddDays(1) : toDate; + + var total = mt.Bars(symbol, tf, fromDate, effectiveTo); + if (total <= 0) return Results.Ok(new { data = Array.Empty(), count = 0, format = "json" }); + if (total > 10000) return Results.BadRequest(new { detail = $"Date range covers {total} bars (max 10000). Narrow the range." }); + + var result = mt.CopyRates(symbol, tf, total - 1, total, out MqlRates[]? rates); + if (rates == null || rates.Length == 0) return Results.Ok(new { data = Array.Empty(), count = 0, format = "json" }); + + var data = rates.Select(r => new + { + time = r.time.ToString("yyyy-MM-ddTHH:mm:ss"), + r.open, + r.high, + r.low, + r.close, + tick_volume = r.tick_volume, + r.spread, + real_volume = r.real_volume + }).ToArray(); + return Results.Ok(new { data, count = data.Length, format = "json" }); + } + catch (Exception ex) + { + loggerFactory.CreateLogger("RatesFromDateEndpoint").LogError(ex, "Failed to read rates for {Symbol} {Timeframe} {DateFrom}~{DateTo}", symbol, timeframe, date_from, date_to); + return Results.Problem("Failed to read rates.", statusCode: StatusCodes.Status502BadGateway); } }); -app.MapGet("/positions", async (MtApi5Client mt, SemaphoreSlim gate) => +app.MapGet("/positions", (string? symbol, MtApi5Client mt) => { - await gate.WaitAsync(); - try + var total = mt.PositionsTotal(); + var positions = new List(); + for (int i = 0; i < total; i++) { - var total = mt.PositionsTotal(); - var positions = new List(); - for (int i = 0; i < total; i++) + var ticket = mt.PositionGetTicket(i); + if (ticket == 0) continue; + var posSymbol = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_SYMBOL); + if (!string.IsNullOrWhiteSpace(symbol) && posSymbol != symbol) continue; + positions.Add(new { - var ticket = mt.PositionGetTicket(i); - if (ticket == 0) continue; - positions.Add(new - { - ticket, - symbol = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_SYMBOL), - type = (int)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_TYPE), - volume = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_VOLUME), - price_open = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_OPEN), - sl = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SL), - tp = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_TP), - price_current = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_CURRENT), - swap = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SWAP), - profit = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PROFIT), - comment = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_COMMENT), - magic = (ulong)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_MAGIC) - }); - } - return Results.Ok(new { data = positions, count = positions.Count, format = "json" }); - } - finally - { - gate.Release(); + ticket, + symbol = posSymbol, + type = (int)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_TYPE), + volume = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_VOLUME), + price_open = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_OPEN), + sl = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SL), + tp = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_TP), + price_current = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_CURRENT), + swap = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SWAP), + profit = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PROFIT), + comment = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_COMMENT), + magic = (ulong)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_MAGIC) + }); } + return Results.Ok(new { data = positions, count = positions.Count, format = "json" }); }); -app.MapGet("/orders", async (string? symbol, MtApi5Client mt, SemaphoreSlim gate) => +app.MapGet("/orders", (string? symbol, MtApi5Client mt) => { - await gate.WaitAsync(); - try + var total = mt.OrdersTotal(); + var orders = new List(); + for (int i = 0; i < total; i++) { - var total = mt.OrdersTotal(); - var orders = new List(); - for (int i = 0; i < total; i++) + var ticket = mt.OrderGetTicket(i); + if (ticket == 0) continue; + var orderSymbol = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_SYMBOL); + if (!string.IsNullOrWhiteSpace(symbol) && orderSymbol != symbol) continue; + orders.Add(new { - var ticket = mt.OrderGetTicket(i); - if (ticket == 0) continue; - var orderSymbol = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_SYMBOL); - if (!string.IsNullOrWhiteSpace(symbol) && orderSymbol != symbol) continue; - orders.Add(new - { - ticket, - symbol = orderSymbol, - type = (int)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_TYPE), - volume_initial = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_VOLUME_INITIAL), - price_open = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_PRICE_OPEN), - sl = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_SL), - tp = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_TP), - magic = (ulong)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_MAGIC), - comment = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_COMMENT) - }); - } - return Results.Ok(new { data = orders, count = orders.Count, format = "json" }); - } - finally - { - gate.Release(); + ticket, + symbol = orderSymbol, + type = (int)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_TYPE), + volume_initial = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_VOLUME_INITIAL), + price_open = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_PRICE_OPEN), + sl = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_SL), + tp = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_TP), + magic = (ulong)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_MAGIC), + comment = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_COMMENT) + }); } + return Results.Ok(new { data = orders, count = orders.Count, format = "json" }); }); app.MapPost("/order/check", async (TradeRequestDto body, MtApi5Client mt, SemaphoreSlim gate) => @@ -344,6 +521,7 @@ app.MapPost("/order/check", async (TradeRequestDto body, MtApi5Client mt, Semaph var validationError = ValidateTradeRequest(body); if (validationError != null) return Results.BadRequest(new { detail = validationError }); + body.type_filling = (uint)ResolveFillingMode(mt, body.symbol, body.type_filling ?? 0); var request = ToMqlTradeRequest(body); await gate.WaitAsync(); try @@ -363,6 +541,7 @@ app.MapPost("/order/send", async (TradeRequestBody body, MtApi5Client mt, Semaph var validationError = ValidateTradeRequest(body.request); if (validationError != null) return Results.BadRequest(new { detail = validationError }); + body.request.type_filling = (uint)ResolveFillingMode(mt, body.request.symbol, body.request.type_filling ?? 0); var request = ToMqlTradeRequest(body.request); await gate.WaitAsync(); try @@ -377,88 +556,234 @@ app.MapPost("/order/send", async (TradeRequestBody body, MtApi5Client mt, Semaph } }); -app.MapGet("/history/deals", async (string date_from, string date_to, string? symbol, MtApi5Client mt, SemaphoreSlim gate) => +app.MapPost("/position/close", async (ClosePositionDto body, MtApi5Client mt, SemaphoreSlim gate) => +{ + if (body == null || body.ticket == 0) return Results.BadRequest(new { detail = "ticket is required." }); + await gate.WaitAsync(); + try + { + bool ok; + if (body.volume is > 0) + { + // ponytail: PositionClosePartial has no MqlTradeResult overload — return bool only. + ok = mt.PositionClosePartial(body.ticket, body.volume.Value, body.deviation ?? 10); + } + else + { + mt.PositionClose(body.ticket, body.deviation ?? 10, out MqlTradeResult? result); + if (result == null) return Results.Ok(new { data = new { retcode = 0u, order = 0ul, comment = "Close failed" }, count = 1, format = "json" }); + return Results.Ok(new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" }); + } + return Results.Ok(new + { + data = new { retcode = ok ? 10009u : 10004u, order = body.ticket, comment = ok ? (body.volume is > 0 ? "Partial closed" : "Closed") : "Close failed" }, + count = 1, + format = "json" + }); + } + finally + { + gate.Release(); + } +}); + +app.MapPost("/position/modify", async (ModifyPositionDto body, MtApi5Client mt, SemaphoreSlim gate) => +{ + if (body == null || body.ticket == 0) return Results.BadRequest(new { detail = "ticket is required." }); + if (body.sl < 0 || body.tp < 0) return Results.BadRequest(new { detail = "sl/tp must be non-negative." }); + await gate.WaitAsync(); + try + { + // ponytail: MtApi5 PositionModify returns bool, no MqlTradeResult — pack into our uniform envelope. + var ok = mt.PositionModify(body.ticket, body.sl, body.tp); + return Results.Ok(new + { + data = new { retcode = ok ? 10009u : 10004u, order = body.ticket, comment = ok ? "Modified" : "Modify failed" }, + count = 1, + format = "json" + }); + } + finally + { + gate.Release(); + } +}); + +app.MapPost("/order/cancel", async (OrderTicketDto body, MtApi5Client mt, SemaphoreSlim gate) => +{ + if (body == null || body.ticket == 0) return Results.BadRequest(new { detail = "ticket is required." }); + await gate.WaitAsync(); + try + { + // ponytail: MtApi5 has no OrderDelete — reuse OrderSend with TRADE_ACTION_REMOVE. + var req = new MqlTradeRequest + { + Action = ENUM_TRADE_REQUEST_ACTIONS.TRADE_ACTION_REMOVE, + Order = body.ticket, + }; + mt.OrderSend(req, out MqlTradeResult? result); + if (result == null) return Results.Ok(new { data = new { retcode = 0u, order = 0ul, comment = "Cancel failed" }, count = 1, format = "json" }); + return Results.Ok(new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" }); + } + finally + { + gate.Release(); + } +}); + +app.MapPost("/position/close-by", async (CloseByDto body, MtApi5Client mt, SemaphoreSlim gate) => +{ + if (body == null || body.position == 0 || body.position_by == 0) + return Results.BadRequest(new { detail = "position and position_by are required." }); + await gate.WaitAsync(); + try + { + // ponytail: TRADE_ACTION_CLOSE_BY lets MT5 net two opposite positions instead of + // two separate market closes — saves one side of the spread. + var req = new MqlTradeRequest + { + Action = ENUM_TRADE_REQUEST_ACTIONS.TRADE_ACTION_CLOSE_BY, + Position = body.position, + PositionBy = body.position_by, + }; + mt.OrderSend(req, out MqlTradeResult? result); + if (result == null) return Results.Ok(new { data = new { retcode = 0u, order = 0ul, comment = "Close-by failed" }, count = 1, format = "json" }); + return Results.Ok(new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" }); + } + finally + { + gate.Release(); + } +}); + +app.MapPost("/positions/close-batch", async (BatchCloseDto body, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) => +{ + if (body == null || (string.IsNullOrWhiteSpace(body.symbol) && body.magic == null)) + return Results.BadRequest(new { detail = "At least one of `symbol` or `magic` is required." }); + + var logger = loggerFactory.CreateLogger("BatchClose"); + var results = new List(); + int closed = 0, failed = 0; + // ponytail: hold the gate for the whole batch — one logical op, brief duration. + // Clients wanting concurrent writes should not use batch close. + await gate.WaitAsync(); + try + { + var total = mt.PositionsTotal(); + for (int i = 0; i < total; i++) + { + var ticket = mt.PositionGetTicket(i); + if (ticket == 0) continue; + var posSymbol = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_SYMBOL); + if (!string.IsNullOrWhiteSpace(body.symbol) && posSymbol != body.symbol) continue; + if (body.magic is ulong m && mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_MAGIC) != (long)m) continue; + + mt.PositionClose(ticket, body.deviation ?? 10, out MqlTradeResult? result); + if (result != null && result.Retcode == 10009) + { + closed++; + results.Add(new { ticket, symbol = posSymbol, retcode = result.Retcode, comment = "closed" }); + } + else + { + failed++; + var comment = result?.Comment ?? "unknown"; + logger.LogWarning("Batch close failed for ticket {Ticket} ({Symbol}): {Comment}", ticket, posSymbol, comment); + results.Add(new { ticket, symbol = posSymbol, retcode = result?.Retcode ?? 0u, comment }); + } + } + return Results.Ok(new { data = results, count = results.Count, closed, failed, format = "json" }); + } + finally + { + gate.Release(); + } +}); + +app.MapPost("/order/modify", async (ModifyOrderDto body, MtApi5Client mt, SemaphoreSlim gate) => +{ + if (body == null || body.ticket == 0) return Results.BadRequest(new { detail = "ticket is required." }); + if (body.price <= 0) return Results.BadRequest(new { detail = "price must be positive." }); + if (body.sl < 0 || body.tp < 0) return Results.BadRequest(new { detail = "sl/tp must be non-negative." }); + await gate.WaitAsync(); + try + { + var req = new MqlTradeRequest + { + Action = ENUM_TRADE_REQUEST_ACTIONS.TRADE_ACTION_MODIFY, + Order = body.ticket, + Price = body.price, + Sl = body.sl, + Tp = body.tp, + }; + mt.OrderSend(req, out MqlTradeResult? result); + if (result == null) return Results.Ok(new { data = new { retcode = 0u, order = 0ul, comment = "Modify failed" }, count = 1, format = "json" }); + return Results.Ok(new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" }); + } + finally + { + gate.Release(); + } +}); + +app.MapGet("/history/deals", (string date_from, string date_to, string? symbol, MtApi5Client mt) => { if (!TryParseClientDate(date_from, out var fromDt) || !TryParseClientDate(date_to, out var toDt)) return Results.BadRequest(new { detail = "date_from and date_to must be valid ISO-8601 or yyyy-MM-dd values." }); if (fromDt > toDt) return Results.BadRequest(new { detail = "date_from must be earlier than or equal to date_to." }); - await gate.WaitAsync(); - try + mt.HistorySelect(fromDt, toDt); + var total = mt.HistoryDealsTotal(); + var dealList = new List(); + for (int i = 0; i < total; i++) { - mt.HistorySelect(fromDt, toDt); - var total = mt.HistoryDealsTotal(); - var dealList = new List(); - for (int i = 0; i < total; i++) + var ticket = mt.HistoryDealGetTicket(i); + if (ticket == 0) continue; + var dealSymbol = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_SYMBOL); + if (!string.IsNullOrWhiteSpace(symbol) && dealSymbol != symbol) continue; + var dealTime = MtTimeToDateTime(mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_TIME)); + dealList.Add(new { - var ticket = mt.HistoryDealGetTicket(i); - if (ticket == 0) continue; - var dealSymbol = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_SYMBOL); - if (!string.IsNullOrWhiteSpace(symbol) && dealSymbol != symbol) continue; - var dealTime = MtTimeToDateTime(mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_TIME)); - dealList.Add(new - { - ticket, - time = dealTime.ToString("yyyy-MM-ddTHH:mm:ss"), - entry = (int)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_ENTRY), - magic = (ulong)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_MAGIC), - volume = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_VOLUME), - price = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PRICE), - profit = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PROFIT), - commission = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_COMMISSION), - swap = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_SWAP), - symbol = dealSymbol, - comment = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_COMMENT) ?? "" - }); - } - return Results.Ok(new { data = dealList, count = dealList.Count, format = "json" }); - } - finally - { - gate.Release(); + ticket, + time = dealTime.ToString("yyyy-MM-ddTHH:mm:ss"), + entry = (int)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_ENTRY), + magic = (ulong)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_MAGIC), + volume = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_VOLUME), + price = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PRICE), + profit = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PROFIT), + commission = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_COMMISSION), + swap = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_SWAP), + symbol = dealSymbol, + comment = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_COMMENT) ?? "" + }); } + return Results.Ok(new { data = dealList, count = dealList.Count, format = "json" }); }); -app.MapGet("/gvar", async (HttpContext context, SemaphoreSlim gate) => +app.MapGet("/gvar", (HttpContext context) => { var mt = context.RequestServices.GetRequiredService(); - await gate.WaitAsync(); - try + var total = mt.GlobalVariablesTotal(); + var vars = new List(); + for (int i = 0; i < total; i++) { - var total = mt.GlobalVariablesTotal(); - var vars = new List(); - for (int i = 0; i < total; i++) - { - var name = mt.GlobalVariableName(i) ?? ""; - var value = mt.GlobalVariableGet(name); - vars.Add(new { name, value }); - } - return Results.Ok(new { data = vars, count = vars.Count, format = "json" }); - } - finally - { - gate.Release(); + var name = mt.GlobalVariableName(i) ?? ""; + var value = mt.GlobalVariableGet(name); + vars.Add(new { name, value }); } + return Results.Ok(new { data = vars, count = vars.Count, format = "json" }); }); -app.MapGet("/gvar/{name}", async (HttpContext context, string name, SemaphoreSlim gate) => +app.MapGet("/gvar/{name}", (HttpContext context, string name) => { if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest(new { detail = "Global variable name is required." }); var mt = context.RequestServices.GetRequiredService(); - await gate.WaitAsync(); - try - { - if (!mt.GlobalVariableCheck(name)) - return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" }); - var value = mt.GlobalVariableGet(name); - return Results.Ok(new { name, value }); - } - finally - { - gate.Release(); - } + if (!mt.GlobalVariableCheck(name)) + return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" }); + var value = mt.GlobalVariableGet(name); + return Results.Ok(new { name, value }); }); app.MapPost("/gvar/{name}", async (HttpContext context, string name, SemaphoreSlim gate) => @@ -530,6 +855,45 @@ bool TryParseClientDate(string value, out DateTime parsed) || DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out parsed); } +MqlTick? WaitForTick(MtApi5Client mt, string symbol, int timeoutMs = 3000) +{ + for (int i = 0; i < timeoutMs / 100; i++) + { + var tick = mt.SymbolInfoTick(symbol); + if (tick != null && tick.bid > 0) return tick; + Thread.Sleep(100); + } + return mt.SymbolInfoTick(symbol); +} + +uint ResolveFillingMode(MtApi5Client mt, string symbol, uint requestedFilling) +{ + try + { + var modeFlags = (long)mt.SymbolInfoInteger(symbol, ENUM_SYMBOL_INFO_INTEGER.SYMBOL_FILLING_MODE); + bool fokSupported = (modeFlags & 1) != 0; + bool iocSupported = (modeFlags & 2) != 0; + + bool requestedSupported = requestedFilling switch + { + 0 => fokSupported, + 1 => iocSupported, + 2 => true, + _ => false + }; + + if (requestedSupported) return requestedFilling; + + if (iocSupported) return 1; + if (fokSupported) return 0; + return 2; + } + catch + { + return requestedFilling; + } +} + string? ValidateTradeRequest(TradeRequestDto request) { if (!Enum.IsDefined(typeof(ENUM_TRADE_REQUEST_ACTIONS), (int)request.action)) @@ -567,7 +931,9 @@ MqlTradeRequest ToMqlTradeRequest(TradeRequestDto r) Comment = r.comment ?? "", Order = r.order ?? 0, Position = r.position ?? 0, - Deviation = r.deviation ?? 0 + Deviation = r.deviation ?? 0, + Type_filling = (ENUM_ORDER_TYPE_FILLING)(r.type_filling ?? 0), + Type_time = (ENUM_ORDER_TYPE_TIME)(r.type_time ?? 0), }; } @@ -585,6 +951,8 @@ class TradeRequestDto public ulong? order { get; set; } public ulong? position { get; set; } public ulong? deviation { get; set; } + public uint? type_filling { get; set; } // 0=FOK, 1=IOC, 2=RETURN — 经纪商常要求显式指定 + public uint? type_time { get; set; } // 0=GTC, 1=DAY, 2=SPECIFIED, 3=SPECIFIED_DAY } class TradeRequestBody @@ -597,18 +965,102 @@ class GvarSetDto public double value { get; set; } } +class ClosePositionDto +{ + public ulong ticket { get; set; } + public double? volume { get; set; } // null/0 = 全平;>0 = 部分平仓 + public ulong? deviation { get; set; } +} + +class ModifyPositionDto +{ + public ulong ticket { get; set; } + public double sl { get; set; } + public double tp { get; set; } +} + +class OrderTicketDto +{ + public ulong ticket { get; set; } +} + +class ModifyOrderDto +{ + public ulong ticket { get; set; } + public double price { get; set; } + public double sl { get; set; } + public double tp { get; set; } +} + +class CloseByDto +{ + public ulong position { get; set; } + public ulong position_by { get; set; } +} + +class BatchCloseDto +{ + public string? symbol { get; set; } + public ulong? magic { get; set; } + public ulong? deviation { get; set; } +} + +abstract class TickSubscriber +{ + public abstract ValueTask SendAsync(ReadOnlyMemory payload); + public abstract bool IsAlive { get; } +} + +sealed class WsSubscriber : TickSubscriber +{ + readonly WebSocket _ws; + public WsSubscriber(WebSocket ws) => _ws = ws; + public override bool IsAlive => _ws.State == WebSocketState.Open; + public override ValueTask SendAsync(ReadOnlyMemory payload) => + _ws.SendAsync(payload, WebSocketMessageType.Text, true, CancellationToken.None); +} + +sealed class SseSubscriber : TickSubscriber +{ + readonly Stream _stream; + static readonly byte[] Prefix = Encoding.UTF8.GetBytes("data: "); + static readonly byte[] Suffix = Encoding.UTF8.GetBytes("\n\n"); + public SseSubscriber(Stream stream) => _stream = stream; + public override bool IsAlive => _stream.CanWrite; + public override async ValueTask SendAsync(ReadOnlyMemory payload) + { + await _stream.WriteAsync(Prefix); + await _stream.WriteAsync(payload); + await _stream.WriteAsync(Suffix); + await _stream.FlushAsync(); + } +} + class MtConnectionService : BackgroundService { + // Tick subscribers, keyed by symbol name. Each value is the set of connected + // client sinks (WebSocket or SSE) listening to that symbol's tick stream. + public static readonly ConcurrentDictionary> TickSubscribers = new(); + public const int MaxSubscribersPerSymbol = 50; + private readonly MtApi5Client _client; private readonly ILogger _logger; private readonly TimeSpan _connectTimeout; private const int Port = 8228; + private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(30); + + private static readonly JsonSerializerSettings JsonSettings = new() + { + DateFormatString = "yyyy-MM-ddTHH:mm:ss", + Formatting = Formatting.None + }; public MtConnectionService(MtApi5Client client, ILogger logger) { _client = client; _logger = logger; _connectTimeout = TimeSpan.FromSeconds(15); + _client.QuoteUpdate += OnQuoteUpdate; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -677,4 +1129,51 @@ class MtConnectionService : BackgroundService _client.ConnectionStateChanged -= handler; } } -} + + private void OnQuoteUpdate(object? sender, Mt5QuoteEventArgs e) + { + if (!MtConnectionService.TickSubscribers.TryGetValue(e.Quote.Instrument, out var subs)) return; + var payload = JsonConvert.SerializeObject(new + { + type = "tick", + symbol = e.Quote.Instrument, + time = e.Quote.Time, + bid = e.Quote.Bid, + ask = e.Quote.Ask, + last = e.Quote.Last, + volume = e.Quote.Volume, + time_msc = e.Quote.Time.ToString("yyyy-MM-ddTHH:mm:ss.fff000"), + flags = 6 + }, JsonSettings); + var bytes = Encoding.UTF8.GetBytes(payload); + // ponytail: fire-and-forget. Slow clients get dropped by SendAsync fault; cleanup happens on disconnect. + foreach (var sub in subs.Keys) + { + if (sub.IsAlive) _ = sub.SendAsync(bytes); + } + } + + // ponytail: periodic heartbeat so clients behind NAT/firewall can detect liveness + // even on low-liquidity symbols with no ticks for minutes. + private static readonly byte[] HeartbeatPayload = Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject(new { type = "heartbeat", time = DateTime.UtcNow })); + + private static void BroadcastHeartbeat(object? _) + { + foreach (var subs in TickSubscribers.Values) + { + foreach (var sub in subs.Keys) + { + if (sub.IsAlive) _ = sub.SendAsync(HeartbeatPayload); + } + } + } + + private readonly Timer _heartbeat = new(BroadcastHeartbeat, null, HeartbeatInterval, HeartbeatInterval); + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _heartbeat.DisposeAsync(); + await base.StopAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/README.md.md b/README.md.md index 8c1f449..130ffdf 100644 --- a/README.md.md +++ b/README.md.md @@ -167,7 +167,7 @@ Start-ScheduledTask -TaskName "Mt5Bridge" 说明: - 仅更新 `Mt5Bridge.dll` / `Mt5Bridge.exe` 这一侧,就会影响 HTTP API、健康检查、参数校验、错误处理、重连逻辑等桥接服务行为。 -- `Alpha Trend.ex5` 不属于桥接服务本体,不替换也不会影响 `/health`、`/account`、`/symbols`、`/rates/from-pos`、`/positions`、`/orders`、`/history/deals`、`/gvar`、`/order/check`、`/order/send` 这些 API 的正常工作。 +- `Alpha Trend.ex5` 不属于桥接服务本体,不替换也不会影响 `/health`、`/account`、`/symbols`、`/rates/from-pos`、`/positions`、`/orders`、`/history/deals`、`/gvar`、`/order/check`、`/order/send`、`/stream/ticks/{symbol}`、`/stream/ticks-sse/{symbol}` 这些 API 的正常工作。 - 只有在你需要新版指标导出逻辑时,才需要同步替换 `Alpha Trend.ex5`。新版指标变化包括:只输出已收盘 K 线信号,以及使用带周期/参数作用域的 GlobalVariable 名称。 --- @@ -310,6 +310,8 @@ GET /account GET /symbols/{symbol} ``` +bid/ask 从实时 tick 数据获取(自动等待最多 3 秒),避免品种刚加入 Market Watch 时返回 0 的问题。 + **示例:** `/symbols/XAUUSDc` **响应字段:** @@ -338,6 +340,8 @@ GET /symbols/{symbol} GET /symbols/{symbol}/tick ``` +自动将品种加入 MT5 Market Watch,并等待最多 3 秒获取真实 tick 数据(解决品种未订阅时返回空值的问题)。 + **示例:** `/symbols/XAUUSDc/tick` **响应字段:** @@ -352,6 +356,79 @@ GET /symbols/{symbol}/tick | time_msc | string | 毫秒级时间 | | volume_real | number | 真实成交量 | +### 实时 Tick 推送(WebSocket) + +``` +WS /stream/ticks/{symbol} +``` + +MT5 每收到一条 tick 就立即推给所有订阅者,避免轮询开销。 + +- 走 `X-API-Key` 认证(握手 Header 或 `?key=` query 参数) +- 连接时自动把品种加入 MT5 Market Watch;最后一个订阅者断开时自动移除 +- 同一品种可被多个客户端同时订阅,互不影响 +- 每个品种最多 50 个订阅者(WS + SSE 共用),超出返回 `429` +- 每 30 秒发一条心跳包,无 tick 时也发,用于穿透 NAT 保持连接 + +**Python 示例(需 `pip install websocket-client`):** + +```python +import websocket, json + +ws = websocket.WebSocketApp( + "ws://你的IP:13485/stream/ticks/XAUUSDc", + header=["X-API-Key: 你的密码"], + on_message=lambda ws, msg: print(json.loads(msg)), +) +ws.run_forever() +``` + +**浏览器示例(用 query 参数传 key):** + +```js +const ws = new WebSocket("ws://你的IP:13485/stream/ticks/XAUUSDc?key=你的密码"); +ws.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +**推送格式(每条 tick 一帧 JSON):** + +```json +{ + "type": "tick", + "symbol": "XAUUSDc", + "time": "2026-07-08T10:30:45", + "bid": 4180.0, + "ask": 4180.5, + "last": 4180.2, + "volume": 100, + "time_msc": "2026-07-08T10:30:45.123000", + "flags": 6 +} +``` + +心跳包:`{"type":"heartbeat","time":"2026-07-08T10:31:15"}` + +### 实时 Tick 推送(SSE,不能用 WebSocket 时) + +``` +GET /stream/ticks-sse/{symbol} → text/event-stream +``` + +面向无法建 WebSocket 的客户端(部分老浏览器、内网代理、curl 等)。 + +```bash +curl -N -H "X-API-Key: 你的密码" http://你的IP:13485/stream/ticks-sse/XAUUSDc +``` + +每条 tick 一帧 SSE: + +``` +data: {"type":"tick","symbol":"XAUUSDc","bid":4180.0,...} + +data: {"type":"heartbeat","time":"2026-07-08T10:31:15"} + +``` + ### 历史 K 线 ``` @@ -385,9 +462,11 @@ GET /rates/from-pos?symbol={symbol}&timeframe={timeframe}&start_pos={start}&coun ### 当前持仓 ``` -GET /positions +GET /positions[?symbol={symbol}] ``` +symbol 为可选过滤参数。 + **响应字段:** | 字段 | 类型 | 说明 | @@ -405,6 +484,76 @@ GET /positions | comment | string | 注释 | | magic | number | Magic Number | +### 平仓 + +``` +POST /position/close +``` + +```json +{ "ticket": 12345678 } +{ "ticket": 12345678, "volume": 0.05 } // 部分平仓 +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ticket | number | ✅ | 持仓编号 | +| volume | number | ❌ | 平仓手数;不传/0=全平,>0=部分平仓 | +| deviation | number | ❌ | 允许滑点(默认 10) | + +### 改持仓 SL/TP + +``` +POST /position/modify +``` + +```json +{ "ticket": 12345678, "sl": 4170.0, "tp": 4190.0 } +``` + +`sl`/`tp` 设为 `0` 表示清除。 + +### 对冲平仓(节省点差) + +``` +POST /position/close-by +``` + +```json +{ "position": 111, "position_by": 222 } +``` + +两张持仓必须 **同品种 + 反向**。MT5 用净额结算,只收一次点差。 + +### 批量平仓 + +``` +POST /positions/close-batch +``` + +```json +{ "magic": 123456 } +{ "symbol": "XAUUSDc", "magic": 123456 } +``` + +`symbol` 和 `magic` **至少传一个**。返回 `{closed, failed, data:[...]}`。 + +### 移动止损 + +服务端不做轮询,由客户端结合 `/positions` 拉取和 `/position/modify` 实现: + +```python +while True: + for p in bridge.positions(): + cur = p["price_current"] + new_sl = cur - 50 if p["type"] == 0 else cur + 50 + if abs(new_sl - p["sl"]) >= 10: + bridge.modify_position(p["ticket"], sl=new_sl) + time.sleep(1) +``` + +也可订阅 WebSocket tick 流(`/stream/ticks/{symbol}`),按 tick 回调触发,更实时。 + ### 挂单 ``` @@ -413,12 +562,34 @@ GET /orders?symbol={symbol} symbol 为可选参数,不传则返回所有挂单。 +### 撤单 + +``` +POST /order/cancel +``` + +```json +{ "ticket": 87654321 } +``` + +### 改挂单 + +``` +POST /order/modify +``` + +```json +{ "ticket": 87654321, "price": 4180.0, "sl": 4170.0, "tp": 4190.0 } +``` + ### 订单预检 ``` POST /order/check ``` +**填充模式自动适配**:服务端会根据品种的 `SYMBOL_FILLING_MODE` 自动选择经纪商支持的填充模式。如果请求的 `type_filling` 不被支持,会按 IOC(1) → FOK(0) → RETURN(2) 顺序降级,无需客户端手动判断。 + **请求体:** ```json { @@ -431,7 +602,8 @@ POST /order/check "tp": 4190.0, "magic": 123456, "comment": "test", - "deviation": 10 + "deviation": 10, + "type_filling": 0 } ``` @@ -447,6 +619,17 @@ POST /order/check | magic | number | Magic Number | | comment | string | 注释 | | deviation | number | 偏差 | +| type_filling | number | 填充模式:0=FOK, 1=IOC, 2=RETURN(自动适配,不传也行) | + +**type_filling 说明:** + +| 值 | 含义 | 说明 | +|------|------|------| +| 0 | FOK (Fill or Kill) | 必须全部成交,否则取消 | +| 1 | IOC (Immediate or Cancel) | 能成交多少成交多少 | +| 2 | RETURN | 剩余部分留在订单簿 | + +> 不同经纪商支持的填充模式不同(如 ICMarkets 只支持 IOC),服务端会自动降级,客户端无需关心。 ### 下单 @@ -454,6 +637,8 @@ POST /order/check POST /order/send ``` +与 `/order/check` 相同,`type_filling` 会自动适配经纪商支持的填充模式。 + **请求体:** ```json { @@ -467,7 +652,8 @@ POST /order/send "tp": 4190.0, "magic": 123456, "comment": "test", - "deviation": 10 + "deviation": 10, + "type_filling": 0 } } ``` @@ -609,7 +795,8 @@ resp = requests.post(f"{BRIDGE}/order/send", headers=HEADERS, json={ "tp": 4190.0, "magic": 123456, "comment": "test", - "deviation": 10 + "deviation": 10, + "type_filling": 0 } }) print(resp.json()) @@ -628,7 +815,7 @@ curl -H "X-API-Key: 你的密码" http://IP:端口/account curl -X POST http://IP:端口/order/send \ -H "Content-Type: application/json" \ -H "X-API-Key: 你的密码" \ - -d '{"request":{"action":1,"symbol":"XAUUSDc","volume":0.01,"order_type":0,"price":4180.0,"sl":4170.0,"tp":4190.0,"magic":123456,"comment":"test","deviation":10}}' + -d '{"request":{"action":1,"symbol":"XAUUSDc","volume":0.01,"order_type":0,"price":4180.0,"sl":4170.0,"tp":4190.0,"magic":123456,"comment":"test","deviation":10,"type_filling":0}}' ``` --- diff --git a/__pycache__/test_all.cpython-312.pyc b/__pycache__/test_all.cpython-312.pyc new file mode 100644 index 0000000..f491b06 Binary files /dev/null and b/__pycache__/test_all.cpython-312.pyc differ diff --git a/bin/Release/net8.0/Mt5Bridge.dll b/bin/Release/net8.0/Mt5Bridge.dll index fb622b2..48fc6e0 100644 Binary files a/bin/Release/net8.0/Mt5Bridge.dll and b/bin/Release/net8.0/Mt5Bridge.dll differ diff --git a/bin/Release/net8.0/Mt5Bridge.exe b/bin/Release/net8.0/Mt5Bridge.exe index 77a528e..7ce54dc 100644 Binary files a/bin/Release/net8.0/Mt5Bridge.exe and b/bin/Release/net8.0/Mt5Bridge.exe differ diff --git a/bin/Release/net8.0/Mt5Bridge.pdb b/bin/Release/net8.0/Mt5Bridge.pdb index a22ee06..2fe07de 100644 Binary files a/bin/Release/net8.0/Mt5Bridge.pdb and b/bin/Release/net8.0/Mt5Bridge.pdb differ diff --git a/obj/Mt5Bridge.csproj.nuget.dgspec.json b/obj/Mt5Bridge.csproj.nuget.dgspec.json index 654655c..90b2ebd 100644 --- a/obj/Mt5Bridge.csproj.nuget.dgspec.json +++ b/obj/Mt5Bridge.csproj.nuget.dgspec.json @@ -1,17 +1,17 @@ { "format": 1, "restore": { - "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj": {} + "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj": {} }, "projects": { - "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj": { + "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj": { "version": "1.0.0", "restore": { - "projectUniqueName": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj", + "projectUniqueName": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj", "projectName": "Mt5Bridge", - "projectPath": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj", + "projectPath": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj", "packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\", - "outputPath": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\obj\\", + "outputPath": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\obj\\", "projectStyle": "PackageReference", "configFilePaths": [ "C:\\Users\\Administrator\\AppData\\Roaming\\NuGet\\NuGet.Config" diff --git a/obj/Release/net8.0/Mt5Bridge.AssemblyInfo.cs b/obj/Release/net8.0/Mt5Bridge.AssemblyInfo.cs index 9138b15..1f71d2a 100644 --- a/obj/Release/net8.0/Mt5Bridge.AssemblyInfo.cs +++ b/obj/Release/net8.0/Mt5Bridge.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Mt5Bridge")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+72a254d088108ba984483d64325ad4a99a15c745")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] [assembly: System.Reflection.AssemblyProductAttribute("Mt5Bridge")] [assembly: System.Reflection.AssemblyTitleAttribute("Mt5Bridge")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/obj/Release/net8.0/Mt5Bridge.AssemblyInfoInputs.cache b/obj/Release/net8.0/Mt5Bridge.AssemblyInfoInputs.cache index 3aa6e0d..732a378 100644 --- a/obj/Release/net8.0/Mt5Bridge.AssemblyInfoInputs.cache +++ b/obj/Release/net8.0/Mt5Bridge.AssemblyInfoInputs.cache @@ -1 +1 @@ -73a436c0ddfd44461ffa8081e17c16a265a632353ba2c55532dfe1190818c1f9 +5bdc0376692346f9b7ab5460cdfbdf4f42e8718c2f108a0051f076dfd3e58d91 diff --git a/obj/Release/net8.0/Mt5Bridge.GeneratedMSBuildEditorConfig.editorconfig b/obj/Release/net8.0/Mt5Bridge.GeneratedMSBuildEditorConfig.editorconfig index 556a6f0..928e9e5 100644 --- a/obj/Release/net8.0/Mt5Bridge.GeneratedMSBuildEditorConfig.editorconfig +++ b/obj/Release/net8.0/Mt5Bridge.GeneratedMSBuildEditorConfig.editorconfig @@ -9,11 +9,11 @@ build_property.EnforceExtendedAnalyzerRules = build_property._SupportedPlatformList = Linux,macOS,Windows build_property.RootNamespace = Mt5Bridge build_property.RootNamespace = Mt5Bridge -build_property.ProjectDir = C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge\ +build_property.ProjectDir = C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\ build_property.EnableComHosting = build_property.EnableGeneratedComInterfaceComImportInterop = build_property.RazorLangVersion = 8.0 build_property.SupportLocalizedComponentNames = build_property.GenerateRazorMetadataSourceChecksumAttributes = -build_property.MSBuildProjectDirectory = C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge +build_property.MSBuildProjectDirectory = C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge build_property._RazorSourceGeneratorDebug = diff --git a/obj/Release/net8.0/Mt5Bridge.assets.cache b/obj/Release/net8.0/Mt5Bridge.assets.cache index 7e29c0c..aff32d3 100644 Binary files a/obj/Release/net8.0/Mt5Bridge.assets.cache and b/obj/Release/net8.0/Mt5Bridge.assets.cache differ diff --git a/obj/Release/net8.0/Mt5Bridge.csproj.AssemblyReference.cache b/obj/Release/net8.0/Mt5Bridge.csproj.AssemblyReference.cache index 4c38602..055737c 100644 Binary files a/obj/Release/net8.0/Mt5Bridge.csproj.AssemblyReference.cache and b/obj/Release/net8.0/Mt5Bridge.csproj.AssemblyReference.cache differ diff --git a/obj/Release/net8.0/Mt5Bridge.csproj.CoreCompileInputs.cache b/obj/Release/net8.0/Mt5Bridge.csproj.CoreCompileInputs.cache index e912a63..8868596 100644 --- a/obj/Release/net8.0/Mt5Bridge.csproj.CoreCompileInputs.cache +++ b/obj/Release/net8.0/Mt5Bridge.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -fa7261daa8f6cd35b2cabdce49d64b4efa111ee76853926074d5c790bfffb9d6 +af30adee33ca0803429a173f4f51c3233f7e012d4c5cf604a4e29af73b3bdae7 diff --git a/obj/Release/net8.0/Mt5Bridge.csproj.FileListAbsolute.txt b/obj/Release/net8.0/Mt5Bridge.csproj.FileListAbsolute.txt index 5d5deb0..c258df0 100644 --- a/obj/Release/net8.0/Mt5Bridge.csproj.FileListAbsolute.txt +++ b/obj/Release/net8.0/Mt5Bridge.csproj.FileListAbsolute.txt @@ -26,3 +26,31 @@ C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge\obj\Release\net8.0 C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge\obj\Release\net8.0\Mt5Bridge.pdb C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge\obj\Release\net8.0\Mt5Bridge.genruntimeconfig.cache C:\Users\Administrator\Desktop\active-fx-traders\my-Mt5Bridge\obj\Release\net8.0\ref\Mt5Bridge.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Mt5Bridge.exe +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Mt5Bridge.deps.json +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Mt5Bridge.runtimeconfig.json +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Mt5Bridge.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Mt5Bridge.pdb +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\MtApi5.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\MtClient.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\bin\Release\net8.0\Newtonsoft.Json.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.csproj.AssemblyReference.cache +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.GeneratedMSBuildEditorConfig.editorconfig +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.AssemblyInfoInputs.cache +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.AssemblyInfo.cs +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.csproj.CoreCompileInputs.cache +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.MvcApplicationPartsAssemblyInfo.cache +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets.build.json +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets.development.json +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets\msbuild.Mt5Bridge.Microsoft.AspNetCore.StaticWebAssets.props +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets\msbuild.build.Mt5Bridge.props +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets\msbuild.buildMultiTargeting.Mt5Bridge.props +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets\msbuild.buildTransitive.Mt5Bridge.props +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\staticwebassets.pack.json +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\scopedcss\bundle\Mt5Bridge.styles.css +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.csproj.Up2Date +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\refint\Mt5Bridge.dll +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.pdb +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\Mt5Bridge.genruntimeconfig.cache +C:\Users\Administrator\Desktop\my-Mt5Bridge-main\my-mt5bridge\obj\Release\net8.0\ref\Mt5Bridge.dll diff --git a/obj/Release/net8.0/Mt5Bridge.dll b/obj/Release/net8.0/Mt5Bridge.dll index fb622b2..48fc6e0 100644 Binary files a/obj/Release/net8.0/Mt5Bridge.dll and b/obj/Release/net8.0/Mt5Bridge.dll differ diff --git a/obj/Release/net8.0/Mt5Bridge.genruntimeconfig.cache b/obj/Release/net8.0/Mt5Bridge.genruntimeconfig.cache index ee3f349..9af975e 100644 --- a/obj/Release/net8.0/Mt5Bridge.genruntimeconfig.cache +++ b/obj/Release/net8.0/Mt5Bridge.genruntimeconfig.cache @@ -1 +1 @@ -78336b0d7c9548c22381256b41e61d95e308b055ebb5bde17d5389da512a3fb0 +ad262a91a68b408c9e09c711f30b4c7e0d95eec95987ff49ef173d2adbd218f3 diff --git a/obj/Release/net8.0/Mt5Bridge.pdb b/obj/Release/net8.0/Mt5Bridge.pdb index a22ee06..2fe07de 100644 Binary files a/obj/Release/net8.0/Mt5Bridge.pdb and b/obj/Release/net8.0/Mt5Bridge.pdb differ diff --git a/obj/Release/net8.0/apphost.exe b/obj/Release/net8.0/apphost.exe index 77a528e..7ce54dc 100644 Binary files a/obj/Release/net8.0/apphost.exe and b/obj/Release/net8.0/apphost.exe differ diff --git a/obj/Release/net8.0/ref/Mt5Bridge.dll b/obj/Release/net8.0/ref/Mt5Bridge.dll index ed94ae4..701b726 100644 Binary files a/obj/Release/net8.0/ref/Mt5Bridge.dll and b/obj/Release/net8.0/ref/Mt5Bridge.dll differ diff --git a/obj/Release/net8.0/refint/Mt5Bridge.dll b/obj/Release/net8.0/refint/Mt5Bridge.dll index ed94ae4..701b726 100644 Binary files a/obj/Release/net8.0/refint/Mt5Bridge.dll and b/obj/Release/net8.0/refint/Mt5Bridge.dll differ diff --git a/obj/project.assets.json b/obj/project.assets.json index 619a9e7..cbf79ce 100644 --- a/obj/project.assets.json +++ b/obj/project.assets.json @@ -13,11 +13,11 @@ "project": { "version": "1.0.0", "restore": { - "projectUniqueName": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj", + "projectUniqueName": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj", "projectName": "Mt5Bridge", - "projectPath": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj", + "projectPath": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj", "packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\", - "outputPath": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\obj\\", + "outputPath": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\obj\\", "projectStyle": "PackageReference", "configFilePaths": [ "C:\\Users\\Administrator\\AppData\\Roaming\\NuGet\\NuGet.Config" diff --git a/obj/project.nuget.cache b/obj/project.nuget.cache index 12920dd..86159bc 100644 --- a/obj/project.nuget.cache +++ b/obj/project.nuget.cache @@ -1,8 +1,8 @@ { "version": 2, - "dgSpecHash": "G66hRH3mPGs=", + "dgSpecHash": "rCHMtHhZMMQ=", "success": true, - "projectFilePath": "C:\\Users\\Administrator\\Desktop\\active-fx-traders\\my-Mt5Bridge\\Mt5Bridge.csproj", + "projectFilePath": "C:\\Users\\Administrator\\Desktop\\my-Mt5Bridge-main\\my-mt5bridge\\Mt5Bridge.csproj", "expectedPackageFiles": [], "logs": [] } \ No newline at end of file diff --git a/test_all.py b/test_all.py new file mode 100644 index 0000000..2f5dedc --- /dev/null +++ b/test_all.py @@ -0,0 +1,416 @@ +#!/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() \ No newline at end of file diff --git a/延迟测试.py b/延迟测试.py deleted file mode 100644 index 077a5d3..0000000 --- a/延迟测试.py +++ /dev/null @@ -1,53 +0,0 @@ -import requests -import time -import statistics - -BRIDGE = "http://61.164.252.86:13485" -KEY = "UiHMqtaYLZzwBdcuS4RFmEGhgDO8N2eI" -HEADERS = {"X-API-Key": KEY} - -N = 20 # 测试次数 - -def test(name, func): - times = [] - for i in range(N): - t0 = time.perf_counter() - try: - func() - except Exception as e: - print(f" ❌ {name} 失败: {e}") - return - t1 = time.perf_counter() - times.append((t1 - t0) * 1000) - print(f" #{i+1:2d}: {times[-1]:.1f} ms", end="\r") - - print(f"\n{'='*50}") - print(f"{name}") - print(f" 最小: {min(times):.1f} ms") - print(f" 最大: {max(times):.1f} ms") - print(f" 平均: {statistics.mean(times):.1f} ms") - print(f" 中位: {statistics.median(times):.1f} ms") - -print(f"测试 {N} 次,请稍候...\n") - -# 1. 健康检查 -test("健康检查 /health", lambda: requests.get(f"{BRIDGE}/health", headers=HEADERS).raise_for_status()) - -# 2. 账户信息 -test("账户信息 /account", lambda: requests.get(f"{BRIDGE}/account", headers=HEADERS).raise_for_status()) - -# 3. 实时 Tick -test("实时 Tick /tick", lambda: requests.get(f"{BRIDGE}/symbols/XAUUSDc/tick", headers=HEADERS).raise_for_status()) - -# 4. 持仓查询 -test("持仓查询 /positions", lambda: requests.get(f"{BRIDGE}/positions", headers=HEADERS).raise_for_status()) - -# 5. 品种信息 -test("品种信息 /symbols", lambda: requests.get(f"{BRIDGE}/symbols/XAUUSDc", headers=HEADERS).raise_for_status()) - -# 6. K 线数据 -test("K 线 /rates (100根 H1)", lambda: requests.get(f"{BRIDGE}/rates/from-pos", headers=HEADERS, params={ - "symbol": "XAUUSDc", "timeframe": "TIMEFRAME_H1", "start_pos": 0, "count": 100 -}).raise_for_status()) - -print(f"\n总结: 你的网络 → MT5 云端 往返延迟") \ No newline at end of file