永续合约 08 - 数据解析实战

本文用 Go 分别对接 GMX v2 (EVM 合约调用), dYdX v4 (gRPC/REST) 和 Hyperliquid (REST/WebSocket), 读取 funding rate, Mark Price, OI 等核心数据. 在此基础上设计一个统一的跨协议抽象层, 并实现三个监控工具: funding rate 多协议对比, 仓位清算风险预警, OI 不平衡分析.

一、目录

# 章节 内容
- 术语表 数据读取相关术语
1 概述: 链上数据读取方法 三种协议的数据获取方式概览
2 GMX v2 数据读取 (Go) 通过合约调用读取 GMX v2 数据
3 dYdX v4 数据读取 (Go) 通过 gRPC/REST 读取 dYdX 数据
4 Hyperliquid 数据读取 (Go) 通过 REST/WS 读取 Hyperliquid 数据
5 通用数据结构 (Go) 统一的跨协议数据结构定义
6 实战: Funding Rate 监控 多协议资金费率监控工具
7 实战: 清算风险监控 仓位清算风险预警工具
8 实战: OI 分析 未平仓量数据分析工具
9 数据源对比表 三个协议数据源特性对比
10 小结: 全系列回顾 永续合约系列总结

二、术语表

术语 英文 含义
ABI 编码 ABI Encoding Solidity 函数调用/返回的二进制编码格式, 每个参数占 32 bytes
Event Log Event Log EVM 合约发出的日志, 存在 receipt 中, 用 topic + data 编码
Multicall Multicall 一次 RPC 调用中批量执行多个合约读取, 减少网络往返
OI Open Interest 未平仓合约总量. 注意: GMX 的 “long OI / short OI” 指交易者单侧敞口 (LP 池做对手方, 两侧可不等); 订单簿中 long OI = short OI
Funding Rate Funding Rate 多空定期互付的费率, 详见《永续合约机制详解》
Margin Ratio Margin Ratio 保证金 / 头寸价值, 低于维持保证金率触发清算
gRPC gRPC Google 的 RPC 框架, dYdX v4 的主要查询接口
WebSocket WebSocket 全双工通信协议, 用于实时数据订阅
L2 Data Level 2 Data 订单簿深度数据, 包含多个价格档位的挂单量
GM Pool GM Pool GMX v2 的流动性池, 每个 market 一个独立池

三、概述: 链上数据读取方法

3.1 两种数据获取范式

链上数据获取: 状态读取 vs 事件监听 状态读取 (eth_call) 调用合约的 view/pure 函数 获取当前快照: position, OI, price 优点: 简单直接, 拿到的就是最新状态 缺点: 无法获取历史变化, 需要轮询 事件监听 (eth_getLogs) 订阅合约发出的 Event Log 捕获状态变更: 开仓, 平仓, 清算 优点: 实时推送, 可回溯历史区块 缺点: 需要解析编码, topic 索引有限 Off-chain API (REST / gRPC / WebSocket) 适用于 appchain (dYdX v4) 和自研 L1 (Hyperliquid) 这些链不是 EVM, 没有 eth_call / getLogs, 用专有 API dYdX v4: Cosmos SDK → gRPC + REST indexer Hyperliquid: 自研 L1 → REST API + WebSocket

3.2 ABI 编码回顾

回顾: Solidity ABI 编码的核心规则是 每个参数对齐到 32 bytes.

1
2
3
4
5
6
// function getPosition(address account, bytes32 key) returns (Position)
//
// 编码结构:
// [0:4] function selector (函数选择器) = keccak256("getPosition(address,bytes32)")[:4]
// [4:36] account (地址, 左填充 032 bytes)
// [36:68] key (键, bytes32, 原样 32 bytes)

在 Go 中, 用 go-ethereum/accounts/abi 包处理编码/解码:

1
2
3
4
5
6
7
8
9
10
11
import "github.com/ethereum/go-ethereum/accounts/abi"

// 解析 ABI JSON
parsed, _ := abi.JSON(strings.NewReader(abiJSON))

// 编码调用
data, _ := parsed.Pack("getPosition", account, key)

// 解码返回
result := new(Position)
parsed.UnpackIntoInterface(result, "getPosition", output)

3.3 本文数据源分布

协议 数据获取方式 主要工具
GMX v2 Arbitrum (EVM) eth_call + getLogs go-ethereum
dYdX v4 Cosmos appchain REST + gRPC + WS net/http, google.golang.org/grpc
Hyperliquid 自研 L1 REST + WS net/http, gorilla/websocket

四、GMX v2 数据读取 (Go)

GMX v2 部署在 Arbitrum, 核心数据存储在 DataStore 合约中, 通过 Reader 合约批量读取.

4.1 合约地址与 ABI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package gmxv2

import (
"context"
"fmt"
"math/big"
"reflect"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)

// GMX v2 Arbitrum 合约地址
var (
ReaderAddr = common.HexToAddress("0xf60becbba223EEA9495Da3f606753867eC10d139")
DataStoreAddr = common.HexToAddress("0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8")
EventEmitter = common.HexToAddress("0xC8ee91A54287DB53897056e12D9819156D3822Fb")
)

// Reader ABI (精简, 仅包含常用函数)
const readerABI = `[
{
"name": "getMarket",
"type": "function",
"stateMutability": "view",
"inputs": [
{"name": "dataStore", "type": "address"},
{"name": "key", "type": "address"}
],
"outputs": [
{
"name": "",
"type": "tuple",
"components": [
{"name": "marketToken", "type": "address"},
{"name": "indexToken", "type": "address"},
{"name": "longToken", "type": "address"},
{"name": "shortToken", "type": "address"}
]
}
]
},
{
// NOTE: 简化版 ABI, 实际 GMX v2 Reader.getPosition 返回嵌套结构:
// Position.Addresses (account, market, collateralToken)
// Position.Numbers (sizeInUsd, sizeInTokens, collateralAmount, ...)
// Position.Flags (isLong)
// 如需 abigen 或 reflect 解码, 请使用完整嵌套 ABI
"name": "getPosition",
"type": "function",
"stateMutability": "view",
"inputs": [
{"name": "dataStore", "type": "address"},
{"name": "key", "type": "bytes32"}
],
"outputs": [
{
"name": "",
"type": "tuple",
"components": [
{"name": "sizeInUsd", "type": "uint256"},
{"name": "sizeInTokens", "type": "uint256"},
{"name": "collateralAmount", "type": "uint256"},
{"name": "borrowingFactor", "type": "uint256"},
{"name": "fundingFeeAmountPerSize", "type": "uint256"},
{"name": "longTokenClaimableFundingAmountPerSize", "type": "uint256"},
{"name": "shortTokenClaimableFundingAmountPerSize", "type": "uint256"},
{"name": "increasedAtBlock", "type": "uint256"},
{"name": "decreasedAtBlock", "type": "uint256"},
{"name": "isLong", "type": "bool"}
]
}
]
},
{
"name": "getOpenInterestWithPnl",
"type": "function",
"stateMutability": "view",
"inputs": [
{"name": "dataStore", "type": "address"},
{"name": "market", "type": "tuple", "components": [
{"name": "marketToken", "type": "address"},
{"name": "indexToken", "type": "address"},
{"name": "longToken", "type": "address"},
{"name": "shortToken", "type": "address"}
]},
{"name": "indexTokenPrice", "type": "tuple", "components": [
{"name": "min", "type": "uint256"},
{"name": "max", "type": "uint256"}
]},
{"name": "isLong", "type": "bool"},
{"name": "maximize", "type": "bool"}
],
"outputs": [
{"name": "", "type": "int256"}
]
}
]`

4.2 读取 Market 信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// GMXClient 封装 GMX v2 的读取逻辑
type GMXClient struct {
client *ethclient.Client
readerABI abi.ABI
}

// Market 表示一个 GMX v2 市场
type Market struct {
MarketToken common.Address
IndexToken common.Address
LongToken common.Address
ShortToken common.Address
}

func NewGMXClient(rpcURL string) (*GMXClient, error) {
client, err := ethclient.Dial(rpcURL)
if err != nil {
return nil, fmt.Errorf("dial rpc: %w", err)
}
parsed, err := abi.JSON(strings.NewReader(readerABI))
if err != nil {
return nil, fmt.Errorf("parse abi: %w", err)
}
return &GMXClient{client: client, readerABI: parsed}, nil
}

