DEV Community

Wataru Suda
Wataru Suda

Posted on

GMO Coin API in Python: HMAC signing, post-only limit orders, and the 4 errors that will bite you

GMO Coin is one of the larger Japanese crypto exchanges, and its REST API is genuinely pleasant: public endpoints need no key, the docs are bilingual, and the JSON is flat. But the private (authenticated) half has four sharp edges that are not obvious from the documentation, and I hit all four while building a small spot trading bot that runs once a day on about ¥24,000 of my own money.

This post is the client I ended up with, plus the four errors, in the order you will meet them.

If you only want the public half — daily candles, backtesting, no account required — I packaged that separately as a free download: GMO Coin Trend Lab (source also on GitHub). Everything below is about the authenticated half.

The public API: no key, no ceremony

Two base URLs, and the public one needs nothing:

import requests

PUBLIC = "https://api.coin.z.com/public"

def pub(path, **params):
    r = requests.get(PUBLIC + path, params=params, timeout=20)
    d = r.json()
    if d.get("status") != 0:          # note: 0 means success
        raise RuntimeError(f"{path} {d.get('messages')}")
    return d["data"]

print(pub("/v1/ticker", symbol="BTC")[0])
print(pub("/v1/klines", symbol="BTC", interval="1day", date="2026"))
Enter fullscreen mode Exit fullscreen mode

Two things to notice straight away. First, status == 0 means success — not 1, and not the HTTP status code. A failed private call can still come back as HTTP 200 with status: 1 and a messages array. Always branch on the body.

Second, spot symbols are bare tickers: BTC, ETH, XRP. The pair-style symbols (BTC_JPY) belong to the leveraged product, which is a different account with different rules. Sending BTC_JPY to a spot endpoint fails in a way that reads like an auth problem, which will waste twenty minutes of your life.

Signing a private request

The scheme is HMAC-SHA256 over the concatenation timestamp + method + path + body, hex-encoded, with the secret as the key:

import hashlib, hmac, json, time

PRIVATE = "https://api.coin.z.com/private"

def priv(session, key, secret, method, path, body=None, params=None):
    ts = str(int(time.time() * 1000))
    raw = json.dumps(body) if body else ""
    sign = hmac.new(secret.encode(),
                    (ts + method + path + raw).encode(),
                    hashlib.sha256).hexdigest()
    headers = {"API-KEY": key, "API-TIMESTAMP": ts,
               "API-SIGN": sign, "Content-Type": "application/json"}
    r = session.request(method, PRIVATE + path, headers=headers,
                        params=params, data=raw or None, timeout=20)
    d = r.json()
    if d.get("status") != 0:
        raise RuntimeError(f"{method} {path} {d.get('messages')}")
    return d.get("data")
Enter fullscreen mode Exit fullscreen mode

Error 1: signing a body you don't send

Look closely at raw. It is serialized once, used for the signature, and then passed to data=. The tempting version is to build the signature from json.dumps(body) and then let requests serialize it again with json=body. Those two strings differ — requests uses different separators — so the server recomputes a different HMAC and rejects you.

The path in the signature is also the path without the /private prefix and without the query string: /v1/order, not https://api.coin.z.com/private/v1/order?symbol=BTC. Query parameters are not signed, which is why GET endpoints take params while POST endpoints take a signed body.

Error 2: your clock

This one cost me an evening. My Windows PC was running about 16 seconds ahead of the exchange, and every private call came back with:

ERR-5009  The API timestamp is too fast.
Enter fullscreen mode Exit fullscreen mode

GMO rejects timestamps outside a narrow window around server time. If your machine sleeps, dual-boots, or simply has a lazy NTP client, you will drift into it eventually.

You do not need NTP to fix this. Every HTTP response carries a Date header, so you can measure the offset yourself and subtract it when signing:

import email.utils

class Clock:
    def __init__(self, session):
        self.s, self.offset = session, None

    def sync(self, refresh=False):
        """local clock minus server clock, in seconds."""
        if self.offset is None or refresh:
            try:
                t0 = time.time()
                r = self.s.get(PUBLIC + "/v1/status", timeout=15)
                t1 = time.time()
                srv = email.utils.parsedate_to_datetime(r.headers["Date"]).timestamp()
                self.offset = (t0 + t1) / 2 - srv
            except Exception:
                self.offset = 0.0
        return self.offset
Enter fullscreen mode Exit fullscreen mode

Then sign with ts = str(int((time.time() - clock.sync() - 0.5) * 1000)). The extra half second is deliberate: Date has one-second resolution, so biasing slightly into the past is free insurance against landing on the wrong side of the boundary. Round-trip time makes this accurate to roughly ±0.5 s, which is far inside the tolerance.

