Zaif APIのorder_id=0でハマった話。未約定と全約定を見分ける方法
Zaifの取引APIを使って自動売買を組んでいて、注文が通ったのか通っていないのか分からなくなったことがある人向けの記事。
自分は約定管理のロジックを書いていて、このorder_id=0の挙動に3日ほど溶かした。原因が分かればどうということはないが、ドキュメントを読んだだけでは絶対に気づけない仕様だった。
結論から書く。Zaifのtradeエンドポイントは、注文が即座に全部約定した場合、order_idとして0を返す。板に注文が残った場合は普通に0以外のIDが返る。つまりorder_idが0かどうかで「即座に全約定したか」「未約定(or部分約定)で板に残っているか」を判定できる。この仕様を知らずにorder_idをキーにして注文管理テーブルを組むと、全約定した注文がキー重複でエラーになったり、逆に上書きされて消えたりする。
なぜこの仕様でハマるのか
普通のAPI設計だと、注文を出したら必ず一意なIDが返ってくると考える。楽天証券のAPIでもGMOコインのAPIでも、注文にはユニークなIDが振られるのが当たり前だ。自分もその前提でコードを書いていた。
Zaifの場合、成行に近い指値(板の反対側にすぐマッチする価格)で注文を出すと、その場で約定が完了してしまう。この時Zaifのサーバー内部では、もう「オープンな注文」として管理する必要がないので、通常のオーダーIDを発行しない。代わりに0を返してくる。
自分が最初に書いたコードはこうだった。
order_id = response["return"]["order_id"]
pending_orders[order_id] = {
"amount": amount,
"price": price,
"created_at": now,
}
このコードで注文を3回連続で即約定させたら、pending_ordersのキーが全部0になって、2回目・3回目の注文情報で1回目のレコードが上書きされた。ログを見返しても矛盾だらけで、最初は自分の非同期処理のバグを疑って半日デバッグに使った。実際は仕様通りの挙動だった。
Zaifの公式ドキュメントにはこの挙動について一応記載があるが、「order_idが0の場合は即座に約定したことを意味する」という一文がAPIリファレンスの注釈の中に埋もれていて、実装前に読み込んでいても見落としやすい位置にある。少なくとも自分は3周読んで気づかなかった。
解決手順
ステップ1:trade APIのレスポンスを丸ごと記録する
まず対策として、order_idだけでなくレスポンス全体をログに残すようにした。received(約定した数量)というフィールドも一緒に返ってくるので、これを見れば注文が部分約定か全約定かも分かる。
import requests
import time
import hmac
import hashlib
from urllib.parse import urlencode
def zaif_trade(key, secret, currency_pair, action, price, amount):
nonce = str(time.time())
params = {
"method": "trade",
"nonce": nonce,
"currency_pair": currency_pair,
"action": action,
"price": price,
"amount": amount,
}
body = urlencode(params)
sign = hmac.new(secret.encode(), body.encode(), hashlib.sha512).hexdigest()
headers = {"key": key, "sign": sign}
res = requests.post(
"https://api.zaif.jp/tapi",
data=body,
headers=headers,
timeout=10,
)
return res.json()
ステップ2:order_idではなく独自の内部IDで注文を管理する
order_idをそのままキーにするのをやめて、自分側で発行したUUIDを注文の主キーにした。Zaifのorder_idは「サーバー側で板に残っている注文を追跡するための参照情報」でしかないと割り切って、自分のシステムの主キーとしては扱わないようにした。
import uuid
def place_order(key, secret, currency_pair, action, price, amount):
internal_id = str(uuid.uuid4())
res = zaif_trade(key, secret, currency_pair, action, price, amount)
if res.get("success") != 1:
raise RuntimeError(f"注文失敗: {res}")
zaif_order_id = res["return"]["order_id"]
received = res["return"].get("received", 0)
is_fully_filled = (zaif_order_id == 0)
order_record = {
"internal_id": internal_id,
"zaif_order_id": zaif_order_id,
"requested_amount": amount,
"received_amount": received,
"is_fully_filled": is_fully_filled,
"status": "filled" if is_fully_filled else "open",
}
return order_record
ステップ3:部分約定の追跡はactive_ordersで別管理する
order_idが0以外だった場合、その注文はまだ板に残っている可能性がある。この場合はactive_ordersというAPIで、そのorder_idが実際にまだ生きているか確認する必要がある。ここでもう一段ハマりどころがあって、active_ordersは「未約定または部分約定で残っている注文」しか返さない。つまり全部約定して板から消えた注文はactive_ordersには出てこない。ポーリングで注文状況を追う設計にしていると、注文が消えた=キャンセルされたと誤判定してしまうことがある。
自分はこの判定を、trade_historyAPI(自分の約定履歴を取得するAPI)と突き合わせることで解決した。active_ordersに無い、かつtrade_historyに約定記録がある場合は「約定済み」、どちらにも無い場合は「キャンセル済み」と判定するようにロジックを分けた。
この部分約定・全約定・キャンセルの3状態を正しく判定するロジックを組むのに、結局トータルで週3時間ペースで2週間くらいかかった。仕様書だけ読んで実装したら1日で終わると思っていたので、想定の10倍以上時間を食った実感がある。
自分と同じところで詰まる人がいるだろうと思って、この状態遷移のロジックをまるごと実装例として整理したのが、AutoTrader 実装学習キットという教材になっている。Zaifだけでなく他の取引所ごとのクセも含めて、注文状態管理のパターンをコードで見られるようにしてある。
落とし穴と対処
一番踏みやすい落とし穴は、テスト環境や少額検証だとこのorder_id=0のケースにほぼ遭遇しないことだ。板が薄い通貨ペアや、指値を板から離して出していると、注文は基本的に未約定のままorder_idが発行される。自分も最初の2週間くらいはずっと未約定側のパスしか通っておらず、「動いてる」と思い込んでいた。
本番に近い条件(成行に近い価格、流動性の高いBTC/JPYなど)でテストして初めてorder_id=0のケースに当たった。この時すでに注文管理テーブルの設計がほぼ固まっていて、主キー設計をやり直す手戻りが発生した。設計の初期段階で「IDが重複するケースがあるかもしれない」という前提を置いておけば防げた手戻りだったと思う。
もう一つの落とし穴は、receivedフィールドの扱いだ。全約定時はreceivedが要求したamountと一致するはずだが、手数料や丸め誤差で厳密には一致しないことがある。自分は最初received == amountで全約定判定をしていて、これが誤差でfalseになるケースに気づかず、全約定した注文がいつまでもopenのまま残るバグを2週間放置していた。order_id == 0で判定する方が確実だった。
NDAの関係で具体的な取引所名や金額は書けないが、この手の「ドキュメントの注釈に埋もれた仕様」で実装が止まるのは、Zaifに限らず取引所APIあるあるだという感覚がある。
関連ツール
Zaifのorder_id=0のような取引所固有のクセを吸収する設計パターンを、FastAPI × React Nativeの実装例としてまとめたのがAutoTrader 実装学習キットになる。5取引所分(Zaif/bitFlyer/GMO/Coincheck/Binance Japan)の連携サンプルとテストコードが入っている。
まとめ
Zaifのorder_id=0は、即座に全約定したことを示す仕様であって、バグでもエラーでもない。この仕様を知らずにorder_idを注文の主キーとして扱うと、全約定した注文同士でキー衝突が起きる。自分の内部IDを別に発行して主キーにし、order_idは「板に残っているかどうかの参照情報」として割り切って扱うのが安全だと感じている。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- AutoTrader 実装学習キット - FastAPI × React Native で作る外部 API 連携アプリ実装学習キット
(その他:(なし))