// GetMarket 读取单个 market 信息
func (g *GMXClient) GetMarket(ctx context.Context, marketAddr common.Address) (*Market, error) {
data, err := g.readerABI.Pack("getMarket", DataStoreAddr, marketAddr)
if err != nil {
return nil, fmt.Errorf("pack getMarket: %w", err)
}

result, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &ReaderAddr,
Data: data,
}, nil) // nil = latest block
if err != nil {
return nil, fmt.Errorf("call getMarket: %w", err)
}

var market Market
if err := g.readerABI.UnpackIntoInterface(&market, "getMarket", result); err != nil {
return nil, fmt.Errorf("unpack getMarket: %w", err)
}
return &market, nil
}

4.3 读取 Open Interest

GMX v2 OI 数据结构 DataStore keccak256( "OPEN_INTEREST", market, collateral, isLong ) → uint256 key-value 存储 Reader getOpenInterest() 从 DataStore 读取 long OI + short OI getOpenInterestWithPnl() OI + 未实现盈亏 输出 Long OI: $12.5M Short OI: $8.3M L/S Ratio: 1.51 单位: USD (30 精度)

GMX v2 的 OI 存储在 DataStore 中, 用 keccak256("OPEN_INTEREST", market, collateralToken, isLong) 作为 key. 可以直接读 DataStore, 也可以通过 Reader 合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// OIData 表示某个 market 的 OI (Open Interest, 未平仓量)
type OIData struct {
LongOI *big.Int // 多头 OI, 30 decimals (USD)
ShortOI *big.Int // 空头 OI, 30 decimals (USD)
}

// GetOpenInterest 读取指定 market 的 long/short OI
// GMX v2 的 OI 存储用 30 位精度 (1e30 = 1 USD)
func (g *GMXClient) GetOpenInterest(ctx context.Context, marketKey [32]byte) (*OIData, error) {
// DataStore 直接读取方式: getUint(bytes32 key)
// key = keccak256(abi.encode("OPEN_INTEREST", market, collateral, isLong))
//
// 这里用更简单的方式: 通过 Reader 合约的 getMarketInfo
// 内部会聚合 long/short OI

dataStoreABI, _ := abi.JSON(strings.NewReader(`[{
"name": "getUint",
"type": "function",
"stateMutability": "view",
"inputs": [{"name": "key", "type": "bytes32"}],
"outputs": [{"name": "", "type": "uint256"}]
}]`))

// 构造 OI key: keccak256(abi.encode(keccak256("OPEN_INTEREST"), market, collateral, isLong))
// 实际实现中需要知道 collateral token 地址
// 简化示例: 直接用预计算的 key

longKey := computeOIKey(marketKey, true)
shortKey := computeOIKey(marketKey, false)

// 批量读取 (见 2.6 Multicall)
longData, _ := dataStoreABI.Pack("getUint", longKey)
shortData, _ := dataStoreABI.Pack("getUint", shortKey)

longResult, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &DataStoreAddr, Data: longData,
}, nil)
if err != nil {
return nil, fmt.Errorf("get long OI: %w", err)
}

shortResult, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &DataStoreAddr, Data: shortData,
}, nil)
if err != nil {
return nil, fmt.Errorf("get short OI: %w", err)
}

longOI := new(big.Int).SetBytes(longResult)
shortOI := new(big.Int).SetBytes(shortResult)

return &OIData{LongOI: longOI, ShortOI: shortOI}, nil
}

4.4 读取 Funding Rate

GMX v2 的 funding rate 不是单一数值, 而是累积值 (cumulative funding amount per size). 要计算当前费率需要差值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// FundingData 表示 GMX v2 的 funding (资金费率) 累积数据
type FundingData struct {
LongFundingAmountPerSize *big.Int // 多头每单位头寸累积资金费
ShortFundingAmountPerSize *big.Int // 空头每单位头寸累积资金费
Timestamp uint64
}

// GetFundingFactorPerSecond 读取 funding factor (资金费率因子) (每秒)
// GMX v2 funding = fundingFactor * (longOI (交易者做多敞口) - shortOI (交易者做空敞口)) / (longOI + shortOI) * elapsed (经过时间)
func (g *GMXClient) GetFundingFactorPerSecond(ctx context.Context, marketKey [32]byte) (*big.Int, error) {
// DataStore key: keccak256(abi.encode(keccak256("FUNDING_FACTOR"), market))
key := computeFundingFactorKey(marketKey)

dataStoreABI, _ := abi.JSON(strings.NewReader(`[{
"name": "getUint",
"type": "function",
"stateMutability": "view",
"inputs": [{"name": "key", "type": "bytes32"}],
"outputs": [{"name": "", "type": "uint256"}]
}]`))

data, _ := dataStoreABI.Pack("getUint", key)
result, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &DataStoreAddr, Data: data,
}, nil)
if err != nil {
return nil, fmt.Errorf("get funding factor: %w", err)
}

return new(big.Int).SetBytes(result), nil
}

// CalculateCurrentFundingRate 根据 OI 不平衡计算当前 funding rate (资金费率)
// fundingRate = fundingFactor (费率因子) * (longOI (做多敞口) - shortOI (做空敞口)) / (longOI + shortOI)
// 结果为每秒费率, annualized (年化) = result * 86400 * 365
func CalculateCurrentFundingRate(fundingFactor, longOI, shortOI *big.Int) *big.Float {
if new(big.Int).Add(longOI, shortOI).Sign() == 0 {
return new(big.Float) // 无 OI 时 funding = 0
}

diff := new(big.Int).Sub(longOI, shortOI) // longOI - shortOI
total := new(big.Int).Add(longOI, shortOI) // longOI + shortOI

// fundingFactor * diff / total
// 使用 big.Float 避免精度损失
fFactor := new(big.Float).SetInt(fundingFactor)
fDiff := new(big.Float).SetInt(diff)
fTotal := new(big.Float).SetInt(total)

rate := new(big.Float).Mul(fFactor, fDiff)
rate.Quo(rate, fTotal)

return rate
}

4.5 读取用户 Position

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// GMXPosition 表示 GMX v2 上的一个头寸 (position)
type GMXPosition struct {
SizeInUsd *big.Int // 头寸规模 (USD), 30 decimals
SizeInTokens *big.Int // 头寸代币数量
CollateralAmount *big.Int // 抵押品数量
BorrowingFactor *big.Int // 借贷因子
IsLong bool // 多/空方向
IncreasedAtBlock *big.Int // 加仓区块号
DecreasedAtBlock *big.Int // 减仓区块号
}

// GetPosition 读取用户的 position
// positionKey = keccak256(abi.encode(account, market, collateralToken, isLong))
func (g *GMXClient) GetPosition(ctx context.Context, positionKey [32]byte) (*GMXPosition, error) {
data, err := g.readerABI.Pack("getPosition", DataStoreAddr, positionKey)
if err != nil {
return nil, fmt.Errorf("pack getPosition: %w", err)
}

result, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &ReaderAddr,
Data: data,
}, nil)
if err != nil {
return nil, fmt.Errorf("call getPosition: %w", err)
}

// 手动解码 tuple
values, err := g.readerABI.Unpack("getPosition", result)
if err != nil {
return nil, fmt.Errorf("unpack getPosition: %w", err)
}

// GMX v2 Reader.getPosition 返回的是一个嵌套 struct
// 推荐方式: 用 abigen 生成类型绑定, 避免手动解码
//
// abigen --abi Reader.json --pkg gmx --type Reader --out reader.go
//
// 生成后直接调用:
// reader, _ := gmx.NewReader(ReaderAddr, client)
// posInfo, _ := reader.GetPosition(nil, DataStoreAddr, positionKey)
// posInfo.Position.Numbers.SizeInUsd // *big.Int, 30 decimals
//
// GMX v2 的 Position 结构体是深层嵌套的 (Addresses, Numbers, Flags 子结构)
// 手动 reflect 解码容易出错, 强烈推荐 abigen:
//
// abigen --abi Reader.json --pkg gmx --type Reader --out reader.go
// reader, _ := gmx.NewReader(ReaderAddr, client)
// posInfo, _ := reader.GetPosition(nil, DataStoreAddr, positionKey)
//
// 如需手动解码, 需使用完整的嵌套 ABI 定义 (非上方的简化版)
pos := &GMXPosition{} // placeholder, 实际需要完整 ABI + abigen
_ = values

return pos, nil
}