There is a second-order lesson here that has nothing to do with GMO. My bot treated "cannot authenticate" as "fall back to the dry-run balance", computed equity against a paper ¥10,000 instead of the real balance, concluded it was 58% underwater, and tripped its own kill switch. The exchange was fine; the account was fine; my error handling was not. An auth failure should stop the program, not change its inputs.

Error 3: post-only is a timeInForce, not a flag

If you are running any strategy where fees matter, you want to be a maker. On GMO Coin spot, the maker fee is −0.01% (a rebate) and the taker fee is 0.05% — a 6 bp swing per side, which on a small account is the difference between a strategy and a donation.

There is no postOnly: true parameter. You get post-only behaviour by setting timeInForce to SOK:

body = {
    "symbol": "BTC",
    "side": "BUY",
    "executionType": "LIMIT",
    "timeInForce": "SOK",     # post-only: cancelled if it would take liquidity
    "price": "14500000",
    "size": "0.0002",
}
order_id = priv(session, key, secret, "POST", "/v1/order", body=body)
Enter fullscreen mode Exit fullscreen mode

SOK means the order is cancelled outright if it would cross the spread and execute immediately. That is the behaviour you want, but it changes the shape of your code: a post-only order is not a fire-and-forget instruction. You have to place it, poll /v1/activeOrders, and decide what to do when it is neither filled nor resting — typically re-quote one tick behind the touch and try again, with a bounded number of attempts and a hard stop.

My own execution loop places at the best bid (for a buy), waits, re-quotes a few times, and then simply gives up for the day rather than crossing the spread. Missing a day costs nothing on a daily-bar strategy. Paying taker fees on every entry costs about 12 bp round trip, every trade, forever.

Error 4: sizes, ticks, and float formatting

Each symbol has a minimum size, a size step and a price tick, and the API rejects anything off-grid:

SPOT_RULES = {
    "BTC": {"min": 0.00001, "step": 0.00001, "tick": 1},
    "ETH": {"min": 0.0001,  "step": 0.0001,  "tick": 1},
    "XRP": {"min": 1,       "step": 1,       "tick": 0.001},
}
Enter fullscreen mode Exit fullscreen mode

(Check these in your account screen before going live — exchanges change them.)

Two traps in one. First, rounding: round(x, 5) is not the same as rounding to a step, and floats will hand you 0.30000000000000004 at the worst moment. Round to the step explicitly:

def round_step(x, step):
    return round(round(x / step) * step, 8)
Enter fullscreen mode Exit fullscreen mode

Second, formatting. Send strings, and send them without scientific notation. str(0.00001) gives '1e-05' in Python, which the API does not accept:

def fmt(x):
    s = f"{x:.8f}".rstrip("0").rstrip(".")
    return s or "0"

fmt(0.00001)   # '0.00001'  — not '1e-05'
Enter fullscreen mode Exit fullscreen mode

Note also that XRP's minimum order is 1 XRP. I hold 0.9357 XRP in that account from an old purchase, and it is permanently unsellable through the API. It sits in the equity calculation and outside the trading universe, which is its own small lesson in writing position code that tolerates dust.

Putting it together

The full client is about 110 lines and covers assets, active orders, cancel, executions and post-only limit orders. The pieces above are the whole of it — there is no hidden complexity, only the four sharp edges.

The strategy on top is deliberately dull: daily bars, a Donchian 20/10 breakout with a 50-day moving-average filter, long only, no leverage, 1% risk per trade. Over 2018-09 to 2026-09 on GMO daily data, starting from ¥10,000 with 5 bp of cost per side, the backtest gives CAGR 16.9%, Sharpe 1.11, max drawdown −17.7%, across 148 trades. Buy-and-hold BTC at a 20% volatility target over the same window gives CAGR 17.4% with a −35.7% drawdown. So: not "beats buy and hold" — about the same return for roughly half the drawdown, in a backtest, before the next regime arrives. Ranging years (2019, 2022, 2025–26) are flat to slightly negative.

I mention the numbers only so you know what the plumbing is in service of. The plumbing is the transferable part, and the four errors above are exchange-agnostic in spirit: sign what you send, don't trust your clock, know what your venue calls post-only, and never send a float where the venue wants a string.


The daily-bar backtester and data fetcher are free and need no API key: GMO Coin Trend Lab.

If you want the authenticated half as a finished package — the full client, the walk-forward analysis, the daily execution loop with the post-only re-quote logic, and the verification scripts that reproduce every number above — that is the GMO Coin Spot API Toolkit ($29, code + report, no subscription). It is software and research, not investment advice, and it promises no returns.

Top comments (0)