Skip to content

Commit 6caf66f

Browse files
Merge pull request #7 from franco-bianco/feat/spot-trading
Add spot trading support
2 parents bcf1a25 + 3dad3fd commit 6caf66f

12 files changed

+423
-124
lines changed

hyperliquid/api.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,27 +39,28 @@ func MakeUniversalRequest[T any](api IAPIService, request any) (*T, error) {
3939
if api.Endpoint() == "/exchange" && api.KeyManager() == nil {
4040
return nil, APIError{Message: "API key not set"}
4141
}
42+
4243
response, err := api.Request(api.Endpoint(), request)
4344
if err != nil {
4445
return nil, err
4546
}
47+
4648
var result T
4749
err = json.Unmarshal(response, &result)
50+
if err == nil {
51+
return &result, nil
52+
}
53+
54+
var errResult map[string]interface{}
55+
err = json.Unmarshal(response, &errResult)
4856
if err != nil {
49-
api.debug("Error json.Unmarshal: %s", err)
50-
var errResult map[string]interface{}
51-
err = json.Unmarshal(response, &errResult)
52-
if err != nil {
53-
api.debug("Error second json.Unmarshal: %s", err)
54-
return nil, APIError{Message: "Unexpected response"}
55-
}
56-
// Check if the result is an error
57-
// Return an APIError if it is
58-
if errResult["status"] == "err" {
59-
return nil, APIError{Message: errResult["response"].(string)}
60-
} else {
61-
return nil, APIError{Message: fmt.Sprintf("Unexpected response: %v", errResult)}
62-
}
57+
api.debug("Error second json.Unmarshal: %s", err)
58+
return nil, APIError{Message: "Unexpected response"}
6359
}
64-
return &result, nil
60+
61+
if errResult["status"] == "err" {
62+
return nil, APIError{Message: errResult["response"].(string)}
63+
}
64+
65+
return nil, APIError{Message: fmt.Sprintf("Unexpected response: %v", errResult)}
6566
}

hyperliquid/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ func (client *Client) debug(format string, v ...interface{}) {
8686

8787
// SetPrivateKey sets the private key for the client.
8888
func (client *Client) SetPrivateKey(privateKey string) error {
89+
if strings.HasPrefix(privateKey, "0x") {
90+
privateKey = strings.TrimPrefix(privateKey, "0x") // remove 0x prefix from private key
91+
}
8992
client.privateKey = privateKey
9093
var err error
9194
client.keyManager, err = NewPKeyManager(privateKey)

hyperliquid/consts.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ const GLOBAL_DEBUG = false // Defualt debug that is used in all tests
44

55
// Execution constants
66
const DEFAULT_SLIPPAGE = 0.005 // 0.5% default slippage
7-
var SZ_DECIMALS = 2 // Default decimals for size
7+
const SPOT_MAX_DECIMALS = 8 // Default decimals for spot
8+
const PERP_MAX_DECIMALS = 6 // Default decimals for perp
9+
var SZ_DECIMALS = 2 // Default decimals for usdc
810

911
// Signing constants
1012
const HYPERLIQUID_CHAIN_ID = 1337

hyperliquid/convert.go

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,22 @@ func OrderWiresToOrderAction(orders []OrderWire, grouping Grouping) PlaceOrderAc
4242
}
4343
}
4444

