Metadata-Version: 2.4
Name: open-api-mt5
Version: 0.10.6
Summary: REST and WebSocket API project for MetaTrader 5
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: certifi>=2024.7.4
Requires-Dist: confluent-kafka<3.0.0,>=2.15.0
Requires-Dist: fastapi<1.0.0,>=0.116.0
Requires-Dist: lmdb<3.0.0,>=2.2.0
Requires-Dist: MetaTrader5<6.0.0,>=5.0.0; platform_system == "Windows"
Requires-Dist: pyzmq<28.0.0,>=26.0.0
Requires-Dist: tzdata>=2024.1; platform_system == "Windows"
Requires-Dist: uvicorn[standard]<1.0.0,>=0.35.0
Provides-Extra: test
Requires-Dist: httpx<1.0.0,>=0.28.0; extra == "test"
Requires-Dist: jsonschema<5.0.0,>=4.26.0; extra == "test"
Requires-Dist: pytest<9.0.0,>=8.4.0; extra == "test"
Requires-Dist: pytest-asyncio<2.0.0,>=1.0.0; extra == "test"
Requires-Dist: pytest-cov<7.0.0,>=6.2.0; extra == "test"

# open-api-mt5

MetaTrader 5 API service with FastAPI.

## Important limitation

This API controls a **single MT5 terminal/session instance** per running service process.

- A single API instance can be connected to only one account at a time.
- `/account/connect` switches that single active session.
- If you run multiple API services, use separate MT5 terminal instances/data folders for reliable isolation.

## Setup

1. Create a virtual environment:
   - Windows PowerShell: `python -m venv .venv`
2. Activate it:
   - `.\.venv\Scripts\Activate.ps1`
3. Install dependencies:
   - `python -m pip install -U pip`
   - `pip install -e .`

## MT5 startup config

The API initializes MetaTrader 5 when FastAPI starts and closes it when FastAPI stops.
It also checks MT5 connection every 5 seconds and tries to reconnect automatically if disconnected.

You can configure startup with a `server_config.json` file in the project directory:

```json
{
  "path": "C:\\Program Files\\MetaTrader 5\\terminal64.exe",
  "username": "12345678",
  "password": "your-password",
  "server": "YourBroker-Server",
  "account": "12345678",
  "multiTerminal": true,
  "portable": true,
  "port": 8000,
  "apiKey": "optional-api-key"
}
```

`account` is required and is always checked against the connected MT5 account after initialization. The API loads MT5 and server startup settings from the JSON config file only; environment variables are not used for these settings. When the connected account does not match, it restarts the configured terminal path and retries up to 3 times; if the account still does not match, the API exits. If `path` is set, the API starts MT5 with `portable=true` by default so separate API instances can attach to separate MT5 terminal folders. Set `"portable": false` only if you intentionally want the normal shared MT5 data-directory behavior.

When running multiple API instances on the same Windows machine, start each process with its own explicit `--config` file containing a different `path`, `account`, and `port`.
Mode, `apiKey`, `apiSecret`, and `registryUrl` are config-file startup settings and are not stored by `/account/connect`.

## Virtual terminal mode

Virtual mode runs the same HTTP market-data API without starting MetaTrader 5. Dukascopy supplies symbols, quotes, ticks, and OHLC bars; `POST /orders` sends an MT5-shaped order request as JSON through ZeroMQ or Kafka.

