rustrade-execution 0.16.0

Stream private account data from financial venues, and execute (live or mock) orders.
Documentation
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Error mapping for Hyperliquid SDK errors to rustrade-execution error types.

use crate::error::{
    ApiError, ConnectivityError, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
};
use rustrade_instrument::instrument::name::InstrumentNameExchange;
use smol_str::SmolStr;

/// Why [`HyperliquidClient::connect`](super::HyperliquidClient::connect) failed.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HyperliquidConnectError {
    /// Hyperliquid could not be reached, or did not answer as it should. Transient: retry with
    /// backoff.
    #[error("{0}")]
    Connectivity(#[from] ConnectivityError),

    /// A DEX in [`HyperliquidConfig::dexes`](super::HyperliquidConfig::dexes) is not one
    /// Hyperliquid lists on the configured network. Retrying does not help: fix the config.
    #[error("Hyperliquid lists no perpetual DEX named {0:?} on this network")]
    UnknownDex(SmolStr),

    /// Hyperliquid's description of a configured DEX could not be read or makes no sense, such
    /// as a response that does not parse or a collateral token it does not list. Not transient:
    /// a retry is unlikely to read anything different.
    #[error("Hyperliquid perpetual DEX metadata: {0}")]
    Metadata(String),
}

impl From<UnindexedClientError> for HyperliquidConnectError {
    /// A connectivity error stays one, and a rate limit becomes one, since both pass; anything
    /// else means the response could not be used.
    fn from(error: UnindexedClientError) -> Self {
        match error {
            UnindexedClientError::Connectivity(error) => Self::Connectivity(error),
            UnindexedClientError::Api(UnindexedApiError::RateLimit { .. }) => {
                Self::Connectivity(ConnectivityError::Socket(error.to_string()))
            }
            other => Self::Metadata(other.to_string()),
        }
    }
}

/// Maps Hyperliquid SDK errors to `UnindexedClientError`.
///
/// The SDK uses its own error type internally, so we pattern-match on error messages
/// to determine transient vs permanent errors.
///
/// A rate limit carries no `retry_after`: the SDK keeps no response headers, and Hyperliquid
/// documents no wait.
pub fn map_sdk_error(error: hyperliquid_rust_sdk::Error) -> UnindexedClientError {
    // An HTTP 429 is a rate limit whatever its body says.
    if let hyperliquid_rust_sdk::Error::ClientRequest {
        status_code: 429, ..
    } = error
    {
        return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
    }

    let msg = error.to_string();
    let msg_lower = msg.to_lowercase();

    // Check for rate limiting first (transient). Matched on the words, not a bare "429", which
    // an order id or a price in the message can hold.
    if msg_lower.contains("rate limit") || msg_lower.contains("too many requests") {
        return UnindexedClientError::Api(UnindexedApiError::rate_limited(None));
    }

    // Check for connectivity/network errors (transient)
    // Includes HTTP 5xx server errors which are typically transient
    if msg_lower.contains("connection")
        || msg_lower.contains("timeout")
        || msg_lower.contains("network")
        || msg_lower.contains("dns")
        || msg_lower.contains("tls")
        || msg_lower.contains("ssl")
        || msg_lower.contains(" 500")
        || msg_lower.contains(" 502")
        || msg_lower.contains(" 503")
        || msg_lower.contains(" 504")
        || msg_lower.contains("bad gateway")
        || msg_lower.contains("service unavailable")
        || msg_lower.contains("gateway timeout")
    {
        return UnindexedClientError::Connectivity(ConnectivityError::Socket(msg));
    }

    // Check for authentication errors (permanent)
    // Includes "eip712" for SDK's Error::Eip712 variant (EIP-712 signing failures)
    if msg_lower.contains("signature")
        || msg_lower.contains("unauthorized")
        || msg_lower.contains("invalid key")
        || msg_lower.contains("authentication")
        || msg_lower.contains("eip712")
    {
        return UnindexedClientError::Api(UnindexedApiError::Unauthenticated(msg));
    }

    // Default to internal error (assumed non-transient)
    UnindexedClientError::Internal(msg)
}

