Trade outcome webhook: grade MT5 trades on position.closed

12 min readMetaKit

You sent a signal at 09:41. Buy XAUUSD.m, stop 2310.50, target 2362.00. Six hours later you want one word next to it: hit or miss. Getting that word out of MT5 has historically meant a cron job that pulls deal history, groups deals by position, matches the exit to something you recorded at entry, and reads a numeric reason code off the closing deal. It works, eventually, and it's the least pleasant code in the service.

The position.closed webhook now carries the outcome itself: realised profit, close price and time, the closing deal ticket, and a reason that says whether the stop or the target did it. That makes it a trade outcome webhook you can grade from directly, and the rest of this is how.

Polling deal history for outcomes is the wrong tool

Three reasons, all of them about timing.

Deal history is cached for about 60 seconds per account on the server. Poll /v1/accounts/2/deals every ten seconds and five of every six polls return the same data. Your "detect stop loss hit" latency is a minute in the best case, and a stop that fired at 15:10:01 is graded some time after 15:11.

Deals are not trades. A closed position is an entry: "in" deal and one or more entry: "out" deals sharing a positionId, with the profit on the out leg. You have to group before you can grade, which the orders, deals and positions post covers, and every poll has to re-group the tail of the history to find out legs that arrived since last time.

And there's a gap between a position vanishing from /positions and its out deal being visible in /deals. Diff the position list and you know something closed, but not how, not at what price, and not yet for how much. So you poll deals again. And the cache returns what it returned a moment ago.

None of that is a bug. History endpoints are for reconciliation, backfills and reports. For "did it hit", you want the event.

What position.closed carries now

The header is X-MetaKit-Event: trade.position.closed; the body's event drops the trade. prefix:

{
  "event": "position.closed",
  "timestamp": "2026-09-22T15:10:02.000Z",
  "account_id": 2,
  "data": {
    "ticket": 48812231, "symbol": "XAUUSD.m", "type": "buy", "volume": 0.12,
    "priceOpen": 2331.42, "sl": 2310.50, "tp": 2362.00,
    "profit": 366.96, "commission": -0.84, "swap": 0,
    "close_price": 2362.00, "close_time": "2026-09-22T15:10:01.000Z",
    "reason": "tp", "deal_ticket": 91004402
  }
}