```json
{
  "virtual": true,
  "dataSource": "dukascopy",
  "port": 8000,
  "dukascopy": {
    "baseUrl": "https://jetta.dukascopy.com/v1",
    "timeoutSeconds": 15,
    "maxBucketsPerRequest": 400,
    "cacheTtlSeconds": 15,
    "cacheMaxEntries": 4000,
    "closedBucketGraceSeconds": 900,
    "staleMaxSeconds": 300,
    "minRequestIntervalSeconds": 0.25,
    "rateLimitBackoffSeconds": 60,
    "rateLimitBackoffMaxSeconds": 900
  },
  "orderTransport": "kafka",
  "zeroMq": {
    "host": "127.0.0.1",
    "port": 5555,
    "topic": "ORDER",
    "socketType": "PUSH",
    "mode": "connect",
    "lingerMilliseconds": 1000
  },
  "kafka": {
    "bootstrapServers": "broker.example.com:9093",
    "topic": "orders",
    "clientId": "open-api-mt5",
    "securityProtocol": "SASL_SSL",
    "saslMechanism": "PLAIN",
    "saslUsername": "your-kafka-username",
    "saslPassword": "your-kafka-password",
    "sslCaBase64": "base64-encoded-ca-pem",
    "acks": "all",
    "deliveryTimeoutSeconds": 10
  }
}
```

The Dukascopy data path is keyless: it uses the same `jetta.dukascopy.com/v1` instrument and compact historical/live bucket infrastructure used by current `dukascopy-node`, so no Freeserv API key is required. `maxBucketsPerRequest` prevents a single ranged query from generating an unbounded number of upstream requests.

Upstream buckets are cached in memory and shared by all callers, so many bots polling the same symbol do not multiply Dukascopy traffic. Completed buckets are fetched once and kept until evicted (`cacheMaxEntries`, least recently used first); buckets that closed less than `closedBucketGraceSeconds` ago and the forming bucket are refreshed after `cacheTtlSeconds`. Concurrent requests for one bucket share a single upstream call, and upstream calls are spaced at least `minRequestIntervalSeconds` apart. When Dukascopy answers HTTP 429 the client stops calling upstream for `rateLimitBackoffSeconds` (doubling up to `rateLimitBackoffMaxSeconds`) and serves cached buckets up to `staleMaxSeconds` old. `GET /virtual/data-cache` reports cache size, hits, misses, stale responses, upstream calls, 429 count and any remaining backoff.

`dataSource` accepts both the correct `dukascopy` spelling and the legacy/requested `ducascopy` spelling. `zeroMq.endpoint` may be supplied instead of `host` and `port` (for example, `"ipc:///tmp/orders.sock"`). `socketType` is any socket type exposed by pyzmq; `PUSH` with `connect` is the default. Orders are sent as a multipart ZeroMQ message whose first frame is the configurable `ORDER` topic and whose second frame is the JSON payload.

`orderTransport` selects `kafka` (the default) or `zeromq`. For Kafka, set `bootstrapServers`, `topic`, and SASL credentials in the `kafka` block. `securityProtocol` supports the librdkafka values such as `SASL_SSL` and `SASL_PLAINTEXT`; `saslMechanism` can be `PLAIN`, `SCRAM-SHA-256`, or `SCRAM-SHA-512`, depending on the broker. Set `sslCaLocation` to a CA file path, or omit it and provide the complete PEM certificate encoded as Base64 in `sslCaBase64`. The location takes priority when both are present; Base64 certificates are decoded in memory and no temporary file is created. Other optional TLS client settings are `sslCertificateLocation`, `sslKeyLocation`, and `sslKeyPassword`. The producer waits up to `deliveryTimeoutSeconds` for broker acknowledgement and uses the order symbol as the Kafka message key unless `messageKey` is configured. Set `securityProtocol` to `PLAINTEXT` or `SSL` for a broker that does not use SASL.

The transport message contains the same request keys sent to `MetaTrader5.order_send`, including `action`, `symbol`, `volume`, `type`, `price`, `deviation`, `magic`, `comment`, `type_time`, `type_filling`, and optional `sl`, `tp`, or `stoplimit`. After a successful publish, the API returns an MT5-shaped mock result with `retcode: 10009`, `success: true`, and `message: "order created"`. Transport details and the transmitted request remain available in the result. A delivery failure is returned as an error and is never reported as a created order.

The Kafka value JSON Schema is stored at `schemas/order.schema.json`. When registering it with Aiven Schema Registry, use schema type `JSON` and the conventional subject name `orders-value`.