/// Maps an SDK error from placing or cancelling an order to `UnindexedOrderError`, by whether
/// Hyperliquid may have acted on the request.
///
/// The SDK reports whatever goes wrong once the request is signed as one variant,
/// [`Error::JsonParse`](hyperliquid_rust_sdk::Error::JsonParse), holding the underlying error's
/// text: a failure to send the request or to read the answer, an HTTP 4xx or 5xx, and an answer
/// that does not parse. So its text decides:
/// - **An HTTP 4xx is a refusal.** The request was turned away: Hyperliquid answers an
///   `/exchange` request it acts on with 200, refusals included. This rests on the venue's
///   behaviour; a proxy in between that forwarded the request and then answered 4xx would be
///   misread. A 429 is [`ApiError::RateLimit`]; any other 4xx is classified as below.
/// - **Anything else in `JsonParse` is [`OrderError::Connectivity`]**: the request may have
///   reached the venue, so its outcome is unknown, and the engine looks an open up by its client
///   order id. `JsonParse` also covers encoding and building the request before the send, which
///   the requests this client builds do not fail; if one did, the lookups would find the order
///   never placed.
///
/// Every other variant fails before the request is sent (an asset the SDK does not list,
/// signing, a number it cannot encode, ...), so it is a refusal too.
///
/// A refusal naming an unknown instrument is [`ApiError::InstrumentInvalid`]. Any other is
/// classified by its text, as a refusal in a placement response is, so a shortfall of funds is
/// [`ApiError::BalanceInsufficient`]. A cancel never fails for want of funds, so it shares the
/// classifier harmlessly.
///
/// [`OrderError::Connectivity`]: crate::error::OrderError::Connectivity
pub fn map_order_error(
    error: hyperliquid_rust_sdk::Error,
    instrument: &InstrumentNameExchange,
) -> UnindexedOrderError {
    let hyperliquid_rust_sdk::Error::JsonParse(text) = error else {
        return refusal(error.to_string(), instrument);
    };
    match client_error_status(&text) {
        Some(429) => UnindexedOrderError::Rejected(ApiError::rate_limited(None)),
        Some(_) => refusal(text, instrument),
        None => UnindexedOrderError::Connectivity(ConnectivityError::Socket(text)),
    }
}

/// What the SDK's text for an HTTP 4xx answer (`Error::ClientRequest`'s `Display`) starts with,
/// followed by the status code and a comma.
///
/// The SDK's text is the contract here: if it changed, a 4xx would read as an unknown outcome.
/// The tests build their input from the SDK's own `Display`, so such a change fails them.
const CLIENT_ERROR_PREFIX: &str = "Client error: status code: ";

/// The HTTP status of a 4xx answer, read from the text the SDK's `/exchange` post wraps in
/// `JsonParse`. `None` for any other text.
fn client_error_status(text: &str) -> Option<u16> {
    let (status, _) = text.strip_prefix(CLIENT_ERROR_PREFIX)?.split_once(',')?;
    status
        .parse()
        .ok()
        .filter(|status| (400..500).contains(status))
}

/// A refusal of an order request, classified by its text `msg`.
fn refusal(msg: String, instrument: &InstrumentNameExchange) -> UnindexedOrderError {
    let msg_lower = msg.to_lowercase();

    // Instrument not found / invalid
    if msg_lower.contains("unknown")
        || msg_lower.contains("not found")
        || msg_lower.contains("invalid symbol")
    {
        return UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(instrument.clone(), msg));
    }

    // Anything else is classified by its text like a refusal in a response: a shortfall of
    // funds is BalanceInsufficient, the rest OrderRejected.
    UnindexedOrderError::Rejected(order_rejection(msg))
}

/// An order answered `ok` with a bare `success` status or with none: Hyperliquid took the
/// request but did not say what became of the order, which may be live. Its outcome is unknown,
/// as [`map_order_error`] reads a failure after the send.
pub(super) fn order_outcome_unanswered() -> UnindexedOrderError {
    UnindexedOrderError::Connectivity(ConnectivityError::Socket(
        "Hyperliquid answered the order without its status".to_owned(),
    ))
}

