A production-shaped bridge between MetaTrader 5 and Python: reconnection that actually detects a dead link, broker symbol resolution, order retries that handle the failures brokers really return, and timezone-correct history sync.
The MetaTrader5 package is a thin ctypes wrapper over the terminal. It works,
and then it does not, in ways that are silent:
| What the code does | What actually happens |
|---|---|
mt5.symbol_info("XAUUSD") |
Returns None — this broker calls it XAUUSD.m. The order is never sent and nothing raises. |
Checks mt5.initialize() for health |
Keeps returning True after the server link drops. Quotes go stale, orders fail one by one. |
| Sends an order with one filling mode | Retcode 10030. Which of FOK/IOC/RETURN a symbol accepts is a per-broker, per-symbol decision. |
| Retries a requote at the original price | Fails again. The price that caused the requote is the one being resent. |
Treats retcode 10010 as success |
It is a partial fill. The position is smaller than the code believes. |
history_deals_get(datetime(2026,3,1), ...) |
Naive datetime read as broker server time, which is usually not local. The window silently shifts by hours. |
| Places a stop 5 points away | Retcode 10016 — under the symbol's trade_stops_level. |
Every row is a passing test in this repository.
Nothing outside real.py imports MetaTrader5. Everything programs against
TerminalProtocol.
That is not architecture for its own sake. It buys three concrete things:
- The package installs and tests anywhere.
MetaTrader5is a Windows-only binary wheel; CI here runs on Linux, macOS and Windows. - The nasty paths become ordinary tests. A requote, a rejected filling mode, a half fill, a terminal that drops mid-session — three lines of setup each, instead of waiting for production.
- Swapping backends touches one file.
terminal = FakeTerminal()
terminal.add_symbol("XAUUSD.m", bid=2400.00, ask=2400.30, point=0.01, digits=2)
terminal.supported_filling = (FillingMode.RETURN,) # this broker refuses IOC and FOK
terminal.fail_next_orders(RETCODE_REQUOTE, times=2) # then two requotes
terminal.partial_fill_next(0.5) # then a half fillpip install git+https://github.com/PTHAICAP/mt5-python-bridge.git
# On Windows, with a terminal installed, add the real backend:
pip install "mt5-python-bridge[live] @ git+https://github.com/PTHAICAP/mt5-python-bridge.git"from mt5_bridge import (
ConnectionConfig, MT5Connection, OrderExecutor, OrderRequest,
OrderType, SymbolResolver,
)
from mt5_bridge.real import RealTerminal
terminal = RealTerminal()
conn = MT5Connection(terminal, ConnectionConfig(
login=12345678, password=os.environ["MT5_PASSWORD"], server="Broker-Live",
))
conn.connect()
resolver = SymbolResolver(terminal)
executor = OrderExecutor(terminal, resolver)
# "XAUUSD" resolves to XAUUSD.m / GOLD / XAUUSD.raw — whatever this broker uses
result = executor.send(OrderRequest(
symbol="XAUUSD",
order_type=OrderType.BUY,
volume=0.10,
stop_loss=2380.00,
deviation_points=20,
comment="swing-gold",
))
if result.partial:
remaining = 0.10 - result.volume # your call whether to chase itIn a trading loop, one call keeps the link alive:
while running:
conn.ensure_connected(probe_symbol="XAUUSD") # reconnects if the link died
...History, with the timezone handled:
from mt5_bridge import HistorySync, detect_server_offset, summarise
offset = detect_server_offset(terminal, "EURUSD") # do not hard-code it: DST moves it
sync = HistorySync(terminal, server_utc_offset=offset)
deals = sync.fetch_since(datetime(2026, 3, 1, tzinfo=timezone.utc))
print(summarise(deals))
# {'count': 42, 'gross_profit': 1840.5, 'commission': -126.0,
# 'swap': -18.4, 'net_profit': 1696.1, 'symbols': ('EURUSD', 'XAUUSD')}summarise reports gross and net separately on purpose: on a high-frequency
account commission and swap routinely turn a positive gross into a negative
net, and a summary that hides that is worse than none.
Symbol resolution. 18 common broker suffixes (.m, m, .raw, .ecn,
.pro, _i, .cent, …) plus outright renames — XAUUSD→GOLD,
USTEC→NAS100, USOIL→WTI. Resolved once, cached, and the symbol is
added to Market Watch: a symbol that exists but is not selected returns no
ticks forever, with no error.
Connection health. is_healthy() probes for a live quote instead of
trusting initialize(). ensure_connected() reconnects with exponential
backoff and jitter — jitter matters when several desks share one VPS, or every
process retries in lockstep.
Order execution. Filling-mode fallback with caching, requote retries at a
freshly read price, partial fills surfaced rather than silently retried, stop
distances validated against trade_stops_level before sending, volumes
rounded down to the broker's step, comments truncated to MT5's 31 characters.
Retryable vs terminal. Retcodes are classified. A requote is retried; a
no money or market closed is raised immediately rather than burning three
attempts on something that cannot succeed.
Closing. close_position() sends an opposite deal referencing the
position ticket. A plain opposite order opens a hedge on hedging accounts,
leaving both legs live.
Credentials. Nothing in the package reads a file, an environment variable
or a keyring — the caller supplies them. ConnectionConfig.__repr__ masks the
password so it cannot reach a log line or a traceback.
pip install -e ".[dev]"
pytest --cov=src/mt5_bridge71 tests, 90% branch coverage, no MetaTrader 5 required. CI runs the full suite on Ubuntu, Windows and macOS across Python 3.10–3.12.
tests/test_symbols.py suffix + alias resolution, Market Watch, stops levels
tests/test_orders.py filling fallback, requotes, partial fills, close/modify
tests/test_connection_history.py backoff, health probing, server timezone, dedup
real.py is excluded from coverage: exercising it needs a running Windows
terminal, so pretending otherwise would be a dishonest number.
This is a connectivity layer. No signals, no strategy, no risk logic — for the risk side see trading-risk-monitor.
Not affiliated with MetaQuotes. MetaTrader5 is a trademark of MetaQuotes Ltd.
MIT — see LICENSE.