### Stateless virtual account

Virtual mode does not simulate or retain trading state. `/account` always returns an MT5-shaped account with a `100000` balance and equity. `/tickets`, `/positions/open`, `/orders/pending`, `/exposure`, and `/trades/history` return empty collections. Orders are still delivered through the configured Kafka or ZeroMQ transport, then acknowledged with an MT5-shaped mock creation result.

### Trading dashboard

Open `http://127.0.0.1:8000/trading` for the responsive trading dashboard. The page uses the public REST API only and therefore works in normal MT5 and virtual modes. Select a symbol, timeframe, and bar count to display OHLC candles together with open-position, SL/TP, pending-order, and historical-deal overlays. The tables show the same MT5-compatible position, order, and history payloads returned by the API. Swagger always links to this dashboard; in virtual mode the dashboard also links to the transport message monitor.

Virtual mode returns MT5-shaped mock successes for position stop modifications and money-based adjustments. It returns HTTP 501 for close operations, MT5 calendar data, market depth, news-close jobs, weekend-close jobs, and the open-positions WebSocket. Position details return HTTP 404 because virtual state is always empty. The Fair Economy calendar remains available. Dukascopy virtual market-data responses keep the existing `/symbols`, `/quotes`, `/ticks`, and `/bars` schemas.

## Run

```bash
open-api-mt5
```

Optional flags:

```bash
open-api-mt5 --reload
open-api-mt5 --config C:\path\to\server_config.json
```

Default port is `8000`. Set `host` and `port` in the config file to change the bind address.

Run unit tests with coverage:

```bash
pip install -e ".[test]"
pytest
```

The calendar, bars, and trade services require 100% statement coverage.

Run only the mocked news-close integration scenarios:

```bash
pytest -m integration --no-cov
```

API docs:
- Swagger UI: `http://127.0.0.1:8000/docs`
- WebSocket docs in Swagger:
  - `GET /ws/positions/open/docs`

Health endpoint:
- `GET http://127.0.0.1:8000/health`

Bars endpoints:
- `GET http://127.0.0.1:8000/bars/{symbol}?timeframe=M1&n=100`
- `GET http://127.0.0.1:8000/bars/{symbol}/range?timeframe=M1&fromDate=2026-03-20T08:00:00Z&toDate=2026-03-20T12:00:00Z`
- `GET http://127.0.0.1:8000/quotes/{symbol}`
- `GET http://127.0.0.1:8000/ticks/{symbol}?count=100`
- `GET http://127.0.0.1:8000/market-depth/{symbol}`
- `GET http://127.0.0.1:8000/exposure`
- `GET http://127.0.0.1:8000/exposure/{symbol}`

### MT5/Dukascopy spread tracing

In normal MT5 mode, `GET /spread-trace/{symbol}` fetches both quotes and records `bidSpread`, `askSpread`, and `closeSpread`. Each spread is signed and calculated as `MT5 - Dukascopy`. The response also includes `avarageBidSpread` and `avarageAskSpread`, calculated from samples taken during the preceding 60 seconds. For tick quotes without a last-traded price, `closeSpread` uses the quote midpoint.

Samples are collected only when the fresh-sample endpoint is called, retained in memory, and reset when the service restarts. The only spread-trace configuration is `symbolMap`, which supports broker-specific MT5 symbol suffixes.

```json
{
  "spreadTrace": {
    "symbolMap": {"GBPUSD.a": "GBPUSD"}
  }
}
```

- `GET /spread-trace/{symbol}` records and returns a fresh sample.
- `GET /spread-trace/{symbol}/history?limit=100` returns retained samples in chronological order.

Account connection endpoints:
- `POST http://127.0.0.1:8000/account/connect`
  - Body: `username`, `password`, `server`, optional `path`, optional `portable`
  - Example body:
    ```json
    {
      "username": "12345678",
      "password": "your-password",
      "server": "YourBroker-Server",
      "path": "C:\\Program Files\\MetaTrader 5\\terminal64.exe",
      "portable": true,
      "account": "12345678"
    }
    ```