/// Classify why Hyperliquid refused an order: the error of a placement response or of the SDK
/// call that sent it, or a status ending in `Rejected` from `orderUpdates` or `orderStatus`.
///
/// The account's funds running short is [`ApiError::BalanceInsufficient`], with no asset, since
/// Hyperliquid does not name the one that ran short:
/// - a perp order's "Insufficient margin to place order." and the `perpMarginRejected` status;
/// - a spot order's "Order has insufficient spot balance to trade" and the
///   `insufficientSpotBalanceRejected` status.
///
/// Anything else (minimum notional, reduce-only, open-interest caps, oracle, margin-tier limit,
/// ...) is [`ApiError::OrderRejected`]. Either way the venue's text is kept.
pub(super) fn order_rejection(reason: String) -> UnindexedApiError {
    let lower = reason.to_ascii_lowercase();
    let short_of_funds = matches!(
        reason.as_str(),
        "perpMarginRejected" | "insufficientSpotBalanceRejected"
    ) || lower.contains("insufficient margin")
        || lower.contains("insufficient spot balance");
    if short_of_funds {
        ApiError::BalanceInsufficient(None, reason)
    } else {
        ApiError::OrderRejected(reason)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use hyperliquid_rust_sdk::Error;

    fn make_sdk_error(msg: &str) -> Error {
        Error::GenericReader(msg.to_string())
    }

    #[test]
    fn test_map_sdk_error_connectivity() {
        let err = map_sdk_error(make_sdk_error("connection refused"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("request timeout occurred"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("DNS lookup failed"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("TLS handshake error"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
    }

    #[test]
    fn test_map_sdk_error_5xx_transient() {
        // HTTP 5xx errors should be transient (server-side failures)
        let err = map_sdk_error(make_sdk_error("HTTP 500 Internal Server Error"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("502 Bad Gateway"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("503 Service Unavailable"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));

        let err = map_sdk_error(make_sdk_error("504 Gateway Timeout"));
        assert!(matches!(err, UnindexedClientError::Connectivity(_)));
    }

    #[test]
    fn test_map_sdk_error_rate_limit() {
        let rate_limited = UnindexedClientError::Api(UnindexedApiError::rate_limited(None));

        let err = map_sdk_error(make_sdk_error("rate limit exceeded"));
        assert_eq!(err, rate_limited);

        let err = map_sdk_error(make_sdk_error("too many requests"));
        assert_eq!(err, rate_limited);

        let err = map_sdk_error(Error::ClientRequest {
            status_code: 429,
            error_code: None,
            error_message: "slow down".to_string(),
            error_data: None,
        });
        assert_eq!(err, rate_limited);
    }

    #[test]
    fn a_message_merely_holding_429_is_not_a_rate_limit() {
        let err = map_sdk_error(Error::ClientRequest {
            status_code: 400,
            error_code: None,
            error_message: "Order 74291 has invalid price 4290".to_string(),
            error_data: None,
        });
        assert!(
            !matches!(
                err,
                UnindexedClientError::Api(UnindexedApiError::RateLimit { .. })
            ),
            "{err:?}"
        );
    }

    #[test]
    fn test_map_sdk_error_auth() {
        let err = map_sdk_error(make_sdk_error("invalid signature"));
        assert!(matches!(
            err,
            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
        ));

        let err = map_sdk_error(make_sdk_error("unauthorized access"));
        assert!(matches!(
            err,
            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
        ));

        // SDK's Error::Eip712 variant formats as "Error from Eip712 struct: ..."
        let err = map_sdk_error(make_sdk_error("Error from Eip712 struct: invalid key"));
        assert!(matches!(
            err,
            UnindexedClientError::Api(UnindexedApiError::Unauthenticated(_))
        ));
    }

    #[test]
    fn test_map_sdk_error_internal() {
        let err = map_sdk_error(make_sdk_error("some unknown error"));
        assert!(matches!(err, UnindexedClientError::Internal(_)));
    }

    #[test]
    fn test_map_order_error_instrument_invalid() {
        let instrument = InstrumentNameExchange::from("INVALID-USDC-PERP");

        let err = map_order_error(make_sdk_error("unknown asset"), &instrument);
        assert!(matches!(
            err,
            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
        ));

        let err = map_order_error(make_sdk_error("Asset not found"), &instrument);
        assert!(matches!(
            err,
            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(_, _))
        ));
    }

    #[test]
    fn a_shortfall_of_funds_is_balance_insufficient_and_anything_else_a_rejection() {
        for reason in [
            "perpMarginRejected",
            "insufficientSpotBalanceRejected",
            "Insufficient margin to place order. asset=0",
            "Order has insufficient spot balance to trade",
            "INSUFFICIENT MARGIN TO PLACE ORDER.",
        ] {
            assert_eq!(
                order_rejection(reason.to_owned()),
                ApiError::BalanceInsufficient(None, reason.to_owned()),
                "{reason}"
            );
        }
        for reason in [
            "minTradeNtlRejected",
            "reduceOnlyRejected",
            "positionIncreaseAtOpenInterestCapRejected",
            "oracleRejected",
            "perpMaxPositionRejected",
            "marketOrderNoLiquidityRejected",
            "badAloPxRejected",
            "Order must have minimum value of $10.",
        ] {
            assert_eq!(
                order_rejection(reason.to_owned()),
                ApiError::OrderRejected(reason.to_owned()),
                "{reason}"
            );
        }
    }

    #[test]
    fn test_map_order_error_rejected() {
        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");

        let err = map_order_error(make_sdk_error("insufficient margin"), &instrument);
        assert!(matches!(
            err,
            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
        ));

        let err = map_order_error(make_sdk_error("price precision too high"), &instrument);
        assert!(matches!(
            err,
            UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
        ));
    }

    /// The `/exchange` post wraps the text of what failed in `JsonParse`, as the SDK writes it.
    fn after_the_send(error: Error) -> Error {
        Error::JsonParse(error.to_string())
    }

    fn client_error(status_code: u16, error_message: &str) -> Error {
        after_the_send(Error::ClientRequest {
            status_code,
            error_code: None,
            error_message: error_message.to_owned(),
            error_data: None,
        })
    }

    /// An HTTP 4xx was refused before Hyperliquid acted on it, so it ends the order, classified
    /// as any refusal is. Read from the SDK's own text for one, so a change to it fails here.
    #[test]
    fn a_4xx_is_a_refusal_and_a_429_a_rate_limit() {
        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
        for status in [400, 422, 499] {
            assert!(
                matches!(
                    map_order_error(client_error(status, "Failed to deserialize"), &instrument),
                    UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))
                        if reason.starts_with(&format!("{CLIENT_ERROR_PREFIX}{status},"))
                ),
                "{status}"
            );
        }
        assert!(matches!(
            map_order_error(client_error(400, "Insufficient margin"), &instrument),
            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
        ));
        assert_eq!(
            map_order_error(client_error(429, "too many"), &instrument),
            UnindexedOrderError::Rejected(ApiError::rate_limited(None))
        );
    }

    /// Anything after the send other than a 4xx may have reached the venue: a 5xx, a failure to
    /// send or to read, an answer that does not parse, and text that only looks like a 4xx.
    #[test]
    fn any_other_failure_after_the_send_has_an_unknown_outcome() {
        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
        for text in [
            Error::ServerRequest {
                status_code: 502,
                error_message: "Bad Gateway".to_owned(),
            }
            .to_string(),
            Error::GenericRequest(
                "error sending request for url (https://api.hyperliquid.xyz/exchange)".to_owned(),
            )
            .to_string(),
            "expected value at line 1 column 1".to_owned(),
            format!("{CLIENT_ERROR_PREFIX}500, error code: None"),
            format!("{CLIENT_ERROR_PREFIX}abc, error code: None"),
            format!("{CLIENT_ERROR_PREFIX}399, error code: None"),
            format!("{CLIENT_ERROR_PREFIX}422 without a comma"),
        ] {
            let mapped = map_order_error(Error::JsonParse(text.clone()), &instrument);
            assert_eq!(
                mapped,
                UnindexedOrderError::Connectivity(ConnectivityError::Socket(text.clone())),
                "{text}"
            );
            assert!(mapped.is_outcome_unknown(), "{text}");
        }
    }

    /// Every variant other than `JsonParse` comes from before the send, so it ends the order.
    #[test]
    fn a_failure_before_the_send_is_a_refusal() {
        let instrument = InstrumentNameExchange::from("BTC-USDC-PERP");
        assert!(matches!(
            map_order_error(Error::AssetNotFound, &instrument),
            UnindexedOrderError::Rejected(ApiError::InstrumentInvalid(name, _)) if name == instrument
        ));
        for error in [
            Error::Eip712("bad domain".to_owned()),
            Error::SignatureFailure("bad key".to_owned()),
            Error::FloatStringParse,
            Error::NoCloid,
        ] {
            assert!(
                matches!(
                    map_order_error(error.clone(), &instrument),
                    UnindexedOrderError::Rejected(ApiError::OrderRejected(_))
                ),
                "{error}"
            );
        }
    }

    #[test]
    fn an_order_answered_without_its_status_has_an_unknown_outcome() {
        assert!(order_outcome_unanswered().is_outcome_unknown());
    }

    #[test]
    fn a_connect_error_is_transient_only_when_the_request_was() {
        let socket = ConnectivityError::Socket("reset".to_owned());
        assert_eq!(
            HyperliquidConnectError::from(UnindexedClientError::Connectivity(socket.clone())),
            HyperliquidConnectError::Connectivity(socket)
        );
        assert!(matches!(
            HyperliquidConnectError::from(UnindexedClientError::Api(
                UnindexedApiError::rate_limited(None)
            )),
            HyperliquidConnectError::Connectivity(ConnectivityError::Socket(_))
        ));
        assert!(matches!(
            HyperliquidConnectError::from(UnindexedClientError::Internal("bad json".to_owned())),
            HyperliquidConnectError::Metadata(reason) if reason.contains("bad json")
        ));
    }
}