Metadata-Version: 2.5
Name: pmt5api
Version: 1.1.2
Summary: Complete Python interface to the wmt5api MetaTrader 5 client library (market data, account, sizing, order entry, events).
Project-URL: Homepage, https://mtapi.one
Author: Sen Trading
License-File: LICENSE.txt
Keywords: market-data,metatrader,mt5,trading,wmt5api
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pythonnet>=3.0.3
Provides-Extra: pandas
Requires-Dist: pandas>=1.5; extra == 'pandas'
Provides-Extra: test
Requires-Dist: pandas>=1.5; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# pmt5api — Python for wmt5api

`pmt5api` is the complete Python interface to **wmt5api**, the MetaTrader 5 client library from
[mtapi.one](https://mtapi.one). The protocol, licensing and trading logic stay in the licensed core
(`wmt5api.dll`), which this package loads in-process; every public capability of the C# library is available here
under a Pythonic name, with plain data types, a small error hierarchy, context managers, and sync, async and
callback styles.

No MetaTrader terminal is needed — the core talks to your broker directly.

**Parity with the C# library is measured, not claimed:** `pmt5api.COVERAGE` maps every public member of the C#
`MT5API` class to its Python counterpart (or states why it has none), and the test suite fails if the core gains a
member that is not accounted for. `pmt5api/docs/FROM-CSHARP.md` (inside the installed package) is that table;
`pmt5api/docs/API.md` (inside the installed package; `python -m pmt5api` prints its folder) is the reference.

## Install

```
pip install pmt5api
```

pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
(or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
file: `pip install pmt5api-<version>-py3-none-<platform>.whl`.

**Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt5api`. Check it with `python -m pmt5api`: it prints
the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
PowerShell: `$env:WMTAPI_KEY = "wk_..."`, cmd: `set WMTAPI_KEY=wk_...`. A string the broker sent can hold characters your
console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.

You also need your **API key** (`wk_…`, the same key you use with wmt5api):

```
export WMTAPI_KEY=wk_...          # or pass api_key=... to Client(...)
```

| | |
|---|---|
| Python | 3.9, 3.12 and 3.13 verified; 3.9 or newer required |
| Platform | **Linux x86-64 verified** (glibc 2.28 or newer). Windows and macOS are not verified yet — see *Known limitations* |
| Optional | `pip install pandas` for `to_frame` |

## Quick start

```python
from pmt5api import Client

with Client(login=12345678, password="investor-password", host="broker-access-host", port=443) as c:
    acct = c.account()
    print(acct.name, acct.balance, acct.currency, "equity", acct.equity)

    print(c.quote("EURUSD"))                       # Quote(symbol='EURUSD', bid=..., ask=..., time=...)

    for p in c.positions():
        print(p.ticket, p.symbol, p.order_type, p.lots, p.profit)
```

Use an investor (read-only) password for anything that does not trade.

## Market data

```python
from datetime import datetime
import pmt5api

c.symbols()                       # dict[str, SymbolInfo]: digits, contract size, min/max/step lots, swaps, ...
c.symbol("EURUSD")                # one symbol; find_symbol("EURUSD") resolves a suffixed name such as EURUSDm
c.sessions("EURUSD")              # when it is quoted and traded, per weekday
c.is_trade_session("EURUSD")      # inside a trade session right now?

h1 = c.bars("EURUSD", "H1", count=500)                   # the latest 500 H1 bars, oldest first
d1 = c.bars("XAUUSD", "D1", start=datetime(2026, 1, 1))  # everything since a date
df = pmt5api.to_frame(h1)                                # pandas DataFrame indexed by time
```

Timeframes: `M1 M2 M3 M4 M5 M6 M10 M12 M15 M20 M30 H1 H2 H3 H4 H6 H8 H12 D1 W1 MN1`. The server delivers one-minute
bars; higher timeframes are built from them, aligned to the server's calendar (a day starts at server midnight, an H4
bar at 00, 04, 08, … server time, a week on Sunday, a month on the 1st), with `tick_volume` summed. Minutes after
`end` are not used; the last bar may still be forming. `spread` is present on M1 bars only.

**What a request costs.** The server hands the one-minute bars over in blocks of about two weeks, so the time of a
`bars()` call grows with how far back it reaches, not with the timeframe. Measured from a datacentre against a
broker's demo server: 5 000 one-minute bars 0.4 s; 100 `D1` bars 3 s; 5 000 `H1` bars 9 s; 500 `D1` bars 16 s;
1 000 `D1` bars 32 s (your link and your broker will differ). The default budget is `bars_timeout` plus a second per
block the request needs, so these finish without a `timeout=`; pass one to bound the call yourself.

**MT4 and MT5 differ here.** On MT5 `end=` includes the bar stamped exactly `end` and the bar that is still forming; the
MT4 package returns completed bars only and excludes a bar that opens at `end`. Code that runs on both has to drop
the forming MT5 bar itself.

### Live quotes

```python
with Client(...) as c:
    for q in c.quotes("EURUSD", "XAUUSD", timeout=30):    # ends after 30 s without a tick
        print(q.symbol, q.bid, q.ask)
```

```python
async with Client(...) as c:                               # connects without blocking the event loop
    async with c.aquotes("EURUSD") as quotes:              # attached on entry, detached on exit
        async for q in quotes:
            print(q)
```

```python
remove = c.on_quote(lambda q: print(q))                    # callback style
c.subscribe("EURUSD")
...
remove()
```

Callbacks are delivered by **one dispatcher thread per client**, one at a time, in the order the events arrived — you need no locks
between callbacks, and a slow callback does not stall the connection. If a callback falls more than 20,000 events
behind, the oldest events are dropped, a warning is logged and `c.events_dropped` counts them. An exception raised
inside a callback is logged (`logging.getLogger("pmt5api")`) and swallowed. Order updates keep the state they had at
the moment they arrived (they are snapshots). The iterators attach when you create them, drop the oldest item when the
consumer falls behind, and detach on `close()` / `async with` / garbage collection.

A tick's `time` is the server's: on some brokers about one tick in a hundred carries a time slightly earlier than the
previous tick of the same symbol (the feed is merged from several sources). Delivery order is arrival order; sort by
`time` if you need the market's order.

## Trading

```python
from datetime import datetime

o = c.buy("EURUSD", 0.10, sl=1.0950, tp=1.1100, magic=424242)   # market order at the ask; sell() at the bid
c.modify(o.ticket, sl=1.0970)                     # change stops (returns the order as the server holds it)
c.close(o.ticket, 0.05)                           # close half (returns the closing record); close(ticket) closes it all

p = c.place_order("EURUSD", "buy_stop_limit", 0.10, 1.1200, stop_limit=1.1190,
                  expiration=datetime(2026, 12, 31, 23, 0))   # also buy_limit sell_limit buy_stop sell_stop
c.modify(p.ticket, expiration="gtc")
c.cancel(p.ticket)

c.close(a.ticket, close_by=b.ticket)              # close one position against an opposite one (hedging accounts)
```

`magic` is your own number for the order: it comes back on `Order.magic` and `Deal.magic` (including the deals
that close the position), so a bot can find its own orders and history after a restart.

**Without waiting** — a request id now, the outcome through events:

```python
c.on_order_progress(lambda p: print(p.request_id, p.status, p.ticket))     # REQUEST_ACCEPTED, REQUEST_EXECUTED, ...
rid = c.buy_async("EURUSD", 0.10)
```

**In asyncio** — the synchronous calls on a worker thread: `await c.abuy(...)`, `asell`, `aplace_order`, `amodify`,
`aclose`, `acancel`, `aquote`. Cancelling the `await` (a `wait_for` timeout, a cancelled task) does not recall a request
that is already on its way: the order may still be sent, so check `orders()` afterwards.

**The server, not the library, enforces volume rules.** A volume below the minimum or off the step is sent and refused
(`ServerError` `INVALID_VOLUME`); `symbol(name)` has `min_lots`, `max_lots` and `lots_step` to check against first. A
pending order's expiration is stored to the minute (rounded **down**), so an order can expire up to a minute before the
time you asked for. Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to
`connect_timeout`), not at once.

**Test on a demo account first.** Nothing here is ever retried or resent for you: if the server does not confirm in
time the call raises `RequestTimeout` and the order **may have been accepted** — look at `orders()` before sending
again. `NoQuoteError` is different: no price was available, so nothing was sent. `orders()` can lag a moment behind
a call that has just returned; `close()` and `modify()` read the position's volume and stops from it, so wait for a
partial close to show up before acting on the same position again. `positions()` lists open positions only (orders of
type `Buy` or `Sell`); `orders()` also lists pending orders. A market order needs a price, so even the `*_async`
calls first wait (up to `quote_timeout`) for the symbol's first quote.

## Sizing and risk

Do not size a trade from a one-line formula: what a price unit is worth depends on how *your* broker configured
*your* account (two accounts in the same cent currency can differ by a factor of one hundred). The core measures it
from your own closed deals and refuses to size without that evidence:

```python
from datetime import datetime

if c.calibrate_money_scale(datetime(2025, 1, 1)):                 # read-only; places no order
    r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
    for w in r.warnings:
        print("warning:", w)              # read these: ok can still be True with a caveat
    if r.ok and r.expected_loss_at_stop <= 50.0 * 1.05:
        c.buy("EURUSD", r.lots, sl=1.0950)
    else:
        print("not sizing:", r.reason)
else:
    print(c.money_scale_evidence)
```

`ok` is true even when the smallest allowed volume risks more than you asked for (up to `max_ratio`, default 2×), or
when nothing independent corroborated the price unit — which is why `warnings` and `expected_loss_at_stop` are there
to be checked. Also: `required_margin(symbol, lots)`, `order_profit(symbol, open, close, lots)`, `tick_value(symbol)`,
`ledger_value_per_lot(symbol)`, `money_scale_from_deal(deal)`.

## Account, history and curves

| Call | Returns |
|---|---|
| `account(refresh=True)` | `AccountInfo` — balance, equity, floating P/L (with and without swap/commission), margin, free margin, margin level, leverage, account mode, contact details |
| `orders()` / `positions()` / `closed_orders()` | `list[Order]` — working / open positions only / what this session saw close |
| `history(start, end)` / `deal_history(year, month)` | `History` — `.orders` and `.deals` (the ledger, including deposits and withdrawals) |
| `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals, from the deal ledger |
| `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
| `request_history(...)`, `request_bars(...)` | without waiting; the data arrives through `on_history` / `on_quote_history` |

Times are naive `datetime` values in the **broker's server time**, as the server reports them; an unset time is `None`.

`account()` first quotes each open position's symbol once and recalculates profits, so equity, profit and margin
level are current (a cold read would otherwise show equity equal to balance). Pass `refresh=False` to skip that.

## Events

`on_quote`, `on_order_update`, `on_order_progress`, `on_connect_progress`, `on_history`, `on_quote_history`,
`on_symbols_update`, `on_symbol_update` — each returns a function that removes the callback. `quotes()` and
`order_updates()` (and the `a…` async versions) turn a stream into an iterator.

## Staying connected

Pass `auto_reconnect=True` to let the core re-establish a dropped session (exponential backoff with jitter, failover to
the broker's cluster members, re-subscription). Watch `on_connect_progress` to tell a permanent failure from a
transient one, and bound it with `reconnect_max_attempts`. Do not poll `connected` and call `connect()` in a loop —
brokers rate-limit logins that hammer their access servers. After a reconnect, re-query `account()` and `orders()`;
subscriptions are restored, cached snapshots are not.

## Configuration

| Setting | Argument | Environment variable |
|---|---|---|
| API key | `api_key=` | `WMTAPI_KEY` |
| Licence service URL (leave at its default) | `token_url=` | `WMTAPI_TOKEN_URL` |
| Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMTAPI_DLL` |
| Timeouts (seconds, each > 0) | `connect_timeout` (the whole connect; default 30), `cluster_member_timeout` (per server while failing over; 10), `token_timeout` (10), `execution_timeout` (a trade confirmation; 30), `quote_timeout` (10) / `history_timeout` (30) / `bars_timeout` (10, plus a second per two weeks of one-minute bars a `bars()` call needs) — the defaults of `quote`, `history`, `bars`, `ping_interval` (20), `no_message_timeout` (30) | — |
| Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit) | — |
| Proxy | `proxy=Proxy("socks5" \| "socks4" \| "https", host, port, user, password)` — `host` must be an IP address | — |
| Client certificate / common key | `certificate=`, `certificate_password=`, `common_key=` | — |
| .NET runtime location | — | `DOTNET_ROOT` |

A core path you give (argument or `WMTAPI_DLL`) is used exactly as given and is an error if it does not exist. The
.NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
The bundled runtime runs in invariant-culture mode on machines without ICU.

### Diagnostics

```python
import logging
logging.basicConfig(level=logging.DEBUG)
pmt5api.enable_core_logging()          # the core's own messages -> logging.getLogger("pmt5api.core")
```

### Offline helpers and account administration

`load_servers_dat(path_or_bytes)` / `save_servers_dat(records)` read and write a terminal's `servers.dat`;
`request_demo_account(DemoAccountRequest(...), host, port)` asks a broker for a demo account (it creates a real
account at that broker: use it deliberately); `c.change_password(new, investor=False)`.

`python -m pmt5api` prints what is installed and whether it works here (`--connect` also reads an account, using
`PMT5_LOGIN`, `PMT5_PASSWORD`, `PMT5_HOST` — a bare host, or `host:port`, or the port in `PMT5_PORT` — and the key in
`WMTAPI_KEY`); the output holds no secrets and is what to send when asking for help.

## Errors

Everything the core raises reaches you as one of these, all subclasses of `pmt5api.WmtError`; the original
exception stays available as `__cause__`.

| Exception | Meaning |
|---|---|
| `LicenseError` | The licence service refused the key. `.reason` is one of `NoKey`, `Invalid`, `Expired`, `Revoked`, `RateLimited`, `LoginNotAllowed`, `AccountLimitReached`, `Unknown`. |
| `TokenServiceError` | The licence service could not be reached or answered unusably — retry, don't re-prompt for a key. (`token_timeout` bounds one request and a failed request is tried again once, so a connect to an unreachable licence service can wait about twice that.) |
| `ConnectError` | The broker's access server (or the network, or a proxy) could not be reached. |
| `ConnectionLostError` | An established connection dropped (the core's `Disconnected`). |
| `ServerError` / `InvestorModeError` | The broker refused the request (wrong login or password, a rejected order / a read-only login tried to trade). `.code` is the server's return code by name (`REQUOTE`, `NO_MONEY`, `INVALID_PRICE`, …). |
| `InvalidRequestError` | The request was rejected before it was sent, e.g. an unknown symbol (also a `ValueError`). |
| `BusyError` | The core allows one request of this kind at a time and one is still running (a history or bars download). |
| `ProxyError` | The configured proxy failed. |
| `RequestTimeout` | No answer in time (also a built-in `TimeoutError`). For a trade call the request **was sent**: the order may have been accepted. |
| `NoQuoteError` | No quote arrived in time to read or to trade against. Nothing was sent. |
| `NotConnectedError` | The call needs a connection and the client is not connected. |
| `CoreNotFoundError` / `RuntimeNotFoundError` | `wmt5api.dll` or the .NET runtime was not found; the message says where it looked. |

Calls made through `client.raw` bypass this translation and raise the core's own exceptions.

```python
from pmt5api import Client, LicenseError, TokenServiceError

try:
    c = Client(...).connect()
except LicenseError as e:
    print("licence refused:", e.reason)
except TokenServiceError as e:
    print("licence service unreachable:", e)
```

## What has been verified

Each of these was run against real demo accounts (not only unit-tested), from a clean container with no .NET
installed: connect and licensing (including the production HTTPS licence path); account, symbols and trading rules;
quotes and streams; bars (H1 identical to the core's own conversion on every period compared); history; the sizing
chain (calibration, sizing, refusal of a zero-distance stop, cross-check from a closed deal); summary reconciled
against the deal ledger and an equity curve ending at the account's balance; order entry — market, pending,
stop-limit with expiration, modify, partial close, close-by, cancel, asynchronous with progress events — with the
magic number round-tripping through every deal; a rejected order; connecting through SOCKS5 and HTTPS proxies;
dropping the connection twice mid-stream and recovering with subscriptions restored; a broker that refuses connections
for a while; a link that goes silent without closing (noticed at `no_message_timeout`, then recovered); and a
three-hour unattended run on a demo account — five symbols streamed (147 732 ticks), the account polled every minute
(179 reads), 17 bar and 17 history downloads, 17 far-away pending orders placed and cancelled, the connection cut
four times (each recovered in 2.0–2.5 s with the subscriptions restored) — which finished with no error. Resident
memory settled at about 200 MB (it rises to ~190 MB in the first half hour; between the two halves of the run it
moved by 4 MB), threads and file descriptors stayed flat. Every shipped example was run against a real account too.

## Known limitations

- **Sizing needs evidence from the account's own closed deals.** On an account whose deals all carry swap or
  commission, or whose deals do not have the shape the calibration can read (a Tickmill demo, for one), `calibrate_money_scale`
  finds no symbol to measure from and `lots_for_risk` refuses: that is the intended answer, not an error. `tick_value()`
  and `required_margin()` still work, and the refusal says what was missing.
- **Linux x86-64 is the verified platform.** Windows, macOS, arm64 and musl-based Linux have not been verified.
- Netting accounts have not been verified (the available demo accounts are all hedging).
- Client certificates (`certificate=`), `common_key=` and `request_demo_account` have been unit-tested but not run
  against a broker that requires or serves them.
- One `Client` is one broker connection. Its methods can be called from several threads, but a trading call is
  neither queued nor serialized for you; two threads acting on the same ticket is your responsibility.
- The `state` of an order is the core's value and is not a reliable way to tell a position from a pending order (a
  freshly opened position can stay `Started`). Use `positions()`, or the order's type, for that.
- A folder named `wmt5api` in your working directory is handled automatically (it would otherwise hide the core's
  .NET namespace).

## Licensing and security

Your connection is authorised per session by the hosted licence service, using your API key; the core talks to your
broker directly. What is sent to the licence service and what is not, and the security notes, are in the core's own
documents, which travel with the wheel: `pmt5api/_native/docs/LICENSING.md`, `SECURITY.md` and `ERRORS.md`
(and the core's `CHANGELOG.md` next to them). `python -m pmt5api` prints where they are.

## Third-party components

The Linux wheel includes the Microsoft .NET runtime (MIT licence; its notices are in `pmt5api/_dotnet/`) and the
wmt5api core with its own notices (`pmt5api/_native/THIRD-PARTY-NOTICES.txt`).

## Trademarks

MetaTrader, MT4 and MT5 are trademarks of MetaQuotes Ltd. wmt4api/wmt5api/pmt5api are independent products of
Sen Trading ([mtapi.one](https://mtapi.one)); they are not affiliated with, endorsed by or sponsored by MetaQuotes
Ltd. All other names are the property of their respective owners.

## Licence and support

See `LICENSE.txt`. Use of the core library is governed by the wmt5api commercial SDK licence. For keys, support
and licence terms contact mtapi.one.