- `POST http://127.0.0.1:8000/account/disconnect`
- If `registryUrl` is set in config, the API sends a `POST` call to that URL with JSON body fields `address`, `port`, `apiKey`, and `accountId`

Security modes:
- Set `mode` in config to `standalone`, `secure`, or `secure-client`
- Set `apiKey` in config and, for `secure-client`, `apiSecret`
- Set the registry target with `registryUrl` in config
- `standalone`: default mode, no `X-apiKey` or `X-apiSecret` header checks
- `secure`: every HTTP endpoint and the open positions WebSocket require `X-apiKey` to match the locally stored `apiKey`
- `secure-client`: every HTTP endpoint and the open positions WebSocket require both `X-apiKey` and `X-apiSecret` to match the locally stored values

Server info endpoint:
- `GET http://127.0.0.1:8000/api/server/info`
- Returns JSON with the locally stored `apiKey` and the detected machine `ipAddress`

Trade history endpoint:
- `GET http://127.0.0.1:8000/trades/history`
- Optional query params: `fromDate`, `toDate` (ISO datetime, UTC recommended)
- If omitted, it returns the last 7 days by default

Trade MAE/MFE endpoint:
- `GET http://127.0.0.1:8000/trades/{positionId}/mae-mfe?timeframe=M5&horizonBars=12`
- Available only in MT5 mode; virtual/Dukascopy mode returns HTTP 501.
- Reconstructs a closed position from its MT5 deals and calculates directional MAE/MFE from OHLC bars.
- `tradePeriod` covers entry through exit. `extendedPeriod` treats the trade as if it remained open for the requested bars after exit.
- `postExit.potentialGain` and `postExit.potentialLoss` measure the favorable/adverse move from the actual exit price during those bars.
- `extendedRRR` is extended MFE money divided by extended MAE money. `postExit.potentialRRR` is potential gain divided by potential loss from the exit price. A ratio is `null` when no adverse excursion occurred.
- Excursions include price distance, points, percent, and estimated account-currency value. Because OHLC bars do not contain the tick path, the entry and exit candles can include extremes that occurred outside the exact trade timestamps; use a smaller timeframe for greater precision.

Modify an open position's stop loss / take profit:
- `POST http://127.0.0.1:8000/positions/modify`
  - Body: `ticket`, optional `sl`, optional `tp`, optional `comment`
  - At least one of `sl` or `tp` is required. If one is omitted, its current MT5 value is kept.
  - Example body:
    ```json
    {
      "ticket": 123456789,
      "sl": 1.0825,
      "tp": 1.095
    }
    ```

Open position details:
- `GET http://127.0.0.1:8000/positions/{ticket}/details`
- Returns `entryPrice`, `stopLossPrice`, `takeProfitPrice`, `volume`, `contractSize`, `stopLossValue`, and `takeProfitValue`
- Value formula:
  - Buy stop loss: `(entryPrice - stopLossPrice) * volume * contractSize`
  - Buy take profit: `(takeProfitPrice - entryPrice) * volume * contractSize`
  - Sell stop loss: `(stopLossPrice - entryPrice) * volume * contractSize`
  - Sell take profit: `(entryPrice - takeProfitPrice) * volume * contractSize`

Adjust an open position's stop loss / take profit by money values:
- `POST http://127.0.0.1:8000/positions/adjust-by-money`
  - Body: `ticket`, optional `stopLossValueInMoney`, optional `takeProfitValueInMoney`, optional `comment`
  - Defaults: `stopLossValueInMoney = 10`, `takeProfitValueInMoney = 30`
  - The API converts money values to SL/TP price distances in account currency using MT5 profit calculation when available, falling back to position entry price, volume, and symbol contract size.
  - If the exact value cannot be represented by the symbol price step, the API uses the nearest lower value.
  - Example body:
    ```json
    {
      "ticket": 123456789,
      "stopLossValueInMoney": 10,
      "takeProfitValueInMoney": 30
    }
    ```
  - Response includes `stopLossPrice`, `takeProfitPrice`, `stopLossValueInMoney`, and `takeProfitValueInMoney` after rounding.

