Building a Trading Bot Without a Server: Google Apps Script as the Backend

🌐 Korean

The first wall you hit when building a trading bot is not the strategy. It is "where do I run this?" It has to stay awake through the whole session, poll prices every few minutes, and if the process dies at 3 a.m. you lose an entire trading day. Renting a VPS solves it for a few dollars a month, but now you own a server: OS updates, certificates, restart-on-boot, log rotation.

This post is about not building that server at all, and using Google Apps Script (GAS) as the backend instead. The short version: it costs nothing and runs around the clock. In exchange you inherit a specific set of constraints, and those constraints end up deciding a surprising amount of the design.

Why Apps Script can be a backend at all

Apps Script is usually filed under "spreadsheet automation," but it hands you three things for free:

  • An HTTP endpoint — deploy as a web app and doGet/doPost get a public URL
  • A scheduler — time-based triggers can run a function every minute
  • Persistent storagePropertiesService is a key-value store

That is essentially the whole shopping list for a backend: an API server, cron, and a database. For a bot that manages a few dozen parameters and two or three tickers, you do not need a relational database. That is why this combination works.

doPost as the REST endpoint

Once deployed as a web app, a single doPost(e) receives every request. There is no router, so the simplest thing that works is to put an action in the body and switch on it.

function doPost(e) {

  const body = JSON.parse(e.postData.contents);

  const action = body.action;

  const payload = body.payload;

  switch (action) {

    case "getParameters":

      return json(getParameters());

    case "saveParameters":

      return json({ ok: saveParameters(payload) });

    case "startBot":

      return json({ status: startBot() });

    case "stopBot":

      return json({ status: stopBot() });

    default:

      return json({ error: "unknown action" });

  }

}

function json(obj) {

  return ContentService

    .createTextOutput(JSON.stringify(obj))

    .setMimeType(ContentService.MimeType.JSON);

}

Here is the first sharp edge. An Apps Script web app does not accept PUT or DELETE, and you cannot set custom response headers. Rather than fighting to preserve REST verb semantics, it is cleaner to accept that this is one POST with an action field. The result looks more like RPC than REST, but when there is exactly one client, that simplicity pays.

The second sharp edge is redirects. The /exec URL responds with a 302 to script.googleusercontent.com. Browser fetch follows it by default, so you never notice — but any client that does not follow redirects automatically needs that option turned on.

Where the state lives: the two faces of PropertiesService

PropertiesService has three scopes, and mixing them up breaks things quietly.

  • getScriptProperties() — one per script, identical for every caller
  • getUserProperties()a different value per executing user
  • getDocumentProperties() — per bound document
function GetPropertyData(sKey) {

  return PropertiesService.getScriptProperties().getProperty(sKey);

}

function SetPropertyData(sKey, sVal) {

  PropertiesService.getScriptProperties().setProperty(sKey, sVal);

}

Bot parameters (account, tickers, position size) belong to a person, so they live in UserProperties; values shared by the library live in ScriptProperties. If you deploy the web app to execute as the accessing user rather than as yourself, every user gets their own parameter set, and one copy of the code serves multiple accounts.

Two things to watch. Values are strings only. Numbers, booleans and objects must be serialized by hand, and on the way back you have to distinguish a literal "OFF" from null. And there is a 9KB per-property, 500KB total cap. Do not accumulate fill history here — that belongs in a spreadsheet or an external store.

Account settings screen with trading mode, account number, position size and order gap parameters

The parameters stored in PropertiesService. Each input field on this screen maps to one property key.

The trading loop is a trigger

The heart of the bot is a time-based trigger. The "start" button is really a function that creates a trigger, and "stop" is a function that deletes them.

function startBot() {

  const props = PropertiesService.getUserProperties();

  // Already running? Do not create a second trigger.

  const running = props.getProperty("RunBot");

  if (running == null) {

    props.setProperty('RunBot', "OFF");

  } else if (running !== "OFF") {

    return 'ON';

  }

  const trigger = ScriptApp.newTrigger("RunBotFunction")

    .timeBased()

    .everyMinutes(1)

    .create();

  // Store the trigger's uniqueId as the state value itself

  props.setProperty('RunBot', trigger.getUniqueId());

  return 'ON';

}

