oto-nari
M5Stick-C の液晶に WTI 原油価格とチャートが表示されている様子

Hyperliquid と Polymarket の価格を M5Stick-C に表示する

Kojiro Tanaka

スタックチャンが流行ってESP32プログラミングに興味を持った。手元に少し古い M5Stick-C があったので、まずはREST APIを叩いて画面に何か出すところから始めた。

M5Stick-Cはバッテリがないので持ち運びには向かない。デスクの余ったUSBポートから給電して、情報を常時表示しておく端末として使う。

Hyperliquid の API

オンチェーンDEX。Perpのラインナップが異常に広く、仮想通貨だけでなく指数・FX・コモディティもカバーする。

近似値(合成ポジション)なので「雰囲気をつかむ」用途。厳密な執行価格ではない。

Info APIは認証不要。POST一本で済む。

POST https://api.hyperliquid.xyz/info
Content-Type: application/json

{ "type": "allMids" }

全銘柄のミッドプライスが返る。ローソク足は candleSnapshot、板は l2Book

Polymarket の API

予測市場のDEX。選挙やイベントの結果をYES/NOで売買する。

GET https://gamma-api.polymarket.com/markets?limit=10&active=true&order=volume24hr&ascending=false

市場一覧が取れる。各市場の clobTokenIds で価格を引く。

GET https://clob.polymarket.com/prices/<token-id>

0〜1の確率が返る。そのままパーセント表示すればよい。

データ利用上の注意

両サービスとも個人でAPIを叩いて自分で見る分には問題ない。再配信・再販は規約違反の恐れがあるので、デバイス内で完結させる。

実装

Arduino IDE / PlatformIO どちらでも動く。ライブラリは M5StickCHTTPClientArduinoJson。ボタンで銘柄切替、15秒ごとに更新。

Hyperliquid から原油価格を取る

原油のシンボルは cash:WTI。1時間足を24時間分取得し、最新の終値を現在価格、24時間前の終値との差を前日比として表示する。あわせて折れ線グラフも描画。サムネイルの画像がそのまま動作イメージ。

最近はAIの書いた未検証サンプルが出回りがちだが、以下のコードは実機で動作確認済み。

#if __has_include(<M5StickCPlus.h>)
#include <M5StickCPlus.h>
#define IS_PLUS 1
#else
#include <M5StickC.h>
#define IS_PLUS 0
#endif

#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <time.h>

WiFiMulti wifiMulti;

unsigned long lastUpdateTime = 0;
const unsigned long updateInterval = 60000; // 1分ごとに更新

void connectWiFi() {
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(10, 10);
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
    M5.Lcd.println("Scanning Wi-Fi...");
    Serial.println("Scanning Wi-Fi networks...");

    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);

    int n = WiFi.scanNetworks();
    Serial.printf("Scan done. %d networks found.\n", n);

    bool apAdded = false;

    // スキャンで見つかったSSIDの中に部分一致するものがあれば、対応するパスワードで登録
    for (int i = 0; i < n; ++i) {
        String ssid = WiFi.SSID(i);
        int32_t rssi = WiFi.RSSI(i);
        Serial.printf("SSID: %s (RSSI: %d)\n", ssid.c_str(), rssi);

        if (ssid.indexOf("YOUR_SSID_A") != -1) {
            wifiMulti.addAP(ssid.c_str(), "YOUR_PASSWORD_A");
            apAdded = true;
        }
        else if (ssid.indexOf("YOUR_SSID_B") != -1) {
            wifiMulti.addAP(ssid.c_str(), "YOUR_PASSWORD_B");
            apAdded = true;
        }
    }

    WiFi.scanDelete();

    // 該当するSSIDが見つからなかった場合のフォールバック(直接指定)
    if (!apAdded) {
        wifiMulti.addAP("YOUR_SSID_A_2G", "YOUR_PASSWORD_A");
        wifiMulti.addAP("YOUR_SSID_B_1", "YOUR_PASSWORD_B");
    }

    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(10, 10);
    M5.Lcd.print("Connecting to Wi-Fi");

    int attempts = 0;
    while (wifiMulti.run() != WL_CONNECTED && attempts < 30) {
        delay(500);
        M5.Lcd.print(".");
        attempts++;
    }

    if (WiFi.status() == WL_CONNECTED) {
        M5.Lcd.println("\nConnected!");
        M5.Lcd.print("SSID: ");
        M5.Lcd.println(WiFi.SSID());

        // NTPサーバーと同期 (日本標準時: JST-9)
        configTzTime("JST-9", "ntp.nict.jp", "time.google.com");
        Serial.println("NTP configured.");

        delay(1500);
    } else {
        M5.Lcd.println("\nFailed to connect.");
        delay(2000);
    }
}

