How to setup Grid Copier with Google Apps Script (step-by-step)








Why do we need Google Apps Script?
To exchange trade data between separate MetaTrader terminals, a simple relay server is required. Google Apps Script acts as a free intermediary that transfers trade events from the Master account to the Slave account. 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
-
Go to https://script.google.com and Click Start scripting

-
Click New project

-
Delete the default code and paste the script provided below the instruction steps

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

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

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

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

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

const API_KEY = 'I_AM_API_KEY'; const MAX_PRUNE = 200; const CONSUMER_TTL_MS = 6 * 60 * 60 * 1000; const MAX_CONSUMERS_PER_CHANNEL = 50; 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(); 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) }); } _touchConsumerFast(store, channel, c); 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)); _pruneByMinAckFast(store, channel); return _resp({ ok:true, ack:lastId }); } const nextId = _nextSeq(store, channel); body.id = nextId; body.server_time_ms = Date.now(); store.setProperty(_evKey(channel, nextId), JSON.stringify(body)); 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(); _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 (minId > 0) { const floorAck = Math.max(0, minId - 1); if (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') { 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; for (let id = ack + 1; 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 (missing_id && minId > 0 && missing_id const newAck = Math.max(0, minId - 1); store.setProperty(ackKey, String(newAck)); const events2 = []; let missing2 = 0; for (let id = newAck + 1; 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 }); } 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(_) {} } } } function _nextSeq(store, channel) { const k = _seqKey(channel); const next = Number(store.getProperty(k) || '0') + 1; store.setProperty(k, String(next)); return next; } 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); 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); if (ackStr === null || ackStr === undefined || ackStr === '') { const seq = Number(store.getProperty(_seqKey(channel)) || '0'); store.setProperty(ackKey, String(Math.max(0, seq))); return; } 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 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 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 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 const seq = Number(store.getProperty(_seqKey(channel)) || '0'); if (minId > seq) { store.deleteProperty(minKey); } else { store.setProperty(minKey, String(minId)); } } 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) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); }
Source link