45-
func OrderRequestToWire(req OrderRequest, meta map[string]AssetInfo) OrderWire {
45+
func OrderRequestToWire(req OrderRequest, meta map[string]AssetInfo, isSpot bool) OrderWire {
4646
info := meta[req.Coin]
47+
var assetId, maxDecimals int
48+
if isSpot {
49+
// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids
50+
assetId = info.AssetId + 10000
51+
maxDecimals = SPOT_MAX_DECIMALS
52+
} else {
53+
assetId = info.AssetId
54+
maxDecimals = PERP_MAX_DECIMALS
55+
}
4756
return OrderWire{
48-
Asset: info.AssetId,
57+
Asset: assetId,
4958
IsBuy: req.IsBuy,
50-
LimitPx: FloatToWire(req.LimitPx, nil),
51-
SizePx: FloatToWire(req.Sz, &info.SzDecimals),
59+
LimitPx: FloatToWire(req.LimitPx, maxDecimals, info.SzDecimals),
60+
SizePx: FloatToWire(req.Sz, maxDecimals, info.SzDecimals),
5261
ReduceOnly: req.ReduceOnly,
5362
OrderType: OrderTypeToWire(req.OrderType),
5463
}
@@ -75,28 +84,28 @@ func OrderTypeToWire(orderType OrderType) OrderTypeWire {
7584
return OrderTypeWire{}
7685
}
7786

78-
// Format the float with custom decimal places, default is 6.
79-
// Hyperliquid only allows at most 6 digits.
80-
func FloatToWire(x float64, szDecimals *int) string {
87+
// Format the float with custom decimal places, default is 6 (perp), 8 (spot).
88+
// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
89+
func FloatToWire(x float64, maxDecimals int, szDecimals int) string {
8190
bigf := big.NewFloat(x)
8291
var maxDecSz uint
83-
if szDecimals != nil {
84-
maxDecSz = uint(*szDecimals)
92+
intPart, _ := bigf.Int64()
93+
intSize := len(strconv.FormatInt(intPart, 10))
94+
if intSize >= maxDecimals {
95+
maxDecSz = 0
8596
} else {
86-
intPart, _ := bigf.Int64()
87-
intSize := len(strconv.FormatInt(intPart, 10))
88-
if intSize >= 6 {
89-
maxDecSz = 0
90-
} else {
91-
maxDecSz = uint(6 - intSize)
92-
}
97+
maxDecSz = uint(maxDecimals - intSize)
9398
}
9499
x, _ = bigf.Float64()
95100
rounded := fmt.Sprintf("%.*f", maxDecSz, x)
96-
for strings.HasSuffix(rounded, "0") {
97-
rounded = strings.TrimSuffix(rounded, "0")
101+
if strings.Contains(rounded, ".") {
102+
for strings.HasSuffix(rounded, "0") {
103+
rounded = strings.TrimSuffix(rounded, "0")
104+
}
105+
}
106+
if strings.HasSuffix(rounded, ".") {
107+
rounded = strings.TrimSuffix(rounded, ".")
98108
}
99-
rounded = strings.TrimSuffix(rounded, ".")
100109
return rounded
101110
}
102111

hyperliquid/exchange_service.go

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type ExchangeAPI struct {
3636
address string
3737
baseEndpoint string
3838
meta map[string]AssetInfo
39+
spotMeta map[string]AssetInfo
3940
}
4041

4142
// NewExchangeAPI creates a new default ExchangeAPI.
@@ -54,6 +55,14 @@ func NewExchangeAPI(isMainnet bool) *ExchangeAPI {
5455
api.debug("Error building meta map: %s", err)
5556
}
5657
api.meta = meta
58+
59+
spotMeta, err := api.infoAPI.BuildSpotMetaMap()
60+
if err != nil {
61+
api.SetDebugActive()
62+
api.debug("Error building spot meta map: %s", err)
63+
}
64+
api.spotMeta = spotMeta
65+
5766
return &api
5867
}
5968

@@ -71,6 +80,17 @@ func (api *ExchangeAPI) SlippagePrice(coin string, isBuy bool, slippage float64)
7180
return CalculateSlippage(isBuy, marketPx, slippage)
7281
}
7382

83+
// SlippagePriceSpot is a helper function to calculate the slippage price for a spot coin.
84+
func (api *ExchangeAPI) SlippagePriceSpot(coin string, isBuy bool, slippage float64) float64 {
85+
marketPx, err := api.infoAPI.GetSpotMarketPx(coin)
86+
if err != nil {
87+
api.debug("Error getting market price: %s", err)
88+
return 0.0
89+
}
90+
slippagePrice := CalculateSlippage(isBuy, marketPx, slippage)
91+
return slippagePrice
92+
}
93+
7494
// Open a market order.
7595
// Limit order with TIF=IOC and px=market price * (1 +- slippage).
7696
// Size determines the amount of the coin to buy/sell.
@@ -98,6 +118,34 @@ func (api *ExchangeAPI) MarketOrder(coin string, size float64, slippage *float64
98118
return api.Order(orderRequest, GroupingNa)
99119
}
100120

121+
// MarketOrderSpot is a market order for a spot coin.
122+
// It is used to buy/sell a spot coin.
123+
// Limit order with TIF=IOC and px=market price * (1 +- slippage).
124+
// Size determines the amount of the coin to buy/sell.
125+
//
126+
// MarketOrderSpot("HYPE", 0.1, nil) // Buy 0.1 HYPE
127+
// MarketOrderSpot("HYPE", -0.1, nil) // Sell 0.1 HYPE
128+
// MarketOrderSpot("HYPE", 0.1, &slippage) // Buy 0.1 HYPE with slippage
129+
func (api *ExchangeAPI) MarketOrderSpot(coin string, size float64, slippage *float64) (*PlaceOrderResponse, error) {
130+
slpg := GetSlippage(slippage)
131+
isBuy := IsBuy(size)
132+
finalPx := api.SlippagePriceSpot(coin, isBuy, slpg)
133+
orderType := OrderType{
134+
Limit: &LimitOrderType{
135+
Tif: TifIoc,
136+
},
137+
}
138+
orderRequest := OrderRequest{
139+
Coin: coin,
140+
IsBuy: isBuy,
141+
Sz: math.Abs(size),
142+
LimitPx: finalPx,
143+
OrderType: orderType,
144+
ReduceOnly: false,
145+
}
146+
return api.OrderSpot(orderRequest, GroupingNa)
147+
}
148+
101149
// Open a limit order.
102150
// Order type can be Gtc, Ioc, Alo.
103151
// Size determines the amount of the coin to buy/sell.
@@ -165,15 +213,26 @@ func (api *ExchangeAPI) ClosePosition(coin string) (*PlaceOrderResponse, error)
165213

166214
// Place single order
167215
func (api *ExchangeAPI) Order(request OrderRequest, grouping Grouping) (*PlaceOrderResponse, error) {
168-
return api.BulkOrders([]OrderRequest{request}, grouping)
216+
return api.BulkOrders([]OrderRequest{request}, grouping, false)
217+
}
218+
219+
// OrderSpot places a spot order
220+
func (api *ExchangeAPI) OrderSpot(request OrderRequest, grouping Grouping) (*PlaceOrderResponse, error) {
221+
return api.BulkOrders([]OrderRequest{request}, grouping, true)
169222
}
170223

171224
// Place orders in bulk
172225
// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-an-order
173-
func (api *ExchangeAPI) BulkOrders(requests []OrderRequest, grouping Grouping) (*PlaceOrderResponse, error) {
226+
func (api *ExchangeAPI) BulkOrders(requests []OrderRequest, grouping Grouping, isSpot bool) (*PlaceOrderResponse, error) {
174227
var wires []OrderWire
228+
var meta map[string]AssetInfo
229+
if isSpot {
230+
meta = api.spotMeta
231+
} else {
232+
meta = api.meta
233+
}
175234
for _, req := range requests {
176-
wires = append(wires, OrderRequestToWire(req, api.meta))
235+
wires = append(wires, OrderRequestToWire(req, meta, isSpot))
177236
}
178237
timestamp := GetNonce()
179238
action := OrderWiresToOrderAction(wires, grouping)
@@ -283,7 +342,7 @@ func (api *ExchangeAPI) Withdraw(destination string, amount float64) (*WithdrawR
283342
action := WithdrawAction{
284343
Type: "withdraw3",
285344
Destination: destination,
286-
Amount: FloatToWire(amount, &SZ_DECIMALS),
345+
Amount: FloatToWire(amount, PERP_MAX_DECIMALS, SZ_DECIMALS),
287346
Time: nonce,
288347
}
289348
signatureChainID, chainType := api.getChainParams()
@@ -315,7 +374,7 @@ func (api *ExchangeAPI) getChainParams() (string, string) {
315374
func (api *ExchangeAPI) BuildBulkOrdersEIP712(requests []OrderRequest, grouping Grouping) (apitypes.TypedData, error) {
316375
var wires []OrderWire
317376
for _, req := range requests {
318-
wires = append(wires, OrderRequestToWire(req, api.meta))
377+
wires = append(wires, OrderRequestToWire(req, api.meta, false))
319378
}
320379
timestamp := GetNonce()
321380
action := OrderWiresToOrderAction(wires, grouping)

hyperliquid/exchange_test.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ func TestExchangeAPI_MarketOpen(testing *testing.T) {
8080
if totalSize != math.Abs(size) {
8181
testing.Errorf("res.Response.Data.Statuses[0].Filled.TotalSz = %v", totalSize)
8282
}
83+
time.Sleep(2 * time.Second) // wait to execute order
8384
accountState, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress())
8485
if err != nil {
8586
testing.Errorf("GetAccountState() error = %v", err)
@@ -157,7 +158,7 @@ func TestExchangeAPI_MarketClose(testing *testing.T) {
157158

158159
func TestExchangeAPI_TestWithdraw(testing *testing.T) {
159160
exchangeAPI := GetExchangeAPI()
160-
withdrawAmount := 2.0
161+
withdrawAmount := 10.0
161162
stateBefore, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress())
162163
if err != nil {
163164
testing.Errorf("GetAccountState() error = %v", err)
@@ -184,3 +185,18 @@ func TestExchangeAPI_TestWithdraw(testing *testing.T) {
184185
testing.Errorf("Balance not updated: %v", stateAfter)
185186
}
186187
}
188+
189+
func TestExchageAPI_TestMarketOrderSpot(testing *testing.T) {
190+
exchangeAPI := GetExchangeAPI()
191+
size := 1600.0
192+
coin := "YEETI"
193+
res, err := exchangeAPI.MarketOrderSpot(coin, size, nil)
194+
if err != nil {
195+
testing.Errorf("MakeOpen() error = %v", err)
196+
}
197+
testing.Logf("MakeOpen() = %v", res)
198+
avgPrice := res.Response.Data.Statuses[0].Filled.AvgPx
199+
if avgPrice == 0 {
200+
testing.Errorf("res.Response.Data.Statuses[0].Filled.AvgPx = %v", avgPrice)
201+
}
202+
}

hyperliquid/exchange_types.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ type ExchangeRequest struct {
1515
}
1616

1717
type AssetInfo struct {
18-
SzDecimals int
19-
AssetId int
18+
SzDecimals int
19+
WeiDecimals int
20+
AssetId int
21+
SpotName string // for spot asset (e.g. "@107")
2022
}
2123

2224
type OrderRequest struct {

hyperliquid/go.mod

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
module github.com/Logarithm-Labs/go-hyperliquid/hyperliquid
22

3-
go 1.21.10
3+
go 1.23.4
44

55
require (
6-
github.com/ethereum/go-ethereum v1.14.3
6+
github.com/ethereum/go-ethereum v1.14.12
77
github.com/sirupsen/logrus v1.9.3
88
github.com/vmihailenco/msgpack/v5 v5.4.1
99
)
1010

1111
require (
12-
github.com/bits-and-blooms/bitset v1.10.0 // indirect
13-
github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
12+
github.com/bits-and-blooms/bitset v1.13.0 // indirect
1413
github.com/consensys/bavard v0.1.13 // indirect
1514
github.com/consensys/gnark-crypto v0.12.1 // indirect
15+
github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect
1616
github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect
1717
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
1818
github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
19-
github.com/holiman/uint256 v1.2.4 // indirect
19+
github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect
20+
github.com/holiman/uint256 v1.3.1 // indirect
2021
github.com/mmcloughlin/addchain v0.4.0 // indirect
21-
github.com/supranational/blst v0.3.11 // indirect
22+
github.com/supranational/blst v0.3.13 // indirect
2223
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
23-
golang.org/x/crypto v0.23.0 // indirect
24+
golang.org/x/crypto v0.22.0 // indirect
2425
golang.org/x/sync v0.7.0 // indirect
25-
golang.org/x/sys v0.20.0 // indirect
26+
golang.org/x/sys v0.22.0 // indirect
2627
rsc.io/tmplfunc v0.0.3 // indirect
2728
)

0 commit comments

Comments
 (0)