void getOilPrice() {
    Serial.println("Fetching oil price from Hyperliquid...");
    if (WiFi.status() != WL_CONNECTED) {
        connectWiFi();
        if (WiFi.status() != WL_CONNECTED) return;
    }

    struct tm timeinfo;
    char timeStr[6] = "--:--";
    time_t now = time(nullptr);

    if (getLocalTime(&timeinfo)) {
        sprintf(timeStr, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
    }

    // NTP同期が未完了(1970年など)の場合は少し待つ
    if (now < 1000000) {
        Serial.println("Waiting for NTP sync...");
        delay(1000);
        now = time(nullptr);
    }

    uint64_t endTimeMs = (uint64_t)now * 1000;
    uint64_t startTimeMs = endTimeMs - (24ULL * 60 * 60 * 1000); // 24時間前

    M5.Lcd.fillScreen(BLACK);

    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(1);
    if (IS_PLUS) {
        M5.Lcd.setCursor(195, 10);
    } else {
        M5.Lcd.setCursor(125, 5);
    }
    M5.Lcd.print(timeStr);

    M5.Lcd.setCursor(10, IS_PLUS ? 25 : 15);
    M5.Lcd.setTextColor(YELLOW);
    M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
    M5.Lcd.println("Updating price...");

    WiFiClientSecure client;
    client.setInsecure(); // SSL証明書の検証を無効化

    HTTPClient http;
    http.begin(client, "https://api.hyperliquid.xyz/info");
    http.addHeader("Content-Type", "application/json");

    char postBody[256];
    snprintf(postBody, sizeof(postBody),
             "{\"type\":\"candleSnapshot\",\"req\":{\"coin\":\"cash:WTI\",\"interval\":\"1h\",\"startTime\":%llu,\"endTime\":%llu}}",
             startTimeMs, endTimeMs);

    Serial.print("Sending POST request to Hyperliquid... ");
    int httpCode = http.POST(postBody);
    Serial.printf("HTTP Response Code: %d\n", httpCode);

    if (httpCode == HTTP_CODE_OK) {
        String payload = http.getString();
        Serial.printf("Received payload of length: %d\n", payload.length());

        DynamicJsonDocument* doc = new DynamicJsonDocument(12288);
        DeserializationError error = deserializeJson(*doc, payload);

        if (!error) {
            JsonArray arr = doc->as<JsonArray>();
            int quotesCount = arr.size();

            if (quotesCount == 0) {
                M5.Lcd.fillScreen(BLACK);
                M5.Lcd.setCursor(10, 10);
                M5.Lcd.setTextColor(RED);
                M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
                M5.Lcd.println("No data returned");
                Serial.println("Error: Empty JSON array returned from API.");
                delete doc;
                http.end();
                return;
            }

            // 最新ローソク足の終値を現在価格とする
            double currentPrice = arr[quotesCount - 1]["c"].as<double>();
            // 24時間前の終値を前日比の基準とする
            double prevClose = arr[0]["c"].as<double>();
            double diff = currentPrice - prevClose;
            double diffPercent = (prevClose != 0.0) ? ((diff / prevClose) * 100.0) : 0.0;

            Serial.printf("Parsed %d quotes. Current Price: $%.2f, Prev Close: $%.2f, Diff: %.2f (%.2f%%)\n",
                          quotesCount, currentPrice, prevClose, diff, diffPercent);

            M5.Lcd.fillScreen(BLACK);

            // 1. 時刻を右上に再描画
            M5.Lcd.setTextColor(WHITE);
            M5.Lcd.setTextSize(1);
            if (IS_PLUS) {
                M5.Lcd.setCursor(195, 10);
            } else {
                M5.Lcd.setCursor(125, 5);
            }
            M5.Lcd.print(timeStr);

            // 2. タイトル
            M5.Lcd.setTextColor(WHITE);
            M5.Lcd.setCursor(10, IS_PLUS ? 15 : 10);
            M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
            M5.Lcd.println("WTI Crude Oil");

            // 3. 現在価格
            M5.Lcd.setCursor(10, IS_PLUS ? 40 : 25);
            M5.Lcd.setTextSize(IS_PLUS ? 4 : 3);
            M5.Lcd.setTextColor(YELLOW);
            M5.Lcd.printf("$%.2f", currentPrice);

            // 4. 前日比 (24時間前からの差分)
            M5.Lcd.setCursor(10, IS_PLUS ? 80 : 53);
            M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);

            if (diff >= 0) {
                M5.Lcd.setTextColor(GREEN);
                M5.Lcd.printf("+%.2f (+%.2f%%)", diff, diffPercent);
            } else {
                M5.Lcd.setTextColor(RED);
                M5.Lcd.printf("%.2f (%.2f%%)", diff, diffPercent);
            }

            // 5. 折れ線グラフの描画
            int maxPoints = IS_PLUS ? 60 : 40;
            int actualCount = (quotesCount > maxPoints) ? maxPoints : quotesCount;

            if (actualCount > 1) {
                float prices[60];
                float minPrice = 999999.0;
                float maxPrice = -999999.0;
                float lastValidPrice = prevClose;

                for (int i = 0; i < actualCount; i++) {
                    int idx = quotesCount - actualCount + i;
                    if ((*doc)[idx]["c"].isNull()) {
                        prices[i] = lastValidPrice;
                    } else {
                        prices[i] = (*doc)[idx]["c"].as<float>();
                        lastValidPrice = prices[i];
                    }

                    if (prices[i] < minPrice) minPrice = prices[i];
                    if (prices[i] > maxPrice) maxPrice = prices[i];
                }

                float priceRange = maxPrice - minPrice;
                if (priceRange == 0) priceRange = 1.0;

                int graphX = 10;
                int graphY = IS_PLUS ? 105 : 68;
                int graphWidth = IS_PLUS ? 220 : 140;
                int graphHeight = IS_PLUS ? 25 : 10;

                M5.Lcd.drawFastHLine(graphX, graphY + graphHeight, graphWidth, 0x7BEF);

                int prevX = -1;
                int prevY = -1;

                for (int i = 0; i < actualCount; i++) {
                    int x = graphX + i * graphWidth / (actualCount - 1);
                    int y = graphY + graphHeight - (int)((prices[i] - minPrice) * graphHeight / priceRange);

                    if (prevX != -1) {
                        M5.Lcd.drawLine(prevX, prevY, x, y, (diff >= 0) ? GREEN : RED);
                    }
                    prevX = x;
                    prevY = y;
                }
            }

            delete doc;

        } else {
            M5.Lcd.fillScreen(BLACK);
            M5.Lcd.setCursor(10, 10);
            M5.Lcd.setTextColor(RED);
            M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
            M5.Lcd.println("JSON Parse Error");
            M5.Lcd.println(error.c_str());
            Serial.printf("JSON Parse Error: %s\n", error.c_str());
            Serial.printf("Payload snippet: %s\n", payload.substring(0, 200).c_str());
            delete doc;
        }
    } else {
        String errorPayload = http.getString();
        M5.Lcd.fillScreen(BLACK);
        M5.Lcd.setCursor(10, 10);
        M5.Lcd.setTextColor(RED);
        M5.Lcd.setTextSize(IS_PLUS ? 2 : 1);
        M5.Lcd.printf("HTTP Error: %d", httpCode);
        Serial.printf("HTTP Error: %d, Payload: %s\n", httpCode, errorPayload.c_str());
    }

    http.end();
}