// ComputePositionKey 计算 position key
func ComputePositionKey(account, market, collateralToken common.Address, isLong bool) [32]byte {
// keccak256(abi.encode(account, market, collateralToken, isLong))
isLongByte := byte(0)
if isLong {
isLongByte = 1
}

// 手动 ABI encode (每个参数 32 bytes)
var encoded []byte
encoded = append(encoded, common.LeftPadBytes(account.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(market.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes(collateralToken.Bytes(), 32)...)
encoded = append(encoded, common.LeftPadBytes([]byte{isLongByte}, 32)...)

return crypto.Keccak256Hash(encoded)
}

// CalculatePnL 计算头寸的未实现盈亏
// Long: PnL (盈亏) = (currentPrice (当前价格) - entryPrice (开仓价)) * sizeInTokens (头寸代币数量)
// Short: PnL = (entryPrice - currentPrice) * sizeInTokens
func CalculatePnL(pos *GMXPosition, currentPrice *big.Int) *big.Int {
if pos.SizeInTokens.Sign() == 0 {
return big.NewInt(0)
}

// 直接用乘法避免整除精度损失
// Long: PnL = currentPrice * sizeInTokens - sizeInUsd
// Short: PnL = sizeInUsd - currentPrice * sizeInTokens
product := new(big.Int).Mul(currentPrice, pos.SizeInTokens)

var pnl *big.Int
if pos.IsLong {
pnl = new(big.Int).Sub(product, pos.SizeInUsd)
} else {
pnl = new(big.Int).Sub(pos.SizeInUsd, product)
}

return pnl
}

4.6 监听事件

GMX v2 通过 EventEmitter 合约统一发出所有事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import (
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
)

// GMX v2 EventEmitter 的事件 topic
var (
// EventLog1(address msgSender, string eventName, string eventNameHash, EventLogData eventData)
EventLog1Topic = crypto.Keccak256Hash([]byte(
"EventLog1(address,string,string,bytes32,(((string,address)[],(string,address[])[]),((string,uint256)[],(string,uint256[])[]),((string,int256)[],(string,int256[])[]),((string,bool)[],(string,bool[])[]),((string,bytes32)[],(string,bytes32[])[]),((string,bytes)[],(string,bytes[])[]),((string,string)[],(string,string[])[])))",
))

// 常用事件名的 keccak256 (作为 topic[2] 索引)
PositionIncreaseTopic = crypto.Keccak256Hash([]byte("PositionIncrease"))
PositionDecreaseTopic = crypto.Keccak256Hash([]byte("PositionDecrease"))
LiquidationTopic = crypto.Keccak256Hash([]byte("LiquidatePosition"))
)

// WatchPositionEvents 监听仓位变更事件
func (g *GMXClient) WatchPositionEvents(ctx context.Context, fromBlock *big.Int) (<-chan types.Log, error) {
query := ethereum.FilterQuery{
FromBlock: fromBlock,
Addresses: []common.Address{EventEmitter},
Topics: [][]common.Hash{
{EventLog1Topic}, // topic[0]: event signature
{}, // topic[1]: msgSender (any)
{ // topic[2]: eventNameHash (filter by name)
PositionIncreaseTopic,
PositionDecreaseTopic,
LiquidationTopic,
},
},
}

logs := make(chan types.Log)
sub, err := g.client.SubscribeFilterLogs(ctx, query, logs)
if err != nil {
return nil, fmt.Errorf("subscribe: %w", err)
}

// 包装: 处理错误和重连
out := make(chan types.Log, 100)
go func() {
defer close(out)
for {
select {
case log := <-logs:
out <- log
case err := <-sub.Err():
if err != nil {
// 日志记录, 外部处理重连
fmt.Printf("subscription error: %v\n", err)
}
return
case <-ctx.Done():
return
}
}
}()

return out, nil
}

// 也可以用 FilterLogs 查询历史事件
func (g *GMXClient) GetHistoricalPositionEvents(
ctx context.Context,
fromBlock, toBlock *big.Int,
) ([]types.Log, error) {
query := ethereum.FilterQuery{
FromBlock: fromBlock,
ToBlock: toBlock,
Addresses: []common.Address{EventEmitter},
Topics: [][]common.Hash{
{EventLog1Topic},
{},
{PositionIncreaseTopic, PositionDecreaseTopic, LiquidationTopic},
},
}

return g.client.FilterLogs(ctx, query)
}

4.7 Multicall 批量读取

单次 RPC 往返读取多个数据, 大幅减少延迟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// Multicall3 合约地址 (Arbitrum 上与 mainnet 相同)
var Multicall3Addr = common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11")

// Call3 表示 Multicall3 的一次调用
type Call3 struct {
Target common.Address
AllowFailure bool
CallData []byte
}

// Result 表示 Multicall3 的返回
type MulticallResult struct {
Success bool
ReturnData []byte
}

const multicall3ABI = `[{
"name": "aggregate3",
"type": "function",
"stateMutability": "payable",
"inputs": [{
"name": "calls",
"type": "tuple[]",
"components": [
{"name": "target", "type": "address"},
{"name": "allowFailure", "type": "bool"},
{"name": "callData", "type": "bytes"}
]
}],
"outputs": [{
"name": "returnData",
"type": "tuple[]",
"components": [
{"name": "success", "type": "bool"},
{"name": "returnData", "type": "bytes"}
]
}]
}]`

// BatchRead 通过 Multicall3 批量读取
func (g *GMXClient) BatchRead(ctx context.Context, calls []Call3) ([]MulticallResult, error) {
mcABI, _ := abi.JSON(strings.NewReader(multicall3ABI))

data, err := mcABI.Pack("aggregate3", calls)
if err != nil {
return nil, fmt.Errorf("pack multicall: %w", err)
}

result, err := g.client.CallContract(ctx, ethereum.CallMsg{
To: &Multicall3Addr,
Data: data,
}, nil)
if err != nil {
return nil, fmt.Errorf("call multicall: %w", err)
}

values, err := mcABI.Unpack("aggregate3", result)
if err != nil {
return nil, fmt.Errorf("unpack multicall: %w", err)
}

// 解析返回
// values[0] 是 []struct{Success bool, ReturnData []byte}
_ = values
var results []MulticallResult
// ... 解码逻辑
return results, nil
}

// 使用示例: 一次 RPC 读取 5 个 market 的 OI
func (g *GMXClient) BatchGetOI(ctx context.Context, marketKeys [][32]byte) ([]*OIData, error) {
dataStoreABI, _ := abi.JSON(strings.NewReader(`[{
"name": "getUint",
"type": "function",
"stateMutability": "view",
"inputs": [{"name": "key", "type": "bytes32"}],
"outputs": [{"name": "", "type": "uint256"}]
}]`))

var calls []Call3
for _, mk := range marketKeys {
longKey := computeOIKey(mk, true)
shortKey := computeOIKey(mk, false)

longData, _ := dataStoreABI.Pack("getUint", longKey)
shortData, _ := dataStoreABI.Pack("getUint", shortKey)

calls = append(calls,
Call3{Target: DataStoreAddr, AllowFailure: true, CallData: longData},
Call3{Target: DataStoreAddr, AllowFailure: true, CallData: shortData},
)
}

results, err := g.BatchRead(ctx, calls)
if err != nil {
return nil, err
}

// 每 2 个结果对应一个 market (long, short)
var oiList []*OIData
for i := 0; i < len(results); i += 2 {
oi := &OIData{
LongOI: new(big.Int).SetBytes(results[i].ReturnData),
ShortOI: new(big.Int).SetBytes(results[i+1].ReturnData),
}
oiList = append(oiList, oi)
}

return oiList, nil
}

五、dYdX v4 数据读取 (Go)

dYdX v4 运行在 Cosmos appchain 上, 不是 EVM. 数据通过 REST indexergRPC 获取.

5.1 API 基础

dYdX v4 数据获取架构 Go Client 你的程序 REST Indexer API indexer.dydx.trade/v4 gRPC (Full Node) dydx-grpc.polkachu.com:23890 WebSocket indexer.dydx.trade/v4/ws dYdX v4 Cosmos Appchain Indexer: 解析链上事件, 提供 REST API Full Node: 运行 Cosmos SDK, 提供 gRPC WS: Indexer 的实时推送通道 推荐: 快照数据用 REST, 实时用 WS gRPC 用于需要链上原始状态的场景

5.2 REST 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package dydxv4

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
IndexerBaseURL = "https://indexer.dydx.trade/v4"
WSBaseURL = "wss://indexer.dydx.trade/v4/ws"
)

// DYDXClient 封装 dYdX v4 API
type DYDXClient struct {
httpClient *http.Client
baseURL string
}

func NewDYDXClient(baseURL string) *DYDXClient {
return &DYDXClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: baseURL,
}
}

// doGet 通用 GET 请求
func (d *DYDXClient) doGet(ctx context.Context, path string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.baseURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}

resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, body)
}

return json.NewDecoder(resp.Body).Decode(result)
}

5.3 读取 Markets 信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// PerpMarket 表示 dYdX 的永续市场
type PerpMarket struct {
Ticker string `json:"ticker"`
Status string `json:"status"`
BaseOpenInterest string `json:"baseOpenInterest"`
OpenInterest string `json:"openInterest"`
OraclePrice string `json:"oraclePrice"`
NextFundingRate string `json:"nextFundingRate"`
AtomicResolution int `json:"atomicResolution"`
StepBaseQuantums int64 `json:"stepBaseQuantums"`
SubticksPerTick int64 `json:"subticksPerTick"`
InitialMarginFraction string `json:"initialMarginFraction"`
MaintenanceMarginFr string `json:"maintenanceMarginFraction"`
}

type MarketsResponse struct {
Markets map[string]PerpMarket `json:"markets"`
}

// GetMarkets 获取所有永续市场信息
func (d *DYDXClient) GetMarkets(ctx context.Context) (map[string]PerpMarket, error) {
var resp MarketsResponse
if err := d.doGet(ctx, "/perpetualMarkets", &resp); err != nil {
return nil, fmt.Errorf("get markets: %w", err)
}
return resp.Markets, nil
}

5.4 读取 Order Book

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// OrderBookLevel 表示一个价格档位
type OrderBookLevel struct {
Price string `json:"price"`
Size string `json:"size"`
}

// OrderBook 表示订单簿
type OrderBook struct {
Bids []OrderBookLevel `json:"bids"`
Asks []OrderBookLevel `json:"asks"`
}

// GetOrderBook 获取指定 market 的订单簿深度
func (d *DYDXClient) GetOrderBook(ctx context.Context, ticker string) (*OrderBook, error) {
var resp OrderBook
path := fmt.Sprintf("/orderbooks/perpetualMarket/%s", ticker)
if err := d.doGet(ctx, path, &resp); err != nil {
return nil, fmt.Errorf("get orderbook: %w", err)
}
return &resp, nil
}

5.5 读取 Funding Rate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// FundingPayment 表示一次 funding 结算
type FundingPayment struct {
Ticker string `json:"ticker"`
Rate string `json:"rate"`
Price string `json:"price"`
EffectiveAt string `json:"effectiveAt"`
}

type FundingResponse struct {
HistoricalFunding []FundingPayment `json:"historicalFunding"`
}

// GetFundingRates 获取历史 funding rate
func (d *DYDXClient) GetFundingRates(ctx context.Context, ticker string, limit int) ([]FundingPayment, error) {
var resp FundingResponse
path := fmt.Sprintf("/historicalFunding/%s?limit=%d", ticker, limit)
if err := d.doGet(ctx, path, &resp); err != nil {
return nil, fmt.Errorf("get funding: %w", err)
}
return resp.HistoricalFunding, nil
}

// GetCurrentFundingRate 获取当前 funding rate (从 markets 接口)
func (d *DYDXClient) GetCurrentFundingRate(ctx context.Context, ticker string) (string, error) {
markets, err := d.GetMarkets(ctx)
if err != nil {
return "", err
}
market, ok := markets[ticker]
if !ok {
return "", fmt.Errorf("market %s not found", ticker)
}
return market.NextFundingRate, nil
}

5.6 读取用户持仓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Subaccount 表示 dYdX 子账户
type Subaccount struct {
Address string `json:"address"`
SubaccountNumber int `json:"subaccountNumber"`
Equity string `json:"equity"`
FreeCollateral string `json:"freeCollateral"`
OpenPositions map[string]SubaccountPosition `json:"openPerpetualPositions"`
}

// SubaccountPosition 表示子账户持仓
type SubaccountPosition struct {
Market string `json:"market"`
Side string `json:"side"` // "LONG" or "SHORT"
Size string `json:"size"`
EntryPrice string `json:"entryPrice"`
UnrealizedPnl string `json:"unrealizedPnl"`
}

type SubaccountResponse struct {
Subaccount Subaccount `json:"subaccount"`
}

// GetSubaccount 获取子账户信息 (含持仓)
func (d *DYDXClient) GetSubaccount(ctx context.Context, address string, subaccountNum int) (*Subaccount, error) {
var resp SubaccountResponse
path := fmt.Sprintf("/addresses/%s/subaccountNumber/%d", address, subaccountNum)
if err := d.doGet(ctx, path, &resp); err != nil {
return nil, fmt.Errorf("get subaccount: %w", err)
}
return &resp.Subaccount, nil
}

5.7 WebSocket 实时订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import (
"encoding/json"
"fmt"
"log"
"sync"

"github.com/gorilla/websocket"
)

// WSClient 封装 dYdX WebSocket 连接
type WSClient struct {
conn *websocket.Conn
mu sync.Mutex
}

// WSMessage 表示 WebSocket 消息
type WSMessage struct {
Type string `json:"type"`
Channel string `json:"channel"`
ID string `json:"id"`
Contents json.RawMessage `json:"contents"`
Connection string `json:"connection_id,omitempty"`
}

// NewWSClient 建立 WebSocket 连接
func NewWSClient(url string) (*WSClient, error) {
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return nil, fmt.Errorf("ws dial: %w", err)
}
return &WSClient{conn: conn}, nil
}

// Subscribe 订阅频道
func (ws *WSClient) Subscribe(channel, id string) error {
ws.mu.Lock()
defer ws.mu.Unlock()

msg := map[string]string{
"type": "subscribe",
"channel": channel,
"id": id,
}
return ws.conn.WriteJSON(msg)
}

// Listen 持续监听消息
func (ws *WSClient) Listen(ctx context.Context, handler func(WSMessage)) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

var msg WSMessage
if err := ws.conn.ReadJSON(&msg); err != nil {
return fmt.Errorf("ws read: %w", err)
}
handler(msg)
}
}

// Close 关闭连接
func (ws *WSClient) Close() error {
return ws.conn.Close()
}

// 使用示例
func ExampleDYDXWebSocket() {
ws, err := NewWSClient(WSBaseURL)
if err != nil {
log.Fatal(err)
}
defer func() { _ = ws.Close() }()

// 订阅 BTC-USD 的 order book 更新
_ = ws.Subscribe("v4_orderbook", "BTC-USD")

// 订阅 trades
_ = ws.Subscribe("v4_trades", "ETH-USD")

// 订阅子账户更新 (仓位变化, 订单状态等)
_ = ws.Subscribe("v4_subaccounts", "dydx1abc.../0")

ctx := context.Background()
_ = ws.Listen(ctx, func(msg WSMessage) {
fmt.Printf("[%s/%s] %s\n", msg.Channel, msg.ID, msg.Contents)
})
}

六、Hyperliquid 数据读取 (Go)

Hyperliquid 是自研 L1, 所有数据通过 REST API 和 WebSocket 获取. API 设计与 dYdX 类似但更简洁.

6.1 API 基础

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package hyperliquid

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
MainnetAPIURL = "https://api.hyperliquid.xyz"
MainnetWSURL = "wss://api.hyperliquid.xyz/ws"
)

// HLClient 封装 Hyperliquid API
type HLClient struct {
httpClient *http.Client
baseURL string
}

func NewHLClient(baseURL string) *HLClient {
return &HLClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: baseURL,
}
}

// Hyperliquid 用 POST + JSON body 风格的 "info" API
// 所有查询发送到 /info endpoint, 用 type 字段区分
func (h *HLClient) postInfo(ctx context.Context, reqBody interface{}, result interface{}) error {
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.baseURL+"/info", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := h.httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
}

return json.NewDecoder(resp.Body).Decode(result)
}

6.2 读取 Meta (所有 Markets)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// AssetMeta 表示一个交易对的元数据
type AssetMeta struct {
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
}

// Universe 包含所有交易对的元数据
type Universe struct {
Universe []AssetMeta `json:"universe"`
}

// MetaResponse 表示 meta 查询的返回
type MetaResponse struct {
Universe []AssetMeta `json:"universe"`
}

// GetMeta 获取所有市场元数据
func (h *HLClient) GetMeta(ctx context.Context) ([]AssetMeta, error) {
var resp MetaResponse
req := map[string]string{"type": "meta"}
if err := h.postInfo(ctx, req, &resp); err != nil {
return nil, fmt.Errorf("get meta: %w", err)
}
return resp.Universe, nil
}