data is the position as last seen (the snapshot fields keep the position's own casing, hence priceOpen), plus the outcome read from the closing deal.

profit is the realised profit of the closing deal, in the account currency, excluding commission and swap. Those are commission and swap, given separately and usually negative. Net the trade as profit + commission + swap. Read only profit and a scalp that made $0.40 and paid $0.84 in commission grades as a win.

close_price is the price the closing deal executed at. On a tp close it equals tp unless the broker filled through it; on an sl close in a fast market it can be worse than sl, and the difference is your slippage.

close_time is the deal's own time. timestamp on the envelope is when the event was built, usually a second later.

deal_ticket is the out deal. Keep it. It's the join key back to /deals when you do want the full ledger row later.

reason is one of five values. sl and tp: the broker's server triggered the stop or the target. stopout: the broker force-closed for margin, which the margin level post explains. manual: closed by a client, and "client" is broad. It covers a person in the terminal or the mobile app, a DELETE /v1/accounts/2/positions/48812231 from this API, and a copier closing a follower's copy because the master closed. other is whatever the terminal reports that doesn't fit, which in practice is rare and broker-specific.

So reason tells you who ended the trade and profit tells you how it went. Grading needs both.

Grading a signal as hit, miss or scratch

Take the signal from the top: buy 0.12 lots at 2331.42, stop 2310.50, target 2362.00, on a 100-ounce contract. Planned risk is 20.92 dollars of price × 100 × 0.12 = $251.04. Planned reward is 30.58 × 100 × 0.12 = $366.96.

The event above says reason: "tp", profit: 366.96, close_price: 2362.00. Hit, at 1.46R, filled exactly at target. Net after the $0.84 commission is $366.12.

Now the same trade, closed by the trader at 2335.60 with reason: "manual". Profit is 4.18 × 100 × 0.12 = $50.16, net $49.32, which is 0.20R. Positive, but nobody would call that the signal working. That's a scratch.

The rule that falls out:

  • reason is tp: hit, whatever the number says. A slip through the target is still a hit.
  • reason is sl or stopout: miss. Page someone on stopout; that's an account problem, not a signal problem.
  • reason is manual or other: compute R from the price move divided by the planned stop distance. Inside a band around zero (0.25R is a reasonable default) it's a scratch; outside it, the sign of net profit decides.

R in price space is (close_price - priceOpen) / (priceOpen - sl) for a long, mirrored for a short, and it avoids needing the contract size at grading time. One catch: sl in the closed payload is the stop as it was at close. If the trade was trailed, that's not the planned stop and your R is inflated. Record the planned stop at entry, from your own signal or from the position.opened event, and prefer it.

The handler, in Node

Native http, no framework. It verifies the signature on the raw body, acks before doing anything slow, dedupes on data.ticket, copes with a closed arriving before its opened, and writes the grade.

import http from 'node:http';
import crypto from 'node:crypto';
 
const SECRET = process.env.METAKIT_WEBHOOK_SECRET!;
const ACCOUNT_ID = 2;
 
type Grade = 'hit' | 'miss' | 'scratch';
type TradeRow = {
  ticket: number;
  symbol?: string; side?: string; volume?: number;
  entry?: number; planned_sl?: number; planned_tp?: number;
  grade?: Grade; reason?: string; net?: number; r?: number | null;
  close_price?: number; closed_at?: string; deal_ticket?: number;
};
 
// Swap for a table keyed by ticket; upsert is the only operation used.
const trades = new Map<number, TradeRow>();
function upsert(ticket: number, patch: Partial<TradeRow>): TradeRow {
  const row = { ...(trades.get(ticket) ?? { ticket }), ...patch };
  trades.set(ticket, row);
  return row;
}
 
function verify(raw: Buffer, header: string | undefined, toleranceSec = 300): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${raw}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
 
function gradeClose(d: any, row: TradeRow | undefined) {
  const net = d.profit + (d.commission ?? 0) + (d.swap ?? 0);
  const sl = row?.planned_sl ?? d.sl;               // the stop as planned, not as trailed
  const riskDist = sl ? Math.abs(d.priceOpen - sl) : 0;
  const move = d.type === 'buy' ? d.close_price - d.priceOpen : d.priceOpen - d.close_price;
  const r = riskDist > 0 ? move / riskDist : null;
 
  let grade: Grade;
  if (d.reason === 'tp') grade = 'hit';
  else if (d.reason === 'sl' || d.reason === 'stopout') grade = 'miss';
  else if (r !== null && Math.abs(r) < 0.25) grade = 'scratch';
  else grade = net >= 0 ? 'hit' : 'miss';
 
  return { grade, reason: d.reason, net, r, close_price: d.close_price,
           closed_at: d.close_time, deal_ticket: d.deal_ticket };
}
 
function handle(evt: any) {
  if (evt.account_id !== ACCOUNT_ID) return;        // every hook receives every account's events
  const d = evt.data;
 
  switch (evt.event) {
    case 'position.opened':
      // Can land after the close. Fill entry fields only; never touch the grade.
      upsert(d.ticket, { symbol: d.symbol, side: d.type, volume: d.volume,
                         entry: d.priceOpen, planned_sl: d.sl, planned_tp: d.tp });
      break;
 
    case 'position.closed': {
      const row = trades.get(d.ticket);
      if (row?.grade) return;                        // a retry: same body, already graded
      if (d.reason === undefined) {                  // closing deal not found in time; see caveats
        upsert(d.ticket, { closed_at: evt.timestamp });
        void gradeFromDeals(d.ticket);
        return;
      }
      const graded = upsert(d.ticket, { symbol: d.symbol, side: d.type, volume: d.volume,
                                         entry: d.priceOpen, ...gradeClose(d, row) });
      console.log(`${graded.symbol} #${graded.ticket} ${graded.grade} (${graded.reason}, net ${graded.net}, ${graded.r?.toFixed(2)}R)`);
      break;
    }
 
    case 'order.expired':
      console.log(`pending #${d.ticket} on ${d.symbol} lapsed at ${d.expiration}; signal never triggered`);
      break;
  }
}
 
async function gradeFromDeals(ticket: number) {
  // Deal history is cached ~60 s, so the out deal can take a minute to show up.
  const headers = { Authorization: `Bearer ${process.env.METAKIT_KEY}` };
  for (let attempt = 0; attempt < 5; attempt++) {
    await new Promise((r) => setTimeout(r, 30_000));
    const url = new URL(`https://api.metakit.cloud/v1/accounts/${ACCOUNT_ID}/deals`);
    url.searchParams.set('from', new Date(Date.now() - 86_400_000).toISOString());
    url.searchParams.set('limit', '100');
    const { data } = await (await fetch(url, { headers })).json();
    const out = data.find((x: any) => x.positionId === ticket && x.entry === 'out');
    if (out) {
      const net = out.profit + out.commission + out.swap + out.fee;
      upsert(ticket, { grade: net >= 0 ? 'hit' : 'miss', reason: 'deals', net,
                       close_price: out.price, closed_at: out.time });
      return;
    }
  }
}
 
http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/hooks/metakit') { res.statusCode = 404; return res.end(); }
  const chunks: Buffer[] = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers['x-metakit-signature'] as string | undefined)) {
      res.statusCode = 400; return res.end();
    }
    res.statusCode = 200; res.end();                 // ack first; 5 s is the whole budget
    try { handle(JSON.parse(raw.toString('utf8'))); } catch (e) { console.error(e); }
  });
}).listen(3000);

