Metadata-Version: 2.4
Name: open-api-mt5
Version: 0.10.2
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: 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
  },
  "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.

`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, 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 is stored in a durable SQLite file at `.db/news_close_jobs.sqlite3` under the folder where the API process was started
- Restart the API from the same folder to reuse the existing news-close jobs; set `NEWS_CLOSE_DB_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.

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`.
