# Monitor MT5 trader accounts for prop-firm rules

URL: https://docs.fxapis.com/guides/prop-firms

> Use deals, positions and order history from the fxapis MT5 API to check prop-firm rules such as daily loss and maximum drawdown on your side, with read-only…



Prop firms, funded-trader programs and risk desks need to watch many MT5
accounts that **other people trade**, check each against rules — a daily loss
limit, a maximum drawdown, a minimum number of trading days — and act when an
account breaches. This guide shows how to do that with fxapis: what to read,
how to compute the rules on your side, how to keep the keys narrow, and how to
close out a breached account.

It also says plainly what fxapis does not do, because a prop firm's risk system
has to know where its tools end.

## What fxapis gives you [#what-fxapis-gives-you]

| You need                                                             | Endpoint                                                                                                 | Scope            |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------- |
| Everything the broker recorded: trades, commissions, swaps, deposits | [`GET /v1/accounts/{id}/deals`](/api-reference/history/an-accounts-full-deal-history)                    | `history:read`   |
| Open positions with floating profit, and how fresh they are          | [`GET /v1/accounts/{id}/positions`](/api-reference/trading/positions-on-an-account)                      | `trading:read`   |
| Orders placed through fxapis, including closes you made              | [`GET /v1/orders?accountId=…`](/api-reference/history/list-orders)                                       | `trading:read`   |
| Pull the latest deals and positions from the broker now              | [`POST /v1/accounts/{id}/reconcile`](/api-reference/trading/confirm-pending-results-with-the-broker-now) | `trading:read`   |
| Bring accounts online so they can be read                            | [`POST /v1/accounts/prepare`](/api-reference/lifecycle/bring-several-accounts-online-at-once)            | `accounts:write` |
| Close positions on a breached account                                | [`POST /v1/accounts/{id}/positions/{ticket}/close`](/api-reference/trading/close-a-position)             | `trading:reduce` |

The trader keeps trading from their own MetaTrader terminal. Their deals appear
in fxapis's deal history like any other — a deal with no fxapis order behind it
has `orderId: null` — so the history is complete whoever placed the trade.

## Keep the accounts readable [#keep-the-accounts-readable]

fxapis reads an account through a MetaTrader 5 terminal we run for it, logged
in with the account's **trading** password. What you can read depends on
whether that account is online:

* **While online**, positions refresh about every 15 seconds on their own, and `reconcile` pulls the
  broker's deals from the last 24 hours on demand.
* **While offline**, you read the last snapshot. Each position's `observedAt` says how old it is.

For accounts under active rule enforcement, connect them with
`"mode": "always_on"` (a plan feature) so the view is never more than seconds
old. For end-of-day checks, `warm_on_demand` is enough: `prepare` the accounts
(up to 200 per call), wait for `ready`, `reconcile`, read, and let them go
offline by themselves after 15 idle minutes.

Call `reconcile` at least every few hours for each account you track: each
call pulls the broker's deals from the last 24 hours, so a longer gap can leave
deals out of your copy of the history.

## Keys: read-only, reduce-only, and the one in between [#keys-read-only-reduce-only-and-the-one-in-between]