void setup() {
    M5.begin();
    Serial.begin(115200);
    Serial.println("\nM5StickC WTI Oil Price Gadget (Hyperliquid version) Starting...");
    M5.Lcd.setRotation(1); // 画面を横向きに設定
    connectWiFi();
    getOilPrice();
    lastUpdateTime = millis();
}

void loop() {
    M5.update();

    // Aボタン(M5のロゴ部分)を押すと手動で更新
    if (M5.BtnA.wasPressed()) {
        getOilPrice();
        lastUpdateTime = millis();
    }

    // 定期自動更新
    if (millis() - lastUpdateTime >= updateInterval) {
        getOilPrice();
        lastUpdateTime = millis();
    }

    delay(10);
}

ハマりどころ

WiFiのフォールバック。ESP32は2.4GHz専用で5GHzには対応しない。親機が2.4G/5Gで別SSIDを吐いている環境では2.4G側を掴まないといけないので、自宅の2.4GHz帯を WiFiMulti に複数登録している。さらに周辺をスキャンして部分一致するSSIDを動的に足す構成にしたことで、バンド指定の_suffix違い(_2G など)にも吸い付く。接続が切れたら都度つなぎ直す。

JSONパーサとメモリ。ESP32のSRAMは520KBしかない。Hyperliquidのレスポンスは銘柄・期間が長くなるほど膨らむので、不用意に長期のチャートを取るとヒープから溢れてリブートループに陥る。表示に不要なデータは取らない。本記事では直近24時間・1時間足だけに絞り、DynamicJsonDocument も必要最小サイズで確保している。

AIに頼る。ESP32 + ArduinoJson + M5Stack 周りは学習データが豊富で、LLMに投げるとだいたいいい感じに書いてくれる。描画の座標計算や snprintf の組み立て、メモリサイズの見積もりあたりを任せると楽。

シェア
← 記事一覧へ