// MetaAndAssetCtx 包含市场元数据 + 实时数据
type AssetContext struct {
Funding string `json:"funding"`
OpenInterest string `json:"openInterest"`
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
MarkPx string `json:"markPx"`
OraclePx string `json:"oraclePx"`
PrevDayPx string `json:"prevDayPx"`
}

type MetaAndAssetCtxResponse struct {
Meta []AssetMeta `json:"-"` // index 0
Ctx []AssetContext `json:"-"` // index 1
}

// GetMetaAndAssetCtx 获取元数据 + 实时市场数据
func (h *HLClient) GetMetaAndAssetCtx(ctx context.Context) ([]AssetMeta, []AssetContext, error) {
req := map[string]string{"type": "metaAndAssetCtxs"}

// Hyperliquid 返回的是一个 array: [meta, [assetCtx1, assetCtx2, ...]]
var raw []json.RawMessage
if err := h.postInfo(ctx, req, &raw); err != nil {
return nil, nil, fmt.Errorf("get metaAndAssetCtx: %w", err)
}

if len(raw) < 2 {
return nil, nil, fmt.Errorf("unexpected response length: %d", len(raw))
}

var meta MetaResponse
if err := json.Unmarshal(raw[0], &meta); err != nil {
return nil, nil, fmt.Errorf("unmarshal meta: %w", err)
}

var assetCtxs []AssetContext
if err := json.Unmarshal(raw[1], &assetCtxs); err != nil {
return nil, nil, fmt.Errorf("unmarshal assetCtxs: %w", err)
}

return meta.Universe, assetCtxs, nil
}

6.3 读取 Order Book (L2 Data)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// L2Book 表示 Level 2 订单簿
type L2Book struct {
Levels [][]L2Level `json:"levels"` // [bids, asks]
}

// L2Level 表示一个档位
type L2Level struct {
Px string `json:"px"` // price
Sz string `json:"sz"` // size
N int `json:"n"` // number of orders
}

// GetL2Book 获取指定 coin 的 L2 订单簿
func (h *HLClient) GetL2Book(ctx context.Context, coin string) (*L2Book, error) {
var resp L2Book
req := map[string]interface{}{
"type": "l2Book",
"coin": coin,
}
if err := h.postInfo(ctx, req, &resp); err != nil {
return nil, fmt.Errorf("get l2book: %w", err)
}
return &resp, nil
}

6.4 读取 Funding Rate History

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// FundingHistoryEntry 表示一条 funding 记录
type FundingHistoryEntry struct {
Coin string `json:"coin"`
FundingRate string `json:"fundingRate"`
Premium string `json:"premium"`
Time int64 `json:"time"` // unix ms
}

// GetFundingHistory 获取 funding rate 历史
func (h *HLClient) GetFundingHistory(ctx context.Context, coin string, startTime, endTime int64) ([]FundingHistoryEntry, error) {
var resp []FundingHistoryEntry
req := map[string]interface{}{
"type": "fundingHistory",
"coin": coin,
"startTime": startTime,
"endTime": endTime,
}
if err := h.postInfo(ctx, req, &resp); err != nil {
return nil, fmt.Errorf("get funding history: %w", err)
}
return resp, nil
}

6.5 读取用户持仓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// UserPosition 表示用户的一个持仓
type UserPosition struct {
Coin string `json:"coin"`
EntryPx string `json:"entryPx"`
Leverage PositionLeverage `json:"leverage"`
LiquidationPx string `json:"liquidationPx"`
MarginUsed string `json:"marginUsed"`
PositionValue string `json:"positionValue"`
ReturnOnEquity string `json:"returnOnEquity"`
Szi string `json:"szi"` // signed size: positive = long, negative = short
UnrealizedPnl string `json:"unrealizedPnl"`
}

type PositionLeverage struct {
Type string `json:"type"` // "cross" or "isolated"
Value int `json:"value"` // leverage multiplier
}

// ClearinghouseState 表示用户的全部状态
type ClearinghouseState struct {
AssetPositions []struct {
Position UserPosition `json:"position"`
} `json:"assetPositions"`
MarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
TotalNtlPos string `json:"totalNtlPos"`
} `json:"marginSummary"`
}

// GetUserState 获取用户完整的持仓和保证金状态
func (h *HLClient) GetUserState(ctx context.Context, address string) (*ClearinghouseState, error) {
var resp ClearinghouseState
req := map[string]string{
"type": "clearinghouseState",
"user": address,
}
if err := h.postInfo(ctx, req, &resp); err != nil {
return nil, fmt.Errorf("get user state: %w", err)
}
return &resp, nil
}

6.6 WebSocket 订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import (
"github.com/gorilla/websocket"
)

// HLWSClient 封装 Hyperliquid WebSocket
type HLWSClient struct {
conn *websocket.Conn
}

// HLWSMessage 表示 WebSocket 消息
type HLWSMessage struct {
Channel string `json:"channel"`
Data json.RawMessage `json:"data"`
}

func NewHLWSClient(url string) (*HLWSClient, error) {
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return nil, fmt.Errorf("ws dial: %w", err)
}
return &HLWSClient{conn: conn}, nil
}

// SubscribeL2Book 订阅 L2 订单簿更新
func (ws *HLWSClient) SubscribeL2Book(coin string) error {
msg := map[string]interface{}{
"method": "subscribe",
"subscription": map[string]string{
"type": "l2Book",
"coin": coin,
},
}
return ws.conn.WriteJSON(msg)
}

// SubscribeTrades 订阅成交流
func (ws *HLWSClient) SubscribeTrades(coin string) error {
msg := map[string]interface{}{
"method": "subscribe",
"subscription": map[string]string{
"type": "trades",
"coin": coin,
},
}
return ws.conn.WriteJSON(msg)
}

// SubscribeUserEvents 订阅用户事件 (fills, liquidations, funding)
func (ws *HLWSClient) SubscribeUserEvents(address string) error {
msg := map[string]interface{}{
"method": "subscribe",
"subscription": map[string]string{
"type": "userEvents",
"user": address,
},
}
return ws.conn.WriteJSON(msg)
}

// Listen 持续接收消息
func (ws *HLWSClient) Listen(ctx context.Context, handler func(HLWSMessage)) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

var msg HLWSMessage
if err := ws.conn.ReadJSON(&msg); err != nil {
return fmt.Errorf("ws read: %w", err)
}
handler(msg)
}
}

func (ws *HLWSClient) Close() error {
return ws.conn.Close()
}

七、通用数据结构 (Go)

三个协议的数据结构各不相同. 要做跨协议分析, 需要统一抽象.

7.1 统一接口设计

跨协议统一抽象层 PerpDataProvider interface GetMarkets() → []Market GetFundingRate() → FundingRate GetPositions() → []Position GetOI() → OIData Protocol() → string GMXProvider ethclient + ABI on-chain read (eth_call) DYDXProvider REST indexer API off-chain query HLProvider REST /info API POST JSON body 统一数据模型 Position Market FundingRate 所有协议返回的数据统一转换为这些结构, 上层业务逻辑不关心来源

7.2 数据模型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package perp

import (
"context"
"math/big"
"time"
)

// --- 统一数据模型 ---

// Position 表示一个标准化的永续合约仓位 (normalized perpetual position)
type Position struct {
Protocol string // "gmx", "dydx", "hyperliquid"
Market string // 交易对, "ETH-USD", "BTC-USD"
Side Side // Long (多) or Short (空)
Size *big.Float // position size (头寸规模) in USD
Collateral *big.Float // collateral (抵押品) in USD
EntryPrice *big.Float // 开仓价
MarkPrice *big.Float // 标记价格
LiqPrice *big.Float // liquidation price (清算价)
Leverage float64 // 杠杆倍数
UnrealizedPnl *big.Float // 未实现盈亏
UpdatedAt time.Time
}

// Side 表示仓位方向
type Side int

const (
Long Side = 1
Short Side = -1
)

func (s Side) String() string {
if s == Long {
return "LONG"
}
return "SHORT"
}

// Market 表示一个标准化的永续市场 (normalized perpetual market)
type Market struct {
Protocol string
Symbol string // 交易对, "ETH-USD"
IndexPrice *big.Float // 指数价格 (来自预言机)
MarkPrice *big.Float // 标记价格
FundingRate *big.Float // current hourly rate (当前每小时资金费率)
LongOI *big.Float // 多头 OI (USD)
ShortOI *big.Float // 空头 OI (USD)
TotalOI *big.Float // 总 OI (USD), LongOI + ShortOI
MaxLeverage int // 最大杠杆
InitialMarginFr float64 // 初始保证金率 (initial margin fraction)
MaintMarginFr float64 // 维持保证金率 (maintenance margin fraction)
Volume24h *big.Float // 24小时交易量
}

