Hyperliquid と Polymarket の価格を M5Stick-C に表示する
スタックチャンが流行ってESP32プログラミングに興味を持った。手元に少し古い M5Stick-C があったので、まずはREST APIを叩いて画面に何か出すところから始めた。
M5Stick-Cはバッテリがないので持ち運びには向かない。デスクの余ったUSBポートから給電して、情報を常時表示しておく端末として使う。
Hyperliquid の API
オンチェーンDEX。Perpのラインナップが異常に広く、仮想通貨だけでなく指数・FX・コモディティもカバーする。
- 仮想通貨:BTC、ETH、SOL …
- 株式指数:S&P 500、NAS100、日経225
- FX:USDJPY、EURUSD
- コモディティ:金、原油
近似値(合成ポジション)なので「雰囲気をつかむ」用途。厳密な執行価格ではない。
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 どちらでも動く。ライブラリは M5StickC、HTTPClient、ArduinoJson。ボタンで銘柄切替、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 の組み立て、メモリサイズの見積もりあたりを任せると楽。