diff --git a/README.md b/README.md index a421f2f..2f06834 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,11 @@ Legend: ✅ implemented — ⬜ not yet implemented. | General | `legalCheck` | ⬜ | | | General | `preTransferCheck` | ⬜ | | | General | `subAccounts2` | ⬜ | | -| General | `twapHistory` | ⬜ | | +| General | `twapHistory` | ✅ | `RestApi::twapHistory` | | General | `usdcRouting` | ⬜ | | | General | `userBorrowLendInterest` | ⬜ | | | General | `userToMultiSigSigners` | ⬜ | | -| General | `userTwapSliceFillsByTime` | ⬜ | | +| General | `userTwapSliceFillsByTime` | ✅ | `RestApi::userTwapSliceFillsByTime` | | General | `validatorL1Votes` | ⬜ | | | General | `validatorSummaries` | ⬜ | | | General | `vaultSummaries` | ⬜ | | @@ -139,7 +139,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | Perpetuals | `predictedFundings` | ✅ | `RestApi::predictedFundings` | | Perpetuals | `perpsAtOpenInterestCap` | ✅ | `RestApi::perpsAtOpenInterestCap` | | Perpetuals | `perpDeployAuctionStatus` | ✅ | `RestApi::perpDeployAuctionStatus` | -| Perpetuals | `activeAssetData` | ⬜ | | +| Perpetuals | `activeAssetData` | ✅ | `RestApi::activeAssetData` | | Perpetuals | `perpDexLimits` | ✅ | `RestApi::perpDexLimits` | | Perpetuals | `perpDexStatus` | ✅ | `RestApi::perpDexStatus` | | Perpetuals | `allPerpMetas` | ✅ | `RestApi::allPerpMetas` | @@ -161,7 +161,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | Spot / Outcomes | `outcomeDeployerLimits` | ⬜ | | | Spot / Outcomes | `outcomeTemplates` | ⬜ | | -53 of 78 documented info endpoints implemented. One (`tokenDetails`) has a `RestEndpointType` enum value reserved but no request builder or method yet. +56 of 78 documented info endpoints implemented. One (`tokenDetails`) has a `RestEndpointType` enum value reserved but no request builder or method yet. ### Exchange actions (`/exchange`) diff --git a/examples/rest_leverage.cpp b/examples/rest_leverage.cpp index 0758c4e..bc0ba02 100644 --- a/examples/rest_leverage.cpp +++ b/examples/rest_leverage.cpp @@ -41,6 +41,14 @@ int main() const std::string asset = "ETH"; const double size = 0.01; + spdlog::info("=== activeAssetData ({}) ===", asset); + auto activeAssetData = api.activeAssetData(wallet.accountAddress, asset); + spdlog::info("activeAssetData: leverage={}x ({}) maxTradeSz=[{}, {}] availableToTrade=[{}, {}] markPx={}", + activeAssetData.leverageValue, hyperliquid::toString(activeAssetData.leverageType), + activeAssetData.maxTradeSzLong, activeAssetData.maxTradeSzShort, + activeAssetData.availableToTradeLong, activeAssetData.availableToTradeShort, + activeAssetData.markPx); + spdlog::info("=== Switch {} to isolated leverage ===", asset); hyperliquid::UpdateLeverageRequest leverageReq; diff --git a/examples/rest_twap_slices.cpp b/examples/rest_twap_slices.cpp index a1df33d..eb5d2c5 100644 --- a/examples/rest_twap_slices.cpp +++ b/examples/rest_twap_slices.cpp @@ -7,11 +7,22 @@ #include #include +namespace +{ + uint64_t nowMs() + { + return static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + } +} + int main() { auto wallet = loadWalletFromConfig(); std::string userAddress = wallet.accountAddress; hyperliquid::setLogLevel(hyperliquid::LogLevel::Info); + uint64_t startTime = nowMs(); hyperliquid::ApiConfig config; config.env = hyperliquid::Environment::Testnet; @@ -66,5 +77,25 @@ int main() if (cancelResp.error) spdlog::info(" Error: {}", *cancelResp.error); + spdlog::info("=== twapHistory ==="); + auto history = api.twapHistory(userAddress); + spdlog::info("twapHistory: {} entries", history.history.size()); + for (const auto& entry : history.history) + { + if (entry.twapId != *twapResp.twapId) continue; + spdlog::info(" MATCH twapId={} status={} time={}", + entry.twapId, hyperliquid::toString(entry.status), entry.time); + } + + spdlog::info("=== userTwapSliceFillsByTime (since twapOrder was placed) ==="); + auto slicesByTime = api.userTwapSliceFillsByTime(userAddress, startTime); + spdlog::info("userTwapSliceFillsByTime: {} fills since {}", slicesByTime.fills.size(), startTime); + for (const auto& slice : slicesByTime.fills) + { + if (slice.twapId != *twapResp.twapId) continue; + spdlog::info(" MATCH twapId={} coin={} px={} sz={} time={}", + slice.twapId, slice.fill.coin, slice.fill.px, slice.fill.sz, slice.fill.time); + } + return 0; } diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index 0b80f61..0dedadd 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -95,6 +95,12 @@ class RestApi { const std::optional& dex = std::nullopt); HistoricalOrdersResponse historicalOrders(const std::string& user); UserTwapSliceFillsResponse userTwapSliceFills(const std::string& user); + UserTwapSliceFillsResponse userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime = std::nullopt, + const std::optional& aggregateByTime = std::nullopt); + TwapHistoryResponse twapHistory(const std::string& user); + ActiveAssetData activeAssetData(const std::string& user, const std::string& coin); SubAccountsResponse subAccounts(const std::string& user); UserFeesResponse userFees(const std::string& user); MaxBuilderFeeResponse maxBuilderFee(const std::string& user, const std::string& builder); @@ -226,6 +232,12 @@ class RestApi { const std::optional& dex = std::nullopt); void historicalOrdersAsync(const std::string& user); void userTwapSliceFillsAsync(const std::string& user); + void userTwapSliceFillsByTimeAsync(const std::string& user, + uint64_t startTime, + const std::optional& endTime = std::nullopt, + const std::optional& aggregateByTime = std::nullopt); + void twapHistoryAsync(const std::string& user); + void activeAssetDataAsync(const std::string& user, const std::string& coin); void subAccountsAsync(const std::string& user); void userFeesAsync(const std::string& user); void maxBuilderFeeAsync(const std::string& user, const std::string& builder); diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h index 40d12dc..cf26bf6 100644 --- a/include/hyperliquid/rest/RestApiMessageParser.h +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -67,6 +67,9 @@ namespace hyperliquid FrontendOpenOrdersResponse parseFrontendOpenOrders(const std::string& message); HistoricalOrdersResponse parseHistoricalOrders(const std::string& message); UserTwapSliceFillsResponse parseUserTwapSliceFills(const std::string& message); + UserTwapSliceFillsResponse parseUserTwapSliceFillsByTime(const std::string& message); + TwapHistoryResponse parseTwapHistory(const std::string& message); + ActiveAssetData parseActiveAssetData(const std::string& message); SubAccountsResponse parseSubAccounts(const std::string& message); UserFeesResponse parseUserFees(const std::string& message); MaxBuilderFeeResponse parseMaxBuilderFee(const std::string& message); diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h index 245fd83..d1cfcfb 100644 --- a/include/hyperliquid/rest/RestEndpointListener.h +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -51,6 +51,9 @@ class RestEndpointListener { virtual void onFrontendOpenOrders(const FrontendOpenOrdersResponse&, std::optional = std::nullopt) {} virtual void onHistoricalOrders(const HistoricalOrdersResponse&, std::optional = std::nullopt) {} virtual void onUserTwapSliceFills(const UserTwapSliceFillsResponse&, std::optional = std::nullopt) {} + virtual void onUserTwapSliceFillsByTime(const UserTwapSliceFillsResponse&, std::optional = std::nullopt) {} + virtual void onTwapHistory(const TwapHistoryResponse&, std::optional = std::nullopt) {} + virtual void onActiveAssetData(const ActiveAssetData&, std::optional = std::nullopt) {} virtual void onSubAccounts(const SubAccountsResponse&, std::optional = std::nullopt) {} virtual void onUserFees(const UserFeesResponse&, std::optional = std::nullopt) {} virtual void onMaxBuilderFee(const MaxBuilderFeeResponse&, std::optional = std::nullopt) {} diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 31d4f37..b549cba 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -163,6 +163,9 @@ namespace hyperliquid FrontendOpenOrders, HistoricalOrders, UserTwapSliceFills, + UserTwapSliceFillsByTime, + TwapHistory, + ActiveAssetData, SubAccounts, UserFees, MaxBuilderFee, @@ -267,6 +270,9 @@ namespace hyperliquid case RestEndpointType::FrontendOpenOrders: return "frontendOpenOrders"; case RestEndpointType::HistoricalOrders: return "historicalOrders"; case RestEndpointType::UserTwapSliceFills: return "userTwapSliceFills"; + case RestEndpointType::UserTwapSliceFillsByTime: return "userTwapSliceFillsByTime"; + case RestEndpointType::TwapHistory: return "twapHistory"; + case RestEndpointType::ActiveAssetData: return "activeAssetData"; case RestEndpointType::SubAccounts: return "subAccounts"; case RestEndpointType::UserFees: return "userFees"; case RestEndpointType::MaxBuilderFee: return "maxBuilderFee"; @@ -370,6 +376,9 @@ namespace hyperliquid case RestEndpointType::FrontendOpenOrders: return false; case RestEndpointType::HistoricalOrders: return false; case RestEndpointType::UserTwapSliceFills: return false; + case RestEndpointType::UserTwapSliceFillsByTime: return false; + case RestEndpointType::TwapHistory: return false; + case RestEndpointType::ActiveAssetData: return false; case RestEndpointType::SubAccounts: return false; case RestEndpointType::UserFees: return false; case RestEndpointType::MaxBuilderFee: return false; diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 54348d6..51cd82b 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -344,12 +344,14 @@ namespace hyperliquid std::vector states; }; - enum class TwapHistoryStatus { Activated, Terminated, Finished, Error, Unknown }; + enum class TwapHistoryStatus { Activated, Terminated, WaitingForTrigger, Stopped, Finished, Error, Unknown }; inline TwapHistoryStatus stringToTwapHistoryStatus(std::string_view s) { if (s == "activated") return TwapHistoryStatus::Activated; if (s == "terminated") return TwapHistoryStatus::Terminated; + if (s == "waitingForTrigger") return TwapHistoryStatus::WaitingForTrigger; + if (s == "stopped") return TwapHistoryStatus::Stopped; if (s == "finished") return TwapHistoryStatus::Finished; if (s == "error") return TwapHistoryStatus::Error; return TwapHistoryStatus::Unknown; @@ -361,6 +363,8 @@ namespace hyperliquid { case TwapHistoryStatus::Activated: return "activated"; case TwapHistoryStatus::Terminated: return "terminated"; + case TwapHistoryStatus::WaitingForTrigger: return "waitingForTrigger"; + case TwapHistoryStatus::Stopped: return "stopped"; case TwapHistoryStatus::Finished: return "finished"; case TwapHistoryStatus::Error: return "error"; default: return "unknown"; @@ -373,6 +377,7 @@ namespace hyperliquid TwapHistoryStatus status; std::string description; uint64_t time; + uint64_t twapId = 0; // only populated by the REST twapHistory endpoint, not the userTwapHistory websocket channel bool isSnapshot = false; }; @@ -493,10 +498,16 @@ namespace hyperliquid std::string user; std::string coin; LeverageType leverageType; + double leverageValue = 0.0; + // isolated-margin HIP-3 dex only ("rawUsd" alongside "type"/"value" in the leverage object). + std::optional leverageRawUsd; double maxTradeSzLong; double maxTradeSzShort; double availableToTradeLong; double availableToTradeShort; + // Only present on the REST activeAssetData response, confirmed live - not observed on the + // activeAssetData websocket channel payload, so left unset (0.0) there. + double markPx = 0.0; }; struct Notification @@ -1151,6 +1162,11 @@ namespace hyperliquid std::vector fills; }; + struct TwapHistoryResponse + { + std::vector history; + }; + struct SubAccount { std::string name; diff --git a/include/hyperliquid/websocket/WebsocketApi.h b/include/hyperliquid/websocket/WebsocketApi.h index 3a43627..d33d554 100644 --- a/include/hyperliquid/websocket/WebsocketApi.h +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -57,6 +57,14 @@ namespace hyperliquid std::optional correlationId = std::nullopt); void historicalOrders(const std::string& user, std::optional correlationId = std::nullopt); void userTwapSliceFills(const std::string& user, std::optional correlationId = std::nullopt); + void userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime = std::nullopt, + const std::optional& aggregateByTime = std::nullopt, + std::optional correlationId = std::nullopt); + void twapHistory(const std::string& user, std::optional correlationId = std::nullopt); + void activeAssetData(const std::string& user, const std::string& coin, + std::optional correlationId = std::nullopt); void subAccounts(const std::string& user, std::optional correlationId = std::nullopt); void userFees(const std::string& user, std::optional correlationId = std::nullopt); void maxBuilderFee(const std::string& user, const std::string& builder, diff --git a/include/hyperliquid/websocket/WebsocketApiListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h index 63bf87d..ba932cb 100644 --- a/include/hyperliquid/websocket/WebsocketApiListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -41,6 +41,8 @@ class WebsocketApiListener { virtual void onPostResponse(const FrontendOpenOrdersResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const HistoricalOrdersResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const UserTwapSliceFillsResponse&, std::optional = std::nullopt) {} + virtual void onPostResponse(const TwapHistoryResponse&, std::optional = std::nullopt) {} + virtual void onPostResponse(const ActiveAssetData&, std::optional = std::nullopt) {} virtual void onPostResponse(const SubAccountsResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const UserFeesResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const MaxBuilderFeeResponse&, std::optional = std::nullopt) {} diff --git a/src/messages/InfoRequestBuilder.cpp b/src/messages/InfoRequestBuilder.cpp index 4ce2f5f..d179101 100644 --- a/src/messages/InfoRequestBuilder.cpp +++ b/src/messages/InfoRequestBuilder.cpp @@ -326,6 +326,37 @@ nlohmann::ordered_json InfoRequestBuilder::userTwapSliceFills(const std::string& return body; } +nlohmann::ordered_json InfoRequestBuilder::userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime, + const std::optional& aggregateByTime) +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::UserTwapSliceFillsByTime); + body["user"] = user; + body["startTime"] = startTime; + if (endTime) body["endTime"] = *endTime; + if (aggregateByTime) body["aggregateByTime"] = *aggregateByTime; + return body; +} + +nlohmann::ordered_json InfoRequestBuilder::twapHistory(const std::string& user) +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::TwapHistory); + body["user"] = user; + return body; +} + +nlohmann::ordered_json InfoRequestBuilder::activeAssetData(const std::string& user, const std::string& coin) +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::ActiveAssetData); + body["user"] = user; + body["coin"] = coin; + return body; +} + nlohmann::ordered_json InfoRequestBuilder::subAccounts(const std::string& user) { nlohmann::ordered_json body; diff --git a/src/messages/InfoRequestBuilder.h b/src/messages/InfoRequestBuilder.h index 142d28a..d41ebfb 100644 --- a/src/messages/InfoRequestBuilder.h +++ b/src/messages/InfoRequestBuilder.h @@ -69,6 +69,12 @@ class InfoRequestBuilder { const std::optional& dex = std::nullopt); static nlohmann::ordered_json historicalOrders(const std::string& user); static nlohmann::ordered_json userTwapSliceFills(const std::string& user); + static nlohmann::ordered_json userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime = std::nullopt, + const std::optional& aggregateByTime = std::nullopt); + static nlohmann::ordered_json twapHistory(const std::string& user); + static nlohmann::ordered_json activeAssetData(const std::string& user, const std::string& coin); static nlohmann::ordered_json subAccounts(const std::string& user); static nlohmann::ordered_json userFees(const std::string& user); static nlohmann::ordered_json maxBuilderFee(const std::string& user, const std::string& builder); diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 3c77197..8a1f7ef 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -384,6 +384,28 @@ UserTwapSliceFillsResponse RestApi::userTwapSliceFills(const std::string& user) impl_->signAndSendSync(RestEndpointType::UserTwapSliceFills, InfoRequestBuilder::userTwapSliceFills(user))); } +UserTwapSliceFillsResponse RestApi::userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime, + const std::optional& aggregateByTime) +{ + return RestApiMessageParser().parseUserTwapSliceFillsByTime( + impl_->signAndSendSync(RestEndpointType::UserTwapSliceFillsByTime, + InfoRequestBuilder::userTwapSliceFillsByTime(user, startTime, endTime, aggregateByTime))); +} + +TwapHistoryResponse RestApi::twapHistory(const std::string& user) +{ + return RestApiMessageParser().parseTwapHistory( + impl_->signAndSendSync(RestEndpointType::TwapHistory, InfoRequestBuilder::twapHistory(user))); +} + +ActiveAssetData RestApi::activeAssetData(const std::string& user, const std::string& coin) +{ + return RestApiMessageParser().parseActiveAssetData( + impl_->signAndSendSync(RestEndpointType::ActiveAssetData, InfoRequestBuilder::activeAssetData(user, coin))); +} + SubAccountsResponse RestApi::subAccounts(const std::string& user) { return RestApiMessageParser().parseSubAccounts( @@ -971,6 +993,25 @@ void RestApi::userTwapSliceFillsAsync(const std::string& user) impl_->signAndSend(RestEndpointType::UserTwapSliceFills, InfoRequestBuilder::userTwapSliceFills(user)); } +void RestApi::userTwapSliceFillsByTimeAsync(const std::string& user, + uint64_t startTime, + const std::optional& endTime, + const std::optional& aggregateByTime) +{ + impl_->signAndSend(RestEndpointType::UserTwapSliceFillsByTime, + InfoRequestBuilder::userTwapSliceFillsByTime(user, startTime, endTime, aggregateByTime)); +} + +void RestApi::twapHistoryAsync(const std::string& user) +{ + impl_->signAndSend(RestEndpointType::TwapHistory, InfoRequestBuilder::twapHistory(user)); +} + +void RestApi::activeAssetDataAsync(const std::string& user, const std::string& coin) +{ + impl_->signAndSend(RestEndpointType::ActiveAssetData, InfoRequestBuilder::activeAssetData(user, coin)); +} + void RestApi::subAccountsAsync(const std::string& user) { impl_->signAndSend(RestEndpointType::SubAccounts, InfoRequestBuilder::subAccounts(user)); diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index 9884512..f9fa513 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -124,6 +124,15 @@ namespace hyperliquid case RestEndpointType::UserTwapSliceFills: listener.onUserTwapSliceFills(parseUserTwapSliceFills(message), correlationId); break; + case RestEndpointType::UserTwapSliceFillsByTime: + listener.onUserTwapSliceFillsByTime(parseUserTwapSliceFillsByTime(message), correlationId); + break; + case RestEndpointType::TwapHistory: + listener.onTwapHistory(parseTwapHistory(message), correlationId); + break; + case RestEndpointType::ActiveAssetData: + listener.onActiveAssetData(parseActiveAssetData(message), correlationId); + break; case RestEndpointType::SubAccounts: listener.onSubAccounts(parseSubAccounts(message), correlationId); break; @@ -1479,6 +1488,23 @@ namespace hyperliquid return fill; } + TwapState parseTwapStateEntry(simdjson::ondemand::object& obj) + { + TwapState state{}; + state.coin = std::string(obj["coin"].get_string().value()); + state.user = std::string(obj["user"].get_string().value()); + auto sideStr = obj["side"].get_string().value(); + state.side = sideStr.size() > 0 ? sideStr[0] : '?'; + state.sz = parseNumberField(obj, "sz"); + state.executedSz = parseNumberField(obj, "executedSz"); + state.executedNtl = parseNumberField(obj, "executedNtl"); + state.minutes = static_cast(obj["minutes"].get_int64().value()); + state.reduceOnly = obj["reduceOnly"].get_bool().value(); + state.randomize = obj["randomize"].get_bool().value(); + state.timestamp = obj["timestamp"].get_uint64().value(); + return state; + } + OpenOrder parseOpenOrderEntry(simdjson::ondemand::object& obj) { OpenOrder order; @@ -2394,6 +2420,122 @@ namespace hyperliquid return response; } + UserTwapSliceFillsResponse parseUserTwapSliceFillsByTime(const std::string& message) + { + // Same array-of-{fill,twapId} schema as userTwapSliceFills, just filtered by time range. + return parseUserTwapSliceFills(message); + } + + TwapHistoryResponse parseTwapHistory(const std::string& message) + { + TwapHistoryResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + validateStructure(message); + + auto arr = doc.get_array().value(); + for (auto entry : arr) + { + try + { + auto obj = entry.get_object().value(); + + TwapHistoryEntry hist{}; + hist.time = obj["time"].get_uint64().value(); + + auto stateObj = obj["state"].get_object().value(); + hist.state = parseTwapStateEntry(stateObj); + + auto statusObj = obj["status"].get_object().value(); + hist.status = stringToTwapHistoryStatus(statusObj["status"].get_string().value()); + std::string_view desc; + if (!statusObj["description"].get_string().get(desc)) + hist.description = std::string(desc); + + hist.twapId = obj["twapId"].get_uint64().value(); + hist.isSnapshot = false; + + response.history.push_back(std::move(hist)); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("RestMessageParser: parse error in twapHistory entry: {}\n raw: {}", e.what(), message); + } + } + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("RestMessageParser: parse error in twapHistory: {}\n raw: {}", e.what(), message); + } + + return response; + } + + ActiveAssetData parseActiveAssetData(const std::string& message) + { + ActiveAssetData response{}; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + validateStructure(message); + + auto obj = doc.get_object().value(); + response.user = std::string(obj["user"].get_string().value()); + response.coin = std::string(obj["coin"].get_string().value()); + + auto leverageObj = obj["leverage"].get_object().value(); + response.leverageType = stringToLeverageType(leverageObj["type"].get_string().value()); + response.leverageValue = parseNumberField(leverageObj, "value"); + // isolated-margin HIP-3 dex only - absent on the standard perp dex response. + std::string_view rawUsdStr; + if (!leverageObj["rawUsd"].get_string().get(rawUsdStr)) + { + response.leverageRawUsd = toDouble(rawUsdStr); + } + else + { + double rawUsd; + if (!leverageObj["rawUsd"].get_double().get(rawUsd)) + response.leverageRawUsd = rawUsd; + } + + auto maxTradeSzs = obj["maxTradeSzs"].get_array().value(); + size_t idx = 0; + for (auto v : maxTradeSzs) + { + std::string_view sv; + double val = !v.get_string().get(sv) ? toDouble(sv) : v.get_double().value(); + if (idx == 0) response.maxTradeSzLong = val; + else if (idx == 1) response.maxTradeSzShort = val; + idx++; + } + + auto availableToTrade = obj["availableToTrade"].get_array().value(); + idx = 0; + for (auto v : availableToTrade) + { + std::string_view sv; + double val = !v.get_string().get(sv) ? toDouble(sv) : v.get_double().value(); + if (idx == 0) response.availableToTradeLong = val; + else if (idx == 1) response.availableToTradeShort = val; + idx++; + } + + response.markPx = parseNumberField(obj, "markPx"); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("RestMessageParser: parse error in activeAssetData: {}\n raw: {}", e.what(), message); + } + + return response; + } + SubAccountsResponse parseSubAccounts(const std::string& message) { SubAccountsResponse response; @@ -3229,6 +3371,21 @@ namespace hyperliquid return impl_->parseUserTwapSliceFills(message); } + UserTwapSliceFillsResponse RestApiMessageParser::parseUserTwapSliceFillsByTime(const std::string& message) + { + return impl_->parseUserTwapSliceFillsByTime(message); + } + + TwapHistoryResponse RestApiMessageParser::parseTwapHistory(const std::string& message) + { + return impl_->parseTwapHistory(message); + } + + ActiveAssetData RestApiMessageParser::parseActiveAssetData(const std::string& message) + { + return impl_->parseActiveAssetData(message); + } + SubAccountsResponse RestApiMessageParser::parseSubAccounts(const std::string& message) { return impl_->parseSubAccounts(message); diff --git a/src/websocket/PostResponseDispatch.cpp b/src/websocket/PostResponseDispatch.cpp index f6a1193..5592dc7 100644 --- a/src/websocket/PostResponseDispatch.cpp +++ b/src/websocket/PostResponseDispatch.cpp @@ -80,6 +80,15 @@ namespace hyperliquid::internal case RestEndpointType::UserTwapSliceFills: listener.onPostResponse(parser.parseUserTwapSliceFills(dataJson), correlationId); break; + case RestEndpointType::UserTwapSliceFillsByTime: + listener.onPostResponse(parser.parseUserTwapSliceFillsByTime(dataJson), correlationId); + break; + case RestEndpointType::TwapHistory: + listener.onPostResponse(parser.parseTwapHistory(dataJson), correlationId); + break; + case RestEndpointType::ActiveAssetData: + listener.onPostResponse(parser.parseActiveAssetData(dataJson), correlationId); + break; case RestEndpointType::SubAccounts: listener.onPostResponse(parser.parseSubAccounts(dataJson), correlationId); break; diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index 08a9d78..23394e0 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -469,6 +469,30 @@ namespace hyperliquid std::nullopt, std::nullopt, correlationId); } + void WebsocketApi::userTwapSliceFillsByTime(const std::string& user, + uint64_t startTime, + const std::optional& endTime, + const std::optional& aggregateByTime, + std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::UserTwapSliceFillsByTime, + InfoRequestBuilder::userTwapSliceFillsByTime(user, startTime, endTime, aggregateByTime), + std::nullopt, std::nullopt, correlationId); + } + + void WebsocketApi::twapHistory(const std::string& user, std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::TwapHistory, InfoRequestBuilder::twapHistory(user), + std::nullopt, std::nullopt, correlationId); + } + + void WebsocketApi::activeAssetData(const std::string& user, const std::string& coin, + std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::ActiveAssetData, InfoRequestBuilder::activeAssetData(user, coin), + std::nullopt, std::nullopt, correlationId); + } + void WebsocketApi::subAccounts(const std::string& user, std::optional correlationId) { return impl_->signAndSend(RestEndpointType::SubAccounts, InfoRequestBuilder::subAccounts(user), diff --git a/tests/rest_account_test.cpp b/tests/rest_account_test.cpp index 386bc5a..a6a19b8 100644 --- a/tests/rest_account_test.cpp +++ b/tests/rest_account_test.cpp @@ -28,6 +28,31 @@ TEST(InfoRequestBuilderTest, UserTwapSliceFills) EXPECT_EQ(body["user"], "0xabc"); } +TEST(InfoRequestBuilderTest, UserTwapSliceFillsByTime) +{ + auto body = InfoRequestBuilder::userTwapSliceFillsByTime("0xabc", 1681222254710ULL, 1681223254710ULL, true); + EXPECT_EQ(body["type"], "userTwapSliceFillsByTime"); + EXPECT_EQ(body["user"], "0xabc"); + EXPECT_EQ(body["startTime"], 1681222254710ULL); + EXPECT_EQ(body["endTime"], 1681223254710ULL); + EXPECT_EQ(body["aggregateByTime"], true); +} + +TEST(InfoRequestBuilderTest, UserTwapSliceFillsByTimeStartOnly) +{ + auto body = InfoRequestBuilder::userTwapSliceFillsByTime("0xabc", 1681222254710ULL); + EXPECT_EQ(body["startTime"], 1681222254710ULL); + EXPECT_FALSE(body.contains("endTime")); + EXPECT_FALSE(body.contains("aggregateByTime")); +} + +TEST(InfoRequestBuilderTest, TwapHistory) +{ + auto body = InfoRequestBuilder::twapHistory("0xabc"); + EXPECT_EQ(body["type"], "twapHistory"); + EXPECT_EQ(body["user"], "0xabc"); +} + TEST(InfoRequestBuilderTest, SubAccounts) { auto body = InfoRequestBuilder::subAccounts("0xabc"); @@ -115,6 +140,106 @@ TEST(RestApiMessageParserInfoTest, ParseUserTwapSliceFills) EXPECT_EQ(response.fills[0].twapId, 42); } +// userTwapSliceFillsByTime shares the exact array-of-{fill,twapId} schema as +// userTwapSliceFills (confirmed live against mainnet with a real TWAP trader address), +// just filtered by time range. +TEST(RestApiMessageParserInfoTest, ParseUserTwapSliceFillsByTime) +{ + std::string message = R"([{ + "fill": { + "coin": "ETH", "px": "1670.1", "sz": "0.5", "side": "B", "time": 1000, + "startPosition": "0.0", "dir": "Open Long", "closedPnl": "0.0", "hash": "0xabc", + "oid": 1, "crossed": true, "fee": "0.1", "tid": 1, "feeToken": "USDC" + }, + "twapId": 42 + }])"; + + RestApiMessageParser parser; + auto response = parser.parseUserTwapSliceFillsByTime(message); + + ASSERT_EQ(response.fills.size(), 1u); + EXPECT_EQ(response.fills[0].fill.coin, "ETH"); + EXPECT_EQ(response.fills[0].twapId, 42); +} + +// Response shape confirmed live against mainnet (POST /info {"type":"twapHistory","user":..}) - +// a flat array of {time, state, status, twapId}, field order exactly as below. "state" is the +// same shape as the twapStates/userTwapHistory websocket channels' TwapState, plus "trigger"/ +// "stopPx" fields (trigger-based TWAP orders) not yet modeled here - safe to leave unread since +// they're the last fields in the object. "status" is either {"status": "..."} or, for the "error" +// status, {"status": "error", "description": "..."}. All five status strings below +// (activated/terminated/waitingForTrigger/stopped/finished) plus "error" were observed live. +TEST(RestApiMessageParserInfoTest, ParseTwapHistory) +{ + std::string message = R"([ + { + "time": 1788773946, + "state": { + "coin": "HYPE", "user": "0x6973a383b202b4349d256bbf8b0187dc7b2ed6bb", "side": "B", + "sz": "11.45", "executedSz": "0.0", "executedNtl": "0.0", "minutes": 10080, + "reduceOnly": false, "randomize": false, "timestamp": 1788773946267, + "trigger": null, "stopPx": null + }, + "status": {"status": "activated"}, + "twapId": 2193307 + }, + { + "time": 1788843382, + "state": { + "coin": "HYPE", "user": "0x9b3cafa1209ac61f02d7bc3b219697fb171c9c91", "side": "B", + "sz": "10.0", "executedSz": "0.0", "executedNtl": "0.0", "minutes": 4350, + "reduceOnly": false, "randomize": false, "timestamp": 1788830253130, + "trigger": {"px": "80.0", "above": false}, "stopPx": "85.0" + }, + "status": {"status": "waitingForTrigger"}, + "twapId": 2195243 + }, + { + "time": 1787497839, + "state": { + "coin": "HYPE", "user": "0x13c50dcdee4bbcba71baf578b345cdd35c7928be", "side": "A", + "sz": "1535655.0", "executedSz": "0.0", "executedNtl": "0.0", "minutes": 60, + "reduceOnly": false, "randomize": false, "timestamp": 1787497839000, + "trigger": null, "stopPx": null + }, + "status": {"status": "error", "description": "Insufficient spot balance"}, + "twapId": 1535655 + } + ])"; + + RestApiMessageParser parser; + auto response = parser.parseTwapHistory(message); + + ASSERT_EQ(response.history.size(), 3u); + + EXPECT_EQ(response.history[0].time, 1788773946ULL); + EXPECT_EQ(response.history[0].twapId, 2193307ULL); + EXPECT_EQ(response.history[0].status, TwapHistoryStatus::Activated); + EXPECT_EQ(response.history[0].state.coin, "HYPE"); + EXPECT_DOUBLE_EQ(response.history[0].state.sz, 11.45); + EXPECT_EQ(response.history[0].state.side, 'B'); + + EXPECT_EQ(response.history[1].status, TwapHistoryStatus::WaitingForTrigger); + EXPECT_EQ(response.history[1].twapId, 2195243ULL); + + EXPECT_EQ(response.history[2].status, TwapHistoryStatus::Error); + EXPECT_EQ(response.history[2].description, "Insufficient spot balance"); + EXPECT_EQ(response.history[2].twapId, 1535655ULL); +} + +// All six statuses observed live against mainnet twapHistory responses across several real +// TWAP trader addresses (see PR description for the accounts/timestamps queried). +TEST(TwapHistoryStatusTest, AllObservedLiveValuesRoundTrip) +{ + EXPECT_EQ(stringToTwapHistoryStatus("activated"), TwapHistoryStatus::Activated); + EXPECT_EQ(stringToTwapHistoryStatus("terminated"), TwapHistoryStatus::Terminated); + EXPECT_EQ(stringToTwapHistoryStatus("waitingForTrigger"), TwapHistoryStatus::WaitingForTrigger); + EXPECT_EQ(stringToTwapHistoryStatus("stopped"), TwapHistoryStatus::Stopped); + EXPECT_EQ(stringToTwapHistoryStatus("finished"), TwapHistoryStatus::Finished); + EXPECT_EQ(stringToTwapHistoryStatus("error"), TwapHistoryStatus::Error); + EXPECT_EQ(stringToTwapHistoryStatus("somethingNew"), TwapHistoryStatus::Unknown); +} + TEST(RestApiMessageParserInfoTest, ParseSubAccountsEmptyList) { RestApiMessageParser parser; diff --git a/tests/rest_market_data_test.cpp b/tests/rest_market_data_test.cpp index f4d8958..c853fe2 100644 --- a/tests/rest_market_data_test.cpp +++ b/tests/rest_market_data_test.cpp @@ -124,6 +124,14 @@ TEST(InfoRequestBuilderTest, ClearinghouseState) EXPECT_FALSE(body.contains("dex")); } +TEST(InfoRequestBuilderTest, ActiveAssetData) +{ + auto body = InfoRequestBuilder::activeAssetData("0xabc", "ETH"); + EXPECT_EQ(body["type"], "activeAssetData"); + EXPECT_EQ(body["user"], "0xabc"); + EXPECT_EQ(body["coin"], "ETH"); +} + TEST(RestApiMessageParserInfoTest, ParseL2Book) { std::string message = R"({ @@ -366,3 +374,54 @@ TEST(RestApiMessageParserInfoTest, ParseClearinghouseState) EXPECT_TRUE(state.assetPositions[0].hasLiquidationPx); EXPECT_DOUBLE_EQ(state.assetPositions[0].liquidationPx, 2866.26936529); } + +// Response shape confirmed live against mainnet (POST /info {"type":"activeAssetData",...}): +// {"user":..,"coin":..,"leverage":{"type":"cross","value":20},"maxTradeSzs":[..,..], +// "availableToTrade":[..,..],"markPx":".."}. The isolated-margin HIP-3 dex variant that adds +// "rawUsd" alongside "type"/"value" in the leverage object is per the official docs only - not +// independently verified against a live isolated HIP-3 position. +TEST(RestApiMessageParserInfoTest, ParseActiveAssetDataCross) +{ + std::string message = R"({ + "user": "0xa15099a30bbf2e68942d6f4c43d70d04faeab0a0", + "coin": "ETH", + "leverage": {"type": "cross", "value": 20}, + "maxTradeSzs": ["12.34", "56.78"], + "availableToTrade": ["1000.5", "2000.25"], + "markPx": "2479.5" + })"; + + RestApiMessageParser parser; + auto data = parser.parseActiveAssetData(message); + + EXPECT_EQ(data.user, "0xa15099a30bbf2e68942d6f4c43d70d04faeab0a0"); + EXPECT_EQ(data.coin, "ETH"); + EXPECT_EQ(data.leverageType, LeverageType::Cross); + EXPECT_DOUBLE_EQ(data.leverageValue, 20.0); + EXPECT_FALSE(data.leverageRawUsd.has_value()); + EXPECT_DOUBLE_EQ(data.maxTradeSzLong, 12.34); + EXPECT_DOUBLE_EQ(data.maxTradeSzShort, 56.78); + EXPECT_DOUBLE_EQ(data.availableToTradeLong, 1000.5); + EXPECT_DOUBLE_EQ(data.availableToTradeShort, 2000.25); + EXPECT_DOUBLE_EQ(data.markPx, 2479.5); +} + +TEST(RestApiMessageParserInfoTest, ParseActiveAssetDataIsolatedHip3) +{ + std::string message = R"({ + "user": "0xabc", + "coin": "felix:CRCL", + "leverage": {"type": "isolated", "value": 5, "rawUsd": "123.45"}, + "maxTradeSzs": ["1.0", "2.0"], + "availableToTrade": ["3.0", "4.0"], + "markPx": "10.5" + })"; + + RestApiMessageParser parser; + auto data = parser.parseActiveAssetData(message); + + EXPECT_EQ(data.leverageType, LeverageType::Isolated); + EXPECT_DOUBLE_EQ(data.leverageValue, 5.0); + ASSERT_TRUE(data.leverageRawUsd.has_value()); + EXPECT_DOUBLE_EQ(*data.leverageRawUsd, 123.45); +} diff --git a/tests/websocket_api_test.cpp b/tests/websocket_api_test.cpp index 2cd907d..16bf7d9 100644 --- a/tests/websocket_api_test.cpp +++ b/tests/websocket_api_test.cpp @@ -95,6 +95,9 @@ TEST(WebsocketApiInfoWrappers, CompileLinkAndInvokeWithoutCrashing) EXPECT_NO_THROW(ws.frontendOpenOrders("0xabc")); EXPECT_NO_THROW(ws.historicalOrders("0xabc")); EXPECT_NO_THROW(ws.userTwapSliceFills("0xabc")); + EXPECT_NO_THROW(ws.userTwapSliceFillsByTime("0xabc", 0)); + EXPECT_NO_THROW(ws.twapHistory("0xabc")); + EXPECT_NO_THROW(ws.activeAssetData("0xabc", "ETH")); EXPECT_NO_THROW(ws.subAccounts("0xabc")); EXPECT_NO_THROW(ws.userFees("0xabc")); EXPECT_NO_THROW(ws.maxBuilderFee("0xabc", "0xbuilder"));