olza-api-mt5 0.1.0__tar.gz

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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: olza-api-mt5
3
+ Version: 0.1.0
4
+ Summary: REST and WebSocket API project for MetaTrader 5
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi<1.0.0,>=0.116.0
8
+ Requires-Dist: MetaTrader5<6.0.0,>=5.0.0
9
+ Requires-Dist: uvicorn[standard]<1.0.0,>=0.35.0
10
+
11
+ # python-api-mt5
12
+
13
+ Starter project for a MetaTrader 5 API service with FastAPI.
14
+
15
+ ## Setup
16
+
17
+ 1. Create a virtual environment:
18
+ - Windows PowerShell: `python -m venv .venv`
19
+ 2. Activate it:
20
+ - `.\.venv\Scripts\Activate.ps1`
21
+ 3. Install dependencies:
22
+ - `python -m pip install -U pip`
23
+ - `pip install -e .`
24
+
25
+ ## MT5 startup config
26
+
27
+ The API initializes MetaTrader 5 when FastAPI starts and closes it when FastAPI stops.
28
+ It also checks MT5 connection every 5 seconds and tries to reconnect automatically if disconnected.
29
+
30
+ Set these environment variables in PowerShell before running:
31
+
32
+ ```powershell
33
+ $env:MT5_PATH = "C:\Program Files\MetaTrader 5\terminal64.exe"
34
+ $env:MT5_LOGIN = "12345678"
35
+ $env:MT5_PASSWORD = "your-password"
36
+ $env:MT5_SERVER = "YourBroker-Server"
37
+ ```
38
+
39
+ `MT5_PATH` is optional if MT5 is already discoverable, but setting it is recommended.
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ olza-api-mt5
45
+ ```
46
+
47
+ Optional flags:
48
+
49
+ ```bash
50
+ olza-api-mt5 --port 9000
51
+ olza-api-mt5 --host 0.0.0.0 --port 8000
52
+ olza-api-mt5 --reload
53
+ ```
54
+
55
+ Default port is `8000`.
56
+
57
+ API docs:
58
+ - Swagger UI: `http://127.0.0.1:8000/docs`
59
+ - ReDoc: `http://127.0.0.1:8000/redoc`
60
+
61
+ Health endpoint:
62
+ - `GET http://127.0.0.1:8000/health`
63
+
64
+ ## Build and publish package
65
+
66
+ 1. Build distribution files:
67
+
68
+ ```bash
69
+ python -m pip install --upgrade build twine
70
+ python -m build
71
+ ```
72
+
73
+ 2. Upload to PyPI:
74
+
75
+ ```bash
76
+ python -m twine upload dist/*
77
+ ```
78
+
79
+ 3. Install from PyPI and run:
80
+
81
+ ```bash
82
+ pip install olza-api-mt5
83
+ olza-api-mt5 --port 8000
84
+ ```
85
+
86
+ ## Optional: standalone executable (no Python required on target machine)
87
+
88
+ If you want users to run it without installing Python, build an executable:
89
+
90
+ ```bash
91
+ python -m pip install pyinstaller
92
+ pyinstaller --onefile --name olza-api-mt5 app/cli.py
93
+ ```
94
+
95
+ The executable will be in `dist/olza-api-mt5.exe`.
@@ -0,0 +1,85 @@
1
+ # python-api-mt5
2
+
3
+ Starter project for a MetaTrader 5 API service with FastAPI.
4
+
5
+ ## Setup
6
+
7
+ 1. Create a virtual environment:
8
+ - Windows PowerShell: `python -m venv .venv`
9
+ 2. Activate it:
10
+ - `.\.venv\Scripts\Activate.ps1`
11
+ 3. Install dependencies:
12
+ - `python -m pip install -U pip`
13
+ - `pip install -e .`
14
+
15
+ ## MT5 startup config
16
+
17
+ The API initializes MetaTrader 5 when FastAPI starts and closes it when FastAPI stops.
18
+ It also checks MT5 connection every 5 seconds and tries to reconnect automatically if disconnected.
19
+
20
+ Set these environment variables in PowerShell before running:
21
+
22
+ ```powershell
23
+ $env:MT5_PATH = "C:\Program Files\MetaTrader 5\terminal64.exe"
24
+ $env:MT5_LOGIN = "12345678"
25
+ $env:MT5_PASSWORD = "your-password"
26
+ $env:MT5_SERVER = "YourBroker-Server"
27
+ ```
28
+
29
+ `MT5_PATH` is optional if MT5 is already discoverable, but setting it is recommended.
30
+
31
+ ## Run
32
+
33
+ ```bash
34
+ olza-api-mt5
35
+ ```
36
+
37
+ Optional flags:
38
+
39
+ ```bash
40
+ olza-api-mt5 --port 9000
41
+ olza-api-mt5 --host 0.0.0.0 --port 8000
42
+ olza-api-mt5 --reload
43
+ ```
44
+
45
+ Default port is `8000`.
46
+
47
+ API docs:
48
+ - Swagger UI: `http://127.0.0.1:8000/docs`
49
+ - ReDoc: `http://127.0.0.1:8000/redoc`
50
+
51
+ Health endpoint:
52
+ - `GET http://127.0.0.1:8000/health`
53
+
54
+ ## Build and publish package
55
+
56
+ 1. Build distribution files:
57
+
58
+ ```bash
59
+ python -m pip install --upgrade build twine
60
+ python -m build
61
+ ```
62
+
63
+ 2. Upload to PyPI:
64
+
65
+ ```bash
66
+ python -m twine upload dist/*
67
+ ```
68
+
69
+ 3. Install from PyPI and run:
70
+
71
+ ```bash
72
+ pip install olza-api-mt5
73
+ olza-api-mt5 --port 8000
74
+ ```
75
+
76
+ ## Optional: standalone executable (no Python required on target machine)
77
+
78
+ If you want users to run it without installing Python, build an executable:
79
+
80
+ ```bash
81
+ python -m pip install pyinstaller
82
+ pyinstaller --onefile --name olza-api-mt5 app/cli.py
83
+ ```
84
+
85
+ The executable will be in `dist/olza-api-mt5.exe`.
@@ -0,0 +1 @@
1
+ """OLZA MT5 API package."""
@@ -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 OLZA 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)
@@ -0,0 +1,268 @@
1
+ import os
2
+ import asyncio
3
+ from contextlib import asynccontextmanager
4
+
5
+ import MetaTrader5 as mt5
6
+ from fastapi import FastAPI, HTTPException
7
+ from pydantic import BaseModel
8
+ from app.services.account_service import get_account_info
9
+ from app.services.bar_service import get_last_ohlc_bars
10
+ from app.services.symbol_service import get_symbol_details, search_symbols
11
+ from app.services.ticket_service import get_all_tickets
12
+ from app.services.trade_service import close_by_ticket, create_market_order, list_open_positions, list_pending_orders
13
+
14
+
15
+ class CreateOrderRequest(BaseModel):
16
+ symbol: str
17
+ side: str
18
+ volume: float
19
+ orderType: str = "market"
20
+ price: float | None = None
21
+ stopLimit: float | None = None
22
+ deviation: int = 20
23
+ sl: float | None = None
24
+ tp: float | None = None
25
+ magic: int = 0
26
+ comment: str = "api-order"
27
+
28
+
29
+ class CloseOrderRequest(BaseModel):
30
+ ticket: int
31
+ volume: float | None = None
32
+ deviation: int = 20
33
+ comment: str = "api-close"
34
+
35
+
36
+ def _initialize_mt5() -> tuple[bool, str]:
37
+ terminal_path = os.getenv("MT5_PATH")
38
+ login_raw = os.getenv("MT5_LOGIN")
39
+ password = os.getenv("MT5_PASSWORD")
40
+ server = os.getenv("MT5_SERVER")
41
+
42
+ init_kwargs: dict[str, object] = {}
43
+ if terminal_path:
44
+ init_kwargs["path"] = terminal_path
45
+ if login_raw:
46
+ try:
47
+ init_kwargs["login"] = int(login_raw)
48
+ except ValueError:
49
+ return False, "failed: MT5_LOGIN must be a number"
50
+ if password:
51
+ init_kwargs["password"] = password
52
+ if server:
53
+ init_kwargs["server"] = server
54
+
55
+ initialized = mt5.initialize(**init_kwargs)
56
+ if initialized:
57
+ return True, "connected"
58
+
59
+ last_error = mt5.last_error()
60
+ return False, f"failed: {last_error}"
61
+
62
+
63
+ def _check_mt5_connection() -> tuple[bool, str]:
64
+ terminal_info = mt5.terminal_info()
65
+ if terminal_info is None:
66
+ return False, "disconnected"
67
+ return True, "connected"
68
+
69
+
70
+ async def _mt5_monitor_loop(app_instance: FastAPI, interval_seconds: int = 5) -> None:
71
+ while True:
72
+ connected, message = _check_mt5_connection()
73
+ if not connected:
74
+ mt5_ok, mt5_message = _initialize_mt5()
75
+ app_instance.state.mt5_ok = mt5_ok
76
+ app_instance.state.mt5_message = mt5_message
77
+ else:
78
+ app_instance.state.mt5_ok = True
79
+ app_instance.state.mt5_message = message
80
+
81
+ await asyncio.sleep(interval_seconds)
82
+
83
+
84
+ @asynccontextmanager
85
+ async def lifespan(_: FastAPI):
86
+ mt5_ok, mt5_message = _initialize_mt5()
87
+ app.state.mt5_ok = mt5_ok
88
+ app.state.mt5_message = mt5_message
89
+ monitor_task = asyncio.create_task(_mt5_monitor_loop(app))
90
+ try:
91
+ yield
92
+ finally:
93
+ monitor_task.cancel()
94
+ try:
95
+ await monitor_task
96
+ except asyncio.CancelledError:
97
+ pass
98
+ mt5.shutdown()
99
+
100
+ app = FastAPI(
101
+ title="MT5 API",
102
+ version="0.1.0",
103
+ description="Starter FastAPI service for MetaTrader 5 integration.",
104
+ lifespan=lifespan,
105
+ )
106
+
107
+
108
+ @app.get("/health")
109
+ def health() -> dict[str, str]:
110
+ return {
111
+ "status": "ok",
112
+ "mt5": "connected" if app.state.mt5_ok else app.state.mt5_message,
113
+ }
114
+
115
+
116
+ @app.get("/account")
117
+ def account() -> dict:
118
+ if not app.state.mt5_ok:
119
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
120
+
121
+ ok, data = get_account_info()
122
+ if not ok:
123
+ raise HTTPException(status_code=500, detail=str(data))
124
+
125
+ return {"account": data}
126
+
127
+
128
+ @app.get("/tickets")
129
+ def tickets() -> dict:
130
+ if not app.state.mt5_ok:
131
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
132
+
133
+ ok, data = get_all_tickets()
134
+ if not ok:
135
+ raise HTTPException(status_code=500, detail=str(data))
136
+
137
+ return data
138
+
139
+
140
+ @app.get("/symbols")
141
+ def symbols(query: str | None = None) -> dict:
142
+ if not app.state.mt5_ok:
143
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
144
+
145
+ ok, data = search_symbols(query=query)
146
+ if not ok:
147
+ raise HTTPException(status_code=500, detail=str(data))
148
+
149
+ return data
150
+
151
+
152
+ @app.get("/symbols/{name}")
153
+ def symbol_details(name: str) -> dict:
154
+ if not app.state.mt5_ok:
155
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
156
+
157
+ ok, data = get_symbol_details(name=name)
158
+ if not ok:
159
+ raise HTTPException(status_code=404, detail=str(data))
160
+
161
+ return {"symbol": data}
162
+
163
+
164
+ @app.get("/bars/{symbol}")
165
+ def bars(symbol: str, timeframe: str = "M1", n: int = 100, include_volume: bool = False) -> dict:
166
+ if not app.state.mt5_ok:
167
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
168
+
169
+ if n <= 0:
170
+ raise HTTPException(status_code=400, detail="n must be greater than 0")
171
+ if n > 10_000:
172
+ raise HTTPException(status_code=400, detail="n must be less than or equal to 10000")
173
+
174
+ ok, data = get_last_ohlc_bars(
175
+ symbol=symbol,
176
+ timeframe=timeframe,
177
+ count=n,
178
+ include_volume=include_volume,
179
+ )
180
+ if not ok:
181
+ error_message = str(data)
182
+ if "unsupported timeframe" in error_message:
183
+ raise HTTPException(status_code=400, detail=error_message)
184
+ if "symbol not found" in error_message:
185
+ raise HTTPException(status_code=404, detail=error_message)
186
+ raise HTTPException(status_code=500, detail=error_message)
187
+
188
+ return data
189
+
190
+
191
+ @app.post("/orders")
192
+ def create_order(payload: CreateOrderRequest) -> dict:
193
+ if not app.state.mt5_ok:
194
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
195
+
196
+ ok, data = create_market_order(
197
+ symbol=payload.symbol,
198
+ side=payload.side,
199
+ volume=payload.volume,
200
+ orderType=payload.orderType,
201
+ price=payload.price,
202
+ stopLimit=payload.stopLimit,
203
+ deviation=payload.deviation,
204
+ sl=payload.sl,
205
+ tp=payload.tp,
206
+ magic=payload.magic,
207
+ comment=payload.comment,
208
+ )
209
+ if not ok:
210
+ error_message = str(data)
211
+ if (
212
+ "side must be" in error_message
213
+ or "volume must be" in error_message
214
+ or "orderType must be" in error_message
215
+ or "required for" in error_message
216
+ ):
217
+ raise HTTPException(status_code=400, detail=error_message)
218
+ if "symbol not found" in error_message:
219
+ raise HTTPException(status_code=404, detail=error_message)
220
+ raise HTTPException(status_code=500, detail=error_message)
221
+
222
+ return data
223
+
224
+
225
+ @app.get("/positions/open")
226
+ def open_positions() -> dict:
227
+ if not app.state.mt5_ok:
228
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
229
+
230
+ ok, data = list_open_positions()
231
+ if not ok:
232
+ raise HTTPException(status_code=500, detail=str(data))
233
+
234
+ return data
235
+
236
+
237
+ @app.get("/orders/pending")
238
+ def pending_orders() -> dict:
239
+ if not app.state.mt5_ok:
240
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
241
+
242
+ ok, data = list_pending_orders()
243
+ if not ok:
244
+ raise HTTPException(status_code=500, detail=str(data))
245
+
246
+ return data
247
+
248
+
249
+ @app.post("/orders/close")
250
+ def close_order(payload: CloseOrderRequest) -> dict:
251
+ if not app.state.mt5_ok:
252
+ raise HTTPException(status_code=503, detail=app.state.mt5_message)
253
+
254
+ ok, data = close_by_ticket(
255
+ ticket=payload.ticket,
256
+ volume=payload.volume,
257
+ deviation=payload.deviation,
258
+ comment=payload.comment,
259
+ )
260
+ if not ok:
261
+ error_message = str(data)
262
+ if "ticket must be" in error_message or "volume " in error_message:
263
+ raise HTTPException(status_code=400, detail=error_message)
264
+ if "ticket not found" in error_message:
265
+ raise HTTPException(status_code=404, detail=error_message)
266
+ raise HTTPException(status_code=500, detail=error_message)
267
+
268
+ return data
@@ -0,0 +1 @@
1
+ """Service layer for OLZA MT5 API."""
@@ -0,0 +1,18 @@
1
+ from typing import Any
2
+
3
+ import MetaTrader5 as mt5
4
+
5
+
6
+ def get_account_info() -> tuple[bool, dict[str, Any] | str]:
7
+ """
8
+ Fetch account information from the active MT5 session.
9
+
10
+ Returns:
11
+ (True, account_info_dict) on success
12
+ (False, error_message) on failure
13
+ """
14
+ account_info = mt5.account_info()
15
+ if account_info is None:
16
+ return False, f"failed to fetch account info: {mt5.last_error()}"
17
+
18
+ return True, account_info._asdict()
@@ -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
+ include_volume: 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
+ timeframe_value = _TIMEFRAME_MAP.get(timeframe.upper())
46
+ if timeframe_value is None:
47
+ supported = ", ".join(_TIMEFRAME_MAP.keys())
48
+ return False, f"unsupported timeframe '{timeframe}'. supported: {supported}"
49
+
50
+ symbol_info = mt5.symbol_info(symbol)
51
+ if symbol_info is None:
52
+ return False, f"symbol not found or unavailable: {symbol}"
53
+
54
+ if not symbol_info.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, timeframe_value, 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 include_volume:
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
+ }
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+
3
+ import MetaTrader5 as mt5
4
+
5
+
6
+ def search_symbols(query: str | None = None) -> tuple[bool, dict[str, Any] | str]:
7
+ """
8
+ Fetch all available symbols, optionally filtered by substring.
9
+
10
+ Returns:
11
+ (True, payload) on success
12
+ (False, error_message) on failure
13
+ """
14
+ symbols = mt5.symbols_get()
15
+ if symbols is None:
16
+ return False, f"failed to fetch symbols: {mt5.last_error()}"
17
+
18
+ if query:
19
+ lowered_query = query.lower()
20
+ filtered_symbols = [symbol.name for symbol in symbols if lowered_query in symbol.name.lower()]
21
+ else:
22
+ filtered_symbols = [symbol.name for symbol in symbols]
23
+
24
+ return True, {
25
+ "query": query,
26
+ "count": len(filtered_symbols),
27
+ "symbols": filtered_symbols,
28
+ }
29
+
30
+
31
+ def get_symbol_details(name: str) -> tuple[bool, dict[str, Any] | str]:
32
+ """
33
+ Fetch details for a single symbol by its exact name.
34
+
35
+ Returns:
36
+ (True, payload) on success
37
+ (False, error_message) on failure
38
+ """
39
+ symbol_info = mt5.symbol_info(name)
40
+ if symbol_info is None:
41
+ return False, f"symbol not found or unavailable: {name}"
42
+
43
+ return True, symbol_info._asdict()
@@ -0,0 +1,29 @@
1
+ from typing import Any
2
+
3
+ import MetaTrader5 as mt5
4
+
5
+
6
+ def get_all_tickets() -> tuple[bool, dict[str, Any] | str]:
7
+ """
8
+ Fetch currently available MT5 tickets from open positions and active orders.
9
+
10
+ Returns:
11
+ (True, payload) on success
12
+ (False, error_message) on failure
13
+ """
14
+ positions = mt5.positions_get()
15
+ if positions is None:
16
+ return False, f"failed to fetch positions: {mt5.last_error()}"
17
+
18
+ orders = mt5.orders_get()
19
+ if orders is None:
20
+ return False, f"failed to fetch orders: {mt5.last_error()}"
21
+
22
+ position_tickets = [int(position.ticket) for position in positions]
23
+ order_tickets = [int(order.ticket) for order in orders]
24
+
25
+ return True, {
26
+ "position_tickets": position_tickets,
27
+ "order_tickets": order_tickets,
28
+ "total": len(position_tickets) + len(order_tickets),
29
+ }
@@ -0,0 +1,265 @@
1
+ from typing import Any
2
+
3
+ import MetaTrader5 as mt5
4
+
5
+
6
+ def _to_jsonable(value: Any) -> Any:
7
+ if hasattr(value, "_asdict"):
8
+ return {k: _to_jsonable(v) for k, v in value._asdict().items()}
9
+ if isinstance(value, dict):
10
+ return {k: _to_jsonable(v) for k, v in value.items()}
11
+ if isinstance(value, (list, tuple)):
12
+ return [_to_jsonable(item) for item in value]
13
+ if isinstance(value, (str, int, float, bool)) or value is None:
14
+ return value
15
+ return str(value)
16
+
17
+
18
+ def _ensure_symbol(symbol: str) -> tuple[bool, str | Any]:
19
+ info = mt5.symbol_info(symbol)
20
+ if info is None:
21
+ return False, f"symbol not found or unavailable: {symbol}"
22
+
23
+ if not info.visible and not mt5.symbol_select(symbol, True):
24
+ return False, f"failed to select symbol '{symbol}': {mt5.last_error()}"
25
+
26
+ return True, info
27
+
28
+
29
+ def _resolve_filling_mode(info: Any) -> int:
30
+ mode = int(info.filling_mode)
31
+ allowed = {mt5.ORDER_FILLING_FOK, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_RETURN}
32
+ if mode in allowed:
33
+ return mode
34
+ return mt5.ORDER_FILLING_RETURN
35
+
36
+
37
+ def _filling_candidates(info: Any) -> list[int]:
38
+ primary = _resolve_filling_mode(info)
39
+ fallback_order = [primary, mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK]
40
+ candidates: list[int] = []
41
+ for filling in fallback_order:
42
+ if filling not in candidates:
43
+ candidates.append(filling)
44
+ return candidates
45
+
46
+
47
+ def _send_with_filling_fallback(request: dict[str, Any], info: Any) -> Any:
48
+ last_result = None
49
+ for filling in _filling_candidates(info):
50
+ req = dict(request)
51
+ req["type_filling"] = filling
52
+ result = mt5.order_send(req)
53
+ if result is None:
54
+ last_result = None
55
+ continue
56
+ last_result = result
57
+ if int(getattr(result, "retcode", -1)) != mt5.TRADE_RETCODE_INVALID_FILL:
58
+ return result
59
+ return last_result
60
+
61
+
62
+ def create_market_order(
63
+ symbol: str,
64
+ side: str,
65
+ volume: float,
66
+ orderType: str = "market",
67
+ price: float | None = None,
68
+ stopLimit: float | None = None,
69
+ deviation: int = 20,
70
+ sl: float | None = None,
71
+ tp: float | None = None,
72
+ magic: int = 0,
73
+ comment: str = "api-order",
74
+ ) -> tuple[bool, dict[str, Any] | str]:
75
+ normalized_side = side.lower()
76
+ if normalized_side not in {"buy", "sell"}:
77
+ return False, "side must be 'buy' or 'sell'"
78
+ if volume <= 0:
79
+ return False, "volume must be greater than 0"
80
+
81
+ orderTypeKey = orderType.replace("_", "").lower()
82
+ orderTypeMap = {
83
+ "market": "market",
84
+ "limit": "limit",
85
+ "stop": "stop",
86
+ "stoplimit": "stopLimit",
87
+ }
88
+ normalized_order_type = orderTypeMap.get(orderTypeKey)
89
+ supportedKinds = {"market", "limit", "stop", "stopLimit"}
90
+ if normalized_order_type not in supportedKinds:
91
+ return False, "orderType must be one of: market, limit, stop, stopLimit"
92
+
93
+ ok, info_or_error = _ensure_symbol(symbol)
94
+ if not ok:
95
+ return False, str(info_or_error)
96
+ symbol_info = info_or_error
97
+
98
+ tick = mt5.symbol_info_tick(symbol)
99
+ if tick is None:
100
+ return False, f"failed to fetch tick for symbol '{symbol}': {mt5.last_error()}"
101
+
102
+ is_buy = normalized_side == "buy"
103
+
104
+ if normalized_order_type == "market":
105
+ action = mt5.TRADE_ACTION_DEAL
106
+ orderTypeValue = mt5.ORDER_TYPE_BUY if is_buy else mt5.ORDER_TYPE_SELL
107
+ order_price = float(tick.ask if is_buy else tick.bid)
108
+ elif normalized_order_type == "limit":
109
+ if price is None:
110
+ return False, "price is required for limit orders"
111
+ action = mt5.TRADE_ACTION_PENDING
112
+ orderTypeValue = mt5.ORDER_TYPE_BUY_LIMIT if is_buy else mt5.ORDER_TYPE_SELL_LIMIT
113
+ order_price = float(price)
114
+ elif normalized_order_type == "stop":
115
+ if price is None:
116
+ return False, "price is required for stop orders"
117
+ action = mt5.TRADE_ACTION_PENDING
118
+ orderTypeValue = mt5.ORDER_TYPE_BUY_STOP if is_buy else mt5.ORDER_TYPE_SELL_STOP
119
+ order_price = float(price)
120
+ else:
121
+ if price is None:
122
+ return False, "price is required for stopLimit orders"
123
+ if stopLimit is None:
124
+ return False, "stopLimit is required for stopLimit orders"
125
+ action = mt5.TRADE_ACTION_PENDING
126
+ orderTypeValue = mt5.ORDER_TYPE_BUY_STOP_LIMIT if is_buy else mt5.ORDER_TYPE_SELL_STOP_LIMIT
127
+ order_price = float(price)
128
+
129
+ request: dict[str, Any] = {
130
+ "action": action,
131
+ "symbol": symbol,
132
+ "volume": float(volume),
133
+ "type": orderTypeValue,
134
+ "price": order_price,
135
+ "deviation": int(deviation),
136
+ "magic": int(magic),
137
+ "comment": comment,
138
+ "type_time": mt5.ORDER_TIME_GTC,
139
+ }
140
+ if normalized_order_type == "stopLimit":
141
+ request["stoplimit"] = float(stopLimit)
142
+ if sl is not None:
143
+ request["sl"] = float(sl)
144
+ if tp is not None:
145
+ request["tp"] = float(tp)
146
+
147
+ result = _send_with_filling_fallback(request, symbol_info)
148
+ if result is None:
149
+ return False, f"order_send failed: {mt5.last_error()}"
150
+
151
+ result_dict = _to_jsonable(result)
152
+ result_comment = str(getattr(result, "comment", ""))
153
+ return True, {
154
+ "retcode": result.retcode,
155
+ "success": result.retcode == mt5.TRADE_RETCODE_DONE,
156
+ "message": result_comment,
157
+ "hint": (
158
+ "Enable AutoTrading in the MT5 terminal and allow algorithmic trading."
159
+ if "AutoTrading disabled by client" in result_comment
160
+ else None
161
+ ),
162
+ "result": result_dict,
163
+ }
164
+
165
+
166
+ def list_open_positions() -> tuple[bool, dict[str, Any] | str]:
167
+ positions = mt5.positions_get()
168
+ if positions is None:
169
+ return False, f"failed to fetch positions: {mt5.last_error()}"
170
+
171
+ payload = [_to_jsonable(position) for position in positions]
172
+ return True, {
173
+ "count": len(payload),
174
+ "positions": payload,
175
+ }
176
+
177
+
178
+ def list_pending_orders() -> tuple[bool, dict[str, Any] | str]:
179
+ orders = mt5.orders_get()
180
+ if orders is None:
181
+ return False, f"failed to fetch pending orders: {mt5.last_error()}"
182
+
183
+ payload = [_to_jsonable(order) for order in orders]
184
+ return True, {
185
+ "count": len(payload),
186
+ "orders": payload,
187
+ }
188
+
189
+
190
+ def close_by_ticket(
191
+ ticket: int,
192
+ volume: float | None = None,
193
+ deviation: int = 20,
194
+ comment: str = "api-close",
195
+ ) -> tuple[bool, dict[str, Any] | str]:
196
+ if ticket <= 0:
197
+ return False, "ticket must be greater than 0"
198
+
199
+ pending_orders = mt5.orders_get(ticket=ticket)
200
+ if pending_orders is not None and len(pending_orders) > 0:
201
+ remove_request = {
202
+ "action": mt5.TRADE_ACTION_REMOVE,
203
+ "order": int(ticket),
204
+ "comment": comment,
205
+ }
206
+ remove_result = mt5.order_send(remove_request)
207
+ if remove_result is None:
208
+ return False, f"failed to cancel order: {mt5.last_error()}"
209
+ return True, {
210
+ "operation": "cancel_pending_order",
211
+ "retcode": remove_result.retcode,
212
+ "success": remove_result.retcode == mt5.TRADE_RETCODE_DONE,
213
+ "result": _to_jsonable(remove_result),
214
+ }
215
+
216
+ positions = mt5.positions_get(ticket=ticket)
217
+ if positions is None:
218
+ return False, f"failed to fetch ticket {ticket}: {mt5.last_error()}"
219
+ if len(positions) == 0:
220
+ return False, f"ticket not found in open positions or pending orders: {ticket}"
221
+
222
+ position = positions[0]
223
+ close_volume = float(volume) if volume is not None else float(position.volume)
224
+ if close_volume <= 0:
225
+ return False, "volume must be greater than 0"
226
+ if close_volume > float(position.volume):
227
+ return False, f"volume exceeds position volume ({position.volume})"
228
+
229
+ symbol = str(position.symbol)
230
+ ok, info_or_error = _ensure_symbol(symbol)
231
+ if not ok:
232
+ return False, str(info_or_error)
233
+ symbol_info = info_or_error
234
+
235
+ tick = mt5.symbol_info_tick(symbol)
236
+ if tick is None:
237
+ return False, f"failed to fetch tick for symbol '{symbol}': {mt5.last_error()}"
238
+
239
+ is_buy_position = int(position.type) == mt5.POSITION_TYPE_BUY
240
+ close_type = mt5.ORDER_TYPE_SELL if is_buy_position else mt5.ORDER_TYPE_BUY
241
+ close_price = float(tick.bid if is_buy_position else tick.ask)
242
+
243
+ close_request: dict[str, Any] = {
244
+ "action": mt5.TRADE_ACTION_DEAL,
245
+ "position": int(position.ticket),
246
+ "symbol": symbol,
247
+ "volume": close_volume,
248
+ "type": close_type,
249
+ "price": close_price,
250
+ "deviation": int(deviation),
251
+ "magic": int(position.magic),
252
+ "comment": comment,
253
+ "type_time": mt5.ORDER_TIME_GTC,
254
+ }
255
+
256
+ close_result = _send_with_filling_fallback(close_request, symbol_info)
257
+ if close_result is None:
258
+ return False, f"failed to close position: {mt5.last_error()}"
259
+
260
+ return True, {
261
+ "operation": "close_position",
262
+ "retcode": close_result.retcode,
263
+ "success": close_result.retcode == mt5.TRADE_RETCODE_DONE,
264
+ "result": _to_jsonable(close_result),
265
+ }
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: olza-api-mt5
3
+ Version: 0.1.0
4
+ Summary: REST and WebSocket API project for MetaTrader 5
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi<1.0.0,>=0.116.0
8
+ Requires-Dist: MetaTrader5<6.0.0,>=5.0.0
9
+ Requires-Dist: uvicorn[standard]<1.0.0,>=0.35.0
10
+
11
+ # python-api-mt5
12
+
13
+ Starter project for a MetaTrader 5 API service with FastAPI.
14
+
15
+ ## Setup
16
+
17
+ 1. Create a virtual environment:
18
+ - Windows PowerShell: `python -m venv .venv`
19
+ 2. Activate it:
20
+ - `.\.venv\Scripts\Activate.ps1`
21
+ 3. Install dependencies:
22
+ - `python -m pip install -U pip`
23
+ - `pip install -e .`
24
+
25
+ ## MT5 startup config
26
+
27
+ The API initializes MetaTrader 5 when FastAPI starts and closes it when FastAPI stops.
28
+ It also checks MT5 connection every 5 seconds and tries to reconnect automatically if disconnected.
29
+
30
+ Set these environment variables in PowerShell before running:
31
+
32
+ ```powershell
33
+ $env:MT5_PATH = "C:\Program Files\MetaTrader 5\terminal64.exe"
34
+ $env:MT5_LOGIN = "12345678"
35
+ $env:MT5_PASSWORD = "your-password"
36
+ $env:MT5_SERVER = "YourBroker-Server"
37
+ ```
38
+
39
+ `MT5_PATH` is optional if MT5 is already discoverable, but setting it is recommended.
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ olza-api-mt5
45
+ ```
46
+
47
+ Optional flags:
48
+
49
+ ```bash
50
+ olza-api-mt5 --port 9000
51
+ olza-api-mt5 --host 0.0.0.0 --port 8000
52
+ olza-api-mt5 --reload
53
+ ```
54
+
55
+ Default port is `8000`.
56
+
57
+ API docs:
58
+ - Swagger UI: `http://127.0.0.1:8000/docs`
59
+ - ReDoc: `http://127.0.0.1:8000/redoc`
60
+
61
+ Health endpoint:
62
+ - `GET http://127.0.0.1:8000/health`
63
+
64
+ ## Build and publish package
65
+
66
+ 1. Build distribution files:
67
+
68
+ ```bash
69
+ python -m pip install --upgrade build twine
70
+ python -m build
71
+ ```
72
+
73
+ 2. Upload to PyPI:
74
+
75
+ ```bash
76
+ python -m twine upload dist/*
77
+ ```
78
+
79
+ 3. Install from PyPI and run:
80
+
81
+ ```bash
82
+ pip install olza-api-mt5
83
+ olza-api-mt5 --port 8000
84
+ ```
85
+
86
+ ## Optional: standalone executable (no Python required on target machine)
87
+
88
+ If you want users to run it without installing Python, build an executable:
89
+
90
+ ```bash
91
+ python -m pip install pyinstaller
92
+ pyinstaller --onefile --name olza-api-mt5 app/cli.py
93
+ ```
94
+
95
+ The executable will be in `dist/olza-api-mt5.exe`.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ app/__init__.py
4
+ app/cli.py
5
+ app/main.py
6
+ app/services/__init__.py
7
+ app/services/account_service.py
8
+ app/services/bar_service.py
9
+ app/services/symbol_service.py
10
+ app/services/ticket_service.py
11
+ app/services/trade_service.py
12
+ olza_api_mt5.egg-info/PKG-INFO
13
+ olza_api_mt5.egg-info/SOURCES.txt
14
+ olza_api_mt5.egg-info/dependency_links.txt
15
+ olza_api_mt5.egg-info/entry_points.txt
16
+ olza_api_mt5.egg-info/requires.txt
17
+ olza_api_mt5.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ olza-api-mt5 = app.cli:main
@@ -0,0 +1,3 @@
1
+ fastapi<1.0.0,>=0.116.0
2
+ MetaTrader5<6.0.0,>=5.0.0
3
+ uvicorn[standard]<1.0.0,>=0.35.0
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "olza-api-mt5"
3
+ version = "0.1.0"
4
+ description = "REST and WebSocket API project for MetaTrader 5"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.116.0,<1.0.0",
9
+ "MetaTrader5>=5.0.0,<6.0.0",
10
+ "uvicorn[standard]>=0.35.0,<1.0.0",
11
+ ]
12
+
13
+ [project.scripts]
14
+ olza-api-mt5 = "app.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=68", "wheel"]
18
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+