Scopes are per key; issue each part of your system only what it needs. See
[Authentication → Scopes](/authentication#scopes).

| Key                       | Scopes                                          | Can                                                | Cannot                           |
| ------------------------- | ----------------------------------------------- | -------------------------------------------------- | -------------------------------- |
| **Dashboard / reporting** | `accounts:read`, `trading:read`, `history:read` | Read accounts, positions, orders, deals            | Change anything                  |
| **Monitor**               | the above, plus `accounts:write`                | Bring accounts online and `reconcile` them         | Place, close or change any order |
| **Enforcer**              | `trading:reduce`, `trading:read`                | Close positions, move stops, cancel pending orders | Open any exposure                |

`reconcile` needs only `trading:read`: it asks the broker what happened and
updates our records, and places nothing. So the monitor key can bring accounts
online and read them, and can never trade. The enforcer key is the one to hand
to anything that acts on a breach: even if it were stolen, it can only reduce
risk.

## Computing the rules on your side [#computing-the-rules-on-your-side]

fxapis reports deals and positions, not balance or equity, and it does not know
your rules. You compute them — which is also what lets you define "day",
"drawdown" and "trading day" exactly as your terms of business do.

From the deals:

* **Trading result** of a deal = `profit + swap + commission + fee`, for every deal except funding.
* **Funding** = deals whose `dealType` is `balance`, `credit` or `bonus` (deposits, withdrawals,
  credits). They move the balance but are not trading results.
* **Balance** = your recorded starting balance + funding + trading results since the start.

From the positions:

* **Floating result** = Σ `profit + swap` over open positions.
* **Equity** = balance + floating result.

Then, for example:

* **Daily loss** = balance at the start of the day − current equity. Breached if above the daily limit.
* **Maximum drawdown (static)** = starting balance − current equity. Breached if above the limit.
* **Trailing drawdown** = highest equity you have recorded − current equity. You keep the peak.

Two cautions. A position row's `observedAt` is part of the answer — do not
enforce a rule on a snapshot that is minutes old; bring the account online
first. And your day boundary must match your terms: broker server midnight and
UTC midnight are usually different.

## A monitor you can run [#a-monitor-you-can-run]

This Python script checks a list of accounts against a daily loss limit and a
static maximum drawdown, and — only when `ENFORCE=1` — closes every open
position on an account that breaches, using the reduce-only key.

```bash
pip install requests
export FXAPIS_MONITOR_KEY="fx_live_…"    # accounts:read/write, trading:read, history:read
export FXAPIS_ENFORCER_KEY="fx_live_…"   # trading:reduce, trading:read
# account id = starting balance, ISO date the challenge started
export ACCOUNTS="0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51=100000@2026-09-01"
export DAILY_LOSS_LIMIT="0.05" MAX_DRAWDOWN_LIMIT="0.10"
python3 monitor.py
```

```python title="monitor.py"
"""Check MT5 accounts against a daily loss limit and a maximum drawdown.

Rules are computed here, on your side. fxapis supplies the broker's deals and positions.
With ENFORCE=1, a breached account has every open position closed (reduce-only key).
"""
import os
import time
from datetime import datetime, timezone
from decimal import Decimal

import requests

API = os.environ.get("FXAPIS_API", "https://api.fxapis.com")
FUNDING = {"balance", "credit", "bonus"}
DAILY_LOSS_LIMIT = Decimal(os.environ.get("DAILY_LOSS_LIMIT", "0.05"))
MAX_DRAWDOWN_LIMIT = Decimal(os.environ.get("MAX_DRAWDOWN_LIMIT", "0.10"))
ENFORCE = os.environ.get("ENFORCE") == "1"


def client(key_env):
    s = requests.Session()
    s.headers["Authorization"] = f"Bearer {os.environ[key_env]}"
    return s


monitor = client("FXAPIS_MONITOR_KEY")
enforcer = client("FXAPIS_ENFORCER_KEY") if ENFORCE else None


def data(response):
    if response.status_code >= 300:
        error = response.json().get("error", {})
        raise RuntimeError(f"{response.status_code} {error.get('code')}: {error.get('message')}")
    return response.json()


def all_deals(account_id, since):
    deals, cursor = [], None
    while True:
        params = {"since": since, "limit": 200}
        if cursor:
            params["cursor"] = cursor
        body = data(monitor.get(f"{API}/v1/accounts/{account_id}/deals", params=params, timeout=60))
        deals.extend(body["data"])
        if not body["page"]["hasMore"]:
            return deals
        cursor = body["page"]["nextCursor"]


def amount(value):
    return Decimal(value) if value not in (None, "") else Decimal(0)


def trading_result(deal):
    return amount(deal["profit"]) + amount(deal["swap"]) + amount(deal["commission"]) + amount(deal["fee"])


def evaluate(starting_balance, deals, positions, day_start):
    """Pure: the numbers a rule needs, from deals and positions."""
    balance = start_of_day = starting_balance
    for deal in deals:
        change = amount(deal["profit"]) if deal["dealType"] in FUNDING else trading_result(deal)
        balance += change
        if deal["dealtAt"] < day_start:
            start_of_day += change
    floating = sum((amount(p["profit"]) + amount(p["swap"]) for p in positions), Decimal(0))
    equity = balance + floating
    return {
        "balance": balance,
        "equity": equity,
        "daily_loss": start_of_day - equity,
        "drawdown": starting_balance - equity,
        "oldest_position": min((p["observedAt"] for p in positions), default=None),
    }


def ensure_online(account_ids, timeout=120):
    data(monitor.post(f"{API}/v1/accounts/prepare", json={"accountIds": account_ids}, timeout=60))
    deadline = time.monotonic() + timeout
    waiting = set(account_ids)
    while waiting and time.monotonic() < deadline:
        for account_id in list(waiting):
            state = data(monitor.get(f"{API}/v1/accounts/{account_id}/status", timeout=30))["data"]["state"]
            if state in ("ready", "executing"):
                waiting.discard(account_id)
            elif state in ("invalid_credentials", "trading_disabled", "needs_2fa", "needs_certificate"):
                print(f"{account_id}: needs attention ({state}); not checked")
                waiting.discard(account_id)
        time.sleep(2)
    return waiting   # accounts that did not come online in time


def close_everything(account_id, positions):
    for p in positions:
        ticket = p["brokerPositionId"]
        r = enforcer.post(f"{API}/v1/accounts/{account_id}/positions/{ticket}/close", json={},
                          headers={"Idempotency-Key": f"breach:{account_id}:{ticket}"}, timeout=90)
        if r.status_code == 201:
            print(f"  closed #{ticket}")
            continue
        code = r.json().get("error", {}).get("code")
        if code == "POSITION_NOT_FOUND":
            print(f"  #{ticket} already closed")
        elif code == "ORDER_UNRESOLVED":
            print(f"  #{ticket}: close unresolved — do not resend; it is being confirmed with the broker")
        else:
            print(f"  #{ticket}: close failed with {code}; retry with the same key")


def main():
    accounts = {}
    for item in os.environ["ACCOUNTS"].split(","):
        account_id, rest = item.split("=")
        balance, started = rest.split("@")
        accounts[account_id] = (Decimal(balance), started + "T00:00:00Z")

    not_online = ensure_online(list(accounts))
    day_start = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")   # your terms may differ

    for account_id, (starting_balance, started) in accounts.items():
        if account_id in not_online:
            print(f"{account_id}: not online, skipped")
            continue
        data(monitor.post(f"{API}/v1/accounts/{account_id}/reconcile", timeout=60))
        deals = all_deals(account_id, started)
        positions = data(monitor.get(f"{API}/v1/accounts/{account_id}/positions", timeout=30))["data"]
        m = evaluate(starting_balance, deals, positions, day_start)

        breaches = []
        if m["daily_loss"] > starting_balance * DAILY_LOSS_LIMIT:
            breaches.append(f"daily loss {m['daily_loss']:.2f}")
        if m["drawdown"] > starting_balance * MAX_DRAWDOWN_LIMIT:
            breaches.append(f"drawdown {m['drawdown']:.2f}")
        print(f"{account_id}: balance {m['balance']:.2f} equity {m['equity']:.2f} "
              f"positions {len(positions)} (oldest {m['oldest_position']})"
              + (f" BREACH: {', '.join(breaches)}" if breaches else ""))

        if breaches and ENFORCE:
            close_everything(account_id, positions)


if __name__ == "__main__":
    main()
```

The starting balance is the account's balance **at the start date** you give
it, after any initial deposit — every deal from that moment on is added to it.
Choose a start date no earlier than when you connected the account to fxapis:
deals from before then may not be in its history.

Run it on a schedule — every minute for `always_on` accounts, or once at the
end of your trading day for the others — and store what it computes, so you
have a record of every check alongside fxapis's own history.

## Closing out a breached account [#closing-out-a-breached-account]

`close_everything` closes each open position with the enforcer key and an
idempotency key per position (`breach:{account}:{ticket}`), so running the
monitor twice never closes twice. On a hedging account a repeated close would
otherwise open an opposite position. Closing is never refused for plan or quota
reasons.

* `ORDER_UNRESOLVED` on a close means we do not yet know if it executed: do **not** resend it.
  Poll the order it names, or run the monitor again once it settles.
* Pending orders placed through fxapis can be cancelled with
  [`POST /v1/orders/{id}/cancel`](/api-reference/trading/cancel-a-pending-order); list them with
  `GET /v1/orders?accountId=…&state=working`. Pending orders the trader placed from their own
  terminal are not fxapis orders and cannot be cancelled this way.

## What fxapis does not do [#what-fxapis-does-not-do]

* **No MT5 Manager API.** fxapis works with ordinary trading accounts through their login and
  password. It cannot create accounts, change leverage, disable trading, reset passwords, or read
  accounts in bulk at the server level.
* **No account creation at the broker.** You or the broker create the MT5 account; you connect it
  to fxapis afterwards.
* **It cannot stop the trader.** Closing positions does not prevent the trader from opening new ones
  from their terminal. Disabling the account is done at the broker or MT5 server.
* **No rule engine.** Limits are computed by your code, as above. fxapis supplies the facts.
* **No balance or equity field.** Derive them from deals and positions, from a starting balance you
  know.
* **No push alerts.** There are no event webhooks yet; poll on a schedule.
* **MT5 only.** MT4 accounts cannot be connected.

## Related [#related]

* [Connect MT5 accounts](/guides/connect-accounts) — connecting accounts at scale, and their states.
* [Idempotent MT5 orders](/guides/idempotency) — why each close carries a key.
* [Trading → History](/trading#history) — orders, deals and positions compared.
* [Security](/security) — how account passwords are stored.