Close orders before economic news:
- `POST /orders` accepts optional `closeOnNews`, for example `M15_High`, `M10_Medium`, or `M5_Low`
- New orders with `closeOnNews` are rejected with HTTP 409 when a matching event is already inside the configured close window
- `GET /orders/news-close/jobs` lists the current tagged pending orders and open positions watched by the news-close checker
- The policy and MT5 order metadata are stored in a durable LMDB file at `.db/news_close_jobs.lmdb` under the folder where the API process was started
- Order creation first commits an LMDB intent, sends the MT5 order, then atomically promotes the intent to an active job before returning success
- Accepted MT5 responses (`DONE`, `PLACED`, and `DONE_PARTIAL`) all retain the configured close policy; `success` indicates acceptance, not necessarily a complete fill
- Existing `.db/news_close_jobs.sqlite3` records are imported automatically the first time the LMDB store is opened
- Restart the API from the same folder to reuse the existing news-close jobs; set `NEWS_CLOSE_LMDB_PATH` only if you need a custom database path
- `High` closes only for high-impact events; `Medium` closes for medium/high; `Low` closes for low/medium/high
- Events are matched against the symbol's base, profit, or margin currency
- Tagged open positions are closed and tagged pending orders are cancelled when an event enters the configured time window
- Modifying SL/TP values, including money-based adjustments, keeps the stored news policy unchanged
- The check runs once at startup and every 60 seconds; orders without `closeOnNews` are unchanged
- Fair Economy events include an explicit timezone and are compared in UTC. If you test with timezone-less event timestamps, the API also considers the local computer timezone; set `LOCAL_TIMEZONE` (for example `Europe/Paris`) only if you need to override the OS timezone.

Close orders before the instrument's Friday exchange close:

- `POST /orders` accepts optional `closeOnWeekend`: exactly `M15`, `M30`, or `H1`.
- `exchangeSchedule.timezone` is shared by the schedules and accepts `UTC±HH:MM` (for example `UTC+03:00`) or an IANA timezone. Each symbol contains its weekday `open` and `close` times.
- `M15` means 15 minutes before that instrument's configured Friday close; `M30` means 30 minutes and `H1` means one hour.
- The deadline is calculated in the configured schedule timezone and persisted as an exact UTC timestamp before the MT5 order is sent. IANA zones apply daylight-saving changes; a fixed value such as `UTC+03:00` remains at that offset year-round.
- New tagged orders are rejected with HTTP 409 if their Friday-close window is already active.
- The worker checks every 60 seconds. If a deadline was missed while the API or market was unavailable, it keeps retrying the overdue close rather than silently moving it to the following Friday.
- `GET /symbols/{name}` includes `exchangeSchedule`, including the weekly sessions, timezone, and next Friday close in UTC. A missing schedule is returned as `null`.
- `GET /orders/weekend-close/jobs` lists active jobs and their persisted `triggerAtUtc` and `targetCloseUtc` values.
- Jobs are stored in durable LMDB at `.db/weekend_close_jobs.lmdb`; use `WEEKEND_CLOSE_LMDB_PATH` to select another path.

Example configuration:

```json
{
  "exchangeSchedule": {
    "timezone": "UTC+03:00",
    "EURUSD": {
      "Monday": {"open": "00:01", "close": "23:59"},
      "Tuesday": {"open": "00:01", "close": "23:59"},
      "Wednesday": {"open": "00:01", "close": "23:59"},
      "Thursday": {"open": "00:01", "close": "23:59"},
      "Friday": {"open": "00:01", "close": "23:57"},
      "Saturday": null,
      "Sunday": null
    }
  }
}
```