// FundingRate 表示一条标准化的资金费率记录
type FundingRate struct {
Protocol string
Symbol string
Rate *big.Float // hourly rate (每小时费率)
Annualized *big.Float // annualized rate (年化费率) = rate * 8760 (365 * 24)
Timestamp time.Time
}

// OISnapshot 表示标准化的 Open Interest (未平仓量) 快照
type OISnapshot struct {
Protocol string
Symbol string
LongOI *big.Float // 多头未平仓量
ShortOI *big.Float // 空头未平仓量
TotalOI *big.Float // 总未平仓量
LSRatio float64 // Long / Short ratio (多空比)
Timestamp time.Time
}

// --- 统一接口 ---

// PerpDataProvider 定义跨协议的数据获取接口
type PerpDataProvider interface {
// Protocol 返回协议名称
Protocol() string

// GetMarkets 获取所有市场信息
GetMarkets(ctx context.Context) ([]Market, error)

// GetFundingRate 获取指定市场的当前 funding rate
GetFundingRate(ctx context.Context, symbol string) (*FundingRate, error)

// GetFundingHistory 获取历史 funding rate
GetFundingHistory(ctx context.Context, symbol string, limit int) ([]FundingRate, error)

// GetPositions 获取指定账户的所有持仓
GetPositions(ctx context.Context, account string) ([]Position, error)

// GetOI 获取指定市场的 OI
GetOI(ctx context.Context, symbol string) (*OISnapshot, error)
}

7.3 适配器实现 (以 Hyperliquid 为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// HLProvider 实现 PerpDataProvider 接口
type HLProvider struct {
client *HLClient
}

func NewHLProvider(baseURL string) *HLProvider {
return &HLProvider{client: NewHLClient(baseURL)}
}

func (p *HLProvider) Protocol() string { return "hyperliquid" }

func (p *HLProvider) GetMarkets(ctx context.Context) ([]Market, error) {
metas, ctxs, err := p.client.GetMetaAndAssetCtx(ctx)
if err != nil {
return nil, err
}

markets := make([]Market, 0, len(metas))
for i, meta := range metas {
if i >= len(ctxs) {
break
}
c := ctxs[i]

markPx, _ := new(big.Float).SetString(c.MarkPx)
oraclePx, _ := new(big.Float).SetString(c.OraclePx)
fundingRate, _ := new(big.Float).SetString(c.Funding)
oi, _ := new(big.Float).SetString(c.OpenInterest)
vol, _ := new(big.Float).SetString(c.DayNtlVlm)

markets = append(markets, Market{
Protocol: "hyperliquid",
Symbol: meta.Name + "-USD",
MarkPrice: markPx,
IndexPrice: oraclePx,
FundingRate: fundingRate,
TotalOI: oi,
MaxLeverage: meta.MaxLeverage,
Volume24h: vol,
})
}

return markets, nil
}

func (p *HLProvider) GetFundingRate(ctx context.Context, symbol string) (*FundingRate, error) {
markets, err := p.GetMarkets(ctx)
if err != nil {
return nil, err
}

for _, m := range markets {
if m.Symbol == symbol && m.FundingRate != nil {
annualized := new(big.Float).Mul(m.FundingRate, big.NewFloat(8760))
return &FundingRate{
Protocol: "hyperliquid",
Symbol: symbol,
Rate: m.FundingRate,
Annualized: annualized,
Timestamp: time.Now(),
}, nil
}
}

return nil, fmt.Errorf("market %s not found", symbol)
}

func (p *HLProvider) GetPositions(ctx context.Context, account string) ([]Position, error) {
state, err := p.client.GetUserState(ctx, account)
if err != nil {
return nil, err
}

var positions []Position
for _, ap := range state.AssetPositions {
pos := ap.Position
szi, _ := new(big.Float).SetString(pos.Szi)
entryPx, _ := new(big.Float).SetString(pos.EntryPx)
liqPx, _ := new(big.Float).SetString(pos.LiquidationPx)
marginUsed, _ := new(big.Float).SetString(pos.MarginUsed)
posValue, _ := new(big.Float).SetString(pos.PositionValue)
unrealizedPnl, _ := new(big.Float).SetString(pos.UnrealizedPnl)

side := Long
if szi.Sign() < 0 {
side = Short
szi.Neg(szi) // absolute value for size
}

positions = append(positions, Position{
Protocol: "hyperliquid",
Market: pos.Coin + "-USD",
Side: side,
Size: posValue,
Collateral: marginUsed,
EntryPrice: entryPx,
LiqPrice: liqPx,
Leverage: float64(pos.Leverage.Value),
UnrealizedPnl: unrealizedPnl,
UpdatedAt: time.Now(),
})
}

return positions, nil
}

// GetOI 和 GetFundingHistory 的实现类似, 省略

八、实战: Funding Rate 监控

Funding Rate 套利监控流程 1. 定时拉取 每 5 min 查询 3 个协议的 FR 2. 年化计算 hourly × 8760 = annualized % 3. 套利检测 同一 market 跨协议 FR 差值 > 阈值? 4. 告警 输出报告 发送通知 示例输出: ┌─────────┬───────────┬──────────┬──────────────┬────────────┐ │ Market │ GMX v2 │ dYdX v4 │ Hyperliquid │ Max Spread │ ├─────────┼───────────┼──────────┼──────────────┼────────────┤ │ ETH-USD │ +18.2% APR│ +5.1% APR│ -2.3% APR │ 20.5% !! │ │ BTC-USD │ +8.7% APR│ +7.2% APR│ +6.8% APR │ 1.9% │

8.1 完整实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package main

import (
"context"
"fmt"
"math/big"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
)

// FundingMonitor 跨协议 funding rate 监控
type FundingMonitor struct {
providers []PerpDataProvider
symbols []string
threshold float64 // 年化 spread 阈值 (如 0.1 = 10%)
}

func NewFundingMonitor(providers []PerpDataProvider, symbols []string, threshold float64) *FundingMonitor {
return &FundingMonitor{
providers: providers,
symbols: symbols,
threshold: threshold,
}
}

// FundingSnapshot 单次快照 (funding rate snapshot)
type FundingSnapshot struct {
Symbol string
Rates map[string]*FundingRate // protocol → rate (各协议费率)
MaxSpread float64 // 最大年化 spread (费率价差)
BestLong string // 做多最划算的协议 (FR 最负)
BestShort string // 做空最划算的协议 (FR 最正)
}

// Scan 执行一次全量扫描
func (m *FundingMonitor) Scan(ctx context.Context) ([]FundingSnapshot, error) {
var snapshots []FundingSnapshot

for _, symbol := range m.symbols {
snap := FundingSnapshot{
Symbol: symbol,
Rates: make(map[string]*FundingRate),
}

// 并发查询各协议
type result struct {
protocol string
rate *FundingRate
err error
}

ch := make(chan result, len(m.providers))
for _, p := range m.providers {
go func(provider PerpDataProvider) {
rate, err := provider.GetFundingRate(ctx, symbol)
ch <- result{provider.Protocol(), rate, err}
}(p)
}

for range m.providers {
r := <-ch
if r.err != nil {
fmt.Fprintf(os.Stderr, "warning: %s/%s: %v\n", r.protocol, symbol, r.err)
continue
}
snap.Rates[r.protocol] = r.rate
}

// 计算 max spread
if len(snap.Rates) >= 2 {
var rates []struct {
protocol string
annual float64
}
for proto, fr := range snap.Rates {
ann, _ := fr.Annualized.Float64()
rates = append(rates, struct {
protocol string
annual float64
}{proto, ann})
}
sort.Slice(rates, func(i, j int) bool { return rates[i].annual < rates[j].annual })

snap.MaxSpread = rates[len(rates)-1].annual - rates[0].annual
snap.BestLong = rates[0].protocol // 最负的 FR → long 便宜
snap.BestShort = rates[len(rates)-1].protocol // 最正的 FR → short 赚 FR
}

snapshots = append(snapshots, snap)
}

return snapshots, nil
}

// PrintReport 输出格式化报告
func (m *FundingMonitor) PrintReport(snapshots []FundingSnapshot) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer func() { _ = w.Flush() }()

// Header
protocols := make([]string, 0, len(m.providers))
for _, p := range m.providers {
protocols = append(protocols, p.Protocol())
}
fmt.Fprintf(w, "Market\t%s\tMax Spread\tArb?\n", strings.Join(protocols, "\t"))
fmt.Fprintf(w, "------\t%s\t----------\t----\n",
strings.Join(make([]string, len(protocols)), "--------\t"))

for _, snap := range snapshots {
var rateCols []string
for _, proto := range protocols {
if fr, ok := snap.Rates[proto]; ok {
ann, _ := fr.Annualized.Float64()
rateCols = append(rateCols, fmt.Sprintf("%+.1f%% APR", ann*100))
} else {
rateCols = append(rateCols, "N/A")
}
}

arb := ""
if snap.MaxSpread > m.threshold {
arb = fmt.Sprintf("YES: long@%s short@%s", snap.BestLong, snap.BestShort)
}

fmt.Fprintf(w, "%s\t%s\t%.1f%%\t%s\n",
snap.Symbol,
strings.Join(rateCols, "\t"),
snap.MaxSpread*100,
arb,
)
}
}