Four things in there are load-bearing.

The dedupe is if (row?.grade) return. Retries deliver a byte-identical body with the same signature, up to five times, so a handler that grades on every delivery grades the same trade five times. Keyed on data.ticket, the second delivery finds the grade already written and does nothing. In SQL that's an ON CONFLICT (ticket) DO UPDATE where the grade column uses COALESCE(trades.grade, EXCLUDED.grade), so the first grade written wins.

The ordering tolerance is that both branches upsert. A scalp that opens and closes inside a second can deliver the closed first, because each event has its own retry schedule. "On opened insert, on closed update" would lose the update. Here the closed creates the row and grades it from its own payload, which has everything needed; when the opened lands later it fills in entry fields and leaves the grade alone. The delivery post has the full list of things a consumer must assume.

The account_id filter is not optional. Every webhook you own receives every event for every account; there's no per-hook subscription.

The ack comes before handle. The delivery budget is 5 seconds end to end, and a 200 after 5.2 seconds is a failure that gets retried.

If you'd rather have Express, mount express.raw({ type: 'application/json' }) on the route (not express.json()) and pass req.body, a Buffer, to verify. The raw-body pitfalls per framework, secret rotation and the Python version are in the verification post.

order.expired is not order.cancelled

You place a buy stop with expiration set to the end of the session. Besides filling, two things can happen to it. Someone deletes it, through DELETE /v1/accounts/2/orders/{ticket} or the terminal, and you get order.cancelled. Or the clock passes expiration, the broker drops it, and you get order.expired, with data being the order as last seen, expiration included.

For grading they mean different things. Cancelled is a decision: someone withdrew the signal. Expired is a non-event: the market never came to your price in the window you gave it. Count expired separately as "not triggered" rather than folding it into scratches, or your scratch rate ends up measuring your patience instead of your signals.

One detail on the clock: expiration is compared on the broker's server time, not UTC. If your expiries seem to fire an hour or three early, the server time post has the reason.

The caveats

Honest ones.

The outcome fields are looked up right after the position disappears from the terminal. Very rarely the closing deal isn't in history yet at that moment, and then profit, commission, swap, close_price, close_time, deal_ticket and reason are absent from data. You still get the event and the snapshot. That's what the d.reason === undefined branch is for: mark the ticket closed, then go to /deals for the out leg, allowing for the 60-second cache. Grading from deals gives you the net but not a named reason, unless you map the deal's raw MT5 reason code yourself.

Partial closes don't fire position.closed. Close 0.06 of the 0.12 lots and you get position.modified with changes.volume going from 0.12 to 0.06. The position.closed event fires when the position is flat, and its profit is read from the closing deal(s). If you care about per-leg P/L on partially closed trades, sum the out deals by positionId from /deals; if you care about the trade's final grade, the closed event is enough.

manual includes you. If your own code closes the trade with a DELETE on the position because a time stop fired, the reason is manual, same as a human clicking close. Tag the close in your own system when you send it, and treat manual on a ticket you closed as "time stop", not "trader discretion".

And the usual: no ordering, no delivery id, five attempts then silence. The handler above is built for all of that, but you still want a periodic reconcile against /deals to catch the one delivery that failed five times while your process was restarting.

The full trade event shapes are under Webhooks in llms.txt and at app.metakit.cloud/docs. Placing the trade whose outcome you're about to grade is the place orders post.

Your entry already knew the stop and the target. Now the exit tells you which one it was.