open-api-mt5 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Open MT5 API package."""
app/cli.py ADDED
@@ -0,0 +1,14 @@
1
+ import argparse
2
+ import os
3
+
4
+ import uvicorn
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser(description="Run the Open MT5 API server.")
9
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host. Default: 127.0.0.1")
10
+ parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")), help="Bind port. Default: 8000")
11
+ parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
12
+ args = parser.parse_args()
13
+
14
+ uvicorn.run("app.main:app", host=args.host, port=args.port, reload=args.reload)
app/main.py ADDED
@@ -0,0 +1,560 @@
1
+ import os
2
+ import asyncio
3
+ from contextlib import asynccontextmanager
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ import MetaTrader5 as mt5
8
+ from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
9
+ from pydantic import BaseModel, Field
10
+ from app.services.account_service import get_account_info
11
+ from app.services.bar_service import get_last_ohlc_bars
12
+ from app.services.calendar_service import list_calendar_events
13
+ from app.services.connection_service import initialize_connection, shutdown_connection
14
+ from app.services.stream_service import get_open_positions_snapshot
15
+ from app.services.symbol_service import get_symbol_details, search_symbols
16
+ from app.services.ticket_service import get_all_tickets
17
+ from app.services.trade_service import close_by_ticket, create_market_order, list_open_positions, list_pending_orders, list_trade_history
18
+
19
+
20
+ class CreateOrderRequest(BaseModel):
21
+ symbol: str = Field(..., description="Trading symbol (example: EURUSD).")
22
+ side: str = Field(..., description="Order side. Allowed values: buy, sell.", examples=["buy"])
23
+ volume: float = Field(..., gt=0, description="Order lot size.")
24
+ orderType: str = Field(
25
+ "market",
26
+ description="Order type. Allowed values: market, limit, stop, stopLimit.",
27
+ examples=["market"],
28
+ )
29
+ price: float | None = Field(None, description="Trigger/entry price. Required for limit, stop, and stopLimit.")
30
+ stopLimit: float | None = Field(None, description="Stop-limit price. Required only when orderType is stopLimit.")
31
+ deviation: int = Field(20, ge=0, description="Maximum slippage in points.")
32
+ sl: float | None = Field(None, description="Stop loss price.")
33
+ tp: float | None = Field(None, description="Take profit price.")
34
+ magic: int = Field(0, description="Client strategy identifier (magic number).")
35
+ comment: str = Field("api-order", description="Comment attached to the order.")
36
+
37
+
38
+ class CloseOrderRequest(BaseModel):
39
+ ticket: int = Field(..., gt=0, description="Position/order ticket to close or cancel.")
40
+ volume: float | None = Field(None, gt=0, description="Partial close volume. If omitted, closes full position size.")
41
+ deviation: int = Field(20, ge=0, description="Maximum slippage in points.")
42
+ comment: str = Field("api-close", description="Comment attached to close/cancel operation.")
43
+
44
+
45
+ class HealthResponse(BaseModel):
46
+ status: str = Field(..., description="API health indicator.")
47
+ mt5: str = Field(..., description="MT5 connection status or error message.")
48
+
49
+
50
+ class AccountResponse(BaseModel):
51
+ account: dict[str, Any] = Field(..., description="Account payload from MT5. Keys are camelCase.")
52
+
53
+
54
+ class ConnectAccountRequest(BaseModel):
55
+ username: str = Field(..., description="MT5 account login/username (numeric).")
56
+ password: str = Field(..., description="MT5 account password.")
57
+ server: str = Field(..., description="MT5 broker server name.")
58
+ path: str | None = Field(None, description="Optional MT5 terminal executable path.")
59
+
60
+
61
+ class ConnectionResponse(BaseModel):
62
+ success: bool = Field(..., description="True when MT5 connect/disconnect action succeeds.")
63
+ mt5: str = Field(..., description="Connection status or error message.")
64
+
65
+
66
+ class TicketsResponse(BaseModel):
67
+ positionTickets: list[int] = Field(..., description="Open position ticket IDs.")
68
+ orderTickets: list[int] = Field(..., description="Pending order ticket IDs.")
69
+ total: int = Field(..., description="Total number of tickets.")
70
+
71
+
72
+ class SymbolsResponse(BaseModel):
73
+ query: str | None = Field(None, description="Search filter used for symbols.")
74
+ count: int = Field(..., description="Number of symbols returned.")
75
+ symbols: list[str] = Field(..., description="List of symbol names.")
76
+
77
+
78
+ class SymbolDetailsResponse(BaseModel):
79
+ symbol: dict[str, Any] = Field(..., description="Detailed symbol attributes from MT5. Keys are camelCase.")
80
+
81
+
82
+ class BarItem(BaseModel):
83
+ time: str = Field(..., description="Bar timestamp in ISO-8601 format (UTC).")
84
+ open: float = Field(..., description="Open price.")
85
+ high: float = Field(..., description="High price.")
86
+ low: float = Field(..., description="Low price.")
87
+ close: float = Field(..., description="Close price.")
88
+ volume: int | None = Field(None, description="Tick volume when includeVolume=true.")
89
+
90
+
91
+ class BarsResponse(BaseModel):
92
+ symbol: str = Field(..., description="Symbol used in the query.")
93
+ timeframe: str = Field(..., description="Requested timeframe.")
94
+ count: int = Field(..., description="Number of bars returned.")
95
+ bars: list[BarItem] = Field(..., description="OHLC bars.")
96
+
97
+
98
+ class OrderExecutionResponse(BaseModel):
99
+ retcode: int = Field(..., description="MT5 return code.")
100
+ success: bool = Field(..., description="True when MT5 reports successful execution.")
101
+ message: str = Field(..., description="MT5 execution message.")
102
+ hint: str | None = Field(None, description="Actionable hint when available.")
103
+ result: dict[str, Any] = Field(..., description="Raw MT5 execution result. Keys are camelCase.")
104
+
105
+
106
+ class OpenPositionsResponse(BaseModel):
107
+ count: int = Field(..., description="Number of open positions.")
108
+ positions: list[dict[str, Any]] = Field(..., description="Open positions payload. Keys are camelCase.")
109
+
110
+
111
+ class PendingOrdersResponse(BaseModel):
112
+ count: int = Field(..., description="Number of pending orders.")
113
+ orders: list[dict[str, Any]] = Field(..., description="Pending orders payload. Keys are camelCase.")
114
+
115
+
116
+ class TradeHistoryResponse(BaseModel):
117
+ fromDate: str = Field(..., description="Start datetime used for history query (ISO-8601, UTC).")
118
+ toDate: str = Field(..., description="End datetime used for history query (ISO-8601, UTC).")
119
+ count: int = Field(..., description="Number of trade records returned.")
120
+ trades: list[dict[str, Any]] = Field(..., description="Historical deal records from MT5. Keys are camelCase.")
121
+
122
+
123
+ class CloseOrderResponse(BaseModel):
124
+ operation: str = Field(..., description="Executed operation type: closePosition or cancelPendingOrder.")
125
+ retcode: int = Field(..., description="MT5 return code.")
126
+ success: bool = Field(..., description="True when MT5 reports successful execution.")
127
+ result: dict[str, Any] = Field(..., description="Raw MT5 close/cancel result. Keys are camelCase.")
128
+
129
+
130
+ class CalendarEventsResponse(BaseModel):
131
+ fromDate: str = Field(..., description="Start datetime used for calendar query (ISO-8601, UTC).")
132
+ toDate: str = Field(..., description="End datetime used for calendar query (ISO-8601, UTC).")
133
+ country: str | None = Field(None, description="Country filter used in query.")
134
+ currency: str | None = Field(None, description="Currency filter used in query.")
135
+ count: int = Field(..., description="Number of calendar events returned.")
136
+ events: list[dict[str, Any]] = Field(..., description="Calendar event/value entries from MT5. Keys are camelCase.")
137
+
138
+
139
+ class WebSocketDocsResponse(BaseModel):
140
+ stream: str = Field(..., description="Logical stream name.")
141
+ websocketPath: str = Field(..., description="Relative WebSocket path.")
142
+ websocketExampleUrl: str = Field(..., description="Example URL to connect from a client.")
143
+ queryParams: dict[str, str] = Field(..., description="Supported query parameters and value constraints.")
144
+ events: list[str] = Field(..., description="Event types emitted by the stream.")
145
+ sampleMessages: list[dict[str, Any]] = Field(..., description="Sample messages sent by the server.")
146
+
147
+
148
+ def _utc_now_iso() -> str:
149
+ return datetime.now(timezone.utc).isoformat()
150
+
151
+
152
+ def _bounded_interval(value: float, minimum: float = 0.2, maximum: float = 60.0) -> float:
153
+ return max(minimum, min(maximum, value))
154
+
155
+
156
+ def _initialize_mt5() -> tuple[bool, str]:
157
+ connection_config = getattr(app.state, "mt5_connection_config", None)
158
+ if connection_config is None:
159
+ connection_config = {
160
+ "path": os.getenv("MT5_PATH"),
161
+ "username": os.getenv("MT5_LOGIN"),
162
+ "password": os.getenv("MT5_PASSWORD"),
163
+ "server": os.getenv("MT5_SERVER"),
164
+ }
165
+ app.state.mt5_connection_config = connection_config
166
+ return initialize_connection(
167
+ username=connection_config.get("username"),
168
+ password=connection_config.get("password"),
169
+ server=connection_config.get("server"),
170
+ path=connection_config.get("path"),
171
+ )
172
+
173
+
174
+ def _check_mt5_connection() -> tuple[bool, str]:
175
+ terminal_info = mt5.terminal_info()
176
+ if terminal_info is None:
177
+ return False, "disconnected"
178
+ return True, "connected"
179
+
180
+
181
+ async def _mt5_monitor_loop(app_instance: FastAPI, interval_seconds: int = 5) -> None:
182
+ while True:
183
+ connected, message = _check_mt5_connection()
184
+ if not connected:
185
+ if getattr(app_instance.state, "auto_reconnect", True):
186
+ mt5_ok, mt5_message = _initialize_mt5()
187
+ app_instance.state.mt5_ok = mt5_ok
188
+ app_instance.state.mt5_message = mt5_message
189
+ else:
190
+ app_instance.state.mt5_ok = False
191
+ app_instance.state.mt5_message = "disconnected"
192
+ else:
193
+ app_instance.state.mt5_ok = True
194
+ app_instance.state.mt5_message = message
195
+
196
+ await asyncio.sleep(interval_seconds)
197
+
198
+
199
+ @asynccontextmanager
200
+ async def lifespan(_: FastAPI):
201
+ app.state.mt5_connection_config = {
202
+ "path": os.getenv("MT5_PATH"),
203
+ "username": os.getenv("MT5_LOGIN"),
204
+ "password": os.getenv("MT5_PASSWORD"),
205
+ "server": os.getenv("MT5_SERVER"),
206
+ }
207
+ app.state.auto_reconnect = True
208
+ mt5_ok, mt5_message = _initialize_mt5()
209
+ app.state.mt5_ok = mt5_ok
210
+ app.state.mt5_message = mt5_message
211
+ monitor_task = asyncio.create_task(_mt5_monitor_loop(app))
212
+ try:
213
+ yield
214
+ finally:
215
+ monitor_task.cancel()
216
+ try:
217
+ await monitor_task
218
+ except asyncio.CancelledError:
219
+ pass
220
+ mt5.shutdown()
221
+
222
+ app = FastAPI(
223
+ title="MT5 API",
224
+ version="0.1.0",
225
+ description="Starter FastAPI service for MetaTrader 5 integration.",
226
+ lifespan=lifespan,
227
+ openapi_tags=[
228
+ {"name": "Health", "description": "Service and MT5 connection status."},
229
+ {"name": "Account", "description": "Account information endpoints."},
230
+ {"name": "Tickets", "description": "Active position and order tickets."},
231
+ {"name": "Symbols", "description": "Symbol search and metadata."},
232
+ {"name": "Market Data", "description": "Historical market bars."},
233
+ {"name": "Orders", "description": "Order creation, pending orders, and close/cancel."},
234
+ {"name": "Positions", "description": "Open position endpoints."},
235
+ {"name": "Trades", "description": "Trade history endpoints."},
236
+ {"name": "Calendar", "description": "Economic calendar endpoints."},
237
+ {"name": "WebSocket Docs", "description": "Swagger-visible docs for WebSocket streams."},
238
+ ],
239
+ )
240
+
241
+
242
+ @app.get("/health", response_model=HealthResponse, summary="Health check", tags=["Health"])
243
+ def health() -> HealthResponse:
244
+ return {
245
+ "status": "ok",
246
+ "mt5": "connected" if app.state.mt5_ok else app.state.mt5_message,
247
+ }
248
+
249
+
250
+ @app.get("/account", response_model=AccountResponse, summary="Get account information", tags=["Account"])
251
+ def account() -> AccountResponse:
252
+ if not app.state.mt5_ok:
253
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
254
+
255
+ ok, data = get_account_info()
256
+ if not ok:
257
+ raise HTTPException(status_code=500, detail=str(data))
258
+
259
+ return {"account": data}
260
+
261
+
262
+ @app.post("/account/connect", response_model=ConnectionResponse, summary="Connect to MT5 account", tags=["Account"])
263
+ def connect_account(payload: ConnectAccountRequest) -> ConnectionResponse:
264
+ app.state.mt5_connection_config = {
265
+ "path": payload.path,
266
+ "username": payload.username,
267
+ "password": payload.password,
268
+ "server": payload.server,
269
+ }
270
+ app.state.auto_reconnect = True
271
+
272
+ mt5.shutdown()
273
+ ok, message = _initialize_mt5()
274
+ app.state.mt5_ok = ok
275
+ app.state.mt5_message = message
276
+ if not ok:
277
+ raise HTTPException(status_code=400, detail=message)
278
+ return {"success": True, "mt5": message}
279
+
280
+
281
+ @app.post("/account/disconnect", response_model=ConnectionResponse, summary="Disconnect MT5 account", tags=["Account"])
282
+ def disconnect_account() -> ConnectionResponse:
283
+ app.state.auto_reconnect = False
284
+ ok, message = shutdown_connection()
285
+ app.state.mt5_ok = False
286
+ app.state.mt5_message = message if ok else "failed to disconnect"
287
+ if not ok:
288
+ raise HTTPException(status_code=500, detail=message)
289
+ return {"success": True, "mt5": message}
290
+
291
+
292
+ @app.get("/tickets", response_model=TicketsResponse, summary="Get all active tickets", tags=["Tickets"])
293
+ def tickets() -> TicketsResponse:
294
+ if not app.state.mt5_ok:
295
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
296
+
297
+ ok, data = get_all_tickets()
298
+ if not ok:
299
+ raise HTTPException(status_code=500, detail=str(data))
300
+
301
+ return data
302
+
303
+
304
+ @app.get("/symbols", response_model=SymbolsResponse, summary="Search symbols", tags=["Symbols"])
305
+ def symbols(query: str | None = Query(None, description="Optional case-insensitive filter for symbol names.")) -> SymbolsResponse:
306
+ if not app.state.mt5_ok:
307
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
308
+
309
+ ok, data = search_symbols(query=query)
310
+ if not ok:
311
+ raise HTTPException(status_code=500, detail=str(data))
312
+
313
+ return data
314
+
315
+
316
+ @app.get("/symbols/{name}", response_model=SymbolDetailsResponse, summary="Get symbol details", tags=["Symbols"])
317
+ def symbol_details(name: str) -> SymbolDetailsResponse:
318
+ if not app.state.mt5_ok:
319
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
320
+
321
+ ok, data = get_symbol_details(name=name)
322
+ if not ok:
323
+ raise HTTPException(status_code=404, detail=str(data))
324
+
325
+ return {"symbol": data}
326
+
327
+
328
+ @app.get("/bars/{symbol}", response_model=BarsResponse, summary="Get OHLC bars", tags=["Market Data"])
329
+ def bars(
330
+ symbol: str,
331
+ timeframe: str = Query("M1", description="Timeframe code (example: M1, M5, H1, D1)."),
332
+ n: int = Query(100, ge=1, le=10_000, description="Number of bars to fetch (1..10000)."),
333
+ includeVolume: bool = Query(False, description="Include tick volume in each bar."),
334
+ ) -> BarsResponse:
335
+ if not app.state.mt5_ok:
336
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
337
+
338
+ if n <= 0:
339
+ raise HTTPException(status_code=400, detail="n must be greater than 0")
340
+ if n > 10_000:
341
+ raise HTTPException(status_code=400, detail="n must be less than or equal to 10000")
342
+
343
+ ok, data = get_last_ohlc_bars(
344
+ symbol=symbol,
345
+ timeframe=timeframe,
346
+ count=n,
347
+ includeVolume=includeVolume,
348
+ )
349
+ if not ok:
350
+ error_message = str(data)
351
+ if "unsupported timeframe" in error_message:
352
+ raise HTTPException(status_code=400, detail=error_message)
353
+ if "symbol not found" in error_message:
354
+ raise HTTPException(status_code=404, detail=error_message)
355
+ raise HTTPException(status_code=500, detail=error_message)
356
+
357
+ return data
358
+
359
+
360
+ @app.post("/orders", response_model=OrderExecutionResponse, summary="Create an order", tags=["Orders"])
361
+ def create_order(payload: CreateOrderRequest) -> OrderExecutionResponse:
362
+ if not app.state.mt5_ok:
363
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
364
+
365
+ ok, data = create_market_order(
366
+ symbol=payload.symbol,
367
+ side=payload.side,
368
+ volume=payload.volume,
369
+ orderType=payload.orderType,
370
+ price=payload.price,
371
+ stopLimit=payload.stopLimit,
372
+ deviation=payload.deviation,
373
+ sl=payload.sl,
374
+ tp=payload.tp,
375
+ magic=payload.magic,
376
+ comment=payload.comment,
377
+ )
378
+ if not ok:
379
+ error_message = str(data)
380
+ if (
381
+ "side must be" in error_message
382
+ or "volume must be" in error_message
383
+ or "orderType must be" in error_message
384
+ or "required for" in error_message
385
+ ):
386
+ raise HTTPException(status_code=400, detail=error_message)
387
+ if "symbol not found" in error_message:
388
+ raise HTTPException(status_code=404, detail=error_message)
389
+ raise HTTPException(status_code=500, detail=error_message)
390
+
391
+ return data
392
+
393
+
394
+ @app.get("/positions/open", response_model=OpenPositionsResponse, summary="List open positions", tags=["Positions"])
395
+ def open_positions() -> OpenPositionsResponse:
396
+ if not app.state.mt5_ok:
397
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
398
+
399
+ ok, data = list_open_positions()
400
+ if not ok:
401
+ raise HTTPException(status_code=500, detail=str(data))
402
+
403
+ return data
404
+
405
+
406
+ @app.get("/orders/pending", response_model=PendingOrdersResponse, summary="List pending orders", tags=["Orders"])
407
+ def pending_orders() -> PendingOrdersResponse:
408
+ if not app.state.mt5_ok:
409
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
410
+
411
+ ok, data = list_pending_orders()
412
+ if not ok:
413
+ raise HTTPException(status_code=500, detail=str(data))
414
+
415
+ return data
416
+
417
+
418
+ @app.get("/trades/history", response_model=TradeHistoryResponse, summary="Get trade history", tags=["Trades"])
419
+ def trade_history(
420
+ fromDate: str | None = Query(None, description="Start ISO datetime (UTC recommended)."),
421
+ toDate: str | None = Query(None, description="End ISO datetime (UTC recommended)."),
422
+ ) -> TradeHistoryResponse:
423
+ if not app.state.mt5_ok:
424
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
425
+
426
+ ok, data = list_trade_history(fromDate=fromDate, toDate=toDate)
427
+ if not ok:
428
+ error_message = str(data)
429
+ if "must be a valid ISO datetime" in error_message or "must be earlier than" in error_message:
430
+ raise HTTPException(status_code=400, detail=error_message)
431
+ raise HTTPException(status_code=500, detail=error_message)
432
+
433
+ return data
434
+
435
+
436
+ @app.get("/calendar/events", response_model=CalendarEventsResponse, summary="Get calendar events", tags=["Calendar"])
437
+ def calendar_events(
438
+ fromDate: str | None = Query(None, description="Start ISO datetime (UTC recommended)."),
439
+ toDate: str | None = Query(None, description="End ISO datetime (UTC recommended)."),
440
+ country: str | None = Query(None, description="Optional country filter (example: US)."),
441
+ currency: str | None = Query(None, description="Optional currency filter (example: USD)."),
442
+ ) -> CalendarEventsResponse:
443
+ if not app.state.mt5_ok:
444
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
445
+
446
+ ok, data = list_calendar_events(
447
+ from_date=fromDate,
448
+ to_date=toDate,
449
+ country=country,
450
+ currency=currency,
451
+ )
452
+ if not ok:
453
+ error_message = str(data)
454
+ if "must be a valid ISO datetime" in error_message or "must be earlier than" in error_message:
455
+ raise HTTPException(status_code=400, detail=error_message)
456
+ if "not available" in error_message:
457
+ raise HTTPException(status_code=501, detail=error_message)
458
+ raise HTTPException(status_code=500, detail=error_message)
459
+
460
+ return data
461
+
462
+
463
+ @app.post("/orders/close", response_model=CloseOrderResponse, summary="Close position or cancel pending order", tags=["Orders"])
464
+ def close_order(payload: CloseOrderRequest) -> CloseOrderResponse:
465
+ if not app.state.mt5_ok:
466
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
467
+
468
+ ok, data = close_by_ticket(
469
+ ticket=payload.ticket,
470
+ volume=payload.volume,
471
+ deviation=payload.deviation,
472
+ comment=payload.comment,
473
+ )
474
+ if not ok:
475
+ error_message = str(data)
476
+ if "ticket must be" in error_message or "volume " in error_message:
477
+ raise HTTPException(status_code=400, detail=error_message)
478
+ if "ticket not found" in error_message:
479
+ raise HTTPException(status_code=404, detail=error_message)
480
+ raise HTTPException(status_code=500, detail=error_message)
481
+
482
+ return data
483
+
484
+
485
+ @app.websocket("/ws/positions/open")
486
+ async def ws_open_positions(websocket: WebSocket, intervalSeconds: float = 1.0) -> None:
487
+ await websocket.accept()
488
+ interval = _bounded_interval(float(intervalSeconds))
489
+
490
+ try:
491
+ await websocket.send_json(
492
+ {
493
+ "event": "subscribed",
494
+ "timestamp": _utc_now_iso(),
495
+ "stream": "openPositions",
496
+ "intervalSeconds": interval,
497
+ }
498
+ )
499
+
500
+ while True:
501
+ ok, payload = get_open_positions_snapshot()
502
+ if ok:
503
+ await websocket.send_json(payload)
504
+ else:
505
+ await websocket.send_json(
506
+ {
507
+ "event": "error",
508
+ "timestamp": _utc_now_iso(),
509
+ "stream": "openPositions",
510
+ "message": str(payload),
511
+ }
512
+ )
513
+ await asyncio.sleep(interval)
514
+ except WebSocketDisconnect:
515
+ return
516
+
517
+
518
+ @app.get(
519
+ "/ws/positions/open/docs",
520
+ response_model=WebSocketDocsResponse,
521
+ summary="WebSocket docs: open positions stream",
522
+ tags=["WebSocket Docs"],
523
+ )
524
+ def ws_positions_open_docs() -> WebSocketDocsResponse:
525
+ return {
526
+ "stream": "openPositions",
527
+ "websocketPath": "/ws/positions/open",
528
+ "websocketExampleUrl": "ws://127.0.0.1:8000/ws/positions/open?intervalSeconds=1",
529
+ "queryParams": {
530
+ "intervalSeconds": "float, optional, bounded to 0.2..60 (default 1.0)",
531
+ },
532
+ "events": [
533
+ "subscribed",
534
+ "positionsSnapshot",
535
+ "error",
536
+ ],
537
+ "sampleMessages": [
538
+ {
539
+ "event": "subscribed",
540
+ "timestamp": "2026-03-12T10:00:00+00:00",
541
+ "stream": "openPositions",
542
+ "intervalSeconds": 1.0,
543
+ },
544
+ {
545
+ "event": "positionsSnapshot",
546
+ "timestamp": "2026-03-12T10:00:01+00:00",
547
+ "count": 2,
548
+ "totalPnl": 42.7,
549
+ "positions": [
550
+ {
551
+ "ticket": 123456789,
552
+ "symbol": "EURUSD",
553
+ "volume": 0.1,
554
+ "profit": 25.4,
555
+ "pnl": 25.4,
556
+ }
557
+ ],
558
+ },
559
+ ],
560
+ }
@@ -0,0 +1 @@
1
+ """Service layer for Open MT5 API."""
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ import MetaTrader5 as mt5
4
+ from app.services.serialization import toJsonable
5
+
6
+
7
+ def get_account_info() -> tuple[bool, dict[str, Any] | str]:
8
+ """
9
+ Fetch account information from the active MT5 session.
10
+
11
+ Returns:
12
+ (True, account_info_dict) on success
13
+ (False, error_message) on failure
14
+ """
15
+ accountInfo = mt5.account_info()
16
+ if accountInfo is None:
17
+ return False, f"failed to fetch account info: {mt5.last_error()}"
18
+
19
+ return True, toJsonable(accountInfo)
@@ -0,0 +1,79 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ import MetaTrader5 as mt5
5
+
6
+
7
+ _TIMEFRAME_MAP: dict[str, int] = {
8
+ "M1": mt5.TIMEFRAME_M1,
9
+ "M2": mt5.TIMEFRAME_M2,
10
+ "M3": mt5.TIMEFRAME_M3,
11
+ "M4": mt5.TIMEFRAME_M4,
12
+ "M5": mt5.TIMEFRAME_M5,
13
+ "M6": mt5.TIMEFRAME_M6,
14
+ "M10": mt5.TIMEFRAME_M10,
15
+ "M12": mt5.TIMEFRAME_M12,
16
+ "M15": mt5.TIMEFRAME_M15,
17
+ "M20": mt5.TIMEFRAME_M20,
18
+ "M30": mt5.TIMEFRAME_M30,
19
+ "H1": mt5.TIMEFRAME_H1,
20
+ "H2": mt5.TIMEFRAME_H2,
21
+ "H3": mt5.TIMEFRAME_H3,
22
+ "H4": mt5.TIMEFRAME_H4,
23
+ "H6": mt5.TIMEFRAME_H6,
24
+ "H8": mt5.TIMEFRAME_H8,
25
+ "H12": mt5.TIMEFRAME_H12,
26
+ "D1": mt5.TIMEFRAME_D1,
27
+ "W1": mt5.TIMEFRAME_W1,
28
+ "MN1": mt5.TIMEFRAME_MN1,
29
+ }
30
+
31
+
32
+ def get_last_ohlc_bars(
33
+ symbol: str,
34
+ timeframe: str,
35
+ count: int,
36
+ includeVolume: bool = False,
37
+ ) -> tuple[bool, dict[str, Any] | str]:
38
+ """
39
+ Fetch the last N OHLC bars for a symbol and timeframe.
40
+
41
+ Returns:
42
+ (True, payload) on success
43
+ (False, error_message) on failure
44
+ """
45
+ timeframeValue = _TIMEFRAME_MAP.get(timeframe.upper())
46
+ if timeframeValue is None:
47
+ supported = ", ".join(_TIMEFRAME_MAP.keys())
48
+ return False, f"unsupported timeframe '{timeframe}'. supported: {supported}"
49
+
50
+ symbolInfo = mt5.symbol_info(symbol)
51
+ if symbolInfo is None:
52
+ return False, f"symbol not found or unavailable: {symbol}"
53
+
54
+ if not symbolInfo.visible and not mt5.symbol_select(symbol, True):
55
+ return False, f"failed to select symbol '{symbol}': {mt5.last_error()}"
56
+
57
+ rates = mt5.copy_rates_from_pos(symbol, timeframeValue, 0, count)
58
+ if rates is None:
59
+ return False, f"failed to fetch bars: {mt5.last_error()}"
60
+
61
+ bars: list[dict[str, Any]] = []
62
+ for rate in rates:
63
+ bar = {
64
+ "time": datetime.fromtimestamp(int(rate["time"]), tz=timezone.utc).isoformat(),
65
+ "open": float(rate["open"]),
66
+ "high": float(rate["high"]),
67
+ "low": float(rate["low"]),
68
+ "close": float(rate["close"]),
69
+ }
70
+ if includeVolume:
71
+ bar["volume"] = int(rate["tick_volume"])
72
+ bars.append(bar)
73
+
74
+ return True, {
75
+ "symbol": symbol,
76
+ "timeframe": timeframe.upper(),
77
+ "count": len(bars),
78
+ "bars": bars,
79
+ }