// Run 持续运行, 每 interval 扫描一次
func (m *FundingMonitor) Run(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
fmt.Printf("\n=== Funding Rate Scan @ %s ===\n", time.Now().Format("15:04:05"))
snapshots, err := m.Scan(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "scan error: %v\n", err)
} else {
m.PrintReport(snapshots)
}

select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

// main 入口
func main() {
providers := []PerpDataProvider{
// NewGMXProvider("https://arb1.arbitrum.io/rpc"),
// NewDYDXProvider(IndexerBaseURL),
NewHLProvider(MainnetAPIURL),
}

symbols := []string{"ETH-USD", "BTC-USD", "SOL-USD", "ARB-USD", "DOGE-USD"}
monitor := NewFundingMonitor(providers, symbols, 0.10) // 10% 阈值

ctx := context.Background()
if err := monitor.Run(ctx, 5*time.Minute); err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
os.Exit(1)
}
}

九、实战: 清算风险监控

9.1 清算距离计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package main

import (
"context"
"fmt"
"math"
"math/big"
"os"
"text/tabwriter"
"time"
)

// LiquidationAlert 清算风险告警信息 (liquidation risk alert)
type LiquidationAlert struct {
Position Position
MarginRatio float64 // 当前保证金率 (current margin ratio)
MaintMarginRatio float64 // 维持保证金率 (maintenance margin ratio)
DistanceToLiq float64 // 距清算价的百分比距离 (distance to liquidation %)
RiskLevel string // "SAFE", "WARNING", "DANGER", "CRITICAL"
}

// LiquidationMonitor 清算风险监控器
type LiquidationMonitor struct {
providers []PerpDataProvider
accounts map[string][]string // protocol → []account addresses
}

func NewLiquidationMonitor(
providers []PerpDataProvider,
accounts map[string][]string,
) *LiquidationMonitor {
return &LiquidationMonitor{
providers: providers,
accounts: accounts,
}
}

// CalculateMarginRatio 计算保证金率
// marginRatio (保证金率) = (collateral (抵押品) + unrealizedPnl (未实现盈亏)) / positionSize (头寸规模)
func CalculateMarginRatio(pos Position) float64 {
collateral, _ := pos.Collateral.Float64()
pnl, _ := pos.UnrealizedPnl.Float64()
size, _ := pos.Size.Float64()

if size == 0 {
return math.Inf(1)
}

return (collateral + pnl) / size
}

// CalculateDistanceToLiq 计算当前价格到清算价的距离 (distance to liquidation, 百分比)
func CalculateDistanceToLiq(pos Position) float64 {
if pos.MarkPrice == nil || pos.LiqPrice == nil {
return math.Inf(1) // 无法计算
}

markPx, _ := pos.MarkPrice.Float64()
liqPx, _ := pos.LiqPrice.Float64()

if markPx == 0 {
return math.Inf(1)
}

// distance (清算距离) = |markPrice (标记价格) - liqPrice (清算价)| / markPrice
return math.Abs(markPx-liqPx) / markPx
}

// ClassifyRisk 根据距清算距离分级
func ClassifyRisk(distanceToLiq float64) string {
switch {
case distanceToLiq > 0.30:
return "SAFE" // > 30%
case distanceToLiq > 0.15:
return "WARNING" // 15-30%
case distanceToLiq > 0.05:
return "DANGER" // 5-15%
default:
return "CRITICAL" // < 5%
}
}

// CheckAllPositions 检查所有账户的持仓风险
func (m *LiquidationMonitor) CheckAllPositions(ctx context.Context) ([]LiquidationAlert, error) {
var alerts []LiquidationAlert

for _, provider := range m.providers {
proto := provider.Protocol()
addrs, ok := m.accounts[proto]
if !ok {
continue
}

for _, addr := range addrs {
positions, err := provider.GetPositions(ctx, addr)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s/%s: %v\n", proto, addr, err)
continue
}

for _, pos := range positions {
marginRatio := CalculateMarginRatio(pos)
distToLiq := CalculateDistanceToLiq(pos)
risk := ClassifyRisk(distToLiq)

alerts = append(alerts, LiquidationAlert{
Position: pos,
MarginRatio: marginRatio,
DistanceToLiq: distToLiq,
RiskLevel: risk,
})
}
}
}

return alerts, nil
}

// PrintAlerts 输出风险报告
func (m *LiquidationMonitor) PrintAlerts(alerts []LiquidationAlert) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer func() { _ = w.Flush() }()

fmt.Fprintf(w, "Protocol\tMarket\tSide\tSize\tLeverage\tMark Px\tLiq Px\tDist%%\tRisk\n")
fmt.Fprintf(w, "--------\t------\t----\t----\t--------\t-------\t------\t-----\t----\n")

for _, alert := range alerts {
pos := alert.Position
size, _ := pos.Size.Float64()
markPx, _ := pos.MarkPrice.Float64()
liqPx, _ := pos.LiqPrice.Float64()

riskColor := ""
switch alert.RiskLevel {
case "CRITICAL":
riskColor = "!!!"
case "DANGER":
riskColor = "!!"
case "WARNING":
riskColor = "!"
}

fmt.Fprintf(w, "%s\t%s\t%s\t$%.0f\t%.0fx\t%.2f\t%.2f\t%.1f%%\t%s %s\n",
pos.Protocol,
pos.Market,
pos.Side,
size,
pos.Leverage,
markPx,
liqPx,
alert.DistanceToLiq*100,
alert.RiskLevel,
riskColor,
)
}
}

// Run 持续监控
func (m *LiquidationMonitor) Run(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
fmt.Printf("\n=== Liquidation Risk Check @ %s ===\n", time.Now().Format("15:04:05"))
alerts, err := m.CheckAllPositions(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "check error: %v\n", err)
} else {
m.PrintAlerts(alerts)

// 对高风险仓位额外提醒
for _, a := range alerts {
if a.RiskLevel == "CRITICAL" || a.RiskLevel == "DANGER" {
fmt.Printf("\n*** ALERT: %s %s %s - %.1f%% to liquidation ***\n",
a.Position.Protocol,
a.Position.Market,
a.Position.Side,
a.DistanceToLiq*100,
)
}
}
}

select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

十、实战: OI 分析

10.1 OI 快照与趋势分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
package main

import (
"context"
"fmt"
"math/big"
"os"
"sync"
"text/tabwriter"
"time"
)

// OIAnalyzer 跨协议 OI 分析器
type OIAnalyzer struct {
providers []PerpDataProvider
symbols []string
history map[string][]OISnapshot // symbol → 历史快照 (环形缓冲)
maxHist int
mu sync.RWMutex
}

func NewOIAnalyzer(providers []PerpDataProvider, symbols []string, maxHistory int) *OIAnalyzer {
return &OIAnalyzer{
providers: providers,
symbols: symbols,
history: make(map[string][]OISnapshot),
maxHist: maxHistory,
}
}

// AggregatedOI 聚合各协议的 OI (aggregated open interest)
type AggregatedOI struct {
Symbol string
ByProtocol map[string]*OISnapshot
TotalLong *big.Float // 总多头 OI
TotalShort *big.Float // 总空头 OI
TotalOI *big.Float // 总 OI
LSRatio float64 // 多空比 (Long/Short ratio)
DeltaFromPrev float64 // 与上次快照的 OI 变化百分比 (delta %)
}

