feat: add hyperliquid-reader skill + opencli plugin (#76)

* feat: add hyperliquid-reader skill + opencli plugin

Add a read-only Hyperliquid (app.hyperliquid.xyz) reader, mirroring the
tradingview-reader pattern. Unlike TradingView's CDP attach, Hyperliquid
exposes a fully public info API (POST https://api.hyperliquid.xyz/info),
so this needs no API key, wallet, login, or desktop app.

opencli plugin (opencli-plugins/hyperliquid/) — 12 read-only commands:
- Market data: markets, spot-markets, mids, book, candles,
  funding-history, funding-compare (cross-venue HL/Binance/Bybit
  funding-arb screen).
- Account by 0x address: account, positions, spot-balances,
  open-orders, fills.

Skill (plugins/data-providers/skills/hyperliquid-reader/) — SKILL.md
(5-step + error reference), README.md, references/commands.md.

Registrations: root opencli-plugin.json, data-providers plugin.json
(description + keywords), marketplace.json, root README table.

Verification: 31 unit tests pass against captured live wire shapes;
all 12 command data paths smoke-tested end-to-end against the live API.
SKILL.md description is 1019 chars (< 1024 lint cap), no angle brackets.

No trade execution: the plugin exposes no write/exchange endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* refactor: scope hyperliquid-reader to market data only

Drop the account-by-address commands (account, positions, spot-balances,
open-orders, fills) and their lib/account.js + lib/orders.js helpers and
tests. Remove the now-unused normalizeAddress helper from lib/api.js.

The reader is now market-data only: markets, spot-markets, mids, book,
candles, funding-history, funding-compare (7 commands). Updated SKILL.md,
both READMEs, references/commands.md, the plugin/marketplace manifests,
and the root README table accordingly.

21 unit tests pass; all 7 market-data data paths re-verified live.
This commit is contained in:
Alex Yang
2026-06-07 16:21:26 -07:00
committed by GitHub
parent 9674bdd222
commit 8072082fda
27 changed files with 1430 additions and 2 deletions
+1
View File
@@ -91,6 +91,7 @@ Read-only social media and research feeds — Twitter/X, Discord, LinkedIn, Tele
| [finance-sentiment](plugins/data-providers/skills/finance-sentiment/) | Stock sentiment research via Adanos Finance API — Reddit, X.com, news, Polymarket |
| [hormuz-strait](plugins/data-providers/skills/hormuz-strait/) | Strait of Hormuz monitoring — shipping, oil impact, insurance risk, crisis timeline |
| [tradingview-reader](plugins/data-providers/skills/tradingview-reader/) | Read-only TradingView desktop reader — quotes, full options chains with greeks/IV, expiries, chart state, screenshots — via [opencli](https://github.com/jackwener/opencli) + CDP |
| [hyperliquid-reader](plugins/data-providers/skills/hyperliquid-reader/) | Read-only [Hyperliquid](https://app.hyperliquid.xyz) market-data reader — perp/spot markets, mids, funding (incl. cross-venue arb screen), order book, and candles — via [opencli](https://github.com/jackwener/opencli) + public info API |
### Startup Tools (`finance-startup-tools`)
+4 -1
View File
@@ -1,10 +1,13 @@
{
"name": "finance-skills-opencli-plugins",
"description": "opencli plugins shipped alongside the finance-skills repo. Currently: tradingview (read-only TradingView desktop adapter).",
"description": "opencli plugins shipped alongside the finance-skills repo: tradingview (read-only TradingView desktop adapter) and hyperliquid (read-only Hyperliquid info-API adapter).",
"version": "0.1.0",
"plugins": {
"tradingview": {
"path": "opencli-plugins/tradingview"
},
"hyperliquid": {
"path": "opencli-plugins/hyperliquid"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+104
View File
@@ -0,0 +1,104 @@
# opencli-plugin-hyperliquid
Read-only [opencli](https://github.com/jackwener/opencli) adapter for **[Hyperliquid](https://app.hyperliquid.xyz)**, the on-chain perps/spot DEX. Exposes perp + spot market data — markets, mid prices, L2 order book, OHLCV candles, funding history, and a cross-venue funding-arb screen — all from Hyperliquid's **public info API**. No API key, no wallet, no login.
This plugin lives inside the [`himself65/finance-skills`](https://github.com/himself65/finance-skills) monorepo. Install it via opencli's monorepo subpath syntax:
```bash
opencli plugin install github:himself65/finance-skills/hyperliquid
```
## Install
```bash
# Prereqs: Node ≥ 22 (built-in fetch)
npm install -g @jackwener/opencli
opencli plugin install github:himself65/finance-skills/hyperliquid
```
**Zero setup.** Every command hits `https://api.hyperliquid.xyz/info` directly — no API key, no auth, no cookies, no running app.
## Commands
| Command | Description | Output columns |
|---|---|---|
| `hyperliquid markets` | Perp markets table | `coin`, `markPx`, `midPx`, `oraclePx`, `change24hPct`, `fundingHrPct`, `fundingAprPct`, `openInterest`, `oiNotional`, `dayNtlVlm`, `premiumPct`, `maxLeverage` |
| `hyperliquid spot-markets` | Spot pairs table | `pair`, `base`, `markPx`, `midPx`, `change24hPct`, `dayNtlVlm`, `circulatingSupply`, `marketCap`, `canonical` |
| `hyperliquid mids` | Mid price for every market | `coin`, `mid` |
| `hyperliquid book --coin BTC` | L2 order book snapshot | `side`, `level`, `px`, `sz`, `orders` |
| `hyperliquid candles --coin BTC` | OHLCV candles | `time`, `open`, `high`, `low`, `close`, `volume`, `trades` |
| `hyperliquid funding-history --coin BTC` | Historical hourly funding | `coin`, `fundingRatePct`, `fundingAprPct`, `premiumPct`, `time` |
| `hyperliquid funding-compare` | Cross-venue predicted funding (arb) | `coin`, `hlAprPct`, `binanceAprPct`, `bybitAprPct`, `hlVsBinancePct`, `hlVsBybitPct`, `nextHlFunding` |
`markets` flags: `--coin`, `--sort {dayNtlVlm|change24hPct|fundingAprPct|fundingHrPct|openInterest|oiNotional|markPx|coin}` (default `dayNtlVlm`), `--limit`, `--include-delisted`.
`spot-markets` flags: `--pair` (pair or base token), `--sort {dayNtlVlm|change24hPct|marketCap|markPx|pair}`, `--limit`, `--canonical-only`.
`book` flags: `--coin` (required), `--depth` (1-20, default 10), `--n-sig-figs` (2-5 price aggregation).
`candles` flags: `--coin` (required), `--interval {1m|3m|5m|15m|30m|1h|2h|4h|8h|12h|1d|3d|1w|1M}` (default `1h`), `--limit` (default 100, max 5000).
`funding-history` flags: `--coin` (required), `--hours` (default 24), `--limit`.
`funding-compare` flags: `--coin`, `--sort {hlVsBinancePct|hlVsBybitPct|hlAprPct|binanceAprPct|bybitAprPct|coin}` (default `hlVsBinancePct`, ranked by absolute spread), `--limit`.
All commands accept `-f json|yaml|md|csv|table`.
## Data path
Every command issues a single `POST https://api.hyperliquid.xyz/info` with a `{ "type": "..." }` body and normalizes the response:
| Command | info `type` |
|---|---|
| `markets` | `metaAndAssetCtxs` |
| `spot-markets` | `spotMetaAndAssetCtxs` |
| `mids` | `allMids` (+ `spotMeta` to resolve `@index` → pair name) |
| `book` | `l2Book` |
| `candles` | `candleSnapshot` |
| `funding-history` | `fundingHistory` |
| `funding-compare` | `predictedFundings` |
**Numbers** arrive as strings and are coerced to finite numbers (or `null`). **Funding** is reported per interval — Hyperliquid perps fund hourly, so APR = `rate × 24 × 365`; `funding-compare` annualizes each venue with its own interval (Binance/Bybit commonly 4h).
## Auth model
None. The info endpoint is fully public and read-only. There is **no** trading path in this plugin — placing/cancelling orders on Hyperliquid requires wallet-signed actions on the separate `/exchange` endpoint, which this adapter never calls.
## Status
**v0.1 — wire shapes verified live against `api.hyperliquid.xyz` (June 2026).** Pure-helper normalizers are unit-tested (`npm test`); the HTTP path is a single documented `POST /info` per command.
Known notes:
- `mids` resolves non-canonical spot keys (`@<index>`) to `BASE/QUOTE` via the token table; perp coin names and canonical pair names pass through unchanged, and builder-deployed perp-dex keys (`#<n>`) are surfaced as-is.
- `markets`/`spot-markets`/`funding-*` numeric sorts place `null` last.
## Layout
```
opencli-plugins/hyperliquid/
├── opencli-plugin.json # plugin manifest
├── package.json # Node package (type: module)
├── lib/
│ ├── api.js # infoFetch POST helper, num/pctChange/funding/isoTime helpers
│ ├── markets.js # perp + spot market normalizers, allMids resolver
│ ├── funding.js # funding history + cross-venue predicted-funding pivot
│ ├── book.js # l2 book flattener + spread summary
│ └── candles.js # interval table + OHLCV normalizer
├── markets.js # metaAndAssetCtxs → perp markets
├── spot-markets.js # spotMetaAndAssetCtxs → spot pairs
├── mids.js # allMids → mid prices
├── book.js # l2Book → order book
├── candles.js # candleSnapshot → OHLCV
├── funding-history.js # fundingHistory → historical funding
├── funding-compare.js # predictedFundings → cross-venue arb screen
└── tests/
├── api.test.js # num, pctChange, fundingToApr, isoTime
├── markets.test.js # perp/spot normalizers, allMids resolver
├── funding.test.js # funding history + cross-venue pivot/APR
├── book.test.js # l2 flatten + spread
└── candles.test.js # OHLCV normalizer
```
## License
MIT
+36
View File
@@ -0,0 +1,36 @@
/**
* hyperliquid book — L2 order book snapshot via `l2Book`.
* Up to `--depth` levels per side; bids first, then asks.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { normalizeBook } from './lib/book.js';
cli({
site: 'hyperliquid',
name: 'book',
description: 'L2 order book snapshot (top N levels per side) for a coin',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', required: true, help: 'Coin or spot pair (e.g. BTC, PURR/USDC)' },
{ name: 'depth', type: 'int', default: 10, help: 'Levels per side (1-20, default 10)' },
{ name: 'n-sig-figs', type: 'int', help: 'Price aggregation significant figures (2-5). Omit for full precision.' },
],
columns: ['side', 'level', 'px', 'sz', 'orders'],
func: async (args) => {
const coin = String(args.coin).trim();
const body = { type: 'l2Book', coin };
const nSigFigs = Number(args['n-sig-figs']);
if (Number.isFinite(nSigFigs)) body.nSigFigs = nSigFigs;
const payload = await infoFetch(body);
const rows = normalizeBook(payload, args.depth);
if (rows.length === 0) {
throw new Error(`No order book returned for "${coin}" — check the coin/pair symbol.`);
}
return rows;
},
});
+45
View File
@@ -0,0 +1,45 @@
/**
* hyperliquid candles — OHLCV history via `candleSnapshot`.
* Pulls the most recent `--limit` candles of `--interval` for a coin.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { INTERVAL_MS, normalizeCandles } from './lib/candles.js';
const INTERVALS = Object.keys(INTERVAL_MS);
cli({
site: 'hyperliquid',
name: 'candles',
description: 'OHLCV candles for a coin (most recent N of a given interval)',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', required: true, help: 'Coin or spot pair (e.g. BTC, PURR/USDC)' },
{ name: 'interval', default: '1h', choices: INTERVALS, help: 'Candle interval (default 1h)' },
{ name: 'limit', type: 'int', default: 100, help: 'Number of most-recent candles (default 100, max 5000)' },
],
columns: ['time', 'open', 'high', 'low', 'close', 'volume', 'trades'],
func: async (args) => {
const coin = String(args.coin).trim();
const interval = String(args.interval);
const stepMs = INTERVAL_MS[interval];
if (!stepMs) throw new Error(`Unsupported interval "${interval}". One of: ${INTERVALS.join(', ')}`);
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 5000);
const endTime = Date.now();
const startTime = endTime - stepMs * limit;
const rows = await infoFetch({
type: 'candleSnapshot',
req: { coin, interval, startTime, endTime },
});
const out = normalizeCandles(rows);
if (out.length === 0) {
throw new Error(`No candles returned for "${coin}" @ ${interval} — check the coin/pair symbol.`);
}
return out.slice(-limit);
},
});
@@ -0,0 +1,52 @@
/**
* hyperliquid funding-compare — cross-venue predicted funding via
* `predictedFundings`, pivoted to one row per coin with each venue's APR and
* the HL-vs-venue spread. A funding-arb screen.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { normalizePredictedFundings } from './lib/funding.js';
const SORT_FIELDS = ['hlVsBinancePct', 'hlVsBybitPct', 'hlAprPct', 'binanceAprPct', 'bybitAprPct', 'coin'];
cli({
site: 'hyperliquid',
name: 'funding-compare',
description: 'Cross-venue predicted funding (HL vs Binance vs Bybit), annualized, with spreads — funding-arb screen',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', help: 'Filter to one coin (e.g. BTC). Omit for all.' },
{ name: 'sort', default: 'hlVsBinancePct', choices: SORT_FIELDS, help: 'Sort field (by absolute value desc, except coin = asc)' },
{ name: 'limit', type: 'int', help: 'Max rows after sort (omit for all)' },
],
columns: ['coin', 'hlAprPct', 'binanceAprPct', 'bybitAprPct', 'hlVsBinancePct', 'hlVsBybitPct', 'nextHlFunding'],
func: async (args) => {
const data = await infoFetch({ type: 'predictedFundings' });
let rows = normalizePredictedFundings(data, args.coin);
if (args.coin && rows.length === 0) {
throw new Error(`No predicted funding for coin "${args.coin}".`);
}
const sortKey = String(args.sort || 'hlVsBinancePct');
if (sortKey === 'coin') {
rows.sort((a, b) => String(a.coin).localeCompare(String(b.coin)));
} else {
// Spread fields rank by magnitude; raw APR fields rank signed-desc.
const byMagnitude = sortKey.startsWith('hlVs');
rows.sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const ak = av == null ? -Infinity : byMagnitude ? Math.abs(av) : av;
const bk = bv == null ? -Infinity : byMagnitude ? Math.abs(bv) : bv;
return bk - ak;
});
}
const limit = Number(args.limit);
if (Number.isFinite(limit) && limit > 0) rows = rows.slice(0, limit);
return rows;
},
});
@@ -0,0 +1,38 @@
/**
* hyperliquid funding-history — historical hourly funding for a coin via
* `fundingHistory`. Returns prints from now back `--hours` hours.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { normalizeFundingHistory } from './lib/funding.js';
const HOUR_MS = 3_600_000;
cli({
site: 'hyperliquid',
name: 'funding-history',
description: 'Historical hourly funding rates (+ APR, premium) for a coin',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', required: true, help: 'Coin (e.g. BTC)' },
{ name: 'hours', type: 'int', default: 24, help: 'Lookback window in hours (default 24)' },
{ name: 'limit', type: 'int', help: 'Cap rows to the most recent N (omit for all in window)' },
],
columns: ['coin', 'fundingRatePct', 'fundingAprPct', 'premiumPct', 'time'],
func: async (args) => {
const coin = String(args.coin).toUpperCase().trim();
const hours = Math.max(1, Number(args.hours) || 24);
const startTime = Date.now() - hours * HOUR_MS;
const rows = await infoFetch({ type: 'fundingHistory', coin, startTime });
let out = normalizeFundingHistory(rows);
out.sort((a, b) => String(b.time).localeCompare(String(a.time))); // newest first
const limit = Number(args.limit);
if (Number.isFinite(limit) && limit > 0) out = out.slice(0, limit);
return out;
},
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Hyperliquid info API helpers.
*
* Hyperliquid exposes a fully public, read-only POST endpoint:
* POST https://api.hyperliquid.xyz/info body { "type": "...", ... } → JSON
*
* No API key, no auth, no cookies — every market-data read this plugin makes
* works unauthenticated. Placing trades requires wallet-signed actions on the
* separate /exchange endpoint, which this plugin intentionally NEVER touches.
*
* Funding rates come back per funding interval. Hyperliquid perps fund hourly,
* so the hourly rate annualizes as rate * 24 * 365. Other venues surfaced via
* `predictedFundings` may fund on a different interval (commonly 4h) — always
* normalize with the per-row interval, not a hard-coded hour.
*/
const INFO_URL = 'https://api.hyperliquid.xyz/info';
/**
* POST a request to the Hyperliquid info endpoint and return the parsed body.
* @param {object} body e.g. { type: 'metaAndAssetCtxs' }
*/
export async function infoFetch(body) {
const res = await fetch(INFO_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`hyperliquid info ${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
/** Coerce a value (often a numeric string from the API) to a finite Number, else null. */
export function num(v) {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Percent change of `now` vs `prev`; null if either is missing or prev is 0. */
export function pctChange(now, prev) {
const a = num(now);
const b = num(prev);
if (a == null || b == null || b === 0) return null;
return ((a - b) / b) * 100;
}
/**
* Annualize a per-interval funding rate to a percentage APR.
* @param {string|number} rate funding rate for one interval
* @param {string|number} [intervalHours=1] length of that interval in hours
*/
export function fundingToApr(rate, intervalHours = 1) {
const r = num(rate);
const h = num(intervalHours) || 1;
return r == null ? null : (r / h) * 24 * 365 * 100;
}
/** Millisecond epoch → ISO string (null-safe). */
export function isoTime(ms) {
const n = num(ms);
if (n == null) return null;
return new Date(n).toISOString();
}
export { INFO_URL };
+38
View File
@@ -0,0 +1,38 @@
/**
* Order-book normalizer for `l2Book`.
* Payload: { coin, time, levels: [ bids[], asks[] ] }, each level { px, sz, n }.
*/
import { num } from './api.js';
/**
* Flatten an l2 snapshot into rows capped at `depth` levels per side.
* Bids first (best/highest px first as returned), then asks.
*/
export function normalizeBook(payload, depth) {
const levels = payload?.levels ?? [[], []];
const d = Number.isFinite(Number(depth)) && Number(depth) > 0 ? Number(depth) : 10;
const mk = (side) => (lvl, i) => ({
side,
level: i + 1,
px: num(lvl.px),
sz: num(lvl.sz),
orders: lvl.n ?? null,
});
const bids = (levels[0] ?? []).slice(0, d).map(mk('bid'));
const asks = (levels[1] ?? []).slice(0, d).map(mk('ask'));
return [...bids, ...asks];
}
/** Best-bid/ask spread summary from a raw l2 payload (for skill prose). */
export function bookSpread(payload) {
const levels = payload?.levels ?? [[], []];
const bestBid = num(levels[0]?.[0]?.px);
const bestAsk = num(levels[1]?.[0]?.px);
if (bestBid == null || bestAsk == null) {
return { bestBid, bestAsk, mid: null, spread: null, spreadBps: null };
}
const mid = (bestBid + bestAsk) / 2;
const spread = bestAsk - bestBid;
return { bestBid, bestAsk, mid, spread, spreadBps: mid ? (spread / mid) * 10000 : null };
}
@@ -0,0 +1,37 @@
/**
* Candle helpers for `candleSnapshot`.
* Wire row: { t (open ms), T (close ms), s (coin), i (interval), o, h, l, c, v, n }.
*/
import { num, isoTime } from './api.js';
/** Supported interval → milliseconds. Used to derive a startTime from a count. */
export const INTERVAL_MS = {
'1m': 60_000,
'3m': 180_000,
'5m': 300_000,
'15m': 900_000,
'30m': 1_800_000,
'1h': 3_600_000,
'2h': 7_200_000,
'4h': 14_400_000,
'8h': 28_800_000,
'12h': 43_200_000,
'1d': 86_400_000,
'3d': 259_200_000,
'1w': 604_800_000,
'1M': 2_592_000_000,
};
/** Normalize raw candle rows to OHLCV with ISO open time. */
export function normalizeCandles(rows) {
return (rows ?? []).map((c) => ({
time: isoTime(c.t),
open: num(c.o),
high: num(c.h),
low: num(c.l),
close: num(c.c),
volume: num(c.v),
trades: c.n ?? null,
}));
}
@@ -0,0 +1,64 @@
/**
* Funding normalizers: historical funding (`fundingHistory`) and the
* cross-venue predicted-funding screen (`predictedFundings`).
*/
import { num, isoTime, fundingToApr } from './api.js';
/** One row per historical hourly funding print for a coin. */
export function normalizeFundingHistory(rows) {
return (rows ?? []).map((r) => {
const rate = num(r.fundingRate);
const premium = num(r.premium);
return {
coin: r.coin,
fundingRatePct: rate == null ? null : rate * 100,
fundingAprPct: fundingToApr(r.fundingRate, 1),
premiumPct: premium == null ? null : premium * 100,
time: isoTime(r.time),
};
});
}
const VENUE_KEYS = { HlPerp: 'hl', BinPerp: 'binance', BybitPerp: 'bybit' };
/**
* Pivot `predictedFundings` into one row per coin with each venue's funding
* normalized to APR %, plus HL-vs-venue spreads — a funding-arb screen.
*
* Wire shape:
* [ [coin, [ [venueName, {fundingRate, fundingIntervalHours, nextFundingTime}], ... ]], ... ]
*
* Venue funding intervals differ (HL hourly, Binance/Bybit usually 4h), so
* each leg is annualized with its own `fundingIntervalHours`.
*
* @param {Array} data
* @param {string} [coinFilter] exact (case-insensitive) coin match
*/
export function normalizePredictedFundings(data, coinFilter) {
const filter = coinFilter ? String(coinFilter).toUpperCase() : null;
const rows = [];
for (const entry of data ?? []) {
const coin = entry?.[0];
if (filter && String(coin).toUpperCase() !== filter) continue;
const apr = {};
let nextHl = null;
for (const [vName, v] of entry?.[1] ?? []) {
const key = VENUE_KEYS[vName];
if (!key || !v) continue;
apr[key] = fundingToApr(v.fundingRate, v.fundingIntervalHours);
if (key === 'hl') nextHl = v.nextFundingTime;
}
const hl = apr.hl ?? null;
rows.push({
coin,
hlAprPct: hl,
binanceAprPct: apr.binance ?? null,
bybitAprPct: apr.bybit ?? null,
hlVsBinancePct: hl != null && apr.binance != null ? hl - apr.binance : null,
hlVsBybitPct: hl != null && apr.bybit != null ? hl - apr.bybit : null,
nextHlFunding: isoTime(nextHl),
});
}
return rows;
}
@@ -0,0 +1,99 @@
/**
* Market normalizers for Hyperliquid perp/spot metadata + asset contexts.
*
* `metaAndAssetCtxs` and `spotMetaAndAssetCtxs` each return a 2-tuple
* [meta, ctxs] where `meta.universe[i]` and `ctxs[i]` are PARALLEL arrays in
* the same order — zip by index.
*/
import { num, pctChange, fundingToApr } from './api.js';
/**
* Zip perp `meta.universe[]` with `ctxs[]` into one normalized row per market.
* @param {{universe: Array<{name:string,maxLeverage?:number,isDelisted?:boolean}>}} meta
* @param {Array<object>} ctxs
*/
export function normalizePerpMarkets(meta, ctxs) {
const universe = meta?.universe ?? [];
return universe.map((u, i) => {
const c = ctxs?.[i] ?? {};
const markPx = num(c.markPx);
const funding = num(c.funding);
const oi = num(c.openInterest);
const premium = num(c.premium);
return {
coin: u.name,
markPx,
midPx: num(c.midPx),
oraclePx: num(c.oraclePx),
prevDayPx: num(c.prevDayPx),
change24hPct: pctChange(c.markPx, c.prevDayPx),
fundingHrPct: funding == null ? null : funding * 100,
fundingAprPct: fundingToApr(c.funding, 1),
openInterest: oi,
oiNotional: oi != null && markPx != null ? oi * markPx : null,
dayNtlVlm: num(c.dayNtlVlm),
premiumPct: premium == null ? null : premium * 100,
maxLeverage: u.maxLeverage ?? null,
delisted: u.isDelisted === true,
};
});
}
/**
* Zip spot `meta.universe[]` with `ctxs[]` into one normalized row per pair.
* Resolves the base-token name via `meta.tokens[]` (universe.tokens[0] = base).
*/
export function normalizeSpotMarkets(spotMeta, ctxs) {
const universe = spotMeta?.universe ?? [];
const tokens = spotMeta?.tokens ?? [];
const tokenName = (idx) => tokens.find((t) => t.index === idx)?.name ?? `#${idx}`;
return universe.map((u, i) => {
const c = ctxs?.[i] ?? {};
const markPx = num(c.markPx);
const supply = num(c.circulatingSupply);
return {
pair: c.coin ?? u.name,
base: Array.isArray(u.tokens) ? tokenName(u.tokens[0]) : null,
markPx,
midPx: num(c.midPx),
prevDayPx: num(c.prevDayPx),
change24hPct: pctChange(c.markPx, c.prevDayPx),
dayNtlVlm: num(c.dayNtlVlm),
circulatingSupply: supply,
marketCap: markPx != null && supply != null ? markPx * supply : null,
canonical: u.isCanonical === true,
};
});
}
/**
* Resolve `allMids` keys to display names.
*
* allMids keys come in three forms:
* - perp coin name + canonical spot pair name → already friendly, passed through
* - "@<index>" → a non-canonical spot pair; resolved to "BASE/QUOTE" via tokens
* - "#<n>" → a builder-deployed perp-dex market; passed through as-is
*
* @param {Record<string,string>} allMids
* @param {object} spotMeta result of `spotMeta` (universe + tokens)
* @param {string} [coinFilter] case-insensitive substring filter
*/
export function resolveMids(allMids, spotMeta, coinFilter) {
const tokens = spotMeta?.tokens ?? [];
const tokenName = (idx) => tokens.find((t) => t.index === idx)?.name ?? `#${idx}`;
const nameByKey = new Map();
for (const u of spotMeta?.universe ?? []) {
if (Array.isArray(u.tokens) && u.tokens.length === 2) {
nameByKey.set(`@${u.index}`, `${tokenName(u.tokens[0])}/${tokenName(u.tokens[1])}`);
}
}
const filter = coinFilter ? String(coinFilter).toUpperCase() : null;
const rows = [];
for (const [key, px] of Object.entries(allMids ?? {})) {
const coin = nameByKey.get(key) ?? key;
if (filter && !coin.toUpperCase().includes(filter)) continue;
rows.push({ coin, mid: num(px) });
}
return rows;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* hyperliquid markets — perpetual markets table via `metaAndAssetCtxs`.
* Mark/oracle price, 24h change, funding (hourly + annualized), open interest,
* and 24h notional volume for every listed perp.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { normalizePerpMarkets } from './lib/markets.js';
const SORT_FIELDS = ['dayNtlVlm', 'change24hPct', 'fundingAprPct', 'fundingHrPct', 'openInterest', 'oiNotional', 'markPx', 'coin'];
cli({
site: 'hyperliquid',
name: 'markets',
description: 'Perpetual markets — mark/oracle price, 24h change, funding (hourly + APR), open interest, 24h volume',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', help: 'Filter to one coin (e.g. BTC). Omit for all perps.' },
{ name: 'sort', default: 'dayNtlVlm', choices: SORT_FIELDS, help: 'Sort field (desc, except coin = asc)' },
{ name: 'limit', type: 'int', help: 'Max rows after sort (omit for all)' },
{ name: 'include-delisted', type: 'boolean', default: false, help: 'Include delisted markets' },
],
columns: ['coin', 'markPx', 'midPx', 'oraclePx', 'change24hPct', 'fundingHrPct', 'fundingAprPct', 'openInterest', 'oiNotional', 'dayNtlVlm', 'premiumPct', 'maxLeverage'],
func: async (args) => {
const [meta, ctxs] = await infoFetch({ type: 'metaAndAssetCtxs' });
let rows = normalizePerpMarkets(meta, ctxs);
if (!args['include-delisted']) rows = rows.filter((r) => !r.delisted);
if (args.coin) {
const c = String(args.coin).toUpperCase().trim();
rows = rows.filter((r) => r.coin.toUpperCase() === c);
if (rows.length === 0) throw new Error(`No perp market for coin "${args.coin}" — check the symbol.`);
}
const sortKey = String(args.sort || 'dayNtlVlm');
if (sortKey === 'coin') {
rows.sort((a, b) => String(a.coin).localeCompare(String(b.coin)));
} else {
rows.sort((a, b) => (b[sortKey] ?? -Infinity) - (a[sortKey] ?? -Infinity));
}
const limit = Number(args.limit);
if (Number.isFinite(limit) && limit > 0) rows = rows.slice(0, limit);
// Drop internal-only fields from the emitted rows.
return rows.map(({ delisted, prevDayPx, ...rest }) => rest);
},
});
+34
View File
@@ -0,0 +1,34 @@
/**
* hyperliquid mids — current mid price for every market via `allMids`.
* Perp mids are keyed by coin name; spot mids by "@<index>", resolved to the
* pair name via `spotMeta`.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { resolveMids } from './lib/markets.js';
cli({
site: 'hyperliquid',
name: 'mids',
description: 'Current mid price for every perp + spot market (allMids)',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coin', help: 'Case-insensitive substring filter (e.g. BTC). Omit for all markets.' },
],
columns: ['coin', 'mid'],
func: async (args) => {
const [allMids, spotMeta] = await Promise.all([
infoFetch({ type: 'allMids' }),
infoFetch({ type: 'spotMeta' }),
]);
const rows = resolveMids(allMids, spotMeta, args.coin);
if (args.coin && rows.length === 0) {
throw new Error(`No market matching "${args.coin}".`);
}
rows.sort((a, b) => String(a.coin).localeCompare(String(b.coin)));
return rows;
},
});
@@ -0,0 +1,6 @@
{
"name": "hyperliquid",
"description": "Read-only adapter for Hyperliquid's public info API. Perp/spot market data — markets, mid prices, L2 order book, candles, and funding (incl. a cross-venue funding-arb screen).",
"version": "0.1.0",
"opencli": ">=1.8.0"
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@himself65/opencli-plugin-hyperliquid",
"version": "0.1.0",
"description": "Read-only opencli adapter for Hyperliquid's public info API — perp/spot markets, mids, L2 order book, candles, funding history, and a cross-venue funding-arb screen. Market data only, no API key.",
"type": "module",
"private": true,
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test tests/*.test.js"
},
"peerDependencies": {
"@jackwener/opencli": ">=1.8.0"
},
"license": "MIT",
"author": {
"name": "himself65"
},
"repository": {
"type": "git",
"url": "https://github.com/himself65/finance-skills.git",
"directory": "opencli-plugins/hyperliquid"
}
}
@@ -0,0 +1,50 @@
/**
* hyperliquid spot-markets — spot pairs table via `spotMetaAndAssetCtxs`.
* Mark/mid price, 24h change, 24h volume, circulating supply, and market cap.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { infoFetch } from './lib/api.js';
import { normalizeSpotMarkets } from './lib/markets.js';
const SORT_FIELDS = ['dayNtlVlm', 'change24hPct', 'marketCap', 'markPx', 'pair'];
cli({
site: 'hyperliquid',
name: 'spot-markets',
description: 'Spot pairs — mark/mid price, 24h change, 24h volume, circulating supply, market cap',
access: 'read',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'pair', help: 'Filter by pair or base token (e.g. PURR or PURR/USDC). Omit for all.' },
{ name: 'sort', default: 'dayNtlVlm', choices: SORT_FIELDS, help: 'Sort field (desc, except pair = asc)' },
{ name: 'limit', type: 'int', help: 'Max rows after sort (omit for all)' },
{ name: 'canonical-only', type: 'boolean', default: false, help: 'Only show canonical (named) pairs, hiding @index pairs' },
],
columns: ['pair', 'base', 'markPx', 'midPx', 'change24hPct', 'dayNtlVlm', 'circulatingSupply', 'marketCap', 'canonical'],
func: async (args) => {
const [meta, ctxs] = await infoFetch({ type: 'spotMetaAndAssetCtxs' });
let rows = normalizeSpotMarkets(meta, ctxs);
if (args['canonical-only']) rows = rows.filter((r) => r.canonical);
if (args.pair) {
const q = String(args.pair).toUpperCase().trim();
rows = rows.filter((r) => String(r.pair).toUpperCase().includes(q) || String(r.base).toUpperCase() === q);
if (rows.length === 0) throw new Error(`No spot pair matching "${args.pair}".`);
}
const sortKey = String(args.sort || 'dayNtlVlm');
if (sortKey === 'pair') {
rows.sort((a, b) => String(a.pair).localeCompare(String(b.pair)));
} else {
rows.sort((a, b) => (b[sortKey] ?? -Infinity) - (a[sortKey] ?? -Infinity));
}
const limit = Number(args.limit);
if (Number.isFinite(limit) && limit > 0) rows = rows.slice(0, limit);
return rows.map(({ prevDayPx, ...rest }) => rest);
},
});
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { num, pctChange, fundingToApr, isoTime } from '../lib/api.js';
test('num — coerces numeric strings, else null', () => {
assert.equal(num('61791.0'), 61791);
assert.equal(num(42), 42);
assert.equal(num(0), 0);
assert.equal(num(null), null);
assert.equal(num(undefined), null);
assert.equal(num('not-a-number'), null);
});
test('pctChange — basic + guards', () => {
assert.equal(pctChange(110, 100), 10);
assert.equal(pctChange('61791.0', '61001.0').toFixed(4), '1.2951');
assert.equal(pctChange(100, 0), null);
assert.equal(pctChange(null, 100), null);
assert.equal(pctChange(100, null), null);
});
test('fundingToApr — hourly and multi-hour intervals', () => {
// 0.0000125 hourly → 0.0000125 * 24 * 365 * 100 = 10.95% APR
assert.equal(fundingToApr('0.0000125', 1).toFixed(4), '10.9500');
// Same rate over a 4h interval annualizes to a quarter of the hourly figure.
assert.equal(fundingToApr('0.0000125', 4).toFixed(5), '2.73750');
assert.equal(fundingToApr(null), null);
// Missing/zero interval defaults to hourly.
assert.equal(fundingToApr('0.0000125', 0).toFixed(4), '10.9500');
assert.equal(fundingToApr('0.0000125').toFixed(4), '10.9500');
});
test('isoTime — ms epoch → ISO, null-safe', () => {
assert.equal(isoTime(1780813166432), '2026-06-07T06:19:26.432Z');
assert.equal(isoTime(null), null);
assert.equal(isoTime('not-a-number'), null);
});
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeBook, bookSpread } from '../lib/book.js';
// Shape captured live from l2Book.
const BOOK = {
coin: 'BTC', time: 1780813166432,
levels: [
[ // bids (highest first)
{ px: '61794.0', sz: '19.80703', n: 156 },
{ px: '61793.0', sz: '2.87733', n: 25 },
{ px: '61792.0', sz: '2.44433', n: 10 },
],
[ // asks (lowest first)
{ px: '61795.0', sz: '5.0', n: 12 },
{ px: '61796.0', sz: '3.0', n: 8 },
],
],
};
test('normalizeBook — bids then asks, capped at depth', () => {
const rows = normalizeBook(BOOK, 2);
assert.equal(rows.length, 4); // 2 bids + 2 asks
assert.deepEqual(rows[0], { side: 'bid', level: 1, px: 61794, sz: 19.80703, orders: 156 });
assert.equal(rows[1].side, 'bid');
assert.equal(rows[2].side, 'ask');
assert.equal(rows[2].px, 61795);
assert.equal(rows[3].px, 61796);
});
test('normalizeBook — default depth 10, fewer levels ok', () => {
const rows = normalizeBook(BOOK);
assert.equal(rows.filter((r) => r.side === 'bid').length, 3);
assert.equal(rows.filter((r) => r.side === 'ask').length, 2);
});
test('normalizeBook — empty payload', () => {
assert.deepEqual(normalizeBook({}, 5), []);
assert.deepEqual(normalizeBook({ levels: [[], []] }, 5), []);
});
test('bookSpread — best bid/ask, mid, spread, bps', () => {
const s = bookSpread(BOOK);
assert.equal(s.bestBid, 61794);
assert.equal(s.bestAsk, 61795);
assert.equal(s.mid, 61794.5);
assert.equal(s.spread, 1);
assert.equal(s.spreadBps.toFixed(4), (1 / 61794.5 * 10000).toFixed(4));
});
test('bookSpread — missing side → nulls', () => {
const s = bookSpread({ levels: [[], []] });
assert.equal(s.mid, null);
assert.equal(s.spread, null);
});
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeCandles, INTERVAL_MS } from '../lib/candles.js';
// Shape captured live from candleSnapshot.
const CANDLES = [
{ t: 1780804800000, T: 1780808399999, s: 'BTC', i: '1h', o: '61685.0', c: '61871.0', h: '61888.0', l: '61463.0', v: '1376.39573', n: 18040 },
{ t: 1780808400000, T: 1780811999999, s: 'BTC', i: '1h', o: '61871.0', c: '61721.0', h: '62130.0', l: '61630.0', v: '1733.82548', n: 24641 },
];
test('normalizeCandles — OHLCV with ISO open time', () => {
const rows = normalizeCandles(CANDLES);
assert.equal(rows.length, 2);
const c = rows[0];
assert.equal(c.time, new Date(1780804800000).toISOString());
assert.equal(c.open, 61685);
assert.equal(c.high, 61888);
assert.equal(c.low, 61463);
assert.equal(c.close, 61871);
assert.equal(c.volume, 1376.39573);
assert.equal(c.trades, 18040);
});
test('normalizeCandles — empty input', () => {
assert.deepEqual(normalizeCandles([]), []);
assert.deepEqual(normalizeCandles(null), []);
});
test('INTERVAL_MS — covers the documented set, consistent math', () => {
assert.equal(INTERVAL_MS['1h'], 3_600_000);
assert.equal(INTERVAL_MS['1d'], 24 * INTERVAL_MS['1h']);
assert.equal(INTERVAL_MS['1w'], 7 * INTERVAL_MS['1d']);
assert.equal(INTERVAL_MS['15m'], 15 * INTERVAL_MS['1m']);
assert.ok(Object.keys(INTERVAL_MS).includes('1M'));
});
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeFundingHistory, normalizePredictedFundings } from '../lib/funding.js';
// Shape captured live from fundingHistory.
const HISTORY = [
{ coin: 'BTC', fundingRate: '0.0000125', premium: '-0.0003305881', time: 1780808400000 },
{ coin: 'BTC', fundingRate: '0.0000125', premium: '-0.0003786101', time: 1780812000055 },
];
test('normalizeFundingHistory — rate %, APR, premium %', () => {
const rows = normalizeFundingHistory(HISTORY);
assert.equal(rows.length, 2);
const r = rows[0];
assert.equal(r.coin, 'BTC');
assert.equal(r.fundingRatePct.toFixed(5), '0.00125'); // 0.0000125 * 100
assert.equal(r.fundingAprPct.toFixed(4), '10.9500'); // 0.0000125 * 24 * 365 * 100
assert.equal(r.premiumPct.toFixed(6), '-0.033059');
assert.ok(r.time.startsWith('2026-'));
});
// Shape captured live from predictedFundings.
const PREDICTED = [
['0G', [
['BinPerp', { fundingRate: '-0.00004554', nextFundingTime: 1780819200000, fundingIntervalHours: 4 }],
['HlPerp', { fundingRate: '-0.0000397323', nextFundingTime: 1780812000000, fundingIntervalHours: 1 }],
['BybitPerp', { fundingRate: '0.00005', nextFundingTime: 1780819200000, fundingIntervalHours: 4 }],
]],
['BTC', [
['HlPerp', { fundingRate: '0.0000125', nextFundingTime: 1780812000000, fundingIntervalHours: 1 }],
]],
];
test('normalizePredictedFundings — pivots to per-coin APR + spreads', () => {
const rows = normalizePredictedFundings(PREDICTED);
const og = rows.find((r) => r.coin === '0G');
// HL hourly: -0.0000397323 * 24 * 365 * 100
assert.equal(og.hlAprPct.toFixed(2), (-0.0000397323 * 24 * 365 * 100).toFixed(2));
// Binance 4h: -0.00004554 / 4 * 24 * 365 * 100
assert.equal(og.binanceAprPct.toFixed(2), (-0.00004554 / 4 * 24 * 365 * 100).toFixed(2));
assert.equal(og.bybitAprPct.toFixed(2), (0.00005 / 4 * 24 * 365 * 100).toFixed(2));
// Spread = HL APR Binance APR
assert.equal(og.hlVsBinancePct.toFixed(4), (og.hlAprPct - og.binanceAprPct).toFixed(4));
assert.ok(og.nextHlFunding.startsWith('2026-'));
});
test('normalizePredictedFundings — missing venues yield null legs/spreads', () => {
const rows = normalizePredictedFundings(PREDICTED);
const btc = rows.find((r) => r.coin === 'BTC');
assert.ok(btc.hlAprPct != null);
assert.equal(btc.binanceAprPct, null);
assert.equal(btc.bybitAprPct, null);
assert.equal(btc.hlVsBinancePct, null);
assert.equal(btc.hlVsBybitPct, null);
});
test('normalizePredictedFundings — coin filter (exact, case-insensitive)', () => {
const rows = normalizePredictedFundings(PREDICTED, 'btc');
assert.equal(rows.length, 1);
assert.equal(rows[0].coin, 'BTC');
});
@@ -0,0 +1,103 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizePerpMarkets, normalizeSpotMarkets, resolveMids } from '../lib/markets.js';
// Shapes captured live from metaAndAssetCtxs.
const PERP_META = {
universe: [
{ name: 'BTC', maxLeverage: 40, szDecimals: 5 },
{ name: 'MATIC', maxLeverage: 20, isDelisted: true },
],
};
const PERP_CTXS = [
{
funding: '0.0000120074', openInterest: '33163.84416', prevDayPx: '61001.0',
dayNtlVlm: '2384788662.6', premium: '-0.0004852641', oraclePx: '61822.0',
markPx: '61791.0', midPx: '61791.5',
},
{
funding: '0.000001', openInterest: '0.0', prevDayPx: '0.5',
dayNtlVlm: '0.0', premium: '0.0', oraclePx: '0.5', markPx: '0.5', midPx: '0.5',
},
];
test('normalizePerpMarkets — zips universe with ctxs by index', () => {
const rows = normalizePerpMarkets(PERP_META, PERP_CTXS);
assert.equal(rows.length, 2);
const btc = rows[0];
assert.equal(btc.coin, 'BTC');
assert.equal(btc.markPx, 61791);
assert.equal(btc.midPx, 61791.5);
assert.equal(btc.oraclePx, 61822);
assert.equal(btc.maxLeverage, 40);
assert.equal(btc.delisted, false);
// change24h = (61791 - 61001) / 61001 * 100
assert.equal(btc.change24hPct.toFixed(4), '1.2951');
// funding hourly % and APR
assert.equal(btc.fundingHrPct.toFixed(6), '0.001201');
assert.equal(btc.fundingAprPct.toFixed(2), '10.52');
// oi notional = 33163.84416 * 61791
assert.equal(Math.round(btc.oiNotional), Math.round(33163.84416 * 61791));
// premium %
assert.equal(btc.premiumPct.toFixed(6), '-0.048526');
});
test('normalizePerpMarkets — flags delisted', () => {
const rows = normalizePerpMarkets(PERP_META, PERP_CTXS);
assert.equal(rows[1].coin, 'MATIC');
assert.equal(rows[1].delisted, true);
});
// Shapes captured live from spotMetaAndAssetCtxs.
const SPOT_META = {
tokens: [
{ name: 'USDC', index: 0 },
{ name: 'PURR', index: 1 },
{ name: 'HFUN', index: 2 },
],
universe: [
{ tokens: [1, 0], name: 'PURR/USDC', index: 0, isCanonical: true },
{ tokens: [2, 0], name: '@1', index: 1, isCanonical: false },
],
};
const SPOT_CTXS = [
{
prevDayPx: '0.089779', dayNtlVlm: '931726.7', markPx: '0.090247',
midPx: '0.0901115', circulatingSupply: '595295651.9', coin: 'PURR/USDC',
},
{
prevDayPx: '1.0', dayNtlVlm: '10.0', markPx: '2.0',
midPx: '2.0', circulatingSupply: '100.0', coin: '@1',
},
];
test('normalizeSpotMarkets — resolves base token + market cap', () => {
const rows = normalizeSpotMarkets(SPOT_META, SPOT_CTXS);
assert.equal(rows[0].pair, 'PURR/USDC');
assert.equal(rows[0].base, 'PURR');
assert.equal(rows[0].markPx, 0.090247);
assert.equal(rows[0].canonical, true);
// market cap = markPx * circulatingSupply
assert.equal(Math.round(rows[0].marketCap), Math.round(0.090247 * 595295651.9));
// change24h = (0.090247 - 0.089779) / 0.089779 * 100
assert.equal(rows[0].change24hPct.toFixed(4), '0.5213');
// second pair is non-canonical; base token index 2 resolves to HFUN
assert.equal(rows[1].canonical, false);
assert.equal(rows[1].base, 'HFUN');
});
test('resolveMids — perp keys pass through, @index resolves to BASE/QUOTE', () => {
const allMids = { BTC: '61791.5', '@1': '2.0', '#1000': '0.5' };
const rows = resolveMids(allMids, SPOT_META);
const byCoin = Object.fromEntries(rows.map((r) => [r.coin, r.mid]));
assert.equal(byCoin.BTC, 61791.5);
assert.equal(byCoin['HFUN/USDC'], 2.0); // @1 (tokens [2,0]) → HFUN/USDC
assert.equal(byCoin['#1000'], 0.5); // builder perp-dex key passes through
});
test('resolveMids — case-insensitive substring filter', () => {
const allMids = { BTC: '1', ETH: '2', BTCDOM: '3' };
const rows = resolveMids(allMids, SPOT_META, 'btc');
const coins = rows.map((r) => r.coin).sort();
assert.deepEqual(coins, ['BTC', 'BTCDOM']);
});
+6 -1
View File
@@ -16,6 +16,11 @@
"data-provider",
"tradingview",
"options",
"opencli"
"opencli",
"hyperliquid",
"perps",
"funding",
"crypto",
"dex"
]
}
@@ -0,0 +1,56 @@
# hyperliquid-reader
Read-only Hyperliquid market-data reader via [opencli](https://github.com/jackwener/opencli) + the [`hyperliquid`](../../../../opencli-plugins/hyperliquid/) opencli plugin shipped alongside this skill.
## What it does
Reads [Hyperliquid](https://app.hyperliquid.xyz)'s **public info API** — no API key, no wallet, no login, no scraping. Capabilities:
- **Perp markets** — mark/oracle/mid price, 24h change, hourly funding + annualized APR, open interest (coins + notional), and 24h volume for every perpetual
- **Spot markets** — pair price, 24h change, volume, circulating supply, market cap
- **Mids** — current mid price for every perp + spot market in one call
- **Order book** — L2 snapshot, top N levels per side, with spread
- **Candles** — OHLCV history for any interval (1m … 1M)
- **Funding history** — historical hourly funding (rate, APR, premium) per coin
- **Funding compare** — cross-venue predicted funding (Hyperliquid vs Binance vs Bybit), annualized, with spreads — a funding-arbitrage screen
**This skill is read-only and market-data only.** It does NOT read individual accounts, place/modify/cancel orders, or move funds.
## Authentication
None. Hyperliquid's `info` market-data endpoints are fully public — nothing to authenticate.
## Triggers
- "Hyperliquid funding for X", "what's the funding on BTC perp", "HL open interest"
- "Hyperliquid order book for ETH", "HL perp markets", "Hyperliquid spot markets"
- "funding arb Hyperliquid vs Binance", "Hyperliquid candles for SOL"
- "PURR price on Hyperliquid", "Hyperliquid mid prices"
- Any mention of Hyperliquid / app.hyperliquid.xyz / HL DEX in context of reading market data or funding
## Platform
Works on **Claude Code** and other CLI-based agents (any OS with Node ≥ 22). Does **not** work on Claude.ai — the sandbox restricts the network access opencli needs. Unlike the TradingView reader, there is no desktop-app or macOS dependency — it's a plain HTTP API.
## Setup
```bash
# As a plugin (recommended — installs all skills in this group)
npx plugins add himself65/finance-skills --plugin finance-data-providers
# Or install just this skill
npx skills add himself65/finance-skills --skill hyperliquid-reader
```
See the [main README](../../../../README.md) for more installation options.
## Prerequisites
- Node.js >= 22 — for `npm install -g @jackwener/opencli` and the plugin's built-in `fetch`
- The `hyperliquid` opencli plugin: `opencli plugin install github:himself65/finance-skills/hyperliquid` (installs from this repo's monorepo subpath)
No API key, no wallet, no launch step.
## Reference files
- `references/commands.md` — Complete market-data command reference with all flags, output schemas, and analyst workflows
@@ -0,0 +1,160 @@
---
name: hyperliquid-reader
description: >
Read Hyperliquid (app.hyperliquid.xyz) perp + spot market data via
opencli (read-only, public info API). Use whenever the user wants
Hyperliquid perpetual or spot markets, mark/oracle/mid prices, 24h
change, funding rates (hourly or annualized APR), open interest, volume,
the L2 order book, OHLCV candles, historical funding, or a cross-venue
funding comparison (Hyperliquid vs Binance vs Bybit) for funding
arbitrage. Triggers: "Hyperliquid funding for BTC", "HL perp markets",
"funding on BTC perp", "Hyperliquid order book", "HL open interest",
"funding arb Hyperliquid vs Binance", "Hyperliquid candles for SOL",
"Hyperliquid spot markets", "PURR price on Hyperliquid", "hyperliquid",
"hyperliquid.xyz", "HL DEX". READ-ONLY market data — no account, order,
or trade operations.
---
# Hyperliquid Reader (Read-Only)
Reads [Hyperliquid](https://app.hyperliquid.xyz) — the on-chain perps/spot DEX — for market data via [opencli](https://github.com/jackwener/opencli) and the `hyperliquid` plugin in this repo's [`opencli-plugins/hyperliquid`](https://github.com/himself65/finance-skills/tree/main/opencli-plugins/hyperliquid) tree (a separate plugin from opencli's built-in adapters, installed via opencli's monorepo subpath syntax).
**This skill is read-only and market-data only.** It reads Hyperliquid's fully public info API for analysis: market tables, funding, order book, and candles. It does NOT read individual accounts, place/modify/cancel orders, or move funds. There is no trading path in the plugin — order placement requires wallet-signed actions on a separate endpoint this adapter never calls.
**How it works**: every command issues a single `POST https://api.hyperliquid.xyz/info` with a `{ "type": "..." }` body and normalizes the response. **No API key, no wallet, no login, no running app** — the info API is public.
---
## Step 1: Ensure opencli + Plugin Are Installed and Ready
**Current environment status:**
```
!`(command -v opencli && opencli hyperliquid markets --coin BTC -f json 2>&1 | head -3 && echo "READY" || echo "SETUP_NEEDED") 2>/dev/null || echo "NOT_INSTALLED"`
```
If the status above shows `READY`, skip to Step 2. Otherwise:
### NOT_INSTALLED — Install opencli
```bash
npm install -g @jackwener/opencli
```
Requires Node.js >= 22 (built-in `fetch`).
### SETUP_NEEDED — Install the Hyperliquid plugin
The Hyperliquid adapter is **not** built into opencli — it's a separate plugin:
```bash
opencli plugin install github:himself65/finance-skills/hyperliquid
```
That's the entire setup — no auth, no launch step. Verify with `opencli hyperliquid markets --coin BTC`.
### Common setup issues
| Symptom | Fix |
|---|---|
| `opencli: command not found` | `npm install -g @jackwener/opencli` (Node ≥ 22) |
| `Unknown command: hyperliquid` | `opencli plugin install github:himself65/finance-skills/hyperliquid` |
| `hyperliquid info 429` | Rate limited — wait a few seconds and retry |
---
## Step 2: Identify What the User Needs
| User Request | Command | Key Flags |
|---|---|---|
| Perp markets overview / top by volume | `opencli hyperliquid markets` | `--sort`, `--limit`, `--coin` |
| One perp's price + funding + OI | `opencli hyperliquid markets --coin BTC` | — |
| Spot pairs overview | `opencli hyperliquid spot-markets` | `--sort`, `--limit`, `--pair`, `--canonical-only` |
| All current mid prices | `opencli hyperliquid mids` | `--coin <substring>` |
| Order book for a coin | `opencli hyperliquid book --coin ETH` | `--depth`, `--n-sig-figs` |
| OHLCV candles | `opencli hyperliquid candles --coin BTC --interval 1h` | `--limit` |
| Historical funding for a coin | `opencli hyperliquid funding-history --coin BTC` | `--hours`, `--limit` |
| Funding arb: HL vs Binance vs Bybit | `opencli hyperliquid funding-compare` | `--coin`, `--sort`, `--limit` |
---
## Step 3: Execute the Command
### General pattern
```bash
# Use -f json or -f yaml for structured output
opencli hyperliquid markets --sort fundingAprPct --limit 15 -f json
opencli hyperliquid funding-compare --sort hlVsBinancePct --limit 20 -f md
opencli hyperliquid candles --coin BTC --interval 4h --limit 50 -f csv
opencli hyperliquid book --coin ETH --depth 5 -f json
```
### Key rules
1. **Coin symbols are bare perp names**`BTC`, `ETH`, `SOL`, `HYPE` (no exchange prefix). Spot pairs are `BASE/USDC` (e.g. `PURR/USDC`); for `book`/`candles` you can pass either a perp coin or a spot pair.
2. **`markets` is the default lens for "how is X / the market doing"** — it carries mark/oracle/mid price, 24h change, hourly funding + APR, open interest (coins and notional), and 24h volume in one row per perp. Filter with `--coin` for a single asset.
3. **Funding is reported two ways**`fundingHrPct` is the raw hourly rate as a percent; `fundingAprPct` annualizes it (`hourly × 24 × 365`). Lead with APR when comparing carry across assets; use the hourly figure for "what will I pay next hour".
4. **`funding-compare` is the funding-arb screen** — it annualizes each venue with its own interval (HL hourly, Binance/Bybit usually 4h) and reports `hlVsBinancePct` / `hlVsBybitPct` spreads. Default sort ranks by **absolute** HL-vs-Binance spread (widest dislocations first). A positive `hlVsBinancePct` means HL longs pay more than Binance longs.
5. **`book` defaults to 10 levels per side** — raise `--depth` (max 20) for more, or `--n-sig-figs 2..5` to aggregate price levels. Compute the spread/mid from the top bid and ask.
6. **`candles` pulls the most recent `--limit` candles** of `--interval` (default `1h`, 100 candles). Valid intervals: `1m 3m 5m 15m 30m 1h 2h 4h 8h 12h 1d 3d 1w 1M`. Max 5000.
7. **`-f json`** for programmatic processing / feeding other skills; `-f md` or `-f table` for human-readable output.
8. **NEVER call any write operation.** This skill is read-only market data — no account reads, no order placement, modification, or cancellation, and no transfers. The plugin intentionally exposes no write endpoints.
### Output format flag (`-f`)
| Format | Flag | Best for |
|---|---|---|
| Table | `-f table` (default) | Human-readable terminal output |
| JSON | `-f json` | Programmatic processing, LLM context |
| YAML | `-f yaml` | Structured, readable |
| Markdown | `-f md` | Reports |
| CSV | `-f csv` | Spreadsheet export |
### Output columns
- `markets``coin`, `markPx`, `midPx`, `oraclePx`, `change24hPct`, `fundingHrPct`, `fundingAprPct`, `openInterest`, `oiNotional`, `dayNtlVlm`, `premiumPct`, `maxLeverage`
- `spot-markets``pair`, `base`, `markPx`, `midPx`, `change24hPct`, `dayNtlVlm`, `circulatingSupply`, `marketCap`, `canonical`
- `mids``coin`, `mid`
- `book``side`, `level`, `px`, `sz`, `orders`
- `candles``time`, `open`, `high`, `low`, `close`, `volume`, `trades`
- `funding-history``coin`, `fundingRatePct`, `fundingAprPct`, `premiumPct`, `time`
- `funding-compare``coin`, `hlAprPct`, `binanceAprPct`, `bybitAprPct`, `hlVsBinancePct`, `hlVsBybitPct`, `nextHlFunding`
---
## Step 4: Present the Results
1. **Lead with the headline number, then the table.** For `markets --coin BTC`: state mark price, 24h change, funding APR, and open interest in prose first. For a full `markets` dump: lead with the count and the top movers / highest-funding names.
2. **Frame funding in carry terms** — e.g. "BTC perp funding is +10.9% APR (longs pay shorts)". Positive funding ⇒ longs pay shorts; negative ⇒ shorts pay longs.
3. **For `funding-compare`, surface the widest dislocations first** — name the coin, both venues' APRs, and the spread, and remember the spread is annualized; a real arb also pays exchange/withdrawal frictions, so present it as a screen, not a guaranteed edge.
4. **For `book`, report the spread** — best bid, best ask, mid, and spread in bps before (or instead of) dumping every level. Don't paste 20 levels unless asked.
5. **For `candles`, describe the move** — first/last close, high/low, and direction; only show the full OHLCV table when the user wants the series.
6. **Filter aggressively before showing**`markets` has ~180 perps and `mids` ~700 markets; cap to top 15-20 by the relevant sort unless the user asks for the full list.
---
## Step 5: Diagnostics
```bash
opencli hyperliquid markets --coin BTC
```
A successful BTC row confirms opencli, the plugin, and the public API are all reachable. If it errors with `Unknown command: hyperliquid`, reinstall the plugin (Step 1). A `hyperliquid info 4xx/5xx` is an upstream API issue — retry after a short wait.
---
## Error Reference
| Error | Cause | Fix |
|---|---|---|
| `Unknown command: hyperliquid` | Plugin not installed | `opencli plugin install github:himself65/finance-skills/hyperliquid` |
| `hyperliquid info 429` | Rate limited | Wait a few seconds, then retry |
| `hyperliquid info 422/500` | Malformed body or upstream issue | Re-check the coin/interval; retry after a wait |
| `No perp market for coin "X"` | Wrong/unlisted symbol | Run `opencli hyperliquid markets` (or `mids`) to find the exact symbol |
---
## Reference Files
- `references/commands.md` — Every command with all flags, output schemas, and analyst workflows (funding carry, basis/arb, spot snapshot)
@@ -0,0 +1,162 @@
# Hyperliquid Reader — Command Reference
Every command issues a single `POST https://api.hyperliquid.xyz/info` and prints normalized rows. All are **read-only market data**, need no auth, and accept `-f json|yaml|md|csv|table`.
> Symbols: perps are bare names (`BTC`, `ETH`, `HYPE`); spot pairs are `BASE/USDC` (`PURR/USDC`). `book` and `candles` accept either.
---
## Market data
### `markets` — perpetual markets table
`metaAndAssetCtxs` → one row per perp.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (all) | Filter to one coin (exact, case-insensitive) |
| `--sort` | `dayNtlVlm` | One of `dayNtlVlm`, `change24hPct`, `fundingAprPct`, `fundingHrPct`, `openInterest`, `oiNotional`, `markPx`, `coin` (desc; `coin` asc). `null` sorts last |
| `--limit` | (all) | Max rows after sort |
| `--include-delisted` | `false` | Include delisted markets |
Columns: `coin`, `markPx`, `midPx`, `oraclePx`, `change24hPct`, `fundingHrPct`, `fundingAprPct`, `openInterest`, `oiNotional`, `dayNtlVlm`, `premiumPct`, `maxLeverage`.
- `fundingHrPct` — raw hourly funding rate as a percent.
- `fundingAprPct``hourly × 24 × 365` (annualized).
- `openInterest` — in coins; `oiNotional``openInterest × markPx` (USD).
- `premiumPct` — perp premium/discount vs oracle (mark-implied).
```bash
opencli hyperliquid markets --sort dayNtlVlm --limit 15
opencli hyperliquid markets --coin HYPE -f json
opencli hyperliquid markets --sort fundingAprPct --limit 20 # highest carry
```
### `spot-markets` — spot pairs table
`spotMetaAndAssetCtxs` → one row per spot pair.
| Flag | Default | Notes |
|---|---|---|
| `--pair` | (all) | Filter by pair or base token (e.g. `PURR` or `PURR/USDC`) |
| `--sort` | `dayNtlVlm` | One of `dayNtlVlm`, `change24hPct`, `marketCap`, `markPx`, `pair` |
| `--limit` | (all) | Max rows after sort |
| `--canonical-only` | `false` | Only named pairs (hide `@index` pairs) |
Columns: `pair`, `base`, `markPx`, `midPx`, `change24hPct`, `dayNtlVlm`, `circulatingSupply`, `marketCap`, `canonical`.
```bash
opencli hyperliquid spot-markets --canonical-only --sort dayNtlVlm --limit 20
opencli hyperliquid spot-markets --pair PURR -f json
```
### `mids` — all mid prices
`allMids` (+ `spotMeta` to resolve names) → `coin`, `mid` for every market.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (all) | Case-insensitive **substring** filter |
Non-canonical spot keys (`@<index>`) resolve to `BASE/QUOTE`; perp names and canonical pairs pass through; builder perp-dex keys (`#<n>`) are shown as-is.
```bash
opencli hyperliquid mids --coin BTC # BTC, plus any pair containing "BTC"
opencli hyperliquid mids -f json | jq '.[] | select(.coin=="ETH")'
```
### `book` — L2 order book snapshot
`l2Book` → up to `--depth` levels per side, bids first then asks.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (required) | Coin or spot pair |
| `--depth` | `10` | Levels per side (1-20) |
| `--n-sig-figs` | (full) | Price aggregation, 2-5 |
Columns: `side` (`bid`/`ask`), `level`, `px`, `sz`, `orders`.
Spread = best ask best bid; mid = their average. Top-of-book is `level: 1` on each side.
```bash
opencli hyperliquid book --coin ETH --depth 5
opencli hyperliquid book --coin BTC --n-sig-figs 3 -f json
```
### `candles` — OHLCV history
`candleSnapshot` → most recent `--limit` candles of `--interval`.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (required) | Coin or spot pair |
| `--interval` | `1h` | `1m 3m 5m 15m 30m 1h 2h 4h 8h 12h 1d 3d 1w 1M` |
| `--limit` | `100` | Number of candles (max 5000) |
Columns: `time` (ISO, candle open), `open`, `high`, `low`, `close`, `volume`, `trades`.
```bash
opencli hyperliquid candles --coin BTC --interval 4h --limit 60
opencli hyperliquid candles --coin SOL --interval 1d --limit 30 -f csv
```
### `funding-history` — historical funding for a coin
`fundingHistory` → hourly prints within the lookback window, newest first.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (required) | Coin (e.g. `BTC`) |
| `--hours` | `24` | Lookback window in hours |
| `--limit` | (all) | Cap to most recent N |
Columns: `coin`, `fundingRatePct`, `fundingAprPct`, `premiumPct`, `time`.
```bash
opencli hyperliquid funding-history --coin BTC --hours 72
opencli hyperliquid funding-history --coin ETH --hours 168 --limit 24 -f json
```
### `funding-compare` — cross-venue funding (arb screen)
`predictedFundings` → per coin, each venue's predicted funding annualized to APR, plus HL-vs-venue spreads.
| Flag | Default | Notes |
|---|---|---|
| `--coin` | (all) | Filter to one coin (exact) |
| `--sort` | `hlVsBinancePct` | `hlVsBinancePct`, `hlVsBybitPct` (by absolute spread), or `hlAprPct`, `binanceAprPct`, `bybitAprPct`, `coin` (signed) |
| `--limit` | (all) | Max rows after sort |
Columns: `coin`, `hlAprPct`, `binanceAprPct`, `bybitAprPct`, `hlVsBinancePct`, `hlVsBybitPct`, `nextHlFunding`.
Each venue is annualized with its own interval (HL hourly, Binance/Bybit usually 4h). `hlVsBinancePct = hlAprPct binanceAprPct`; positive ⇒ HL longs pay more. Default sort surfaces the widest dislocations first. Treat as a screen — a real arb also pays exchange and transfer frictions.
```bash
opencli hyperliquid funding-compare --limit 20 # widest HL/Binance gaps
opencli hyperliquid funding-compare --coin BTC -f json
opencli hyperliquid funding-compare --sort hlAprPct --limit 15 # highest HL carry
```
---
## Analyst workflows
### Funding carry scan
1. `markets --sort fundingAprPct --limit 20` — highest (and, reversed mentally, lowest) annualized funding.
2. `funding-history --coin <X> --hours 168` — confirm the rate is persistent, not a one-hour spike.
3. `markets --coin <X>` — check open interest and premium to size whether the carry is tradeable.
### Cross-venue funding arbitrage
1. `funding-compare --limit 25` — widest HL-vs-Binance/Bybit dislocations.
2. For a candidate `<X>`: `funding-compare --coin <X>` for all three venues' APRs and the next HL funding time.
3. `book --coin <X>` and `markets --coin <X>` — verify depth and OI can support the size before treating the spread as real (it's annualized and ignores frictions).
### Basis / premium check
1. `markets --coin <X>``premiumPct` shows perp rich/cheap vs oracle; `markPx` vs `oraclePx` is the absolute basis.
2. `candles --coin <X> --interval 1h` — recent price action around the basis.
### Spot token snapshot
1. `spot-markets --canonical-only --sort dayNtlVlm` — most active named pairs.
2. `spot-markets --pair <BASE>` — price, 24h change, market cap for one token.
3. `book --coin <BASE>/USDC` — liquidity at top of book.