From 1a03a084d099f4e364a8d4a201ab804ea4ed7b79 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Tue, 8 Sep 2026 10:43:41 +0100 Subject: [PATCH 1/2] Info endpoints: twapHistory, userTwapSliceFillsByTime, activeAssetData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the TWAP history story (twapOrder/twapCancel/userTwapSliceFills already existed) and adds per-asset leverage/margin data needed for correct order sizing. Response shapes for all three were confirmed live against mainnet (real TWAP trader addresses discovered via hypurrscan, and the HLP vault address for activeAssetData), not just from docs: - twapHistory: flat array of {time, state, status, twapId}. Also discovered two status values missing from the existing TwapHistoryStatus enum (waitingForTrigger, stopped) alongside the previously-known activated/terminated/finished/error, and that state carries twapId as a sibling field rather than nested inside state. - userTwapSliceFillsByTime: identical array-of-{fill,twapId} shape to the existing userTwapSliceFills, just filtered by time range - same pattern as userFills/userFillsByTime. - activeAssetData: matches the docs (user, coin, leverage{type,value}, maxTradeSzs, availableToTrade, markPx). The isolated-margin HIP-3 dex variant that adds "rawUsd" to the leverage object is doc-sourced only, not independently verified live (noted in code comments and PR description). Adds RestApi + WebsocketApi methods (sync/async + post-over-websocket) for all three, request-builder and response-parser tests, and marks the three rows ✅ in README's endpoint coverage table. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Le73N2EZjGLsCqmCScAse --- README.md | 8 +- examples/rest_leverage.cpp | 8 + examples/rest_twap_slices.cpp | 31 ++++ include/hyperliquid/rest/RestApi.h | 12 ++ .../hyperliquid/rest/RestApiMessageParser.h | 3 + .../hyperliquid/rest/RestEndpointListener.h | 3 + include/hyperliquid/types/RequestTypes.h | 9 + include/hyperliquid/types/ResponseTypes.h | 20 ++- include/hyperliquid/websocket/WebsocketApi.h | 8 + .../websocket/WebsocketApiListener.h | 3 + src/messages/InfoRequestBuilder.cpp | 31 ++++ src/messages/InfoRequestBuilder.h | 6 + src/rest/RestApi.cpp | 41 +++++ src/rest/RestApiMessageParser.cpp | 163 ++++++++++++++++++ src/websocket/PostResponseDispatch.cpp | 9 + src/websocket/WebsocketApi.cpp | 24 +++ tests/rest_account_test.cpp | 125 ++++++++++++++ tests/rest_market_data_test.cpp | 59 +++++++ tests/websocket_api_test.cpp | 3 + 19 files changed, 561 insertions(+), 5 deletions(-) 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..f7109c1 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -344,12 +344,16 @@ namespace hyperliquid std::vector states; }; - enum class TwapHistoryStatus { Activated, Terminated, Finished, Error, Unknown }; + // Confirmed live against mainnet (twapHistory info endpoint): activated, terminated, + // waitingForTrigger, stopped, finished, error have all been observed on real accounts. + 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 +365,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 +379,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 +500,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 +1164,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..2404606 100644 --- a/include/hyperliquid/websocket/WebsocketApiListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -40,7 +40,10 @@ class WebsocketApiListener { virtual void onPostResponse(const SpotClearinghouseStateResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const FrontendOpenOrdersResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const HistoricalOrdersResponse&, std::optional = std::nullopt) {} + // userTwapSliceFills/userTwapSliceFillsByTime share this response shape. 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..ebf4e37 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,29 @@ namespace hyperliquid return fill; } + // Shared by twapOrder/twapCancel-style state shapes wherever a REST endpoint embeds one + // (currently just twapHistory's "state" object). Mirrors WebsocketMessageParser's + // crackTwapState. Only reads fields through "timestamp" - "trigger"/"stopPx" also appear on + // the wire (trigger-TWAP orders) but aren't modeled by TwapState yet, and it's safe to leave + // trailing fields unread since simdjson's ondemand only requires in-order reads, not + // exhaustive ones. + 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 +2426,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 +3377,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")); From 3d27a64e106fe923a96f19b44b2c87e86b26afe6 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Tue, 8 Sep 2026 12:56:44 +0100 Subject: [PATCH 2/2] Remove unnecessary comments flagged in review Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Le73N2EZjGLsCqmCScAse --- include/hyperliquid/types/ResponseTypes.h | 2 -- include/hyperliquid/websocket/WebsocketApiListener.h | 1 - src/rest/RestApiMessageParser.cpp | 6 ------ 3 files changed, 9 deletions(-) diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index f7109c1..51cd82b 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -344,8 +344,6 @@ namespace hyperliquid std::vector states; }; - // Confirmed live against mainnet (twapHistory info endpoint): activated, terminated, - // waitingForTrigger, stopped, finished, error have all been observed on real accounts. enum class TwapHistoryStatus { Activated, Terminated, WaitingForTrigger, Stopped, Finished, Error, Unknown }; inline TwapHistoryStatus stringToTwapHistoryStatus(std::string_view s) diff --git a/include/hyperliquid/websocket/WebsocketApiListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h index 2404606..ba932cb 100644 --- a/include/hyperliquid/websocket/WebsocketApiListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -40,7 +40,6 @@ class WebsocketApiListener { virtual void onPostResponse(const SpotClearinghouseStateResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const FrontendOpenOrdersResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const HistoricalOrdersResponse&, std::optional = std::nullopt) {} - // userTwapSliceFills/userTwapSliceFillsByTime share this response shape. 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) {} diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index ebf4e37..f9fa513 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -1488,12 +1488,6 @@ namespace hyperliquid return fill; } - // Shared by twapOrder/twapCancel-style state shapes wherever a REST endpoint embeds one - // (currently just twapHistory's "state" object). Mirrors WebsocketMessageParser's - // crackTwapState. Only reads fields through "timestamp" - "trigger"/"stopPx" also appear on - // the wire (trigger-TWAP orders) but aren't modeled by TwapState yet, and it's safe to leave - // trailing fields unread since simdjson's ondemand only requires in-order reads, not - // exhaustive ones. TwapState parseTwapStateEntry(simdjson::ondemand::object& obj) { TwapState state{};