How to setup 'Grid Copier' and 'Copy Trades' with Google Apps Script (step-by-step)

18 February 2026, 17:35
Aleh Piatrenka
0
195

Why does the setup require Google Apps Script

Copy Trades is designed for remote trade copying between MetaTrader terminals running on different computers or VPS servers. Because of this, the initial setup requires a few additional steps compared to local trade copiers. 

This functionality is rare in the MQL5 Market

If you have any difficulties during the setup, feel free to send me a private message. I will be happy to help you configure the utility step by step.

Google Apps Script acts as a free relay service that transfers trade events from the Master account to the Slave account. Once configured, it ensures reliable delivery of events even after internet interruptions or terminal restarts and does not require a VPS or dedicated server.

How to create and deploy Google Apps Script (8 steps)

  1. Go to https://script.google.com and Click Start scripting



  2. Click New project



  3. Delete the default code and paste the script below


    const API_KEY   = 'I_AM_API_KEY';
    
    // Number of events to prune in a single pass (to avoid execution time limits)
    const MAX_PRUNE = 200;
    
    // A consumer is considered "active" if it was seen within this time window (ms)
    const CONSUMER_TTL_MS = 6 * 60 * 60 * 1000; // 6 h
    
    // max consumers remembered per channel (to avoid growth)
    const MAX_CONSUMERS_PER_CHANNEL = 50;
    
    // ---------------- Web handlers ----------------
    
    function doPost(e) {
      const lock = LockService.getScriptLock();
      let locked = false;
    
      try {
        lock.waitLock(10000);
        locked = true;
    
        if (!e || !e.postData || !e.parameter) return _resp({ ok:false, error:'no data' });
    
        const key = (e.parameter.key || '').toString();
        if (key !== API_KEY) return _resp({ ok:false, error:'forbidden' });
    
        const channel  = (e.parameter.channel || 'default').toString();
        const consumer = (e.parameter.consumer || '').toString();
        const c        = consumer || 'single';
    
        const store = PropertiesService.getScriptProperties();
    
        // tolerant JSON parse
        let raw = e.postData.contents || '{}';
        raw = raw.replace(/[\u0000-\u001F]+$/g, '');
    
        let body;
        try {
          body = JSON.parse(raw);
        } catch (parseErr) {
          return _resp({ ok:false, error:'bad json', details:String(parseErr) });
        }
    
        // mark consumer as seen + register in channel consumer list (FAST)
        // + NEW RULE: if consumer is new => ack = seq (start from "now", no replay)
        _touchConsumerFast(store, channel, c);
    
        // ---- ACK from slave ----
        if (body && body.action === 'ack') {
          const lastId = Number(body.last_id || 0);
          if (!lastId) return _resp({ ok:false, error:'bad ack' });
    
          store.setProperty(_ackKey(channel, c), String(lastId));
    
          // prune ONLY by minAck across all ACTIVE consumers
          _pruneByMinAckFast(store, channel);
    
          return _resp({ ok:true, ack:lastId });
        }
    
        // ---- event from master ----
        const nextId = _nextSeq(store, channel);
    
        body.id = nextId;
        body.server_time_ms = Date.now();
    
        // store event as separate key
        store.setProperty(_evKey(channel, nextId), JSON.stringify(body));
    
        // set min_id if empty
        const minKey = _minKey(channel);
        const curMin = Number(store.getProperty(minKey) || '0');
        if (!curMin) store.setProperty(minKey, String(nextId));
    
        return _resp({ ok:true, last_id: nextId });
    
      } catch (err) {
        return _resp({
          ok:false,
          error:'exception',
          message:String(err),
          stack:(err && err.stack) ? String(err.stack) : ''
        });
      } finally {
        if (locked) {
          try { lock.releaseLock(); } catch(_) {}
        }
      }
    }
    
    function doGet(e) {
      const lock = LockService.getScriptLock();
      let locked = false;
    
      try {
        lock.waitLock(10000);
        locked = true;
    
        if (!e || !e.parameter) return _resp({ ok:false, error:'no params' });
    
        const key = (e.parameter.key || '').toString();
        if (key !== API_KEY) return _resp({ ok:false, error:'forbidden' });
    
        const channel  = (e.parameter.channel || 'default').toString();
        const consumer = (e.parameter.consumer || '').toString();
        const c        = consumer || 'single';
        const limit    = Math.max(1, Math.min(100, Number(e.parameter.limit || 20)));
    
        const store = PropertiesService.getScriptProperties();
    
        // mark consumer as seen + register in channel consumer list (FAST)
        // + NEW RULE: if consumer is new => ack = seq (start from "now", no replay)
        _touchConsumerFast(store, channel, c);
    
        const minId = Number(store.getProperty(_minKey(channel)) || '0');
        const seq   = Number(store.getProperty(_seqKey(channel)) || '0');
    
        const ackKey = _ackKey(channel, c);
        let ack = Number(store.getProperty(ackKey) || '0');
    
        // If events were pruned and consumer is behind, auto-catch-up to min_id-1 (prevents gap_detected)
        if (minId > 0) {
          const floorAck = Math.max(0, minId - 1);
          if (ack < floorAck) {
            ack = floorAck;
            store.setProperty(ackKey, String(ack));
          }
        }
    
        // optional health/debug mode
        const mode = (e.parameter.mode || '').toString();
        if (mode === 'health' || mode === 'debug') {
          const consumers = _listActiveConsumersFast(store, channel);
          const minAck = _minAckFast(store, channel, consumers);
          const out = { ok:true, channel, consumer:c, ack, seq, min_id:minId, active_consumers:consumers, min_ack:minAck };
    
          if (mode === 'debug') {
            // light debug: show consumer seen timestamps (active only)
            const seen = {};
            for (const cc of consumers) {
              seen[cc] = Number(store.getProperty(_seenKey(channel, cc)) || '0');
            }
            out.seen = seen;
          }
          return _resp(out);
        }
    
        const events = [];
        let missing_id = 0;
    
        // strictly sequential read: ack+1, ack+2...
        for (let id = ack + 1; id <= seq && events.length < limit; id++) {
          const evStr = store.getProperty(_evKey(channel, id));
    
          if (!evStr) {
            missing_id = id;
            break;
          }
    
          try {
            events.push(JSON.parse(evStr));
          } catch (parseErr) {
            missing_id = id;
            break;
          }
        }
    
        // If a "gap" is actually just "consumer behind pruned min_id", auto-resync and retry once
        if (missing_id && minId > 0 && missing_id < minId) {
          const newAck = Math.max(0, minId - 1);
          store.setProperty(ackKey, String(newAck));
    
          const events2 = [];
          let missing2 = 0;
    
          for (let id = newAck + 1; id <= seq && events2.length < limit; id++) {
            const evStr = store.getProperty(_evKey(channel, id));
            if (!evStr) { missing2 = id; break; }
            try { events2.push(JSON.parse(evStr)); } catch (_) { missing2 = id; break; }
          }
    
          if (!missing2) {
            return _resp({ ok:true, ack: newAck, seq: seq, events: events2 });
          }
    
          // real gap (corruption), report
          return _resp({
            ok:false,
            error:'gap_detected',
            ack: newAck,
            seq: seq,
            missing_id: missing2
          });
        }
    
        if (missing_id) {
          return _resp({
            ok:false,
            error:'gap_detected',
            ack: ack,
            seq: seq,
            missing_id: missing_id
          });
        }
    
        return _resp({ ok:true, ack: ack, seq: seq, events: events });
    
      } catch (err) {
        return _resp({
          ok:false,
          error:'exception',
          message:String(err),
          stack:(err && err.stack) ? String(err.stack) : ''
        });
      } finally {
        if (locked) {
          try { lock.releaseLock(); } catch(_) {}
        }
      }
    }
    
    // ---------------- helpers ----------------
    
    function _nextSeq(store, channel) {
      const k = _seqKey(channel);
      const next = Number(store.getProperty(k) || '0') + 1;
      store.setProperty(k, String(next));
      return next;
    }
    
    // consumer registry (FAST):
    // - keep lastSeen per channel+consumer
    // - keep compact list of consumers per channel in one JSON key
    // - NEW RULE: if consumer has no ACK yet => ack = seq (start from now, no replay)
    function _touchConsumerFast(store, channel, consumer) {
      const now = Date.now();
      store.setProperty(_seenKey(channel, consumer), String(now));
    
      const listKey = _consumersKey(channel);
      let arr = [];
      try { arr = JSON.parse(store.getProperty(listKey) || '[]'); } catch(_) { arr = []; }
    
      if (arr.indexOf(consumer) < 0) {
        arr.push(consumer);
        // keep bounded
        if (arr.length > MAX_CONSUMERS_PER_CHANNEL) arr = arr.slice(arr.length - MAX_CONSUMERS_PER_CHANNEL);
        store.setProperty(listKey, JSON.stringify(arr));
      }
    
      const ackKey = _ackKey(channel, consumer);
      const ackStr = store.getProperty(ackKey);
    
      // NEW RULE: if consumer is new (no ACK stored yet) => start from "now"
      // ack = current seq (so GET returns only future events)
      if (ackStr === null || ackStr === undefined || ackStr === '') {
        const seq = Number(store.getProperty(_seqKey(channel)) || '0');
        store.setProperty(ackKey, String(Math.max(0, seq)));
        return;
      }
    
      // If consumer exists but is behind pruned min_id, catch it up automatically (prevents gap_detected)
      const minId = Number(store.getProperty(_minKey(channel)) || '0');
      const ack = Number(ackStr || '0');
      if (minId > 0) {
        const floorAck = Math.max(0, minId - 1);
        if (ack < floorAck) store.setProperty(ackKey, String(floorAck));
      }
    }
    
    function _listActiveConsumersFast(store, channel) {
      const now = Date.now();
      const listKey = _consumersKey(channel);
    
      let arr = [];
      try { arr = JSON.parse(store.getProperty(listKey) || '[]'); } catch(_) { arr = []; }
    
      const active = [];
      for (const c of arr) {
        const seen = Number(store.getProperty(_seenKey(channel, c)) || '0');
        if (!seen) continue;
        if (now - seen <= CONSUMER_TTL_MS) active.push(c);
      }
    
      if (active.length === 0) active.push('single');
      return active;
    }
    
    function _minAckFast(store, channel, consumers) {
      let min = null;
      for (const c of consumers) {
        const a = Number(store.getProperty(_ackKey(channel, c)) || '0');
        if (min === null || a < min) min = a;
      }
      return min === null ? 0 : min;
    }
    
    function _pruneByMinAckFast(store, channel) {
      const consumers = _listActiveConsumersFast(store, channel);
      const minAck = _minAckFast(store, channel, consumers);
      if (minAck <= 0) return;
    
      _pruneAckedUpTo(store, channel, minAck);
    }
    
    function _pruneAckedUpTo(store, channel, ackId) {
      const minKey = _minKey(channel);
      let minId = Number(store.getProperty(minKey) || '0');
      if (!minId) return;
    
      let removed = 0;
      while (minId && minId <= ackId && removed < MAX_PRUNE) {
        store.deleteProperty(_evKey(channel, minId));
        minId++;
        removed++;
      }
    
      const seq = Number(store.getProperty(_seqKey(channel)) || '0');
    
      if (minId > seq) {
        store.deleteProperty(minKey); // queue empty
      } else {
        store.setProperty(minKey, String(minId));
      }
    }
    
    // keys
    function _seqKey(channel) { return channel + '__seq'; }
    function _minKey(channel) { return channel + '__min'; }
    function _ackKey(channel, consumer) { return channel + '__ack__' + consumer; }
    function _evKey(channel, id) { return channel + '__ev__' + id; }
    function _seenKey(channel, consumer) { return channel + '__seen__' + consumer; }
    function _consumersKey(channel) { return channel + '__consumers'; }
    
    function _resp(obj) {
      // IMPORTANT: do NOT use out.setHeader() (not supported)
      // and do NOT use ContentService.CacheControl.* (may be undefined).
      // Use client-side cache-buster: add &ts=... to GET on the slave.
      return ContentService
        .createTextOutput(JSON.stringify(obj))
        .setMimeType(ContentService.MimeType.JSON);
    }
    



  4. Press Ctrl + S to save the project (the top menu will become active)



  5. Click Deploy in the top-right corner and select New deployment



  6. In the opened window, click Select type (⚙️) and choose Web app


  7. In the Description field, enter Version 1 (any text is fine). Set Who has access to Anyone and leave Execute as unchanged



  8. Click Deploy - your Apps Script URL will be generated. Copy and paste this URL into the EA input settings



After you created a Google Apps Script (a free relay server used for exchanging trade events between Master and Slave) go to setup Metatrader 5.
  1. In MetaTrader 5 → Tools Options Expert Advisor tab, enable WebRequest for your Google Apps Script URL and allow algorithmic trading.
  2. Attach Grid Copier or Copy Trades on the required symbol on the Master account (the source account) and set the role to Master in the input parameters.
  3. Attach Grid Copier or Copy Trades on the same symbol on the Slave account (the target account) and set the role to Slave in the input parameters.
  4. Enter the Google Apps Script URL in the GridCopier inputs on both Master and Slave.
  5. Enter the Api Key from Google Apps Script (for example, I_AM_API_KEY) in the inputs on both Master and Slave. The Api Key is a simple “password” that protects your copying channel from unauthorized access (you can use any text).
  6. Set the same Channel Name on both Master and Slave (for example: symbol_copier). This is the trade queue name and must match on both sides.
    Each independent MasterSlave pair must use its own unique Channel Name.
  7. Set the Trade ID (magic) for Server on the Slave, for example: slave_1.
  8. Copying will start automatically.

If you use Copy Trades and Grid Copier in one time - it is recommended to create two separate Apps Script URL.

If you have questions or assist please reach out to me.