// Snapshot 执行一次 OI 快照
func (a *OIAnalyzer) Snapshot(ctx context.Context) ([]AggregatedOI, error) {
var results []AggregatedOI

for _, symbol := range a.symbols {
agg := AggregatedOI{
Symbol: symbol,
ByProtocol: make(map[string]*OISnapshot),
TotalLong: new(big.Float),
TotalShort: new(big.Float),
TotalOI: new(big.Float),
}

// 并发查询
type result struct {
protocol string
oi *OISnapshot
err error
}

ch := make(chan result, len(a.providers))
for _, p := range a.providers {
go func(provider PerpDataProvider) {
oi, err := provider.GetOI(ctx, symbol)
ch <- result{provider.Protocol(), oi, err}
}(p)
}

for range a.providers {
r := <-ch
if r.err != nil {
continue
}
agg.ByProtocol[r.protocol] = r.oi

if r.oi.LongOI != nil {
agg.TotalLong.Add(agg.TotalLong, r.oi.LongOI)
}
if r.oi.ShortOI != nil {
agg.TotalShort.Add(agg.TotalShort, r.oi.ShortOI)
}
}

agg.TotalOI.Add(agg.TotalLong, agg.TotalShort)

// L/S Ratio
longF, _ := agg.TotalLong.Float64()
shortF, _ := agg.TotalShort.Float64()
if shortF > 0 {
agg.LSRatio = longF / shortF
}

// 与上次快照比较
a.mu.RLock()
if hist, ok := a.history[symbol]; ok && len(hist) > 0 {
prev := hist[len(hist)-1]
prevTotal, _ := prev.TotalOI.Float64()
currTotal, _ := agg.TotalOI.Float64()
if prevTotal > 0 {
agg.DeltaFromPrev = (currTotal - prevTotal) / prevTotal
}
}
a.mu.RUnlock()

// 存储快照
a.mu.Lock()
snapshot := OISnapshot{
Symbol: symbol,
LongOI: new(big.Float).Copy(agg.TotalLong),
ShortOI: new(big.Float).Copy(agg.TotalShort),
TotalOI: new(big.Float).Copy(agg.TotalOI),
LSRatio: agg.LSRatio,
Timestamp: time.Now(),
}
a.history[symbol] = append(a.history[symbol], snapshot)
if len(a.history[symbol]) > a.maxHist {
a.history[symbol] = a.history[symbol][1:]
}
a.mu.Unlock()

results = append(results, agg)
}

return results, nil
}

// PrintOIReport 输出 OI 分析报告
func (a *OIAnalyzer) PrintOIReport(results []AggregatedOI) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer func() { _ = w.Flush() }()

fmt.Fprintf(w, "Market\tTotal OI\tLong OI\tShort OI\tL/S Ratio\tDelta\n")
fmt.Fprintf(w, "------\t--------\t-------\t--------\t---------\t-----\n")

for _, agg := range results {
totalOI, _ := agg.TotalOI.Float64()
longOI, _ := agg.TotalLong.Float64()
shortOI, _ := agg.TotalShort.Float64()

deltaStr := "N/A"
if agg.DeltaFromPrev != 0 {
deltaStr = fmt.Sprintf("%+.2f%%", agg.DeltaFromPrev*100)
}

fmt.Fprintf(w, "%s\t$%.1fM\t$%.1fM\t$%.1fM\t%.2f\t%s\n",
agg.Symbol,
totalOI/1e6,
longOI/1e6,
shortOI/1e6,
agg.LSRatio,
deltaStr,
)
}

// 各协议 OI 明细
fmt.Fprintf(w, "\n--- 各协议 OI 明细 ---\n")
fmt.Fprintf(w, "Market\tProtocol\tLong OI\tShort OI\tL/S\n")
fmt.Fprintf(w, "------\t--------\t-------\t--------\t---\n")

for _, agg := range results {
for proto, oi := range agg.ByProtocol {
longOI, _ := oi.LongOI.Float64()
shortOI, _ := oi.ShortOI.Float64()
fmt.Fprintf(w, "%s\t%s\t$%.1fM\t$%.1fM\t%.2f\n",
agg.Symbol,
proto,
longOI/1e6,
shortOI/1e6,
oi.LSRatio,
)
}
}
}

十一、数据源对比表

11.1 API Endpoint 对比

维度 GMX v2 dYdX v4 Hyperliquid
链类型 Arbitrum (EVM) Cosmos appchain 自研 L1
主要接口 eth_call (RPC) REST indexer REST POST /info
实时推送 eth_subscribe (logs) WebSocket WebSocket
Funding Rate DataStore 读取 + 计算 /historicalFunding/{ticker} {"type":"fundingHistory"}
OI 数据 DataStore 读取 /perpetualMarkets 字段 {"type":"metaAndAssetCtxs"}
用户持仓 Reader.getPosition() /addresses/{addr}/subaccountNumber/{n} {"type":"clearinghouseState"}
订单簿 N/A (Oracle 定价, 无订单簿) /orderbooks/perpetualMarket/{ticker} {"type":"l2Book"}
批量查询 Multicall3 合约 多次 REST 请求 单次请求可查全部 market

11.2 数据格式对比

维度 GMX v2 dYdX v4 Hyperliquid
价格精度 30 decimals (big.Int) string (float) string (float)
OI 单位 USD, 30 decimals string (float, USD) string (float, coins)
Funding Rate 格式 每秒累积值 (需差值) 每小时费率 (直接用) 每小时费率 (直接用)
Position 方向 bool isLong string “LONG”/“SHORT” signed size (正=long)
认证 无 (public RPC) 无 (公开数据) 无 (公开数据)
Rate Limit 取决于 RPC provider ~100 req/10s ~120 req/min

11.3 更新频率对比

数据 GMX v2 dYdX v4 Hyperliquid
Funding 结算 每秒累积 每小时 每小时
OI 更新 每笔交易 (区块级) 准实时 (indexer) 准实时
价格更新 Chainlink heartbeat (~1min) 每秒 (撮合) 每笔成交
事件延迟 ~1 区块 (0.25s on Arb) ~1s (indexer) ~1s

十二、小结: 全系列回顾

永续合约系列: 知识图谱 P01 永续机制 funding, mark/index, 多空平衡 P02 保证金与清算 IM/MM, 清算引擎, ADL, 保险基金 P03 GMX (Oracle 型) GLP, Oracle 定价, 零滑点 P04 dYdX (订单簿) Cosmos appchain, 链下撮合 P05 vAMM 演进 Perp Protocol, Drift P06 Hyperliquid 自研 L1, 链上订单簿, HLP P07 永续 MEV 清算 MEV, Oracle 抢跑, 跨市套利 P08 数据解析与实战 ← 你在这里 GMX/dYdX/HL 数据读取, FR 监控, 清算预警, OI 分析 实线箭头 = 前置依赖 | 虚线 = 本文聚合引用各协议的知识

系列核心脉络

阶段 笔记 核心问题 关键概念
机制 永续合约机制详解 永续合约为什么存在? 如何锚定价格? funding rate, mark/index price
风控 保证金管理与清算引擎 如何防止穿仓? IM/MM, 清算引擎, ADL
实现 GMX ~ Hyperliquid 不同协议如何设计永续? Oracle 型, 订单簿, vAMM, L1
攻防 永续合约中的 MEV 永续合约有哪些 MEV? 清算 MEV, Oracle 抢跑
实战 永续合约数据解析实战 如何读取和使用这些数据? ABI, API, 统一抽象, 监控工具

从理论到实战的桥梁

前面的系列文章解释了 “是什么” 和 “为什么”, 本文解决 “怎么做”:

  • 《永续合约机制详解》讲了 funding rate 的机制 → 本文展示如何从 3 个协议读取 funding rate 并计算年化
  • 《保证金管理与清算引擎》讲了清算的触发条件 → 本文展示如何读取持仓并计算清算距离
  • 《GMX 协议深度解析》讲了 GMX 的 DataStore 架构 → 本文展示如何用 Go + ABI 读取链上状态
  • 《dYdX 演进之路》讲了 dYdX 的 Cosmos appchain → 本文展示如何用 REST/WS 查询 indexer
  • 《Hyperliquid 深度解析》讲了 Hyperliquid 的 API 设计 → 本文展示如何用 POST /info 风格的接口
  • 《永续合约中的 MEV》讲了 OI 不平衡带来的 MEV 机会 → 本文展示如何跨协议分析 OI

下一步

有了本文的数据基础, 可以继续构建:

  • 自动交易 bot: 在 funding rate 套利机会出现时自动开仓
  • Dashboard: 用 Grafana + Prometheus 可视化各协议数据
  • Alerting: 接入 Telegram/Discord bot 推送清算预警
  • Backtesting: 用历史 funding rate 数据回测套利策略收益

永续合约 08 - 数据解析实战
https://mritd.com/2025/10/22/perp-data/
作者
Kovacs
发布于
2025年10月22日
许可协议