The equity curve in the MT5 terminal only exists while the terminal is open, and it's drawn from closed trades, so it never shows the intraday swings that actually scared you. If you want to glance at your equity from a phone at lunch, or put it on a wall, the terminal has nothing for you.
Here are two ways to build an MT5 equity dashboard without keeping a terminal alive. The first is a MetaTrader 5 to Google Sheets logger: a few lines of Apps Script, no server, done in ten minutes. The second is Grafana over Postgres, fed by webhooks plus a small poller, for when you want trade markers on the curve and alert history next to it. Both are complete as written.
Both use MetaKit as the data source, which keeps the
terminal running in the cloud and gives you GET /v1/accounts/{id} for the
live figures. A readonly API key is all either approach needs.
Log equity, not just balance
Balance only moves when a trade closes, so a balance chart is a staircase that tells you nothing about the day you nearly got stopped out. Equity moves with every tick, and it's the number every risk rule (yours or a prop firm's) is actually measured against. Sample equity, and keep balance alongside it so the gap between the two shows how much is floating.
The account object gives you balance, equity, used_margin and
free_margin. It does not give you margin level, but that's equity divided by
used margin, times a hundred, so both scripts below compute it. Guard the
division: a flat account has used_margin of zero. The
margin level post
covers why that number matters more than free margin once you're anywhere
near a stop-out.
Part A: Google Sheets with Apps Script
Apps Script is the right tool here because it needs no hosting: Google runs the function on a timer, the sheet is the database, and the chart is built in.
Create a spreadsheet, add a sheet named equity, and put a header row in it:
time, balance, equity, free_margin, used_margin, margin_level,
open_trades. Then open Extensions → Apps Script and paste this:
const BASE = 'https://api.metakit.cloud';
const ACCOUNT_ID = 2;
function logEquity() {
const key = PropertiesService.getScriptProperties().getProperty('METAKIT_KEY');
const res = UrlFetchApp.fetch(`${BASE}/v1/accounts/${ACCOUNT_ID}`, {
headers: { 'x-api-key': key },
muteHttpExceptions: true,
});
const body = JSON.parse(res.getContentText());
if (res.getResponseCode() !== 200) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
// Live fields are null/0 unless the terminal is connected. Don't log a zero.
if (body.status !== 'connected') return;
const marginLevel = body.used_margin > 0
? (body.equity / body.used_margin) * 100
: '';
SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('equity')
.appendRow([
new Date(),
body.balance,
body.equity,
body.free_margin,
body.used_margin,
marginLevel,
body.open_trades,
]);
}Store the key under Project Settings → Script Properties as METAKIT_KEY
rather than pasting it into the code. Scripts get shared and copied; script
properties don't travel with them.
Two details in there matter. muteHttpExceptions: true makes UrlFetchApp
hand you the response on a 4xx or 5xx instead of throwing, so you can read
the error envelope and log something useful. And the status guard is not
optional: a starting or error account returns null balance, and a row of
zeros in the middle of your equity curve looks exactly like a blown account.
The trigger, and its granularity
Run logEquity once by hand to authorise the script, then add a time-driven
trigger: Triggers → Add Trigger → logEquity, time-driven, minutes timer,
every minute.
Be honest with yourself about what "every minute" means here. Apps Script timers are minute-granular at best (the options are 1, 5, 10, 15 and 30 minutes), and in practice a one-minute trigger drifts by tens of seconds. You get a sample roughly every minute, not on the minute. For an equity curve that's fine. For anything that needs to catch a spike, it's not, and you want Part B or an equity monitor instead.
A one-minute cadence writes 1,440 rows a day. That's within Google's daily
UrlFetchApp quota and the sheet copes for months, but by year two it's
half a million rows and the chart gets sluggish. Either drop to five minutes
or add a second function that thins rows older than 30 days to hourly.
The chart
Select columns A and C, Insert → Chart, line chart. Set the horizontal axis to column A. That's the dashboard. Add column B as a second series if you want balance stepping along underneath equity, which is a surprisingly good visual for "how much of my equity is floating right now".
Part B: Grafana on Postgres, fed by webhooks
Sheets shows you the curve. Grafana shows you the curve with a marker on every open and close, a red line at every monitor alert, and a time picker. The cost is three pieces: a Postgres database, a webhook receiver, and a poller for the equity samples.
Webhooks alone aren't enough, because MetaKit emits trade and account events, not periodic equity ticks. So the receiver stores events and a tiny poller stores samples. Postgres handles both comfortably; TimescaleDB is a nice upgrade later but not required.
Schema
CREATE TABLE equity_samples (
ts timestamptz NOT NULL,
account_id integer NOT NULL,
balance numeric NOT NULL,
equity numeric NOT NULL,
used_margin numeric NOT NULL,
free_margin numeric NOT NULL,
PRIMARY KEY (account_id, ts)
);
CREATE TABLE trade_events (
id bigserial PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now(),
event_ts timestamptz NOT NULL,
account_id integer NOT NULL,
event text NOT NULL,
ticket bigint,
symbol text,
payload jsonb NOT NULL,
UNIQUE (account_id, event, ticket, event_ts)
);
CREATE TABLE monitor_alerts (
id bigserial PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now(),
event_ts timestamptz NOT NULL,
account_id integer NOT NULL,
monitor_id integer NOT NULL,
metric text NOT NULL,
value numeric NOT NULL,
message text NOT NULL
);The UNIQUE on trade_events is doing real work. Webhook deliveries are
retried with a byte-identical payload and no delivery id, so the same
position.opened can arrive twice. The constraint plus ON CONFLICT DO NOTHING turns duplicates into no-ops. (The
delivery semantics post
goes through why this is the only safe assumption.)
The receiver
Two routes, because there are two kinds of inbound POST. Registered webhooks
(trade.* and the rest) are HMAC-signed and verified against the raw body.
Monitor alerts sent through a webhook channel are not signed, so that
route is authenticated by a secret token in the URL instead.
# receiver.py -- pip install flask psycopg2-binary
import hashlib
import hmac
import json
import os
import time
import psycopg2
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["METAKIT_WEBHOOK_SECRET"] # whsec_... from GET /v1/webhooks
MONITOR_TOKEN = os.environ["MONITOR_TOKEN"] # random string you generate
db = psycopg2.connect(os.environ["DATABASE_URL"])
db.autocommit = True
def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
except ValueError:
return False
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks/metakit")
def registered_webhook():
raw = request.get_data()
if not verify(raw, request.headers.get("X-MetaKit-Signature", "")):
abort(400)
kind = request.headers.get("X-MetaKit-Event", "")
if kind.startswith("trade."):
body = json.loads(raw)
data = body["data"]
with db.cursor() as cur:
cur.execute(
"""INSERT INTO trade_events
(event_ts, account_id, event, ticket, symbol, payload)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING""",
(body["timestamp"], body["account_id"], body["event"],
data.get("ticket"), data.get("symbol"), json.dumps(body)),
)
return "", 200
@app.post("/hooks/monitor/<token>")
def monitor_alert(token):
if not hmac.compare_digest(token, MONITOR_TOKEN):
abort(404)
body = request.get_json(force=True)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO monitor_alerts
(event_ts, account_id, monitor_id, metric, value, message)
VALUES (%s, %s, %s, %s, %s, %s)""",
(body["timestamp"], body["account"]["id"], body["monitor"]["id"],
body["monitor"]["metric"], body["value"], body["message"]),
)
return "", 200Register the first route with POST /v1/webhooks (or in the dashboard under
Settings → Webhooks) and copy the secret. Register the second by creating a
monitor with channel: "webhook" and channel_config.url set to
https://your-host/hooks/monitor/your-token. Every webhook you own receives
every event, so if you run several accounts, the account_id column is how
you tell them apart.
Notice the handler does one insert and returns. Deliveries time out at five seconds, and a timeout is a failure that gets retried. Keep it fast.
The poller
# poll_equity.py -- run every 30s from a systemd timer or a loop
import os
import psycopg2
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"x-api-key": os.environ["METAKIT_KEY"]}
ACCOUNTS = [2, 5]
db = psycopg2.connect(os.environ["DATABASE_URL"])
db.autocommit = True
for account_id in ACCOUNTS:
a = requests.get(f"{BASE}/v1/accounts/{account_id}", headers=HEADERS, timeout=15).json()
if a.get("status") != "connected":
continue
with db.cursor() as cur:
cur.execute(
"""INSERT INTO equity_samples
(ts, account_id, balance, equity, used_margin, free_margin)
VALUES (now(), %s, %s, %s, %s, %s)""",
(account_id, a["balance"], a["equity"], a["used_margin"], a["free_margin"]),
)Thirty seconds is a reasonable floor. The account read is live, but every call reaches a real terminal, and a dashboard nobody is staring at doesn't need sub-second resolution.
The Grafana panels
Add Postgres as a data source, create a time series panel, and use a raw SQL query:
SELECT
ts AS "time",
equity,
balance
FROM equity_samples
WHERE account_id = 2
AND $__timeFilter(ts)
ORDER BY ts;Trade markers come from an annotation query (Dashboard settings → Annotations → New, Postgres):
SELECT
event_ts AS time,
event || ' ' || coalesce(symbol, '') AS text,
event AS tags
FROM trade_events
WHERE account_id = 2
AND event IN ('position.opened', 'position.closed')
AND $__timeFilter(event_ts);And a second annotation set for alerts, in red:
SELECT event_ts AS time, message AS text, metric AS tags
FROM monitor_alerts
WHERE account_id = 2 AND $__timeFilter(event_ts);That's the whole dashboard: equity and balance as lines, a marker on every open and close, a red line wherever a monitor fired. When the curve dips and a red line appears a few seconds later, you can see the monitor doing its job, which is oddly reassuring at 2am.
Which MT5 equity dashboard to build
If the question is "what's my equity right now, roughly", build the sheet. It's ten minutes and it never needs maintenance.
If you want to see why the equity moved, build Grafana. The trade markers are the part you can't get from a poller, and the alert history lined up against the curve is what makes threshold tuning sane.
Either way, verify the signatures on the registered webhook route. The verification post has the same function in Node and Python with the edge cases spelled out, and the full payload shapes are in llms.txt.