The complete supplied schedules for `EURUSD`, `GBPUSD`, `USDJPY`, `XTIUSD`, `UK100`, `DE40`, and `XAUUSD` are in `server_config.example.json`.

Calendar events endpoint:
- `GET http://127.0.0.1:8000/calendar/events`
- The default source is the Fair Economy current-week feed, cached in `calendar_cache.json`
- The cache is loaded at startup, retried once on startup failure, refreshed every Monday at 00:00 UTC, and loaded lazily when missing or stale
- Optional query params: `source` (`fairEconomy` or `mt5`), `fromDate`, `toDate` (ISO datetime), `country`/`currency` (example: `USD`), and `impact` (`Low`, `Medium`, `High`, or `Holiday`)
- Fair Economy defaults to the current Monday-through-Sunday UTC week
- Use `source=mt5` for the previous MT5 calendar behavior (default range: last 7 days to next 7 days)
- Optional environment variables: `FAIR_ECONOMY_CALENDAR_URL` and `FAIR_ECONOMY_CALENDAR_CACHE_PATH`

## WebSocket streams

Open positions stream:
- `ws://127.0.0.1:8000/ws/positions/open`
- Optional query param: `intervalSeconds` (poll interval, bounded to 0.2..60)
- Events:
  - `subscribed`
  - `positionsSnapshot`
  - `error`
- `positionsSnapshot` includes:
  - `positions[].pnl` (position PnL, sourced from MT5 `profit`)
  - `totalPnl` (sum of all open positions PnL)

Example JavaScript client:

```javascript
const ws = new WebSocket("ws://127.0.0.1:8000/ws/positions/open?intervalSeconds=1");
ws.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  console.log(payload.event, payload);
};
```

## Build and publish package

Build distribution files locally:

```bash
python -m pip install --upgrade build
python -m build
```

Install from the local build to smoke-test it:

```bash
python -m pip install --force-reinstall dist/*.whl
open-api-mt5 --help
```

Install from PyPI and run:

```bash
pip install open-api-mt5
open-api-mt5 --config C:\path\to\server_config.json
```

## Release to PyPI

The repository includes `.github/workflows/publish.yml`, which runs tests, builds the package, and publishes to PyPI with a PyPI API token. It only runs from release branches named like `release/0.5`.

One-time PyPI token setup:

1. Create or open your PyPI account.
2. Create a PyPI API token. If the project already exists on PyPI, prefer a project-scoped token for `open-api-mt5`; otherwise create an account-scoped token for the first upload.
3. In GitHub, open repository `Settings` -> `Secrets and variables` -> `Actions`.
4. Add a repository secret named `PYPI_API_TOKEN` with the token value from PyPI. The workflow uses PyPI username `__token__` and this secret as the password.
5. Optional: create a GitHub environment named `pypi` and add required reviewers if you want manual approval before publishing.

Prepare a release:

1. Update `version` in `pyproject.toml`.
2. Run tests:
   ```bash
   python -m pip install -e ".[test]"
   python -m pytest
   ```
3. Build locally:
   ```bash
   python -m pip install --upgrade build
   python -m build
   ```
4. Commit the version change:
   ```bash
   git add pyproject.toml
   git commit -m "Release 0.6.1"
   ```
5. Create and push a release branch:
   ```bash
   git checkout -b release/0.6.1
   git push origin release/0.6.1
   ```
6. The `Publish to PyPI` workflow runs on pushes to `release/**`. You can also run it manually from GitHub Actions, but select a `release/**` branch such as `release/0.6.1`.

PyPI rejects reused versions, so every release must have a new `pyproject.toml` version.

Manual upload with a token, if needed:

```bash
python -m pip install --upgrade twine
python -m twine upload dist/* -u __token__ -p "<your-pypi-token>"
```

## Optional: standalone executable (no Python required on target machine)

If you want users to run it without installing Python, build an executable:

```bash
python -m pip install pyinstaller
pyinstaller --onefile --name open-api-mt5 app/cli.py
```

The executable will be in `dist/open-api-mt5.exe`.