function stopBot() {

  const props = PropertiesService.getUserProperties();

  const triggers = ScriptApp.getProjectTriggers();

  for (let i = 0; i < triggers.length; i++) {

    try {

      ScriptApp.deleteTrigger(triggers[i]);

    } catch (e) {

      Logger.log('deleteTrigger failed: ' + e.message);

    }

  }

  props.setProperty('RunBot', "OFF");

  return 'OFF';

}

There is a small design decision hiding in there. The RunBot property stores the trigger's uniqueId, not the string "ON". That makes "the bot is on" and "a trigger actually exists" the same single fact. Keep a separate flag and you will eventually reach the state where the trigger is gone but the flag still says ON.

stopBot walking every entry from getProjectTriggers() is deliberate too. Triggers get orphaned and duplicated more easily than you would expect — redeploys and failed executions leave residue. Stopping has to be certain, so instead of hunting one ID we delete them all.

The constraints, and how to live with them

1) Six minutes per execution

A free account gets 6 minutes per execution (30 for Workspace accounts). Past that it simply dies. So the shape becomes a one-minute trigger with short executions: wake up, do one slice of the work that is due now, exit. Instead of running a long loop, you leave the state in properties and let the next execution pick it up. You are hand-writing a state machine, and that is the price of serverless.

2) One minute is the floor

Sub-second trading is off the table. This constraint picks your strategy for you. Scalping was never a candidate; only strategies that are fine at minute resolution — grids, rebalancing, rotation — fit here. Read the other way: if your strategy is fine at minute resolution, you have no reason to buy a server.

3) The UrlFetchApp daily quota

Outbound HTTP is capped at roughly 20,000 calls per day on a free account. That sounds generous until you do the arithmetic. One execution per minute over a 390-minute session is 390 runs; add two quote lookups and one balance check per run and you are near 1,500 calls a day. Add tickers or shorten the interval and the ceiling arrives fast. So collapsing duplicate calls within a cycle matters.

// Cache the access token by issue date and reissue once per day.

// Requesting a fresh token on every execution burns hundreds of calls

// per day on its own.

function getAccessToken() {

  const props = PropertiesService.getUserProperties();

  const today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyyMMdd');

  if (props.getProperty('TokenDate') === today) {

    return props.getProperty('AccessToken');

  }

  const res = UrlFetchApp.fetch(TOKEN_URI, {

    method: 'post',

    contentType: 'application/json',

    payload: JSON.stringify({

      grant_type: 'client_credentials',

      appkey:    props.getProperty('APIKey'),

      appsecret: props.getProperty('APISecret')

    })

  });

  const token = JSON.parse(res.getContentText()).access_token;

  props.setProperty('AccessToken', token);

  props.setProperty('TokenDate', today);

  return token;

}

4) The brokerage has its own limits

The Korea Investment REST API issues access tokens with an expiry and enforces its own per-second call limits. The GAS quota and the brokerage rate limit are separate budgets and you have to respect both. The token cache above happens to save on each of them at once.

When this fits, and when it does not

It fits when

  • Minute-level cadence is enough (grids, rebalancing, rotation, scheduled buys)
  • The state you manage is small — dozens of parameters, a handful of tickers
  • One person or a few people use it, and nobody wants to babysit a server
  • You want the running cost to be zero

It does not fit when

  • You need to react in seconds — one minute is the floor
  • You need streaming quotes over WebSocket — GAS cannot hold a persistent connection
  • You accumulate real data volume, like fill history or backtest sets — 500KB of properties will not do
  • You need precise retry and failure tracking — a skipped trigger passes silently

That last one hurts most in practice. When a trigger misses a run, nothing tells you. The fix is to write a last-run timestamp to properties on every execution and surface, on the UI, how many minutes ago that was. Showing "when it actually last ran" is a far more honest status indicator than "the bot is on."

Summary

  • GAS gives you the three things a backend needs — HTTP, scheduling, storage — for free
  • With no router, POST plus an action switch is the simplest workable shape
  • Store the trigger uniqueId itself as the on/off state and the two can never disagree
  • The 6-minute cap pushes you to short executions carrying state in properties rather than long loops
  • The 20,000-call quota is largely tamed by caching the access token
  • If you need sub-second reactions, streaming, or volume, this shape is wrong — buy the server

The next post covers the Chrome extension control panel built on top of this backend.

Note: this is an engineering write-up, not investment advice. Automated trading can lose money through software bugs, network failures, and market conditions nobody planned for; every trading decision and its outcome is your own responsibility. Validate thoroughly with paper trading or minimal size before pointing anything at a live account.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기