# MT5 Trading API — Laravel Application

A production-ready Laravel 11 application that proxies and consumes the **MT5 Trade Execution API** (`https://mytradeapi.duckdns.org`). It provides a clean, validated, rate-limited JSON API layer on top of the raw MT5 endpoints with full error handling, retry logic, and a Pest test suite.

---

## Table of Contents

- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Running the Application](#running-the-application)
- [API Endpoints](#api-endpoints)
  - [GET /api/mt5/health](#get-apimt5health)
  - [GET /api/mt5/positions](#get-apimt5positions)
  - [POST /api/mt5/orders/open](#post-apimt5ordersopen)
  - [POST /api/mt5/orders/close](#post-apimt5ordersclose)
  - [POST /api/mt5/orders/modify](#post-apimt5ordersmodify)
  - [GET /api/mt5/history](#get-apimt5history)
- [Authentication](#authentication)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
- [Running Tests](#running-tests)
- [Deployment](#deployment)
- [Architecture Overview](#architecture-overview)

---

## Requirements

| Tool | Version |
|------|---------|
| PHP  | 8.2+    |
| Composer | 2.x |
| Laravel | 11.x |

No database is required for core functionality. SQLite is used as a default for any future extensions.

---

## Installation

```bash
# 1. Clone the repository
git clone https://github.com/yourorg/mt5-trading-api.git
cd mt5-trading-api

# 2. Install PHP dependencies
composer install --no-dev --optimize-autoloader   # production
# or
composer install                                   # development (includes test tools)

# 3. Copy and configure environment
cp .env.example .env

# 4. Generate the application key
php artisan key:generate

# 5. (Optional) Create SQLite database if needed
touch database/database.sqlite

# 6. Run database migrations (if any)
php artisan migrate
```

---

## Configuration

Open `.env` and set the following variables:

```dotenv
# ── MT5 API (required) ──────────────────────────────────────────────────────
MT5_API_BASE_URL=https://mytradeapi.duckdns.org
MT5_API_KEY=your_secret_api_key_here

# ── Optional: protect this app's own endpoints with a Bearer token ──────────
APP_API_TOKEN=your_app_bearer_token

# ── HTTP client timeouts (seconds) ──────────────────────────────────────────
MT5_API_TIMEOUT=30
MT5_API_CONNECT_TIMEOUT=10

# ── Retry on transient errors ────────────────────────────────────────────────
MT5_API_RETRY_TIMES=3
MT5_API_RETRY_SLEEP=500        # milliseconds between retries

# ── Rate limiting for this application's routes ──────────────────────────────
MT5_RATE_LIMIT_MAX=60          # max requests
MT5_RATE_LIMIT_DECAY=60        # per this many seconds, per IP

# ── Risk controls (applied before reaching MT5) ──────────────────────────────
MT5_MAX_LOT_SIZE=1.0
MT5_MAX_TOTAL_POSITIONS=20
MT5_MAX_POSITIONS_PER_SYMBOL=5
```

> **`APP_API_TOKEN`** — When set, every request to `/api/mt5/*` (except `/health`) must include the header:
> `Authorization: Bearer <your_app_bearer_token>`
> Leave it empty to disable this layer of auth (the MT5 API key is still always used).

---

## Running the Application

```bash
# Development server
php artisan serve

# With a specific host/port
php artisan serve --host=0.0.0.0 --port=8080

# Production (recommended: use Nginx + PHP-FPM or Laravel Octane)
php artisan config:cache
php artisan route:cache
php artisan optimize
```

---

## API Endpoints

All endpoints are prefixed with `/api/mt5/`. Responses are always JSON.

### GET /api/mt5/health

Returns the current status of the MT5 API service and terminal connection. **Does not require `APP_API_TOKEN`.**

**Request:**
```http
GET /api/mt5/health
```

**Response (MT5 connected):**
```json
{
  "success": true,
  "service": "online",
  "mt5_connected": true,
  "mt5_terminal": {
    "build": 4000,
    "connected": true,
    "trade_allowed": true,
    "tradeapi_disabled": false
  },
  "account": {
    "login": 12345678,
    "server": "Broker-Live",
    "balance": 10000.00,
    "equity": 10250.00,
    "margin": 120.00,
    "free_margin": 10130.00,
    "currency": "USD",
    "leverage": 500
  },
  "timestamp": 1740000000.0
}
```

> Check `mt5_connected` before placing orders. If `false`, the MT5 terminal is reconnecting.

---

### GET /api/mt5/positions

Returns all currently open positions. Optionally filter by symbol.

**Headers (when APP_API_TOKEN is set):**
```http
Authorization: Bearer <APP_API_TOKEN>
```

**Query Parameters:**

| Parameter | Type   | Required | Description                        |
|-----------|--------|----------|------------------------------------|
| `symbol`  | string | No       | Filter by symbol, e.g. `Crash+1000+Index` |

**Examples:**
```http
GET /api/mt5/positions
GET /api/mt5/positions?symbol=Crash+1000+Index
GET /api/mt5/positions?symbol=EURUSD
```

**cURL:**
```bash
curl -H "Authorization: Bearer $APP_TOKEN" \
     https://your-app.com/api/mt5/positions
```

**Response:**
```json
{
  "success": true,
  "positions": [
    {
      "position_id": 5578917058,
      "symbol": "Crash 1000 Index",
      "action": "buy",
      "volume": 1.0,
      "open_price": 5867.28,
      "current_price": 5891.44,
      "sl": 5817.28,
      "tp": 5967.28,
      "profit": 24.16,
      "net_profit": 24.04,
      "currency": "USD",
      "open_time": "2026-02-26T09:14:32Z",
      "duration": "0h 12m 45s"
    }
  ],
  "summary": {
    "total_positions": 1,
    "total_profit": 24.16,
    "buy_count": 1,
    "sell_count": 0,
    "symbols": ["Crash 1000 Index"]
  }
}
```

---

### POST /api/mt5/orders/open

Place a market order. Fills immediately at the current bid/ask price.

**Request Body:**

| Field       | Type    | Required | Description                                       |
|-------------|---------|----------|---------------------------------------------------|
| `symbol`    | string  | Yes      | Instrument name, e.g. `Crash 1000 Index`          |
| `action`    | string  | Yes      | `buy` or `sell`                                   |
| `volume`    | number  | Yes      | Lot size (0.01 – MT5_MAX_LOT_SIZE)                |
| `sl`        | number  | No       | Stop loss price level                             |
| `tp`        | number  | No       | Take profit price level                           |
| `comment`   | string  | No       | Order comment (max 31 chars)                      |
| `magic`     | integer | No       | Magic number for EA identification                |
| `client_id` | string  | No       | Your own reference ID (max 50 chars)              |

**Examples:**
```bash
# Buy with no SL/TP
curl -X POST https://your-app.com/api/mt5/orders/open \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"symbol":"Crash 1000 Index","action":"buy","volume":1.0}'

# Sell with SL and TP
curl -X POST https://your-app.com/api/mt5/orders/open \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"symbol":"EURUSD","action":"sell","volume":0.1,"sl":1.0900,"tp":1.0700}'
```

**Response:**
```json
{
  "success": true,
  "order_id": 5578917058,
  "position_id": 5578917058,
  "volume": 1.0,
  "price": 5867.28,
  "symbol": "Crash 1000 Index",
  "action": "buy",
  "retcode": 10009,
  "retcode_description": "Order executed",
  "error": null
}
```

---

### POST /api/mt5/orders/close

Close an open position fully or partially.

**Request Body:**

| Field         | Type    | Required | Description                                             |
|---------------|---------|----------|---------------------------------------------------------|
| `position_id` | integer | Yes      | The position ID to close                                |
| `volume`      | number  | No       | Partial close volume. Omit to close the full position.  |

**Examples:**
```bash
# Full close
curl -X POST https://your-app.com/api/mt5/orders/close \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position_id":5578917058}'

# Partial close — close 0.5 of a 1.0 lot position
curl -X POST https://your-app.com/api/mt5/orders/close \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position_id":5578917058,"volume":0.5}'
```

**Response:**
```json
{
  "success": true,
  "order_id": 5578917099,
  "position_id": 5578917058,
  "volume": 1.0,
  "price": 5901.50,
  "symbol": "Crash 1000 Index",
  "action": "sell",
  "retcode": 10009,
  "retcode_description": "Order executed"
}
```

> Note: `action` reflects the direction of the **closing** order, not the original position.

---

### POST /api/mt5/orders/modify

Update the SL and/or TP of an existing open position. You can use exact price levels or USD amounts — but not both for the same side.

**Request Body:**

| Field         | Type    | Required | Description                                                    |
|---------------|---------|----------|----------------------------------------------------------------|
| `position_id` | integer | Yes      | The position to modify                                         |
| `sl`          | number  | No*      | Stop loss as an exact price level                              |
| `tp`          | number  | No*      | Take profit as an exact price level                            |
| `sl_usd`      | number  | No*      | Stop loss as a USD dollar amount (auto-converted to price)     |
| `tp_usd`      | number  | No*      | Take profit as a USD dollar amount (auto-converted to price)   |

\* At least one of `sl`, `tp`, `sl_usd`, or `tp_usd` must be provided. You cannot mix `sl` and `sl_usd`, or `tp` and `tp_usd`.

**Examples:**
```bash
# Set SL/TP as price levels
curl -X POST https://your-app.com/api/mt5/orders/modify \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position_id":5578917058,"sl":5817.28,"tp":5967.28}'

# Set SL/TP as USD amounts
curl -X POST https://your-app.com/api/mt5/orders/modify \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position_id":5578917058,"sl_usd":50.0,"tp_usd":150.0}'

# Remove SL only
curl -X POST https://your-app.com/api/mt5/orders/modify \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position_id":5578917058,"sl":0}'
```

**Response:**
```json
{
  "success": true,
  "retcode": 10009,
  "retcode_description": "Order executed",
  "error": null,
  "details": {
    "sl_price": 5817.28,
    "tp_price": 5967.28,
    "sl_usd_requested": 50.0,
    "tp_usd_requested": 150.0
  }
}
```

---

### GET /api/mt5/history

Retrieve closed trade history from MT5 within a date range.

**Query Parameters:**

| Parameter     | Type    | Required | Description                                            |
|---------------|---------|----------|--------------------------------------------------------|
| `days`        | integer | No       | Last N days (default: 30). Cannot be combined with `from_date`/`to_date`. |
| `from_date`   | date    | No       | Start date in `YYYY-MM-DD` format.                     |
| `to_date`     | date    | No       | End date in `YYYY-MM-DD` format. Must be ≥ `from_date`.|
| `symbol`      | string  | No       | Filter by symbol.                                      |
| `position_id` | integer | No       | Look up a single position's history.                   |

**Examples:**
```bash
# Last 30 days (default)
curl -H "Authorization: Bearer $APP_TOKEN" \
     https://your-app.com/api/mt5/history

# Last 7 days
curl -H "Authorization: Bearer $APP_TOKEN" \
     "https://your-app.com/api/mt5/history?days=7"

# Specific date range
curl -H "Authorization: Bearer $APP_TOKEN" \
     "https://your-app.com/api/mt5/history?from_date=2026-01-01&to_date=2026-01-31"

# Filter by symbol, last 14 days
curl -H "Authorization: Bearer $APP_TOKEN" \
     "https://your-app.com/api/mt5/history?days=14&symbol=Crash+1000+Index"

# Single position lookup
curl -H "Authorization: Bearer $APP_TOKEN" \
     "https://your-app.com/api/mt5/history?position_id=5578917058"
```

**Response:**
```json
{
  "success": true,
  "count": 2,
  "from_date": "2026-01-29T00:00:00Z",
  "to_date": "2026-02-28T00:00:00Z",
  "trades": [
    {
      "position_id": 5578917058,
      "symbol": "Crash 1000 Index",
      "action": "buy",
      "volume": 1.0,
      "open_price": 5867.28,
      "close_price": 5967.28,
      "profit": 100.00,
      "net_profit": 99.88,
      "currency": "USD",
      "open_time": "2026-02-26T09:14:32Z",
      "close_time": "2026-02-26T11:22:10Z",
      "duration": "2h 7m 38s",
      "close_reason": "take_profit"
    }
  ],
  "summary": {
    "total_trades": 2,
    "winning_trades": 1,
    "losing_trades": 1,
    "win_rate": 50.0,
    "total_net_profit": 74.88,
    "best_trade": 99.88,
    "worst_trade": -25.00
  }
}
```

---

## Authentication

This application has two authentication layers:

| Layer | Header | Config | Purpose |
|-------|--------|--------|---------|
| **App layer** | `Authorization: Bearer <token>` | `APP_API_TOKEN` in `.env` | Protects this Laravel app's routes |
| **MT5 layer** | `X-API-KEY: <key>` | `MT5_API_KEY` in `.env` | Passed internally to the MT5 API |

Callers of **this** application only need to provide the Bearer token (if configured). The MT5 API key is kept server-side and never exposed.

---

## Error Handling

All errors return a consistent JSON structure:

```json
{
  "success": false,
  "error": "Human-readable error message"
}
```

**Validation errors (422):**
```json
{
  "success": false,
  "error": "Validation failed.",
  "errors": {
    "action": ["The action field must be \"buy\" or \"sell\"."],
    "volume": ["The volume field is required."]
  }
}
```

**HTTP status codes used:**

| Code | Meaning |
|------|---------|
| 200  | Success |
| 400  | MT5 rejected the request (risk controls, invalid params) |
| 401  | Invalid or missing API token |
| 422  | Validation error in request payload |
| 429  | Rate limit exceeded |
| 502  | MT5 API returned a server error |
| 503  | Cannot connect to MT5 API |

---

## Rate Limiting

Routes are rate-limited per IP address. Defaults: **60 requests per 60 seconds**.

Configure in `.env`:
```dotenv
MT5_RATE_LIMIT_MAX=60
MT5_RATE_LIMIT_DECAY=60
```

When exceeded, the API returns:
```json
{
  "success": false,
  "error": "Too many requests. Please slow down."
}
```

The HTTP client also automatically **retries** transient errors (connection issues, 5xx, 429 from the MT5 API) up to `MT5_API_RETRY_TIMES` times with `MT5_API_RETRY_SLEEP` ms between attempts.

---

## Running Tests

```bash
# Install dev dependencies (if not done already)
composer install

# Run all tests
./vendor/bin/pest

# Run with coverage
./vendor/bin/pest --coverage

# Run only unit tests
./vendor/bin/pest tests/Unit

# Run only feature tests
./vendor/bin/pest tests/Feature

# Run a specific test file
./vendor/bin/pest tests/Feature/MT5/OrderTest.php

# Verbose output
./vendor/bin/pest --verbose
```

The test suite uses **Mockery** to mock `MT5ApiService`, so tests run entirely offline — no real API calls are made.

---

## Deployment

### Nginx + PHP-FPM (recommended)

```nginx
server {
    listen 80;
    server_name your-app.com;
    root /var/www/mt5-trading-api/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
```

### Deployment checklist

```bash
# 1. Set production environment
APP_ENV=production
APP_DEBUG=false

# 2. Cache config, routes, and views
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize

# 3. Set correct permissions
chmod -R 755 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache

# 4. Verify environment is healthy
php artisan about
```

### Docker (optional)

A `Dockerfile` can be added based on `php:8.2-fpm-alpine`. See Laravel Sail for a full development Docker setup:

```bash
composer require laravel/sail --dev
php artisan sail:install
./vendor/bin/sail up -d
```

---

## Architecture Overview

```
app/
├── Exceptions/
│   └── MT5ApiException.php        # Custom exception with self-rendering JSON response
├── Http/
│   ├── Controllers/MT5/
│   │   ├── HealthController.php   # GET  /api/mt5/health
│   │   ├── PositionController.php # GET  /api/mt5/positions
│   │   ├── OrderController.php    # POST /api/mt5/orders/{open,close,modify}
│   │   └── HistoryController.php  # GET  /api/mt5/history
│   ├── Middleware/
│   │   └── AppApiAuthenticate.php # Optional Bearer token auth
│   └── Requests/MT5/
│       ├── OpenOrderRequest.php   # Validates open order payload
│       ├── CloseOrderRequest.php  # Validates close order payload
│       ├── ModifyOrderRequest.php # Validates modify payload (cross-field rules)
│       └── HistoryRequest.php     # Validates history filters
├── Providers/
│   └── AppServiceProvider.php     # Singleton binding + rate limiter config
└── Services/
    └── MT5ApiService.php          # All HTTP communication with MT5 API

config/
└── mt5.php                        # All MT5-related configuration values

routes/
└── api.php                        # All /api/mt5/* routes

tests/
├── Feature/MT5/
│   ├── HealthTest.php
│   ├── PositionTest.php
│   ├── OrderTest.php
│   └── HistoryTest.php
└── Unit/
    └── MT5ApiServiceTest.php      # Tests the HTTP layer with Http::fake()
```

**Key design decisions:**

- **`MT5ApiService`** is a singleton — the underlying Guzzle HTTP client pool is reused across requests.
- **Form Requests** handle all validation before any service calls are made, keeping controllers thin.
- **`MT5ApiException`** is self-rendering — it converts itself to a JSON response automatically via the exception handler in `bootstrap/app.php`.
- Retry logic lives in the service layer, using Laravel's `Http::retry()`.
- Risk validation (max lot size, etc.) is enforced at the **Form Request level** so bad requests never reach the MT5 API.

---

## License

MIT
