diff --git a/CMakeLists.txt b/CMakeLists.txt index c3dcf25..9cd8cc6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,18 @@ set(POLYMARKET_CLIENT_SOURCES src/evm_reorg_reconciliation.cpp src/evm_utils.cpp src/gamma_market_fetcher.cpp + src/data_analytics_client.cpp + src/data_analytics_parsing.cpp + src/data_analytics_query.cpp + src/data_client.cpp + src/data_leaderboard_client.cpp + src/data_leaderboard_parsing.cpp + src/data_leaderboard_query.cpp + src/data_parsing.cpp + src/data_portfolio_client.cpp + src/data_portfolio_parsing.cpp + src/data_portfolio_query.cpp + src/data_query.cpp src/gamma_catalog_client.cpp src/gamma_catalog_parsing.cpp src/gamma_catalog_query.cpp @@ -123,6 +135,8 @@ set(POLYMARKET_CLIENT_SOURCES src/gamma_query.cpp src/gamma_social_client.cpp src/gamma_social_parsing.cpp + src/json_fields.cpp + src/query_builder.cpp src/geoblock.cpp src/http_client.cpp src/http_client_connection.cpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index cab570c..9fb361a 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -202,6 +202,42 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_gamma_social PRIVATE polymarket::client) add_test(NAME test_gamma_social COMMAND test_gamma_social) + add_executable(test_data_parsing tests/test_data_parsing.cpp) + target_include_directories(test_data_parsing PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_data_parsing PRIVATE polymarket::client) + add_test(NAME test_data_parsing COMMAND test_data_parsing) + + add_executable(test_data_client tests/test_data_client.cpp) + target_link_libraries(test_data_client PRIVATE polymarket::client) + add_test(NAME test_data_client COMMAND test_data_client) + + add_executable(test_data_portfolio_parsing tests/test_data_portfolio_parsing.cpp) + target_include_directories(test_data_portfolio_parsing PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_data_portfolio_parsing PRIVATE polymarket::client) + add_test(NAME test_data_portfolio_parsing COMMAND test_data_portfolio_parsing) + + add_executable(test_data_portfolio_client tests/test_data_portfolio_client.cpp) + target_link_libraries(test_data_portfolio_client PRIVATE polymarket::client) + add_test(NAME test_data_portfolio_client COMMAND test_data_portfolio_client) + + add_executable(test_data_analytics_parsing tests/test_data_analytics_parsing.cpp) + target_include_directories(test_data_analytics_parsing PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_data_analytics_parsing PRIVATE polymarket::client) + add_test(NAME test_data_analytics_parsing COMMAND test_data_analytics_parsing) + + add_executable(test_data_analytics_client tests/test_data_analytics_client.cpp) + target_link_libraries(test_data_analytics_client PRIVATE polymarket::client) + add_test(NAME test_data_analytics_client COMMAND test_data_analytics_client) + + add_executable(test_data_leaderboard_parsing tests/test_data_leaderboard_parsing.cpp) + target_include_directories(test_data_leaderboard_parsing PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_data_leaderboard_parsing PRIVATE polymarket::client) + add_test(NAME test_data_leaderboard_parsing COMMAND test_data_leaderboard_parsing) + + add_executable(test_data_leaderboard_client tests/test_data_leaderboard_client.cpp) + target_link_libraries(test_data_leaderboard_client PRIVATE polymarket::client) + add_test(NAME test_data_leaderboard_client COMMAND test_data_leaderboard_client) + add_executable(test_clob_pagers tests/test_clob_pagers.cpp) target_link_libraries(test_clob_pagers PRIVATE polymarket::client) add_test(NAME test_clob_pagers COMMAND test_clob_pagers) diff --git a/include/polymarket/data_analytics_types.hpp b/include/polymarket/data_analytics_types.hpp new file mode 100644 index 0000000..49b1b6a --- /dev/null +++ b/include/polymarket/data_analytics_types.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +// Data API (/v2) market analytics types, following the official py-sdk models. +// Units and the empty and unset rules are those of data_types.hpp, and `raw` +// holds the whole object as received. +namespace polymarket +{ + // A holder of one outcome. The PnL fields are set only when holders are + // requested with include_pnl. + struct DataHolder + { + std::string wallet; // proxy_wallet + std::string asset_id; // CLOB token ID + double amount{0.0}; // shares + std::optional outcome_index; + bool display_username_public{false}; + bool verified{false}; + std::string name; + std::string pseudonym; + std::string bio; + std::string profile_image; + std::string profile_image_optimized; + std::optional avg_price; + std::optional entry_cost_usdc; + std::optional current_price; + std::optional current_value; + std::optional realized_pnl; + std::optional unrealized_pnl; + std::optional total_pnl; + nlohmann::json raw; + }; + + // Holders of one outcome asset. A page holds up to the page size per + // outcome, so one asset can span pages; merge groups by `asset_id`. + struct DataAssetHolders + { + std::string asset_id; + std::vector holders; + }; + + // Open interest in USDC; an empty `condition_id` is global interest. + struct DataOpenInterest + { + std::string condition_id; + double value{0.0}; + nlohmann::json raw; + }; + + struct DataMarketLiveVolume + { + std::string condition_id; // may be empty + double taker_volume{0.0}; // shares + }; + + struct DataLiveVolume + { + double taker_volume_total{0.0}; // shares + std::vector markets; + nlohmann::json raw; + }; + + // A price in USDC per share; a resolution of 0 is an exact tick. + struct DataPricePoint + { + std::int64_t timestamp{0}; + double price{0.0}; + std::int64_t resolution_seconds{0}; + }; + + // A market resolution. Times are Unix seconds. `status` is initialized, + // posed, proposed, challenged, reproposed, disputed, active, arbitration + // or resolved; `settlement_time_basis` is managed_proposal_expiration, + // proposal_expiration, liveness or dvm_round_estimate; `market_type` is + // BINARY, INCREMENTAL_NEGRISK or ATOMIC_NEGRISK; `resolution_source` is + // reported or derived; `reporter` is UMA_OO, CHAINLINK or EOA. Oracle + // prices are as reported, with the oracle's "no price" (69) unset, and + // `payouts` are USDC per share for outcomes 0 and 1. + struct DataResolution + { + std::string question_id; + std::string condition_id; + std::string status; + bool extended_review{false}; + // Earliest estimated settlement time, not a deadline; it may have passed. + std::optional expected_settlement_time; + std::string settlement_time_basis; + bool was_disputed{false}; + bool question_rules_updated{false}; // new_version_q + std::optional proposed_price; + std::optional reproposed_price; + std::optional price; + std::string transaction_hash; + std::optional log_index; + std::int64_t last_updated_at{0}; + std::string market_type; + std::optional> payouts; + std::string resolution_source; + std::string reporter; + std::optional was_arbitrated; + std::optional resolved_block; + std::optional resolved_at; + nlohmann::json raw; + }; + +} // namespace polymarket diff --git a/include/polymarket/data_client.hpp b/include/polymarket/data_client.hpp new file mode 100644 index 0000000..40bfe99 --- /dev/null +++ b/include/polymarket/data_client.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include "polymarket/data_analytics_types.hpp" +#include "polymarket/data_leaderboard_types.hpp" +#include "polymarket/data_portfolio_types.hpp" +#include "polymarket/data_requests.hpp" +#include "polymarket/data_types.hpp" +#include "polymarket/environment.hpp" +#include "polymarket/http_client.hpp" +#include "polymarket/pagination.hpp" +#include "polymarket/rate_limit.hpp" +#include "polymarket/sdk_error.hpp" + +#include +#include +#include +#include +#include +#include + +namespace polymarket +{ + // Typed client for the Data API v2 (Environment::data_url). Calls may run + // from several threads; requests on one client are serialized. Invalid + // arguments fail with InvalidArgument before any request. Pagers page by + // server cursor, at most 1000 rows per page unless noted, and borrow this + // client, which must outlive them. + class DataClient + { + public: + explicit DataClient(Environment environment = Environment::production(), + const std::optional &http_options = std::nullopt); + + DataClient(const DataClient &) = delete; + DataClient &operator=(const DataClient &) = delete; + + // Trades across Polymarket, or one wallet's with `filter.user`. + Paginator trades_pager(const DataTradeFilter &filter = {}, + std::size_t page_size = 100, + const PageRequest &resume = {}); + // A wallet's activity, deposits and withdrawals included. + Paginator activity_pager(const std::string &user, + const DataActivityFilter &filter = {}, + std::size_t page_size = 100, + const PageRequest &resume = {}); + + Paginator positions_pager(const DataPositionFilter &filter, + std::size_t page_size = 100, + const PageRequest &resume = {}); + // Condition IDs follow DataPositionFilter's rules. + Result + get_portfolio_value(const std::string &user, + const std::vector &condition_ids = {}); + // std::nullopt when the Data API has no stats for the wallet. + Result> get_user_stats(const std::string &user); + // `interval` and `fidelity` are the DataPnlSeries values, or "" for the + // server default. + Result get_user_pnl(const std::string &user, + const std::string &interval = "", + const std::string &fidelity = ""); + Result get_user_volume(const std::string &user, + const DataTimeRange &range = {}); + // The wallet's accounting snapshot as zip archive bytes. + Result download_accounting_snapshot(const std::string &user); + + // Holders of the markets' outcomes. `condition_ids` is required and + // follows DataPositionFilter's rules; `page_size` applies per outcome. + Paginator holders_pager(const std::vector &condition_ids, + const DataHolderFilter &filter = {}, + std::size_t page_size = 100, + const PageRequest &resume = {}); + // Global open interest when `condition_ids` is empty. + Result> + get_open_interests(const std::vector &condition_ids = {}); + // Combined taker volume of the events, which are required. + Result get_event_live_volume(const std::vector &event_ids); + // Prices oldest first, at most 10000 per page. + Paginator price_history_pager(const std::string &asset_id, + const DataPriceHistoryFilter &filter, + std::size_t page_size = 10000, + const PageRequest &resume = {}); + // Rows the Data API does not know are left out. + Result> get_resolutions(const DataResolutionFilter &filter); + + Paginator + trader_leaderboard_pager(const DataLeaderboardFilter &filter = {}, + std::size_t page_size = 100, const PageRequest &resume = {}); + // std::nullopt when the trader is not on the leaderboard. + Result> + get_trader_standing(const std::string &user, const DataLeaderboardFilter &filter = {}); + Paginator biggest_winners_pager(const DataLeaderboardFilter &filter = {}, + std::size_t page_size = 100, + const PageRequest &resume = {}); + + // Reads that get HTTP 429 are retried after the server's Retry-After + // delay. Defaults to RateLimitRetry{}; std::nullopt disables retries. + void set_rate_limit_retry(std::optional policy); + + const Environment &environment() const { return environment_; } + + private: + Environment environment_; + HttpClient http_; + mutable std::mutex rate_limit_mutex_; + std::optional rate_limit_retry_{RateLimitRetry{}}; + + HttpResponse get(const std::string &path); + template + Paginator cursor_pager(const char *endpoint, std::size_t page_size, + std::size_t max_page_size, const PageRequest &resume, + BuildQuery build_query, ParsePage parse_page); + template + Result fetch(BuildPath build_path, Parse parse); + }; + +} // namespace polymarket diff --git a/include/polymarket/data_leaderboard_types.hpp b/include/polymarket/data_leaderboard_types.hpp new file mode 100644 index 0000000..c517c41 --- /dev/null +++ b/include/polymarket/data_leaderboard_types.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include +#include + +// Data API (/v2) leaderboard types, following the official py-sdk models. PnL +// and values are USDC and volume is shares. The empty and unset rules are +// those of data_types.hpp, and `raw` holds the whole object as received. +namespace polymarket +{ + // A ranked trader. Ranks can tie and skip. + struct DataTraderEntry + { + std::int64_t rank{0}; + std::string wallet; // user_id + double pnl{0.0}; + double volume{0.0}; + std::string user_name; + std::string profile_image; + std::string x_username; + bool verified{false}; + nlohmann::json raw; + }; + + // One trader's ranks; a rank is unset when the trader is unranked. + struct DataTraderStanding + { + std::string wallet; // user_id + double pnl{0.0}; + double volume{0.0}; + std::optional pnl_rank; + std::optional volume_rank; + std::string user_name; + std::string profile_image; + std::string x_username; + bool verified{false}; + nlohmann::json raw; + }; + + // A winning position. `kind` is "market" or "combo". A market winner has + // its condition ID, outcome asset ID and event. A combo winner has the + // common fields, a combo condition ID in its 31-byte form, lowercased, and + // its position ID in `asset_id`; its event fields are empty. + struct DataBiggestWinner + { + std::int64_t rank{0}; // win_rank + std::string kind; + std::string wallet; // user_id + double pnl{0.0}; + double initial_value{0.0}; + double final_value{0.0}; + std::int64_t resolved_at{0}; // Unix seconds + std::string user_name; + std::string profile_image; + std::string event_title; + std::string condition_id; + std::string asset_id; // position_id + std::string event_id; + std::string event_slug; + nlohmann::json raw; + }; + +} // namespace polymarket diff --git a/include/polymarket/data_portfolio_types.hpp b/include/polymarket/data_portfolio_types.hpp new file mode 100644 index 0000000..7d1c074 --- /dev/null +++ b/include/polymarket/data_portfolio_types.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include + +#include +#include +#include +#include + +// Data API (/v2) portfolio types, following the official py-sdk models. Units +// and the empty and unset rules are those of data_types.hpp: amounts, costs, +// values and PnL are USDC, sizes and volumes are shares, percentages are +// percentages, and `raw` holds the whole object as received. +namespace polymarket +{ + struct DataPosition + { + std::string wallet; // proxy_wallet + std::string asset_id; // CLOB token ID + std::string condition_id; + double current_size{0.0}; + double avg_price{0.0}; + double entry_cost_usdc{0.0}; // fee-exclusive + double entry_fees_usdc{0.0}; + double total_cost_usdc{0.0}; + double current_price{0.0}; + double current_value{0.0}; + double total_size{0.0}; + double realized_pnl{0.0}; + double unrealized_pnl{0.0}; + double total_pnl{0.0}; + double percent_pnl{0.0}; + double percent_realized_pnl{0.0}; + // OPEN, REDEEMABLE or CLOSED. A lost position reports REDEEMABLE and a + // mergeable one OPEN. + std::string status; + bool redeemable{false}; + bool mergeable{false}; + bool negative_risk{false}; + bool archived{false}; + bool verified{false}; + std::string title; + std::string slug; + std::string icon; + std::string event_slug; + std::string event_id; + std::string outcome; + std::optional outcome_index; + std::string opposite_outcome; + std::string opposite_asset_id; + std::string end_date; // YYYY-MM-DD + std::optional last_event_at; + std::optional first_entry_at; + std::string name; + std::string profile_image; + nlohmann::json raw; + }; + + struct DataPortfolioValue + { + std::string wallet; + double value{0.0}; + nlohmann::json raw; + }; + + // Cumulative wallet metrics at one time. Amounts the Data API leaves out + // stay unset. + struct DataPnlPoint + { + std::int64_t timestamp{0}; + std::int64_t source_block{0}; + std::int64_t trade_count{0}; + double realized_market_pnl{0.0}; + double realized_lp_pnl{0.0}; + double realized_combo_pnl{0.0}; + double realized_pnl{0.0}; + double volume{0.0}; // shares + double volume_usdc{0.0}; + std::optional unrealized_pnl; + std::optional fees_refunded; + std::optional maker_rebate; + std::optional taker_rebate; + std::optional reward_income; + std::optional yield_income; + std::optional referral_income; + std::optional sponsored_income; + std::optional deposits; + std::optional withdrawals; + std::optional cashflow_net; + std::optional wallet_income; + std::optional position_pnl; + std::optional settled_pnl; + std::optional economic_pnl; + std::optional trade_pnl; + std::optional fees; + std::optional fees_paid; + nlohmann::json raw; + }; + + struct DataUserStats + { + std::string wallet; + std::int64_t traded_market_count{0}; // trades + double biggest_win{0.0}; + std::int64_t views{0}; + std::optional join_date; + std::optional all_time_pnl; + nlohmann::json raw; + }; + + // A cumulative PnL series. `interval` is max, all, 1m, 1w, 1d, 12h or 6h; + // `fidelity` and `source_fidelity` are 1d, 18h, 12h, 3h or 1h. Each point + // keeps its own `raw`. + struct DataPnlSeries + { + std::string wallet; + std::string interval; + std::string fidelity; + std::string source_fidelity; + std::vector points; + }; + + struct DataUserVolume + { + double volume{0.0}; // shares + double volume_usdc{0.0}; + std::int64_t trade_count{0}; + nlohmann::json raw; + }; + +} // namespace polymarket diff --git a/include/polymarket/data_requests.hpp b/include/polymarket/data_requests.hpp new file mode 100644 index 0000000..e0cd82a --- /dev/null +++ b/include/polymarket/data_requests.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include + +// Request filters for DataClient, matching the official SDK. Unset optionals, +// empty strings and empty lists are not sent. Condition IDs are 31- or 32-byte +// hex (at most 20 distinct) and event IDs positive 32-bit integers; a filter +// takes condition IDs or event IDs, not both. Times are Unix seconds between +// 1 and 9999-12-31; `full_history` requests all history and cannot be combined +// with `start` or `end`. +namespace polymarket +{ + struct DataTradeFilter + { + std::string user; // all wallets when empty + std::vector condition_ids; + std::vector event_ids; + std::string side; // BUY or SELL + std::optional taker_only; + std::string filter_type; // CASH or TOKENS, with filter_amount + std::optional filter_amount; // finite and non-negative + // Time filters apply only when `user` is set. + std::optional start; + std::optional end; + bool full_history{false}; + }; + + struct DataActivityFilter + { + std::vector condition_ids; + std::vector event_ids; + std::vector types; // DataActivity::type values to keep + std::string side; // BUY or SELL + std::string sort_direction; // ASC or DESC + std::optional start; + std::optional end; + bool full_history{false}; + }; + + // Positions need `user`, or exactly one condition ID without one. Condition + // IDs here are 32-byte, or 31-byte structural market IDs starting 0x01 or + // 0x02, which are padded with 00; all are sent lowercased. Positions are + // unbounded in time by default. + struct DataPositionFilter + { + std::string user; + std::vector condition_ids; + std::vector event_ids; // requires `user` + // OPEN, REDEEMABLE, CLOSED, or REDEEMABLE_LOST and MERGEABLE, which + // require `user`. + std::string status; + std::string title; // at most 200 characters; blank is not sent + std::string filter_type; // CASH or TOKENS, with filter_amount + std::optional filter_amount; // finite and non-negative + std::optional include_archived; // not true with CLOSED + // CURRENT_VALUE, PRICE, TOKENS, UNREALIZED_PNL, REALIZED_PNL, TOTAL_PNL + // or TIMESTAMP. + std::string sort_by; + std::string sort_direction; // ASC or DESC + std::optional start; + std::optional end; + }; + + struct DataTimeRange + { + std::optional start; + std::optional end; + bool full_history{false}; + }; + + struct DataHolderFilter + { + std::optional min_balance; // shares; finite and non-negative + // Adds PnL fields; needs exactly one condition ID and caps pages at 100. + std::optional include_pnl; + }; + + // Exactly one of `interval`, `start` or `as_of`. `interval` is max, all, + // 1m, 1w, 1d, 6h or 1h. A start/end window (end exclusive, now when unset) + // spans at most 15 days. `as_of` returns the price at that time and takes + // neither `bucket_seconds` nor a page size other than the default. + // `bucket_seconds` is at most 86400 and at least 600 for max, all and 1m, + // 300 for 1w and 60 otherwise. + struct DataPriceHistoryFilter + { + std::string interval; + std::optional start; + std::optional end; + std::optional as_of; + std::optional bucket_seconds; + }; + + // Exactly one selector. Condition IDs follow DataPositionFilter's rules; + // at most 20 distinct event IDs. + struct DataResolutionFilter + { + std::string question_id; + std::vector condition_ids; + std::vector event_ids; + }; + + struct DataLeaderboardFilter + { + std::string category; // all categories when empty + std::string window; // day, week, month or all + // PNL or VOLUME; only the trader leaderboard sorts. + std::string sort_by; + }; + +} // namespace polymarket diff --git a/include/polymarket/data_types.hpp b/include/polymarket/data_types.hpp new file mode 100644 index 0000000..19295e8 --- /dev/null +++ b/include/polymarket/data_types.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include +#include +#include + +// Data API (/v2) types, following the official py-sdk models. Amounts are USDC, +// sizes and shares are outcome units and prices are USDC per share; they are +// reporting values, so they are doubles. Timestamps are Unix seconds. An absent +// or empty Data API value leaves a string empty and an optional unset, and an +// outcome index of 999 (Data API's "none") reads as unset. `raw` holds the whole +// object as received. +namespace polymarket +{ + struct DataTrade + { + std::string wallet; // proxy_wallet + std::string asset_id; // CLOB token ID + std::string condition_id; + std::string side; // BUY or SELL + double size{0.0}; + double price{0.0}; + std::int64_t timestamp{0}; + std::string transaction_hash; + std::string title; + std::string slug; + std::string icon; + std::string event_slug; + std::string outcome; + std::optional outcome_index; + std::string name; + std::string pseudonym; + std::string bio; + std::string profile_image; + std::string profile_image_optimized; + nlohmann::json raw; + }; + + // One wallet activity row. `type` is TRADE, SPLIT, MERGE, REDEEM, + // CONVERSION, REWARD, DEPOSIT, WITHDRAWAL, MAKER_REBATE, TAKER_REBATE, + // REFERRAL_REWARD, MIGRATION, TIP, YIELD, or a newer kind kept as received + // (then only the wallet, time, hash and profile fields are read; the rest + // is in `raw`). Fields a kind does not carry stay empty: + // - TRADE: condition_id, asset_id, side, shares, amount, price, outcome, + // outcome_index and market text. A combo trade (is_combo) has a combo + // condition_id, a position ID in asset_id, and only title and icon text. + // - SPLIT, MERGE, REDEEM, CONVERSION: condition_id, amount and market text. + // - Account credits (REWARD through YIELD): amount; TIP also tip_side. + struct DataActivity + { + std::string type; + bool known{false}; // one of the kinds listed above + bool is_combo{false}; + std::string wallet; + std::optional timestamp; // always set for a known kind + std::string transaction_hash; + std::string condition_id; + std::string asset_id; + std::string side; // BUY or SELL for a trade + std::optional shares; // size + std::optional amount; // usdc_size + std::optional price; + std::string outcome; + std::optional outcome_index; + std::string title; + std::string slug; + std::string icon; + std::string event_slug; + std::string tip_side; // IN or OUT for a tip + std::string name; + std::string pseudonym; + std::string bio; + std::string profile_image; + std::string profile_image_optimized; + nlohmann::json raw; + }; + +} // namespace polymarket diff --git a/src/data_analytics_client.cpp b/src/data_analytics_client.cpp new file mode 100644 index 0000000..3e08a5e --- /dev/null +++ b/src/data_analytics_client.cpp @@ -0,0 +1,72 @@ +#include "data_client_internal.hpp" +#include "data_parsing.hpp" +#include "data_query.hpp" + +#include +#include + +namespace polymarket +{ + namespace + { + constexpr std::size_t max_pnl_holders_page_size = 100; + + std::int64_t now_seconds() + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + } + } // namespace + + Paginator + DataClient::holders_pager(const std::vector &condition_ids, + const DataHolderFilter &filter, std::size_t page_size, + const PageRequest &resume) + { + const auto cap = filter.include_pnl.value_or(false) ? max_pnl_holders_page_size + : detail::data_max_page_size; + return cursor_pager( + "/v2/holders", page_size, cap, resume, + [&] { return detail::data_holders_query(condition_ids, filter); }, + detail::parse_data_holders_page); + } + + Result> + DataClient::get_open_interests(const std::vector &condition_ids) + { + return fetch>( + [&] { return "/v2/oi" + detail::data_open_interest_query(condition_ids); }, + detail::parse_data_open_interests); + } + + Result + DataClient::get_event_live_volume(const std::vector &event_ids) + { + return fetch( + [&] { return "/v2/live-volume" + detail::data_live_volume_query(event_ids); }, + detail::parse_data_live_volume); + } + + Paginator DataClient::price_history_pager(const std::string &asset_id, + const DataPriceHistoryFilter &filter, + std::size_t page_size, + const PageRequest &resume) + { + return cursor_pager( + "/v2/prices-history", page_size, detail::data_price_history_page_size, resume, + [&] + { + return detail::data_price_history_query(asset_id, filter, page_size, now_seconds()); + }, + detail::parse_data_price_history_page); + } + + Result> + DataClient::get_resolutions(const DataResolutionFilter &filter) + { + return fetch>( + [&] { return "/v2/resolutions" + detail::data_resolution_query(filter); }, + detail::parse_data_resolutions); + } +} // namespace polymarket diff --git a/src/data_analytics_parsing.cpp b/src/data_analytics_parsing.cpp new file mode 100644 index 0000000..d72575a --- /dev/null +++ b/src/data_analytics_parsing.cpp @@ -0,0 +1,198 @@ +#include "data_parsing.hpp" + +#include +#include +#include +#include + +// Field names and rules follow py-sdk models/data/analytics.py and resolutions.py. +namespace polymarket::detail +{ + namespace + { + const json &envelope_list(const json &body) + { + const auto &data = data_envelope(body); + if (!data.is_array()) throw std::invalid_argument("Data API data must be an array"); + return data; + } + + std::optional oracle_price(const json &item, const char *key) + { + const auto *value = find_field(item, key); + if (value != nullptr && (*value == 69 || *value == "69")) return std::nullopt; + return field_number(item, key); + } + + std::optional log_index(const json &item, const char *key) + { + const auto *value = find_field(item, key); + if (value == nullptr || *value == "") return std::nullopt; + if (value->is_string()) + { + const auto &text = value->get_ref(); + if (text.size() > 18 || text.find_first_not_of("0123456789") != std::string::npos) + throw_bad_field(key, "an integer"); + return std::stoll(text); + } + return field_integer(item, key); + } + + std::optional> payouts(const json &item) + { + const auto *value = find_field(item, "payouts"); + if (value == nullptr) return std::nullopt; + if (!value->is_array() || value->size() != 2) + throw_bad_field("payouts", "a payout pair"); + const json pair = {{"0", value->at(0)}, {"1", value->at(1)}}; + const auto first = field_number(pair, "0"); + const auto second = field_number(pair, "1"); + if (!first || !second || !std::isfinite(*first) || !std::isfinite(*second)) + throw_bad_field("payouts", "a pair of finite amounts"); + return std::pair{*first / 1e6, *second / 1e6}; + } + + DataAssetHolders parse_asset_holders(const json &item) + { + DataAssetHolders group; + group.asset_id = field_required_string(item, "token_id"); + const auto *holders = find_field(item, "holders"); + if (holders == nullptr || !holders->is_array()) throw_bad_field("holders", "an array"); + group.holders.reserve(holders->size()); + for (const auto &holder : *holders) + group.holders.push_back(parse_data_holder(holder)); + return group; + } + + DataPricePoint parse_price_point(const json &item) + { + DataPricePoint point; + point.timestamp = data_epoch_seconds(item, "timestamp"); + point.price = data_required_number(item, "price"); + point.resolution_seconds = data_required_integer(item, "resolution_seconds"); + return point; + } + } // namespace + + DataHolder parse_data_holder(const json &item) + { + DataHolder holder; + holder.wallet = field_required_string(item, "proxy_wallet"); + holder.asset_id = field_required_string(item, "token_id"); + holder.amount = data_required_number(item, "amount"); + holder.outcome_index = data_outcome_index(item, "outcome_index"); + holder.display_username_public = data_required_bool(item, "display_username_public"); + holder.verified = data_required_bool(item, "verified"); + holder.name = field_string(item, "name"); + holder.pseudonym = field_string(item, "pseudonym"); + holder.bio = field_string(item, "bio"); + holder.profile_image = field_string(item, "profile_image"); + holder.profile_image_optimized = field_string(item, "profile_image_optimized"); + holder.avg_price = field_number(item, "avg_price"); + holder.entry_cost_usdc = field_number(item, "entry_cost_usdc"); + holder.current_price = field_number(item, "current_price"); + holder.current_value = field_number(item, "current_value"); + holder.realized_pnl = field_number(item, "realized_pnl"); + holder.unrealized_pnl = field_number(item, "unrealized_pnl"); + holder.total_pnl = field_number(item, "total_pnl"); + holder.raw = item; + return holder; + } + + DataResolution parse_data_resolution(const json &item) + { + DataResolution resolution; + resolution.question_id = field_string(item, "question_id"); + resolution.condition_id = field_hex(item, "condition_id"); + resolution.status = + data_choice(item, "status", + {"initialized", "posed", "proposed", "challenged", "reproposed", "disputed", + "active", "arbitration", "resolved"}, + "a resolution status"); + resolution.extended_review = data_required_bool(item, "extended_review"); + resolution.expected_settlement_time = + data_optional_epoch_or_iso(item, "expected_settlement_time"); + resolution.settlement_time_basis = + data_optional_choice(item, "settlement_time_basis", + {"managed_proposal_expiration", "proposal_expiration", "liveness", + "dvm_round_estimate"}, + "a settlement time basis"); + resolution.was_disputed = data_required_bool(item, "was_disputed"); + resolution.question_rules_updated = data_required_bool(item, "new_version_q"); + resolution.proposed_price = oracle_price(item, "proposed_price"); + resolution.reproposed_price = oracle_price(item, "reproposed_price"); + resolution.price = oracle_price(item, "price"); + resolution.transaction_hash = field_string(item, "transaction_hash"); + resolution.log_index = log_index(item, "log_index"); + resolution.last_updated_at = data_epoch_or_iso(item, "last_update_timestamp"); + resolution.market_type = data_optional_choice( + item, "market_type", {"BINARY", "INCREMENTAL_NEGRISK", "ATOMIC_NEGRISK"}, + "a market type"); + resolution.payouts = payouts(item); + resolution.resolution_source = data_optional_choice( + item, "resolution_source", {"reported", "derived"}, "reported or derived"); + resolution.reporter = data_optional_choice(item, "reporter", {"UMA_OO", "CHAINLINK", "EOA"}, + "UMA_OO, CHAINLINK or EOA"); + resolution.was_arbitrated = field_bool(item, "was_arbitrated"); + resolution.resolved_block = field_integer(item, "resolved_block"); + resolution.resolved_at = data_optional_epoch_or_iso(item, "resolved_at"); + resolution.raw = item; + return resolution; + } + + Page parse_data_holders_page(const std::string &body) + { + return data_page(body, parse_asset_holders); + } + + std::vector parse_data_open_interests(const std::string &body) + { + const auto parsed = json::parse(body); + std::vector interests; + for (const auto &item : envelope_list(parsed)) + { + DataOpenInterest interest; + const auto *condition = find_field(item, "condition_id"); + if (condition == nullptr || *condition != "GLOBAL") + interest.condition_id = field_hex(item, "condition_id"); + interest.value = data_required_number(item, "value"); + interest.raw = item; + interests.push_back(std::move(interest)); + } + return interests; + } + + DataLiveVolume parse_data_live_volume(const std::string &body) + { + const auto parsed = json::parse(body); + const auto &data = data_envelope(parsed); + if (!data.is_object()) throw std::invalid_argument("Data API data must be an object"); + DataLiveVolume volume; + volume.taker_volume_total = data_required_number(data, "taker_volume_total"); + const auto *markets = find_field(data, "conditions"); + if (markets == nullptr || !markets->is_array()) throw_bad_field("conditions", "an array"); + for (const auto &item : *markets) + { + DataMarketLiveVolume market; + market.condition_id = field_hex(item, "condition_id"); + market.taker_volume = data_required_number(item, "taker_volume"); + volume.markets.push_back(std::move(market)); + } + volume.raw = data; + return volume; + } + + Page parse_data_price_history_page(const std::string &body) + { + return data_page(body, parse_price_point); + } + + std::vector parse_data_resolutions(const std::string &body) + { + const auto parsed = json::parse(body); + std::vector resolutions; + for (const auto &item : envelope_list(parsed)) + resolutions.push_back(parse_data_resolution(item)); + return resolutions; + } +} // namespace polymarket::detail diff --git a/src/data_analytics_query.cpp b/src/data_analytics_query.cpp new file mode 100644 index 0000000..669cf64 --- /dev/null +++ b/src/data_analytics_query.cpp @@ -0,0 +1,106 @@ +#include "data_query.hpp" + +#include +#include + +// Market analytics queries, following py-sdk _internal/actions/data.py. +namespace polymarket::detail +{ + namespace + { + constexpr std::int64_t max_price_window_seconds = 15 * 86400; + } // namespace + + std::string data_holders_query(const std::vector &condition_ids, + const DataHolderFilter &filter) + { + const auto conditions = data_market_condition_ids(condition_ids); + if (conditions.empty()) throw std::invalid_argument("condition_ids is required"); + data_check_amount("min_balance", filter.min_balance); + if (filter.include_pnl.value_or(false) && conditions.size() != 1) + throw std::invalid_argument("include_pnl requires exactly one condition"); + QueryBuilder query; + query.add_joined("condition_id", conditions); + query.add("min_balance", filter.min_balance); + query.add("include_pnl", filter.include_pnl); + return query.str(); + } + + std::string data_open_interest_query(const std::vector &condition_ids) + { + QueryBuilder query; + query.add_joined("condition_id", data_market_condition_ids(condition_ids)); + return query.str(); + } + + std::string data_live_volume_query(const std::vector &event_ids) + { + const auto events = data_event_ids(event_ids); + if (events.empty()) throw std::invalid_argument("event_ids is required"); + QueryBuilder query; + query.add_joined("event_id", events); + return query.str(); + } + + std::string data_price_history_query(const std::string &asset_id, + const DataPriceHistoryFilter &filter, + std::size_t page_size, std::int64_t now) + { + check_choice("interval", filter.interval, {"max", "all", "1m", "1w", "1d", "6h", "1h"}); + if (asset_id.empty()) throw std::invalid_argument("asset_id must be a non-empty string"); + const int selectors = + !filter.interval.empty() + filter.start.has_value() + filter.as_of.has_value(); + if (selectors != 1) + throw std::invalid_argument("Provide exactly one of interval, start, or as_of"); + if (filter.end && !filter.start) throw std::invalid_argument("end requires start"); + const auto start = data_timestamp(filter.start); + const auto end = data_timestamp(filter.end); + const auto as_of = data_timestamp(filter.as_of); + if (start) + { + const auto until = end.value_or(now); + if (until < *start || until - *start > max_price_window_seconds) + throw std::invalid_argument( + "Price history windows must not end before start or exceed 15 days"); + } + if (as_of && (filter.bucket_seconds || page_size != data_price_history_page_size)) + throw std::invalid_argument("as_of forbids bucket_seconds and page_size"); + if (filter.bucket_seconds) + { + const auto &interval = filter.interval; + const std::int64_t minimum = interval == "max" || interval == "all" || interval == "1m" + ? 600 + : interval == "1w" ? 300 + : 60; + if (*filter.bucket_seconds < minimum || *filter.bucket_seconds > 86400) + throw std::invalid_argument("bucket_seconds must be between " + + std::to_string(minimum) + " and 86400"); + } + QueryBuilder query; + query.add("token_id", asset_id); + query.add("interval", filter.interval); + query.add("start", start); + query.add("end", end); + query.add("as_of", as_of); + query.add("bucket_seconds", filter.bucket_seconds); + return query.str(); + } + + std::string data_resolution_query(const DataResolutionFilter &filter) + { + const int selectors = + !filter.question_id.empty() + !filter.condition_ids.empty() + !filter.event_ids.empty(); + if (selectors != 1) + throw std::invalid_argument( + "Provide exactly one of question_id, condition_ids, or event_ids"); + const auto conditions = data_market_condition_ids(filter.condition_ids); + const auto events = data_event_ids(filter.event_ids); + if (events.size() > 20) + throw std::invalid_argument("event_ids accepts at most 20 distinct values"); + QueryBuilder query; + query.add("question_id", filter.question_id); + query.add_joined("condition_id", conditions); + query.add_joined("event_id", events); + return query.str(); + } +} // namespace polymarket::detail diff --git a/src/data_client.cpp b/src/data_client.cpp new file mode 100644 index 0000000..c367894 --- /dev/null +++ b/src/data_client.cpp @@ -0,0 +1,60 @@ +#include "polymarket/data_client.hpp" + +#include "data_client_internal.hpp" +#include "data_parsing.hpp" +#include "data_query.hpp" +#include "rate_limit_internal.hpp" + +#include +#include + +namespace polymarket +{ + DataClient::DataClient(Environment environment, + const std::optional &http_options) + : environment_(std::move(environment)) + { + environment_.validate(); + if (http_options) + http_.configure(*http_options); + else + http_.set_timeout_ms(10000); + http_.set_base_url(environment_.data_url); + } + + void DataClient::set_rate_limit_retry(std::optional policy) + { + if (policy) detail::validate_rate_limit_retry(*policy); + std::lock_guard lock(rate_limit_mutex_); + rate_limit_retry_ = policy; + } + + HttpResponse DataClient::get(const std::string &path) + { + std::optional policy; + { + std::lock_guard lock(rate_limit_mutex_); + policy = rate_limit_retry_; + } + return detail::retry_rate_limited(policy, [&] { return http_.get(path); }); + } + + Paginator DataClient::trades_pager(const DataTradeFilter &filter, + std::size_t page_size, const PageRequest &resume) + { + return cursor_pager( + "/v2/trades", page_size, detail::data_max_page_size, resume, + [&] { return detail::data_trade_query(filter); }, detail::parse_data_trade_page); + } + + Paginator DataClient::activity_pager(const std::string &user, + const DataActivityFilter &filter, + std::size_t page_size, + const PageRequest &resume) + { + return cursor_pager( + "/v2/activity", page_size, detail::data_max_page_size, resume, + [&] { return detail::data_activity_query(user, filter); }, + detail::parse_data_activity_page); + } +} // namespace polymarket diff --git a/src/data_client_internal.hpp b/src/data_client_internal.hpp new file mode 100644 index 0000000..c9e2de9 --- /dev/null +++ b/src/data_client_internal.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "polymarket/data_client.hpp" +#include "rest_paging.hpp" + +#include +#include +#include +#include +#include + +// DataClient member templates shared by its translation units. +namespace polymarket +{ + namespace detail + { + inline constexpr std::size_t data_max_page_size = 1000; + } // namespace detail + + template + Result DataClient::fetch(BuildPath build_path, Parse parse) + { + return detail::fetch_result( + build_path, [this](const std::string &path) { return get(path); }, parse); + } + + template + Paginator DataClient::cursor_pager(const char *endpoint, std::size_t page_size, + std::size_t page_cap, const PageRequest &resume, + BuildQuery build_query, ParsePage parse_page) + { + std::string query; + std::optional invalid; + try + { + if (page_size == 0 || page_size > page_cap) + throw std::invalid_argument("page_size must be between 1 and " + + std::to_string(page_cap)); + query = build_query(); + } + catch (const std::invalid_argument &ex) + { + invalid = detail::invalid_argument_error(ex.what(), endpoint); + } + return detail::keyset_pager( + endpoint, query, page_size, resume, std::move(invalid), "cursor", + [this](const std::string &path) { return get(path); }, parse_page); + } +} // namespace polymarket diff --git a/src/data_leaderboard_client.cpp b/src/data_leaderboard_client.cpp new file mode 100644 index 0000000..e9b993e --- /dev/null +++ b/src/data_leaderboard_client.cpp @@ -0,0 +1,36 @@ +#include "data_client_internal.hpp" +#include "data_parsing.hpp" +#include "data_query.hpp" + +#include + +namespace polymarket +{ + Paginator + DataClient::trader_leaderboard_pager(const DataLeaderboardFilter &filter, std::size_t page_size, + const PageRequest &resume) + { + return cursor_pager( + "/v2/leaderboard", page_size, detail::data_max_page_size, resume, + [&] { return detail::data_trader_leaderboard_query(filter); }, + detail::parse_data_trader_leaderboard_page); + } + + Result> + DataClient::get_trader_standing(const std::string &user, const DataLeaderboardFilter &filter) + { + return fetch>( + [&] { return "/v2/leaderboard" + detail::data_trader_standing_query(user, filter); }, + detail::parse_data_trader_standing); + } + + Paginator + DataClient::biggest_winners_pager(const DataLeaderboardFilter &filter, std::size_t page_size, + const PageRequest &resume) + { + return cursor_pager( + "/v2/biggest-winners", page_size, detail::data_max_page_size, resume, + [&] { return detail::data_biggest_winners_query(filter); }, + detail::parse_data_biggest_winners_page); + } +} // namespace polymarket diff --git a/src/data_leaderboard_parsing.cpp b/src/data_leaderboard_parsing.cpp new file mode 100644 index 0000000..40c6ad6 --- /dev/null +++ b/src/data_leaderboard_parsing.cpp @@ -0,0 +1,100 @@ +#include "data_parsing.hpp" + +#include +#include + +// Field names and rules follow py-sdk models/data/leaderboard.py. +namespace polymarket::detail +{ + namespace + { + std::optional standing_rank(const json &item, const char *key) + { + const auto rank = field_integer(item, key); + if (rank && *rank == 0) return std::nullopt; + return rank; + } + + std::string positive_event_id(const json &item, const char *key) + { + const auto id = field_required_id(item, key); + if (id.find_first_not_of("0123456789") != std::string::npos || + id.find_first_not_of('0') == std::string::npos) + throw_bad_field(key, "a positive event ID"); + return id; + } + } // namespace + + DataTraderEntry parse_data_trader_entry(const json &item) + { + DataTraderEntry entry; + entry.rank = data_required_integer(item, "rank"); + entry.wallet = field_required_string(item, "user_id"); + entry.pnl = data_required_number(item, "pnl"); + entry.volume = data_required_number(item, "volume"); + entry.user_name = field_string(item, "user_name"); + entry.profile_image = field_string(item, "profile_image"); + entry.x_username = field_string(item, "x_username"); + entry.verified = data_required_bool(item, "verified"); + entry.raw = item; + return entry; + } + + DataBiggestWinner parse_data_biggest_winner(const json &item) + { + DataBiggestWinner winner; + winner.kind = data_choice(item, "kind", {"market", "combo"}, "market or combo"); + winner.rank = data_required_integer(item, "win_rank"); + winner.wallet = field_required_string(item, "user_id"); + winner.pnl = data_required_number(item, "pnl"); + winner.initial_value = data_required_number(item, "initial_value"); + winner.final_value = data_required_number(item, "final_value"); + winner.resolved_at = data_epoch_or_iso(item, "resolved_at"); + winner.user_name = field_string(item, "user_name"); + winner.profile_image = field_string(item, "profile_image"); + winner.event_title = field_string(item, "event_title"); + winner.asset_id = field_required_string(item, "position_id"); + if (winner.kind == "combo") + { + winner.condition_id = data_combo_condition_id(item, "condition_id"); + } + else + { + winner.condition_id = data_required_hex(item, "condition_id"); + winner.event_id = positive_event_id(item, "event_id"); + winner.event_slug = field_string(item, "event_slug"); + } + winner.raw = item; + return winner; + } + + Page parse_data_trader_leaderboard_page(const std::string &body) + { + return data_page(body, parse_data_trader_entry); + } + + std::optional parse_data_trader_standing(const std::string &body) + { + const auto parsed = json::parse(body); + const auto &data = data_envelope(parsed); + if (data.is_null()) return std::nullopt; + if (!data.is_object()) throw std::invalid_argument("Data API data must be an object"); + DataTraderStanding standing; + standing.wallet = field_required_string(data, "user_id"); + standing.pnl = data_required_number(data, "pnl"); + standing.volume = data_required_number(data, "volume"); + standing.pnl_rank = standing_rank(data, "rank_pnl"); + standing.volume_rank = standing_rank(data, "rank_volume"); + standing.user_name = field_string(data, "user_name"); + standing.profile_image = field_string(data, "profile_image"); + standing.x_username = field_string(data, "x_username"); + standing.verified = data_required_bool(data, "verified"); + standing.raw = data; + return standing; + } + + Page parse_data_biggest_winners_page(const std::string &body) + { + return data_page(body, parse_data_biggest_winner); + } +} // namespace polymarket::detail diff --git a/src/data_leaderboard_query.cpp b/src/data_leaderboard_query.cpp new file mode 100644 index 0000000..11a4412 --- /dev/null +++ b/src/data_leaderboard_query.cpp @@ -0,0 +1,56 @@ +#include "data_query.hpp" + +#include +#include + +// Leaderboard queries, following py-sdk _internal/actions/data.py. +namespace polymarket::detail +{ + namespace + { + void check_window(const DataLeaderboardFilter &filter) + { + check_choice("window", filter.window, {"day", "week", "month", "all"}); + } + + void reject_sort(const DataLeaderboardFilter &filter) + { + if (!filter.sort_by.empty()) + throw std::invalid_argument("sort_by applies only to the trader leaderboard"); + } + } // namespace + + std::string data_trader_leaderboard_query(const DataLeaderboardFilter &filter) + { + check_window(filter); + check_choice("sort_by", filter.sort_by, {"PNL", "VOLUME"}); + QueryBuilder query; + query.add("category", filter.category); + query.add("time_period", filter.window); + query.add("sort_by", filter.sort_by); + return query.str(); + } + + std::string data_trader_standing_query(const std::string &user, + const DataLeaderboardFilter &filter) + { + check_window(filter); + data_require_user(user); + reject_sort(filter); + QueryBuilder query; + query.add("user", user); + query.add("category", filter.category); + query.add("time_period", filter.window); + return query.str(); + } + + std::string data_biggest_winners_query(const DataLeaderboardFilter &filter) + { + check_window(filter); + reject_sort(filter); + QueryBuilder query; + query.add("category", filter.category); + query.add("time_period", filter.window); + return query.str(); + } +} // namespace polymarket::detail diff --git a/src/data_parsing.cpp b/src/data_parsing.cpp new file mode 100644 index 0000000..ac02075 --- /dev/null +++ b/src/data_parsing.cpp @@ -0,0 +1,290 @@ +#include "data_parsing.hpp" + +#include "market_time.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Field names and rules follow py-sdk _internal/data_envelope.py and +// models/data/activity.py and common.py. +namespace polymarket::detail +{ + namespace + { + constexpr double max_epoch_seconds = 253402300799.0; // 9999-12-31T23:59:59Z + + std::string order_side(const json &item, const char *key) + { + auto side = field_required_string(item, key); + if (side != "BUY" && side != "SELL") throw_bad_field(key, "BUY or SELL"); + return side; + } + + void profile_fields(const json &item, DataActivity &activity) + { + activity.name = field_string(item, "name"); + activity.pseudonym = field_string(item, "pseudonym"); + activity.bio = field_string(item, "bio"); + activity.profile_image = field_string(item, "profile_image"); + activity.profile_image_optimized = field_string(item, "profile_image_optimized"); + } + + void market_text(const json &item, DataActivity &activity) + { + activity.title = field_string(item, "title"); + activity.slug = field_string(item, "slug"); + activity.icon = field_string(item, "icon"); + activity.event_slug = field_string(item, "event_slug"); + } + + bool is_market_event(std::string_view type) + { + return type == "SPLIT" || type == "MERGE" || type == "REDEEM" || type == "CONVERSION"; + } + + bool is_account_credit(std::string_view type) + { + return type == "REWARD" || type == "DEPOSIT" || type == "WITHDRAWAL" || + type == "MAKER_REBATE" || type == "TAKER_REBATE" || type == "REFERRAL_REWARD" || + type == "MIGRATION" || type == "TIP" || type == "YIELD"; + } + } // namespace + + const json &data_page_items(const json &body, std::string &next_cursor) + { + if (!body.is_object()) throw std::invalid_argument("Data API page must be an object"); + const auto data = body.find("data"); + const auto pagination = body.find("pagination"); + if (data == body.end() || !data->is_array() || pagination == body.end() || + !pagination->is_object()) + throw std::invalid_argument("Data API page requires data and pagination"); + const auto has_more = pagination->find("has_more"); + const auto cursor = pagination->find("next_cursor"); + if (has_more == pagination->end() || !has_more->is_boolean() || cursor == pagination->end()) + throw std::invalid_argument("Data API page has invalid pagination metadata"); + if (!cursor->is_null() && + (!cursor->is_string() || cursor->get_ref().empty())) + throw std::invalid_argument("Data API page has an invalid next_cursor"); + if (has_more->get() == cursor->is_null()) + throw std::invalid_argument("Data API page has_more and next_cursor disagree"); + next_cursor = cursor->is_null() ? std::string() : cursor->get(); + return *data; + } + + const json &data_envelope(const json &body) + { + if (!body.is_object() || !body.contains("data")) + throw std::invalid_argument("Data API response is missing data"); + return body.at("data"); + } + + std::string data_required_hex(const json &object, const char *key) + { + auto text = field_hex(object, key); + if (text.empty()) throw_bad_field(key, "a 0x-prefixed hex string"); + return text; + } + + double data_required_number(const json &object, const char *key) + { + const auto number = field_number(object, key); + if (!number) throw_bad_field(key, "a number"); + return *number; + } + + bool data_required_bool(const json &object, const char *key) + { + const auto value = field_bool(object, key); + if (!value) throw_bad_field(key, "a boolean"); + return *value; + } + + std::int64_t data_required_integer(const json &object, const char *key) + { + const auto value = field_integer(object, key); + if (!value) throw_bad_field(key, "an integer"); + return *value; + } + + std::string data_choice(const json &object, const char *key, + std::initializer_list choices, const char *expected) + { + auto value = field_required_string(object, key); + for (const auto allowed : choices) + { + if (value == allowed) return value; + } + throw_bad_field(key, expected); + } + + std::string data_optional_choice(const json &object, const char *key, + std::initializer_list choices, + const char *expected) + { + if (find_field(object, key) == nullptr) return {}; + return data_choice(object, key, choices, expected); + } + + std::int64_t data_epoch_or_iso(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value != nullptr && value->is_string()) + { + const auto &text = value->get_ref(); + if (!text.empty() && text.find_first_not_of("0123456789") != std::string::npos) + { + const auto ms = parse_iso8601_ms(text); + if (ms == 0) throw_bad_field(key, "an ISO 8601 UTC time or Unix seconds"); + return static_cast(ms / 1000); + } + } + return data_epoch_seconds(object, key); + } + + std::optional data_optional_epoch_or_iso(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr || *value == "") return std::nullopt; + return data_epoch_or_iso(object, key); + } + + std::int64_t data_epoch_seconds(const json &object, const char *key) + { + const auto seconds = field_number(object, key); + if (!seconds || std::abs(*seconds) > max_epoch_seconds) + throw_bad_field(key, "Unix seconds"); + return static_cast(std::floor(*seconds)); + } + + std::optional data_optional_epoch_seconds(const json &object, const char *key) + { + const auto *time = find_field(object, key); + if (time == nullptr || *time == 0 || *time == "0" || *time == "") return std::nullopt; + return data_epoch_seconds(object, key); + } + + std::optional data_outcome_index(const json &object, const char *key) + { + const auto index = field_integer(object, key); + if (!index || *index == 999) return std::nullopt; + if (*index < std::numeric_limits::min() || *index > std::numeric_limits::max()) + throw_bad_field(key, "an outcome index"); + return static_cast(*index); + } + + std::string data_combo_condition_id(const json &object, const char *key) + { + auto id = data_required_hex(object, key); + std::transform(id.begin(), id.end(), id.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (id.size() == 66 && id.starts_with("0x03") && (id.ends_with("00") || id.ends_with("01"))) + id.resize(64); + if (id.size() != 64 || !id.starts_with("0x03")) + throw_bad_field(key, "a protocol v2 combo condition ID"); + return id; + } + + DataTrade parse_data_trade(const json &item) + { + DataTrade trade; + trade.wallet = field_required_string(item, "proxy_wallet"); + trade.asset_id = field_required_string(item, "token_id"); + trade.condition_id = data_required_hex(item, "condition_id"); + trade.side = order_side(item, "side"); + trade.size = data_required_number(item, "size"); + trade.price = data_required_number(item, "price"); + trade.timestamp = data_epoch_seconds(item, "timestamp"); + trade.transaction_hash = field_required_string(item, "transaction_hash"); + trade.title = field_string(item, "title"); + trade.slug = field_string(item, "slug"); + trade.icon = field_string(item, "icon"); + trade.event_slug = field_string(item, "event_slug"); + trade.outcome = field_string(item, "outcome"); + trade.outcome_index = data_outcome_index(item, "outcome_index"); + trade.name = field_string(item, "name"); + trade.pseudonym = field_string(item, "pseudonym"); + trade.bio = field_string(item, "bio"); + trade.profile_image = field_string(item, "profile_image"); + trade.profile_image_optimized = field_string(item, "profile_image_optimized"); + trade.raw = item; + return trade; + } + + DataActivity parse_data_activity(const json &item) + { + if (!item.is_object()) throw std::invalid_argument("Data API activity must be an object"); + DataActivity activity; + const auto *type = find_field(item, "type"); + activity.type = + type != nullptr && type->is_string() ? type->get() : std::string(); + activity.is_combo = field_bool(item, "is_combo").value_or(false); + activity.raw = item; + profile_fields(item, activity); + + const bool trade = activity.type == "TRADE"; + activity.known = + trade || is_market_event(activity.type) || is_account_credit(activity.type); + if (!activity.known) + { + activity.wallet = field_string(item, "proxy_wallet"); + activity.transaction_hash = field_string(item, "transaction_hash"); + activity.timestamp = data_optional_epoch_seconds(item, "timestamp"); + return activity; + } + + activity.wallet = field_required_string(item, "proxy_wallet"); + activity.timestamp = data_epoch_seconds(item, "timestamp"); + activity.transaction_hash = field_required_string(item, "transaction_hash"); + if (trade) + { + activity.condition_id = activity.is_combo + ? data_combo_condition_id(item, "condition_id") + : data_required_hex(item, "condition_id"); + activity.asset_id = field_required_string(item, "token_id"); + activity.side = order_side(item, "side"); + activity.shares = data_required_number(item, "size"); + activity.amount = data_required_number(item, "usdc_size"); + activity.price = data_required_number(item, "price"); + activity.title = field_string(item, "title"); + activity.icon = field_string(item, "icon"); + if (!activity.is_combo) + { + activity.slug = field_string(item, "slug"); + activity.event_slug = field_string(item, "event_slug"); + activity.outcome = field_string(item, "outcome"); + activity.outcome_index = data_outcome_index(item, "outcome_index"); + } + return activity; + } + activity.amount = data_required_number(item, "usdc_size"); + if (is_market_event(activity.type)) + { + activity.condition_id = data_required_hex(item, "condition_id"); + market_text(item, activity); + } + else if (activity.type == "TIP") + { + activity.tip_side = field_string(item, "side"); + if (!activity.tip_side.empty() && activity.tip_side != "IN" && + activity.tip_side != "OUT") + throw_bad_field("side", "IN or OUT"); + } + return activity; + } + + Page parse_data_trade_page(const std::string &body) + { + return data_page(body, parse_data_trade); + } + + Page parse_data_activity_page(const std::string &body) + { + return data_page(body, parse_data_activity); + } +} // namespace polymarket::detail diff --git a/src/data_parsing.hpp b/src/data_parsing.hpp new file mode 100644 index 0000000..9029548 --- /dev/null +++ b/src/data_parsing.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include "json_fields.hpp" +#include "polymarket/data_analytics_types.hpp" +#include "polymarket/data_leaderboard_types.hpp" +#include "polymarket/data_portfolio_types.hpp" +#include "polymarket/data_types.hpp" +#include "polymarket/pagination.hpp" + +#include +#include +#include +#include +#include +#include + +// Data API v2 response parsing. Malformed input throws std::exception. +namespace polymarket::detail +{ + // The `data` array of a `{data, pagination: {has_more, next_cursor}}` page; + // sets `next_cursor` ("" on the last page). + const json &data_page_items(const json &body, std::string &next_cursor); + // The `data` member of a `{data}` envelope; it may be null. + const json &data_envelope(const json &body); + + template + Page data_page(const std::string &body, Parse parse_item) + { + const auto parsed = json::parse(body); + Page page; + const auto &items = data_page_items(parsed, page.next_cursor); + page.items.reserve(items.size()); + for (const auto &item : items) + page.items.push_back(parse_item(item)); + return page; + } + + std::string data_required_hex(const json &object, const char *key); + double data_required_number(const json &object, const char *key); + bool data_required_bool(const json &object, const char *key); + std::int64_t data_required_integer(const json &object, const char *key); + // One of `choices`; the optional form reads absent or null as "". + std::string data_choice(const json &object, const char *key, + std::initializer_list choices, const char *expected); + std::string data_optional_choice(const json &object, const char *key, + std::initializer_list choices, + const char *expected); + + // Unix seconds from a number or numeric string. + std::int64_t data_epoch_seconds(const json &object, const char *key); + // Unset for an absent, null, 0, "0" or "" value. + std::optional data_optional_epoch_seconds(const json &object, const char *key); + // Unix seconds from an ISO 8601 UTC string, a number or a numeric string; + // the optional form reads absent, null and "" as unset. + std::int64_t data_epoch_or_iso(const json &object, const char *key); + std::optional data_optional_epoch_or_iso(const json &object, const char *key); + // Unset for 999, the Data API's "no outcome". + std::optional data_outcome_index(const json &object, const char *key); + // A combo condition ID in its 31-byte form, lowercased. + std::string data_combo_condition_id(const json &object, const char *key); + + DataTrade parse_data_trade(const json &item); + DataActivity parse_data_activity(const json &item); + Page parse_data_trade_page(const std::string &body); + Page parse_data_activity_page(const std::string &body); + + DataPosition parse_data_position(const json &item); + DataPnlPoint parse_data_pnl_point(const json &item); + Page parse_data_position_page(const std::string &body); + DataPortfolioValue parse_data_portfolio_value(const std::string &body); + std::optional parse_data_user_stats(const std::string &body); + DataPnlSeries parse_data_pnl_series(const std::string &body); + DataUserVolume parse_data_user_volume(const std::string &body); + + DataHolder parse_data_holder(const json &item); + DataResolution parse_data_resolution(const json &item); + Page parse_data_holders_page(const std::string &body); + std::vector parse_data_open_interests(const std::string &body); + DataLiveVolume parse_data_live_volume(const std::string &body); + Page parse_data_price_history_page(const std::string &body); + std::vector parse_data_resolutions(const std::string &body); + + DataTraderEntry parse_data_trader_entry(const json &item); + DataBiggestWinner parse_data_biggest_winner(const json &item); + Page parse_data_trader_leaderboard_page(const std::string &body); + std::optional parse_data_trader_standing(const std::string &body); + Page parse_data_biggest_winners_page(const std::string &body); +} // namespace polymarket::detail diff --git a/src/data_portfolio_client.cpp b/src/data_portfolio_client.cpp new file mode 100644 index 0000000..c04b22a --- /dev/null +++ b/src/data_portfolio_client.cpp @@ -0,0 +1,57 @@ +#include "data_client_internal.hpp" +#include "data_parsing.hpp" +#include "data_query.hpp" + +#include + +namespace polymarket +{ + Paginator DataClient::positions_pager(const DataPositionFilter &filter, + std::size_t page_size, + const PageRequest &resume) + { + return cursor_pager( + "/v2/positions", page_size, detail::data_max_page_size, resume, + [&] { return detail::data_position_query(filter); }, detail::parse_data_position_page); + } + + Result + DataClient::get_portfolio_value(const std::string &user, + const std::vector &condition_ids) + { + return fetch( + [&] { return "/v2/value" + detail::data_portfolio_value_query(user, condition_ids); }, + detail::parse_data_portfolio_value); + } + + Result> DataClient::get_user_stats(const std::string &user) + { + return fetch>( + [&] { return "/v2/user-stats" + detail::data_user_query(user); }, + detail::parse_data_user_stats); + } + + Result DataClient::get_user_pnl(const std::string &user, + const std::string &interval, + const std::string &fidelity) + { + return fetch( + [&] { return "/v2/user-pnl" + detail::data_user_pnl_query(user, interval, fidelity); }, + detail::parse_data_pnl_series); + } + + Result DataClient::get_user_volume(const std::string &user, + const DataTimeRange &range) + { + return fetch( + [&] { return "/v2/user-volume" + detail::data_user_volume_query(user, range); }, + detail::parse_data_user_volume); + } + + Result DataClient::download_accounting_snapshot(const std::string &user) + { + return fetch( + [&] { return "/v1/accounting/snapshot" + detail::data_user_query(user); }, + [](const std::string &body) { return body; }); + } +} // namespace polymarket diff --git a/src/data_portfolio_parsing.cpp b/src/data_portfolio_parsing.cpp new file mode 100644 index 0000000..dee4982 --- /dev/null +++ b/src/data_portfolio_parsing.cpp @@ -0,0 +1,181 @@ +#include "data_parsing.hpp" + +#include +#include +#include + +// Field names and rules follow py-sdk models/data/portfolio.py and common.py. +namespace polymarket::detail +{ + namespace + { + std::string event_id(const json &item, const char *key) + { + auto id = field_id(item, key); + return id == "0" ? std::string() : id; + } + + std::string calendar_date(const json &item, const char *key) + { + auto date = field_string(item, key); + if (date.empty() || date == "1970-01-01") return {}; + bool valid = date.size() == 10 && date[4] == '-' && date[7] == '-'; + for (std::size_t i = 0; valid && i < date.size(); ++i) + valid = i == 4 || i == 7 || std::isdigit(static_cast(date[i])); + if (!valid) throw_bad_field(key, "a YYYY-MM-DD date"); + return date; + } + + const json &envelope_object(const json &body) + { + const auto &data = data_envelope(body); + if (!data.is_object()) throw std::invalid_argument("Data API data must be an object"); + return data; + } + } // namespace + + DataPosition parse_data_position(const json &item) + { + DataPosition position; + position.wallet = field_required_string(item, "proxy_wallet"); + position.asset_id = field_required_string(item, "token_id"); + position.condition_id = data_required_hex(item, "condition_id"); + position.current_size = data_required_number(item, "current_size"); + position.avg_price = data_required_number(item, "avg_price"); + position.entry_cost_usdc = data_required_number(item, "entry_cost_usdc"); + position.entry_fees_usdc = data_required_number(item, "entry_fees_usdc"); + position.total_cost_usdc = data_required_number(item, "total_cost_usdc"); + position.current_price = data_required_number(item, "current_price"); + position.current_value = data_required_number(item, "current_value"); + position.total_size = data_required_number(item, "total_size"); + position.realized_pnl = data_required_number(item, "realized_pnl"); + position.unrealized_pnl = data_required_number(item, "unrealized_pnl"); + position.total_pnl = data_required_number(item, "total_pnl"); + position.percent_pnl = data_required_number(item, "percent_pnl"); + position.percent_realized_pnl = data_required_number(item, "percent_realized_pnl"); + position.status = data_choice(item, "status", {"OPEN", "REDEEMABLE", "CLOSED"}, + "OPEN, REDEEMABLE or CLOSED"); + position.redeemable = data_required_bool(item, "redeemable"); + position.mergeable = data_required_bool(item, "mergeable"); + position.negative_risk = data_required_bool(item, "negative_risk"); + position.archived = data_required_bool(item, "archived"); + position.verified = data_required_bool(item, "verified"); + position.title = field_string(item, "title"); + position.slug = field_string(item, "slug"); + position.icon = field_string(item, "icon"); + position.event_slug = field_string(item, "event_slug"); + position.event_id = event_id(item, "event_id"); + position.outcome = field_string(item, "outcome"); + position.outcome_index = data_outcome_index(item, "outcome_index"); + position.opposite_outcome = field_string(item, "opposite_outcome"); + position.opposite_asset_id = field_string(item, "opposite_token_id"); + position.end_date = calendar_date(item, "end_date"); + position.last_event_at = data_optional_epoch_seconds(item, "last_event_at"); + position.first_entry_at = data_optional_epoch_seconds(item, "first_entry_at"); + position.name = field_string(item, "name"); + position.profile_image = field_string(item, "profile_image"); + position.raw = item; + return position; + } + + DataPnlPoint parse_data_pnl_point(const json &item) + { + DataPnlPoint point; + point.timestamp = data_epoch_seconds(item, "timestamp"); + point.source_block = data_required_integer(item, "source_block"); + point.trade_count = data_required_integer(item, "trade_count"); + point.realized_market_pnl = data_required_number(item, "realized_market_pnl"); + point.realized_lp_pnl = data_required_number(item, "realized_lp_pnl"); + point.realized_combo_pnl = data_required_number(item, "realized_combo_pnl"); + point.realized_pnl = data_required_number(item, "realized_pnl"); + point.volume = data_required_number(item, "volume"); + point.volume_usdc = data_required_number(item, "volume_usdc"); + point.unrealized_pnl = field_number(item, "unrealized_pnl"); + point.fees_refunded = field_number(item, "fees_refunded"); + point.maker_rebate = field_number(item, "maker_rebate"); + point.taker_rebate = field_number(item, "taker_rebate"); + point.reward_income = field_number(item, "reward_income"); + point.yield_income = field_number(item, "yield_income"); + point.referral_income = field_number(item, "referral_income"); + point.sponsored_income = field_number(item, "sponsored_income"); + point.deposits = field_number(item, "deposits"); + point.withdrawals = field_number(item, "withdrawals"); + point.cashflow_net = field_number(item, "cashflow_net"); + point.wallet_income = field_number(item, "wallet_income"); + point.position_pnl = field_number(item, "position_pnl"); + point.settled_pnl = field_number(item, "settled_pnl"); + point.economic_pnl = field_number(item, "economic_pnl"); + point.trade_pnl = field_number(item, "trade_pnl"); + point.fees = field_number(item, "fees"); + point.fees_paid = field_number(item, "fees_paid"); + point.raw = item; + return point; + } + + Page parse_data_position_page(const std::string &body) + { + return data_page(body, parse_data_position); + } + + DataPortfolioValue parse_data_portfolio_value(const std::string &body) + { + const auto parsed = json::parse(body); + const auto &data = envelope_object(parsed); + DataPortfolioValue value; + value.wallet = field_required_string(data, "proxy_wallet"); + value.value = data_required_number(data, "value"); + value.raw = data; + return value; + } + + std::optional parse_data_user_stats(const std::string &body) + { + const auto parsed = json::parse(body); + if (data_envelope(parsed).is_null()) return std::nullopt; + const auto &data = envelope_object(parsed); + DataUserStats stats; + stats.wallet = field_required_string(data, "proxy_wallet"); + stats.traded_market_count = data_required_integer(data, "trades"); + stats.biggest_win = data_required_number(data, "biggest_win"); + stats.views = data_required_integer(data, "views"); + stats.join_date = data_optional_epoch_seconds(data, "join_date"); + if (const auto *pnl = find_field(data, "all_time_pnl")) + stats.all_time_pnl = parse_data_pnl_point(*pnl); + stats.raw = data; + return stats; + } + + DataPnlSeries parse_data_pnl_series(const std::string &body) + { + const auto parsed = json::parse(body); + const auto &data = envelope_object(parsed); + constexpr const char *fidelities = "1d, 18h, 12h, 3h or 1h"; + DataPnlSeries series; + series.wallet = field_required_string(data, "proxy_wallet"); + series.interval = + data_choice(data, "interval", {"max", "all", "1m", "1w", "1d", "12h", "6h"}, + "max, all, 1m, 1w, 1d, 12h or 6h"); + series.fidelity = + data_choice(data, "fidelity", {"1d", "18h", "12h", "3h", "1h"}, fidelities); + series.source_fidelity = + data_choice(data, "source_fidelity", {"1d", "18h", "12h", "3h", "1h"}, fidelities); + const auto *points = find_field(data, "points"); + if (points == nullptr || !points->is_array()) throw_bad_field("points", "an array"); + series.points.reserve(points->size()); + for (const auto &point : *points) + series.points.push_back(parse_data_pnl_point(point)); + return series; + } + + DataUserVolume parse_data_user_volume(const std::string &body) + { + const auto parsed = json::parse(body); + const auto &data = envelope_object(parsed); + DataUserVolume volume; + volume.volume = data_required_number(data, "volume"); + volume.volume_usdc = data_required_number(data, "volume_usdc"); + volume.trade_count = data_required_integer(data, "trade_count"); + volume.raw = data; + return volume; + } +} // namespace polymarket::detail diff --git a/src/data_portfolio_query.cpp b/src/data_portfolio_query.cpp new file mode 100644 index 0000000..9ac6177 --- /dev/null +++ b/src/data_portfolio_query.cpp @@ -0,0 +1,106 @@ +#include "data_query.hpp" + +#include +#include +#include + +// Portfolio queries, following py-sdk _internal/actions/data.py. +namespace polymarket::detail +{ + namespace + { + std::string checked_title(const std::string &title) + { + if (title.find_first_not_of(" \t\n\v\f\r") == std::string::npos) return {}; + const auto characters = std::count_if(title.begin(), title.end(), + [](char c) { return (c & 0xC0) != 0x80; }); + if (characters > 200) + throw std::invalid_argument("title must contain at most 200 characters"); + return title; + } + } // namespace + + std::string data_position_query(const DataPositionFilter &filter) + { + check_choice("status", filter.status, + {"OPEN", "REDEEMABLE", "REDEEMABLE_LOST", "MERGEABLE", "CLOSED"}); + check_choice("filter_type", filter.filter_type, {"CASH", "TOKENS"}); + check_choice("sort_by", filter.sort_by, + {"CURRENT_VALUE", "PRICE", "TOKENS", "UNREALIZED_PNL", "REALIZED_PNL", + "TOTAL_PNL", "TIMESTAMP"}); + check_choice("sort_direction", filter.sort_direction, {"ASC", "DESC"}); + data_check_selectors(filter.condition_ids, filter.event_ids); + const auto event_ids = data_event_ids(filter.event_ids); + const auto condition_ids = data_market_condition_ids(filter.condition_ids); + const auto start = data_timestamp(filter.start); + const auto end = data_timestamp(filter.end); + const bool has_user = !filter.user.empty(); + if (!has_user && condition_ids.size() != 1) + throw std::invalid_argument("Provide user or exactly one condition_id"); + if (!event_ids.empty() && !has_user) throw std::invalid_argument("event_id requires user"); + if ((filter.status == "REDEEMABLE_LOST" || filter.status == "MERGEABLE") && !has_user) + throw std::invalid_argument(filter.status + " requires user"); + if (filter.status == "CLOSED" && filter.include_archived.value_or(false)) + throw std::invalid_argument("include_archived is invalid with CLOSED"); + data_check_amount("filter_amount", filter.filter_amount); + const auto title = checked_title(filter.title); + + QueryBuilder query; + query.add("user", filter.user); + query.add_joined("condition_id", condition_ids); + query.add("status", filter.status); + query.add_joined("event_id", event_ids); + query.add("title", title); + query.add("filter_type", filter.filter_type); + query.add("filter_amount", filter.filter_amount); + query.add("include_archived", filter.include_archived); + query.add("sort_by", filter.sort_by); + query.add("sort_direction", filter.sort_direction); + query.add("start", start); + query.add("end", end); + return query.str(); + } + + std::string data_portfolio_value_query(const std::string &user, + const std::vector &condition_ids) + { + data_require_user(user); + const auto ids = data_market_condition_ids(condition_ids); + QueryBuilder query; + query.add("user", user); + query.add_joined("condition_id", ids); + return query.str(); + } + + std::string data_user_query(const std::string &user) + { + data_require_user(user); + QueryBuilder query; + query.add("user", user); + return query.str(); + } + + std::string data_user_pnl_query(const std::string &user, const std::string &interval, + const std::string &fidelity) + { + check_choice("interval", interval, {"max", "all", "1m", "1w", "1d", "12h", "6h"}); + check_choice("fidelity", fidelity, {"1d", "18h", "12h", "3h", "1h"}); + data_require_user(user); + QueryBuilder query; + query.add("user", user); + query.add("interval", interval); + query.add("fidelity", fidelity); + return query.str(); + } + + std::string data_user_volume_query(const std::string &user, const DataTimeRange &range) + { + data_require_user(user); + const auto [start, end] = data_time_window(range.start, range.end, range.full_history); + QueryBuilder query; + query.add("user", user); + query.add("start", start); + query.add("end", end); + return query.str(); + } +} // namespace polymarket::detail diff --git a/src/data_query.cpp b/src/data_query.cpp new file mode 100644 index 0000000..53c364f --- /dev/null +++ b/src/data_query.cpp @@ -0,0 +1,183 @@ +#include "data_query.hpp" + +#include +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + namespace + { + constexpr std::int64_t max_epoch_seconds = 253402300799; // 9999-12-31T23:59:59Z + constexpr std::size_t max_condition_ids = 20; + + bool is_hex(const std::string &value) + { + return value.size() > 2 && value.starts_with("0x") && + value.find_first_not_of("0123456789abcdefABCDEF", 2) == std::string::npos; + } + + template + std::vector distinct_condition_ids(const std::vector &values, + Normalize normalize) + { + std::vector unique; + std::set seen; + for (const auto &value : values) + { + if (!is_hex(value)) + throw std::invalid_argument("condition_id must be a hex string"); + auto id = normalize(value); + std::string key = id; + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (seen.insert(key).second) unique.push_back(std::move(id)); + } + if (unique.size() > max_condition_ids) + throw std::invalid_argument("condition_id accepts at most 20 distinct values"); + return unique; + } + } // namespace + + void data_check_selectors(const std::vector &condition_ids, + const std::vector &event_ids) + { + if (!condition_ids.empty() && !event_ids.empty()) + throw std::invalid_argument("Provide condition_id or event_id, not both"); + } + + void data_require_user(const std::string &user) + { + if (user.empty()) throw std::invalid_argument("user is required"); + } + + std::optional data_timestamp(std::optional value) + { + if (value && (*value <= 0 || *value > max_epoch_seconds)) + throw std::invalid_argument( + "Timestamp must be positive and no later than 9999-12-31T23:59:59Z"); + return value; + } + + void data_check_amount(const char *name, const std::optional &value) + { + if (value && (!std::isfinite(*value) || *value < 0)) + throw std::invalid_argument(std::string(name) + + " must be a finite non-negative amount"); + } + + std::vector data_condition_ids(const std::vector &values) + { + return distinct_condition_ids( + values, + [](const std::string &value) + { + if (value.size() != 64 && value.size() != 66) + throw std::invalid_argument( + "Expected a 31-byte or 32-byte hex string, received: " + value); + return value; + }); + } + + std::vector data_market_condition_ids(const std::vector &values) + { + return distinct_condition_ids( + values, + [](const std::string &value) + { + std::string id = value; + std::transform(id.begin(), id.end(), id.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (id.size() == 64 && (id.starts_with("0x01") || id.starts_with("0x02"))) + id += "00"; + if (id.size() != 66) + throw std::invalid_argument( + "Expected a 32-byte condition ID or a 31-byte structural market ID"); + return id; + }); + } + + std::vector data_event_ids(const std::vector &values) + { + std::vector unique; + for (const auto value : values) + { + if (value <= 0 || value > 2147483647) + throw std::invalid_argument("event_ids must contain positive 32-bit integers"); + if (std::find(unique.begin(), unique.end(), value) == unique.end()) + unique.push_back(value); + } + return unique; + } + + std::pair, std::optional> + data_time_window(std::optional start, std::optional end, + bool full_history) + { + if (full_history) + { + if (start || end) + throw std::invalid_argument("full_history cannot be combined with start or end"); + return {std::int64_t{1}, std::nullopt}; + } + return {data_timestamp(start), data_timestamp(end)}; + } + + std::string data_trade_query(const DataTradeFilter &filter) + { + check_choice("side", filter.side, {"BUY", "SELL"}); + check_choice("filter_type", filter.filter_type, {"CASH", "TOKENS"}); + data_check_selectors(filter.condition_ids, filter.event_ids); + const auto event_ids = data_event_ids(filter.event_ids); + const auto condition_ids = data_condition_ids(filter.condition_ids); + const auto [start, end] = data_time_window(filter.start, filter.end, filter.full_history); + data_check_amount("filter_amount", filter.filter_amount); + + QueryBuilder query; + query.add("user", filter.user); + query.add_joined("condition_id", condition_ids); + query.add_joined("event_id", event_ids); + query.add("side", filter.side); + query.add("taker_only", filter.taker_only); + query.add("filter_type", filter.filter_type); + query.add("filter_amount", filter.filter_amount); + query.add("start", start); + query.add("end", end); + return query.str(); + } + + std::string data_activity_query(const std::string &user, const DataActivityFilter &filter) + { + check_choice("side", filter.side, {"BUY", "SELL"}); + check_choice("sort_direction", filter.sort_direction, {"ASC", "DESC"}); + data_require_user(user); + data_check_selectors(filter.condition_ids, filter.event_ids); + const auto event_ids = data_event_ids(filter.event_ids); + const auto condition_ids = data_condition_ids(filter.condition_ids); + const auto [start, end] = data_time_window(filter.start, filter.end, filter.full_history); + for (const auto &type : filter.types) + { + if (type.empty()) + throw std::invalid_argument("activity_types cannot contain an empty type"); + check_choice("activity_types", type, + {"TRADE", "SPLIT", "MERGE", "REDEEM", "REWARD", "CONVERSION", "MIGRATION", + "DEPOSIT", "WITHDRAWAL", "YIELD", "MAKER_REBATE", "TAKER_REBATE", + "REFERRAL_REWARD", "TIP"}); + } + + QueryBuilder query; + query.add("user", user); + query.add_joined("condition_id", condition_ids); + query.add_joined("event_id", event_ids); + query.add_joined("type", filter.types); + query.add("side", filter.side); + query.add("sort_direction", filter.sort_direction); + query.add("start", start); + query.add("end", end); + query.add("exclude_deposits_withdrawals", std::optional(false)); + return query.str(); + } +} // namespace polymarket::detail diff --git a/src/data_query.hpp b/src/data_query.hpp new file mode 100644 index 0000000..ac4f6fb --- /dev/null +++ b/src/data_query.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "polymarket/data_requests.hpp" +#include "query_builder.hpp" + +#include +#include +#include +#include +#include +#include + +// Data API v2 query strings and argument checks, following py-sdk +// _internal/actions/data.py and data_params.py. Invalid input throws +// std::invalid_argument. +namespace polymarket::detail +{ + void data_check_selectors(const std::vector &condition_ids, + const std::vector &event_ids); + void data_require_user(const std::string &user); + // Positive and no later than 9999-12-31T23:59:59Z. + std::optional data_timestamp(std::optional value); + void data_check_amount(const char *name, const std::optional &value); + + // Condition IDs as given, without case-insensitive repeats; 31- or 32-byte hex, at most 20. + std::vector data_condition_ids(const std::vector &values); + // Lowercased market condition IDs without repeats, 31-byte structural IDs padded; at most 20. + std::vector data_market_condition_ids(const std::vector &values); + // Event IDs without repeats; each a positive 32-bit integer. + std::vector data_event_ids(const std::vector &values); + // `full_history` becomes start=1 with no end. + std::pair, std::optional> + data_time_window(std::optional start, std::optional end, + bool full_history); + + std::string data_trade_query(const DataTradeFilter &filter); + std::string data_activity_query(const std::string &user, const DataActivityFilter &filter); + + std::string data_position_query(const DataPositionFilter &filter); + std::string data_portfolio_value_query(const std::string &user, + const std::vector &condition_ids); + std::string data_user_query(const std::string &user); + std::string data_user_pnl_query(const std::string &user, const std::string &interval, + const std::string &fidelity); + std::string data_user_volume_query(const std::string &user, const DataTimeRange &range); + + inline constexpr std::size_t data_price_history_page_size = 10000; + + std::string data_holders_query(const std::vector &condition_ids, + const DataHolderFilter &filter); + std::string data_open_interest_query(const std::vector &condition_ids); + std::string data_live_volume_query(const std::vector &event_ids); + // `now` (Unix seconds) ends a window without `end`. + std::string data_price_history_query(const std::string &asset_id, + const DataPriceHistoryFilter &filter, + std::size_t page_size, std::int64_t now); + std::string data_resolution_query(const DataResolutionFilter &filter); + + std::string data_trader_leaderboard_query(const DataLeaderboardFilter &filter); + std::string data_trader_standing_query(const std::string &user, + const DataLeaderboardFilter &filter); + std::string data_biggest_winners_query(const DataLeaderboardFilter &filter); +} // namespace polymarket::detail diff --git a/src/gamma_catalog_client.cpp b/src/gamma_catalog_client.cpp index 88f6955..a3833dc 100644 --- a/src/gamma_catalog_client.cpp +++ b/src/gamma_catalog_client.cpp @@ -100,7 +100,7 @@ namespace polymarket [&] { if (id.empty()) throw std::invalid_argument("id cannot be empty"); - detail::GammaQuery query; + detail::QueryBuilder query; query.add("locale", locale); return "/series/" + detail::percent_encode_query_value(id) + query.str(); }, @@ -125,7 +125,7 @@ namespace polymarket return offset_pager( "/tags", page_size, 100, unlimited_offset, resume, [&] { return detail::gamma_tag_list_query(filter); }, - detail::gamma_list_page(detail::parse_gamma_tag_references)); + detail::list_page(detail::parse_gamma_tag_references)); } Paginator GammaClient::series_pager(const GammaSeriesFilter &filter, @@ -135,7 +135,7 @@ namespace polymarket return offset_pager( "/series", page_size, 50, unlimited_offset, resume, [&] { return detail::gamma_series_list_query(filter); }, - detail::gamma_list_page(detail::parse_gamma_series_list)); + detail::list_page(detail::parse_gamma_series_list)); } Paginator GammaClient::teams_pager(const GammaTeamFilter &filter, @@ -144,6 +144,6 @@ namespace polymarket return offset_pager( "/teams", page_size, 100, unlimited_offset, resume, [&] { return detail::gamma_team_list_query(filter); }, - detail::gamma_list_page(detail::parse_gamma_teams)); + detail::list_page(detail::parse_gamma_teams)); } } // namespace polymarket diff --git a/src/gamma_catalog_parsing.cpp b/src/gamma_catalog_parsing.cpp index d04237a..df334c4 100644 --- a/src/gamma_catalog_parsing.cpp +++ b/src/gamma_catalog_parsing.cpp @@ -26,10 +26,10 @@ namespace polymarket::detail GammaRelatedTag parse_related_tag(const json &item) { GammaRelatedTag tag; - tag.id = gamma_required_id(item, "id"); - tag.tag_id = gamma_integer(item, "tagID"); - tag.related_tag_id = gamma_integer(item, "relatedTagID"); - tag.rank = gamma_integer(item, "rank"); + tag.id = field_required_id(item, "id"); + tag.tag_id = field_integer(item, "tagID"); + tag.related_tag_id = field_integer(item, "relatedTagID"); + tag.rank = field_integer(item, "rank"); tag.raw = item; return tag; } @@ -37,16 +37,16 @@ namespace polymarket::detail GammaSport parse_sport(const json &item) { GammaSport sport; - sport.id = gamma_required_id(item, "id"); - sport.sport = gamma_required_string(item, "sport"); - sport.name = gamma_string(item, "name"); - sport.image = gamma_required_string(item, "image"); - sport.resolution = gamma_required_string(item, "resolution"); - sport.ordering = gamma_required_string(item, "ordering"); - sport.tags = gamma_required_string(item, "tags"); - sport.series = gamma_required_string(item, "series"); - sport.created_at = gamma_string(item, "createdAt"); - sport.updated_at = gamma_string(item, "updatedAt"); + sport.id = field_required_id(item, "id"); + sport.sport = field_required_string(item, "sport"); + sport.name = field_string(item, "name"); + sport.image = field_required_string(item, "image"); + sport.resolution = field_required_string(item, "resolution"); + sport.ordering = field_required_string(item, "ordering"); + sport.tags = field_required_string(item, "tags"); + sport.series = field_required_string(item, "series"); + sport.created_at = field_string(item, "createdAt"); + sport.updated_at = field_string(item, "updatedAt"); sport.raw = item; return sport; } @@ -54,20 +54,20 @@ namespace polymarket::detail GammaTeam parse_team(const json &item) { GammaTeam team; - team.id = gamma_required_id(item, "id"); - team.name = gamma_string(item, "name"); - team.league = gamma_string(item, "league"); - team.record = gamma_string(item, "record"); - team.logo = gamma_string(item, "logo"); - team.abbreviation = gamma_string(item, "abbreviation"); - team.alias = gamma_string(item, "alias"); - team.color = gamma_string(item, "color"); - team.ordering = gamma_string(item, "ordering"); + team.id = field_required_id(item, "id"); + team.name = field_string(item, "name"); + team.league = field_string(item, "league"); + team.record = field_string(item, "record"); + team.logo = field_string(item, "logo"); + team.abbreviation = field_string(item, "abbreviation"); + team.alias = field_string(item, "alias"); + team.color = field_string(item, "color"); + team.ordering = field_string(item, "ordering"); if (!team.ordering.empty() && team.ordering != "home" && team.ordering != "away") throw std::invalid_argument("Gamma field ordering must be home or away"); - team.provider_id = gamma_integer(item, "providerId"); - team.created_at = gamma_string(item, "createdAt"); - team.updated_at = gamma_string(item, "updatedAt"); + team.provider_id = field_integer(item, "providerId"); + team.created_at = field_string(item, "createdAt"); + team.updated_at = field_string(item, "updatedAt"); team.raw = item; return team; } @@ -86,35 +86,35 @@ namespace polymarket::detail GammaSeries parse_gamma_series(const json &item) { GammaSeries series; - series.id = gamma_required_id(item, "id"); - series.ticker = gamma_string(item, "ticker"); - series.slug = gamma_string(item, "slug"); - series.title = gamma_string(item, "title"); - series.subtitle = gamma_string(item, "subtitle"); - series.series_type = gamma_string(item, "seriesType"); - series.recurrence = gamma_string(item, "recurrence"); - series.description = gamma_string(item, "description"); - series.image = gamma_string(item, "image"); - series.icon = gamma_string(item, "icon"); - series.layout = gamma_string(item, "layout"); - series.active = gamma_bool(item, "active"); - series.closed = gamma_bool(item, "closed"); - series.archived = gamma_bool(item, "archived"); - series.is_new = gamma_bool(item, "new"); - series.featured = gamma_bool(item, "featured"); - series.restricted = gamma_bool(item, "restricted"); - series.comments_enabled = gamma_bool(item, "commentsEnabled"); - series.competitive = gamma_string(item, "competitive"); - series.volume = gamma_number(item, "volume"); - series.liquidity = gamma_number(item, "liquidity"); - series.comment_count = gamma_integer(item, "commentCount"); - series.start_date = gamma_string(item, "startDate"); - series.published_at = gamma_string(item, "publishedAt"); - series.created_at = gamma_string(item, "createdAt"); - series.updated_at = gamma_string(item, "updatedAt"); - for (const auto &event : gamma_objects(item, "events")) + series.id = field_required_id(item, "id"); + series.ticker = field_string(item, "ticker"); + series.slug = field_string(item, "slug"); + series.title = field_string(item, "title"); + series.subtitle = field_string(item, "subtitle"); + series.series_type = field_string(item, "seriesType"); + series.recurrence = field_string(item, "recurrence"); + series.description = field_string(item, "description"); + series.image = field_string(item, "image"); + series.icon = field_string(item, "icon"); + series.layout = field_string(item, "layout"); + series.active = field_bool(item, "active"); + series.closed = field_bool(item, "closed"); + series.archived = field_bool(item, "archived"); + series.is_new = field_bool(item, "new"); + series.featured = field_bool(item, "featured"); + series.restricted = field_bool(item, "restricted"); + series.comments_enabled = field_bool(item, "commentsEnabled"); + series.competitive = field_string(item, "competitive"); + series.volume = field_number(item, "volume"); + series.liquidity = field_number(item, "liquidity"); + series.comment_count = field_integer(item, "commentCount"); + series.start_date = field_string(item, "startDate"); + series.published_at = field_string(item, "publishedAt"); + series.created_at = field_string(item, "createdAt"); + series.updated_at = field_string(item, "updatedAt"); + for (const auto &event : field_objects(item, "events")) series.events.push_back(parse_gamma_event(event)); - for (const auto &tag : gamma_objects(item, "tags")) + for (const auto &tag : field_objects(item, "tags")) series.tags.push_back(parse_gamma_tag_reference(tag)); series.raw = item; return series; @@ -137,7 +137,7 @@ namespace polymarket::detail std::vector parse_gamma_sports_market_types(const std::string &body) { - return gamma_string_list(json::parse(body), "marketTypes"); + return field_string_list(json::parse(body), "marketTypes"); } std::vector parse_gamma_teams(const std::string &body) diff --git a/src/gamma_catalog_query.cpp b/src/gamma_catalog_query.cpp index 86f403e..d03d32a 100644 --- a/src/gamma_catalog_query.cpp +++ b/src/gamma_catalog_query.cpp @@ -7,7 +7,7 @@ namespace polymarket::detail { std::string gamma_tag_list_query(const GammaTagFilter &filter) { - GammaQuery query; + QueryBuilder query; query.add("ascending", filter.ascending); query.add("include_template", filter.include_template); query.add("is_carousel", filter.is_carousel); @@ -18,8 +18,8 @@ namespace polymarket::detail std::string gamma_series_list_query(const GammaSeriesFilter &filter) { - gamma_check_choice("recurrence", filter.recurrence, {"daily", "weekly", "monthly"}); - GammaQuery query; + check_choice("recurrence", filter.recurrence, {"daily", "weekly", "monthly"}); + QueryBuilder query; query.add("ascending", filter.ascending); query.add("closed", filter.closed); query.add("exclude_events", filter.exclude_events); @@ -32,7 +32,7 @@ namespace polymarket::detail std::string gamma_team_list_query(const GammaTeamFilter &filter) { - GammaQuery query; + QueryBuilder query; query.add("abbreviation", filter.abbreviations); query.add("ascending", filter.ascending); query.add("league", filter.leagues); @@ -44,7 +44,7 @@ namespace polymarket::detail std::string gamma_tag_lookup_query(const GammaTagLookupOptions &options) { - GammaQuery query; + QueryBuilder query; query.add("include_template", options.include_template); query.add("locale", options.locale); return query.str(); @@ -52,7 +52,7 @@ namespace polymarket::detail std::string gamma_related_tag_query(const GammaRelatedTagOptions &options) { - GammaQuery query; + QueryBuilder query; query.add("omit_empty", options.omit_empty); query.add("status", options.status); return query.str(); @@ -60,7 +60,7 @@ namespace polymarket::detail std::string gamma_related_tag_resource_query(const GammaRelatedTagResourceOptions &options) { - GammaQuery query; + QueryBuilder query; query.add("locale", options.locale); query.add("omit_empty", options.omit_empty); query.add("status", options.status); diff --git a/src/gamma_client.cpp b/src/gamma_client.cpp index 8741b53..9ea3e15 100644 --- a/src/gamma_client.cpp +++ b/src/gamma_client.cpp @@ -142,11 +142,12 @@ namespace polymarket } catch (const std::invalid_argument &ex) { - invalid = detail::gamma_invalid_argument(ex.what(), endpoint); + invalid = detail::invalid_argument_error(ex.what(), endpoint); } - return detail::gamma_keyset_pager( - endpoint, query, page_size, resume, std::move(invalid), [this](const std::string &path) - { return get(path); }, detail::parse_gamma_market_keyset_page); + return detail::keyset_pager( + endpoint, query, page_size, resume, std::move(invalid), "after_cursor", + [this](const std::string &path) { return get(path); }, + detail::parse_gamma_market_keyset_page); } Paginator GammaClient::events_pager(const GammaEventFilter &filter, @@ -163,10 +164,11 @@ namespace polymarket } catch (const std::invalid_argument &ex) { - invalid = detail::gamma_invalid_argument(ex.what(), endpoint); + invalid = detail::invalid_argument_error(ex.what(), endpoint); } - return detail::gamma_keyset_pager( - endpoint, query, page_size, resume, std::move(invalid), [this](const std::string &path) - { return get(path); }, detail::parse_gamma_event_keyset_page); + return detail::keyset_pager( + endpoint, query, page_size, resume, std::move(invalid), "after_cursor", + [this](const std::string &path) { return get(path); }, + detail::parse_gamma_event_keyset_page); } } // namespace polymarket diff --git a/src/gamma_client_internal.hpp b/src/gamma_client_internal.hpp index ac6b7be..5d0df6e 100644 --- a/src/gamma_client_internal.hpp +++ b/src/gamma_client_internal.hpp @@ -1,7 +1,7 @@ #pragma once #include "polymarket/gamma_client.hpp" -#include "query_encoding.hpp" +#include "rest_paging.hpp" #include #include @@ -11,125 +11,15 @@ #include #include -// Request plumbing shared by the GammaClient translation units. +// GammaClient member templates shared by its translation units. namespace polymarket { - namespace detail - { - inline SdkError gamma_invalid_argument(const std::string &message, - const std::string &endpoint) - { - SdkError error; - error.code = SdkErrorCode::InvalidArgument; - error.message = message; - error.endpoint = endpoint; - return error; - } - - template - Result> gamma_page(const HttpResponse &response, const std::string &endpoint, - Parse parse) - { - if (!response.ok()) return Result>::failure(make_sdk_error(response, endpoint)); - try - { - return Result>::success(parse(response.body)); - } - catch (const std::exception &ex) - { - return Result>::failure( - make_parse_error(ex.what(), endpoint, response.body)); - } - } - - // `query` is "" or "?a=b"; Gamma takes the page parameters after the filters. - template - Paginator gamma_keyset_pager(const std::string &endpoint, const std::string &query, - std::size_t page_size, const PageRequest &resume, - std::optional invalid, Get get, - ParsePage parse_page) - { - if (invalid) - { - return Paginator::keyset( - endpoint, [error = std::move(*invalid)](const PageRequest &) - { return Result>::failure(error); }, 1, resume); - } - std::string prefix = endpoint + (query.empty() ? "?" : query + "&") + - "limit=" + std::to_string(page_size); - return Paginator::keyset( - endpoint, - [endpoint, prefix = std::move(prefix), get, parse_page](const PageRequest &request) - { - std::string path = prefix; - if (!request.cursor.empty()) - path += "&after_cursor=" + percent_encode_query_value(request.cursor); - return gamma_page(get(path), endpoint, parse_page); - }, - page_size, resume); - } - - // Parses a JSON array body into one offset page. - template auto gamma_list_page(ParseItems parse_items) - { - return [parse_items](const std::string &body) - { return Page{parse_items(body), ""}; }; - } - - // Offset pages hold at most `max_page_size` items, Gamma's own cap, and - // start at most at `max_offset`, past which Gamma rejects the request. - template - Paginator gamma_offset_pager(const std::string &endpoint, const std::string &query, - std::size_t page_size, std::size_t max_page_size, - std::size_t max_offset, const PageRequest &resume, - std::optional invalid, Get get, - ParsePage parse_page) - { - if (!invalid && (page_size == 0 || page_size > max_page_size)) - invalid = gamma_invalid_argument( - "page_size must be between 1 and " + std::to_string(max_page_size), endpoint); - if (invalid) - { - return Paginator::offset( - endpoint, [error = std::move(*invalid)](const PageRequest &) - { return Result>::failure(error); }, 1, max_offset); - } - std::string prefix = endpoint + (query.empty() ? "?" : query + "&") + - "limit=" + std::to_string(page_size) + "&offset="; - return Paginator::offset( - endpoint, - [endpoint, prefix = std::move(prefix), get, parse_page](const PageRequest &request) - { - return gamma_page(get(prefix + std::to_string(request.offset)), endpoint, - parse_page); - }, - page_size, max_offset, resume.offset); - } - } // namespace detail template Result GammaClient::fetch(BuildPath build_path, Parse parse) { - std::string path; - try - { - path = build_path(); - } - catch (const std::invalid_argument &ex) - { - return Result::failure(detail::gamma_invalid_argument(ex.what(), "")); - } - const std::string endpoint = path.substr(0, path.find('?')); - const auto response = get(path); - if (!response.ok()) return Result::failure(make_sdk_error(response, endpoint)); - try - { - return Result::success(parse(response.body)); - } - catch (const std::exception &ex) - { - return Result::failure(make_parse_error(ex.what(), endpoint, response.body)); - } + return detail::fetch_result( + build_path, [this](const std::string &path) { return get(path); }, parse); } template @@ -146,9 +36,9 @@ namespace polymarket } catch (const std::invalid_argument &ex) { - invalid = detail::gamma_invalid_argument(ex.what(), endpoint); + invalid = detail::invalid_argument_error(ex.what(), endpoint); } - return detail::gamma_offset_pager( + return detail::offset_pager( endpoint, query, page_size, max_page_size, max_offset, resume, std::move(invalid), [this](const std::string &path) { return get(path); }, parse_page); } diff --git a/src/gamma_market_parsing.cpp b/src/gamma_market_parsing.cpp index 8b4bb75..aa325ba 100644 --- a/src/gamma_market_parsing.cpp +++ b/src/gamma_market_parsing.cpp @@ -21,7 +21,7 @@ namespace polymarket::detail if (index < prices.size()) { const json holder{{"price", prices[index]}}; - result.price = gamma_number(holder, "price"); + result.price = field_number(holder, "price"); if (!result.price) throw std::invalid_argument("Gamma outcome price must be a number"); } @@ -30,102 +30,102 @@ namespace polymarket::detail GammaEventReference event_reference(const json &item) { - return {gamma_required_id(item, "id"), gamma_string(item, "slug"), - gamma_string(item, "title")}; + return {field_required_id(item, "id"), field_string(item, "slug"), + field_string(item, "title")}; } GammaSeriesReference series_reference(const json &item) { - return {gamma_required_id(item, "id"), gamma_string(item, "slug"), - gamma_string(item, "title")}; + return {field_required_id(item, "id"), field_string(item, "slug"), + field_string(item, "title")}; } } // namespace std::optional parse_gamma_binary_market(const json &item) { - auto labels = gamma_string_list(item, "outcomes"); + auto labels = field_string_list(item, "outcomes"); if (labels.size() != 2) return std::nullopt; - const auto prices = gamma_sequence(item, "outcomePrices"); - const auto token_ids = gamma_string_list(item, "clobTokenIds"); - const auto position_ids = gamma_string_list(item, "positionIds"); + const auto prices = field_sequence(item, "outcomePrices"); + const auto token_ids = field_string_list(item, "clobTokenIds"); + const auto position_ids = field_string_list(item, "positionIds"); GammaMarket market; - market.id = gamma_required_id(item, "id"); - market.version = gamma_string(item, "version"); - market.slug = gamma_string(item, "slug"); - market.condition_id = gamma_hex(item, "conditionId"); - market.question = gamma_string(item, "question"); - market.group_item_title = gamma_string(item, "groupItemTitle"); - market.description = gamma_string(item, "description"); - market.category = gamma_string(item, "category"); - market.image = gamma_string(item, "image"); - market.icon = gamma_string(item, "icon"); + market.id = field_required_id(item, "id"); + market.version = field_string(item, "version"); + market.slug = field_string(item, "slug"); + market.condition_id = field_hex(item, "conditionId"); + market.question = field_string(item, "question"); + market.group_item_title = field_string(item, "groupItemTitle"); + market.description = field_string(item, "description"); + market.category = field_string(item, "category"); + market.image = field_string(item, "image"); + market.icon = field_string(item, "icon"); auto &state = market.state; - state.active = gamma_bool(item, "active"); - state.closed = gamma_bool(item, "closed"); - state.archived = gamma_bool(item, "archived"); - state.accepting_orders = gamma_bool(item, "acceptingOrders"); - state.enable_order_book = gamma_bool(item, "enableOrderBook"); - state.neg_risk = gamma_bool(item, "negRisk"); - state.combo_status = gamma_string(item, "comboStatus"); - state.start_date = gamma_string(item, "startDate"); - state.end_date = gamma_string(item, "endDate"); - state.closed_time = gamma_string(item, "closedTime"); + state.active = field_bool(item, "active"); + state.closed = field_bool(item, "closed"); + state.archived = field_bool(item, "archived"); + state.accepting_orders = field_bool(item, "acceptingOrders"); + state.enable_order_book = field_bool(item, "enableOrderBook"); + state.neg_risk = field_bool(item, "negRisk"); + state.combo_status = field_string(item, "comboStatus"); + state.start_date = field_string(item, "startDate"); + state.end_date = field_string(item, "endDate"); + state.closed_time = field_string(item, "closedTime"); market.yes = outcome(std::move(labels[0]), token_ids, position_ids, prices, 0); market.no = outcome(std::move(labels[1]), token_ids, position_ids, prices, 1); auto &metrics = market.metrics; - metrics.volume = gamma_number(item, "volume"); - metrics.volume_num = gamma_number(item, "volumeNum"); - metrics.volume_24hr = gamma_number(item, "volume24hr"); - metrics.volume_1wk = gamma_number(item, "volume1wk"); - metrics.volume_1mo = gamma_number(item, "volume1mo"); - metrics.volume_1yr = gamma_number(item, "volume1yr"); - metrics.volume_amm = gamma_number(item, "volumeAmm"); - metrics.volume_clob = gamma_number(item, "volumeClob"); - metrics.liquidity = gamma_number(item, "liquidity"); - metrics.liquidity_num = gamma_number(item, "liquidityNum"); - metrics.liquidity_clob = gamma_number(item, "liquidityClob"); + metrics.volume = field_number(item, "volume"); + metrics.volume_num = field_number(item, "volumeNum"); + metrics.volume_24hr = field_number(item, "volume24hr"); + metrics.volume_1wk = field_number(item, "volume1wk"); + metrics.volume_1mo = field_number(item, "volume1mo"); + metrics.volume_1yr = field_number(item, "volume1yr"); + metrics.volume_amm = field_number(item, "volumeAmm"); + metrics.volume_clob = field_number(item, "volumeClob"); + metrics.liquidity = field_number(item, "liquidity"); + metrics.liquidity_num = field_number(item, "liquidityNum"); + metrics.liquidity_clob = field_number(item, "liquidityClob"); auto &prices_out = market.prices; - prices_out.best_bid = gamma_number(item, "bestBid"); - prices_out.best_ask = gamma_number(item, "bestAsk"); - prices_out.last_trade_price = gamma_number(item, "lastTradePrice"); - prices_out.spread = gamma_number(item, "spread"); - prices_out.one_hour_price_change = gamma_number(item, "oneHourPriceChange"); - prices_out.one_day_price_change = gamma_number(item, "oneDayPriceChange"); - prices_out.one_week_price_change = gamma_number(item, "oneWeekPriceChange"); - prices_out.one_month_price_change = gamma_number(item, "oneMonthPriceChange"); - prices_out.one_year_price_change = gamma_number(item, "oneYearPriceChange"); + prices_out.best_bid = field_number(item, "bestBid"); + prices_out.best_ask = field_number(item, "bestAsk"); + prices_out.last_trade_price = field_number(item, "lastTradePrice"); + prices_out.spread = field_number(item, "spread"); + prices_out.one_hour_price_change = field_number(item, "oneHourPriceChange"); + prices_out.one_day_price_change = field_number(item, "oneDayPriceChange"); + prices_out.one_week_price_change = field_number(item, "oneWeekPriceChange"); + prices_out.one_month_price_change = field_number(item, "oneMonthPriceChange"); + prices_out.one_year_price_change = field_number(item, "oneYearPriceChange"); auto &trading = market.trading; - trading.minimum_order_size = gamma_decimal_text(item, "orderMinSize"); - trading.minimum_tick_size = gamma_decimal_text(item, "orderPriceMinTickSize"); - trading.seconds_delay = gamma_integer(item, "secondsDelay"); - trading.fees_enabled = gamma_bool(item, "feesEnabled"); - trading.fee_type = gamma_string(item, "feeType"); + trading.minimum_order_size = field_decimal_text(item, "orderMinSize"); + trading.minimum_tick_size = field_decimal_text(item, "orderPriceMinTickSize"); + trading.seconds_delay = field_integer(item, "secondsDelay"); + trading.fees_enabled = field_bool(item, "feesEnabled"); + trading.fee_type = field_string(item, "feeType"); auto &resolution = market.resolution; - resolution.question_id = gamma_string(item, "questionID"); - resolution.neg_risk_request_id = gamma_string(item, "negRiskRequestID"); - resolution.uma_resolution_status = gamma_string(item, "umaResolutionStatus"); - resolution.source = gamma_string(item, "resolutionSource"); - resolution.resolved_by = gamma_string(item, "resolvedBy"); - - market.rewards.rewards_min_size = gamma_number(item, "rewardsMinSize"); - market.rewards.rewards_max_spread = gamma_number(item, "rewardsMaxSpread"); - market.rewards.holding_rewards_enabled = gamma_bool(item, "holdingRewardsEnabled"); - - market.sports.sports_market_type = gamma_string(item, "sportsMarketType"); - market.sports.line = gamma_number(item, "line"); - market.sports.game_id = gamma_id(item, "gameId"); - market.sports.game_start_time = gamma_string(item, "gameStartTime"); - - for (const auto &event : gamma_objects(item, "events")) + resolution.question_id = field_string(item, "questionID"); + resolution.neg_risk_request_id = field_string(item, "negRiskRequestID"); + resolution.uma_resolution_status = field_string(item, "umaResolutionStatus"); + resolution.source = field_string(item, "resolutionSource"); + resolution.resolved_by = field_string(item, "resolvedBy"); + + market.rewards.rewards_min_size = field_number(item, "rewardsMinSize"); + market.rewards.rewards_max_spread = field_number(item, "rewardsMaxSpread"); + market.rewards.holding_rewards_enabled = field_bool(item, "holdingRewardsEnabled"); + + market.sports.sports_market_type = field_string(item, "sportsMarketType"); + market.sports.line = field_number(item, "line"); + market.sports.game_id = field_id(item, "gameId"); + market.sports.game_start_time = field_string(item, "gameStartTime"); + + for (const auto &event : field_objects(item, "events")) market.events.push_back(event_reference(event)); - for (const auto &tag : gamma_objects(item, "tags")) + for (const auto &tag : field_objects(item, "tags")) market.tags.push_back(parse_gamma_tag_reference(tag)); market.raw = item; return market; @@ -134,67 +134,67 @@ namespace polymarket::detail GammaEvent parse_gamma_event(const json &item) { GammaEvent event; - event.id = gamma_required_id(item, "id"); - event.parent_event_id = gamma_id(item, "parentEventId"); - event.ticker = gamma_string(item, "ticker"); - event.slug = gamma_string(item, "slug"); - event.title = gamma_string(item, "title"); - event.subtitle = gamma_string(item, "subtitle"); - event.description = gamma_string(item, "description"); - event.category = gamma_string(item, "category"); - event.subcategory = gamma_string(item, "subcategory"); - event.image = gamma_string(item, "image"); - event.icon = gamma_string(item, "icon"); - event.resolution_source = gamma_string(item, "resolutionSource"); + event.id = field_required_id(item, "id"); + event.parent_event_id = field_id(item, "parentEventId"); + event.ticker = field_string(item, "ticker"); + event.slug = field_string(item, "slug"); + event.title = field_string(item, "title"); + event.subtitle = field_string(item, "subtitle"); + event.description = field_string(item, "description"); + event.category = field_string(item, "category"); + event.subcategory = field_string(item, "subcategory"); + event.image = field_string(item, "image"); + event.icon = field_string(item, "icon"); + event.resolution_source = field_string(item, "resolutionSource"); auto &state = event.state; - state.active = gamma_bool(item, "active"); - state.closed = gamma_bool(item, "closed"); - state.archived = gamma_bool(item, "archived"); - state.is_new = gamma_bool(item, "new"); - state.featured = gamma_bool(item, "featured"); - state.restricted = gamma_bool(item, "restricted"); - state.cyom = gamma_bool(item, "cyom"); - state.live = gamma_bool(item, "live"); - state.ended = gamma_bool(item, "ended"); - state.comments_enabled = gamma_bool(item, "commentsEnabled"); + state.active = field_bool(item, "active"); + state.closed = field_bool(item, "closed"); + state.archived = field_bool(item, "archived"); + state.is_new = field_bool(item, "new"); + state.featured = field_bool(item, "featured"); + state.restricted = field_bool(item, "restricted"); + state.cyom = field_bool(item, "cyom"); + state.live = field_bool(item, "live"); + state.ended = field_bool(item, "ended"); + state.comments_enabled = field_bool(item, "commentsEnabled"); auto &schedule = event.schedule; - schedule.start_date = gamma_string(item, "startDate"); - schedule.creation_date = gamma_string(item, "creationDate"); - schedule.end_date = gamma_string(item, "endDate"); - schedule.closed_time = gamma_string(item, "closedTime"); - schedule.start_time = gamma_string(item, "startTime"); - schedule.event_date = gamma_string(item, "eventDate"); + schedule.start_date = field_string(item, "startDate"); + schedule.creation_date = field_string(item, "creationDate"); + schedule.end_date = field_string(item, "endDate"); + schedule.closed_time = field_string(item, "closedTime"); + schedule.start_time = field_string(item, "startTime"); + schedule.event_date = field_string(item, "eventDate"); auto &metrics = event.metrics; - metrics.liquidity = gamma_number(item, "liquidity"); - metrics.liquidity_clob = gamma_number(item, "liquidityClob"); - metrics.volume = gamma_number(item, "volume"); - metrics.volume_24hr = gamma_number(item, "volume24hr"); - metrics.volume_1wk = gamma_number(item, "volume1wk"); - metrics.volume_1mo = gamma_number(item, "volume1mo"); - metrics.volume_1yr = gamma_number(item, "volume1yr"); - metrics.open_interest = gamma_number(item, "openInterest"); - metrics.competitive = gamma_number(item, "competitive"); - metrics.comment_count = gamma_integer(item, "commentCount"); + metrics.liquidity = field_number(item, "liquidity"); + metrics.liquidity_clob = field_number(item, "liquidityClob"); + metrics.volume = field_number(item, "volume"); + metrics.volume_24hr = field_number(item, "volume24hr"); + metrics.volume_1wk = field_number(item, "volume1wk"); + metrics.volume_1mo = field_number(item, "volume1mo"); + metrics.volume_1yr = field_number(item, "volume1yr"); + metrics.open_interest = field_number(item, "openInterest"); + metrics.competitive = field_number(item, "competitive"); + metrics.comment_count = field_integer(item, "commentCount"); auto &trading = event.trading; - trading.enable_order_book = gamma_bool(item, "enableOrderBook"); - trading.neg_risk = gamma_bool(item, "negRisk"); - trading.neg_risk_market_id = gamma_string(item, "negRiskMarketID"); - trading.enable_neg_risk = gamma_bool(item, "enableNegRisk"); - trading.neg_risk_augmented = gamma_bool(item, "negRiskAugmented"); - trading.cumulative_markets = gamma_bool(item, "cumulativeMarkets"); - - for (const auto &market : gamma_objects(item, "markets")) + trading.enable_order_book = field_bool(item, "enableOrderBook"); + trading.neg_risk = field_bool(item, "negRisk"); + trading.neg_risk_market_id = field_string(item, "negRiskMarketID"); + trading.enable_neg_risk = field_bool(item, "enableNegRisk"); + trading.neg_risk_augmented = field_bool(item, "negRiskAugmented"); + trading.cumulative_markets = field_bool(item, "cumulativeMarkets"); + + for (const auto &market : field_objects(item, "markets")) { if (auto parsed = parse_gamma_binary_market(market)) event.markets.push_back(std::move(*parsed)); } - for (const auto &series : gamma_objects(item, "series")) + for (const auto &series : field_objects(item, "series")) event.series.push_back(series_reference(series)); - for (const auto &tag : gamma_objects(item, "tags")) + for (const auto &tag : field_objects(item, "tags")) event.tags.push_back(parse_gamma_tag_reference(tag)); event.raw = item; return event; diff --git a/src/gamma_parsing.cpp b/src/gamma_parsing.cpp index 62fd916..3e1f4f9 100644 --- a/src/gamma_parsing.cpp +++ b/src/gamma_parsing.cpp @@ -1,6 +1,5 @@ #include "gamma_parsing.hpp" -#include "rest_numeric.hpp" #include #include @@ -12,19 +11,6 @@ namespace polymarket::detail { namespace { - const json *field(const json &object, const char *key) - { - if (!object.is_object()) throw std::invalid_argument("Gamma value must be an object"); - const auto found = object.find(key); - if (found == object.end() || found->is_null()) return nullptr; - return &*found; - } - - [[noreturn]] void bad_field(const char *key, const char *expected) - { - throw std::invalid_argument(std::string("Gamma field ") + key + " must be " + expected); - } - template Page parse_keyset_page(const std::string &body, const char *items_key, Parse parse_item) { @@ -32,163 +18,40 @@ namespace polymarket::detail if (!parsed.is_object()) throw std::invalid_argument("Gamma keyset page must be an object"); const auto items = parsed.find(items_key); - if (items == parsed.end() || !items->is_array()) bad_field(items_key, "an array"); + if (items == parsed.end() || !items->is_array()) throw_bad_field(items_key, "an array"); Page page; page.items.reserve(items->size()); for (const auto &item : *items) { if (auto value = parse_item(item)) page.items.push_back(std::move(*value)); } - if (const auto *cursor = field(parsed, "next_cursor")) + if (const auto *cursor = find_field(parsed, "next_cursor")) { if (!cursor->is_string() || cursor->get_ref().empty()) - bad_field("next_cursor", "a non-empty string when present"); + throw_bad_field("next_cursor", "a non-empty string when present"); page.next_cursor = cursor->get(); } return page; } } // namespace - std::string gamma_string(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return {}; - if (!value->is_string()) bad_field(key, "a string"); - return value->get(); - } - - std::string gamma_required_string(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr || !value->is_string()) bad_field(key, "a string"); - return value->get(); - } - - std::string gamma_id(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return {}; - if (value->is_string()) return value->get(); - if (value->is_number_unsigned()) return std::to_string(value->get()); - if (value->is_number_integer()) return std::to_string(value->get()); - bad_field(key, "a string or integer ID"); - } - - std::string gamma_required_id(const json &object, const char *key) - { - auto id = gamma_id(object, key); - if (id.empty()) bad_field(key, "a non-empty ID"); - return id; - } - - std::string gamma_hex(const json &object, const char *key) - { - auto text = gamma_string(object, key); - if (text.empty()) return text; - const bool hex = text.size() > 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X') && - text.find_first_not_of("0123456789abcdefABCDEF", 2) == std::string::npos; - if (!hex) bad_field(key, "a 0x-prefixed hex string"); - return text; - } - - std::string gamma_decimal_text(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return {}; - if (value->is_number()) return value->dump(); - if (value->is_string()) - { - const auto &text = value->get_ref(); - if (text.empty() || is_decimal_number(text)) return text; - } - bad_field(key, "a decimal number"); - } - - std::optional gamma_number(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return std::nullopt; - if (value->is_string() && value->get_ref().empty()) - return std::nullopt; - if (value->is_boolean()) bad_field(key, "a number"); - return strict_json_number(*value); - } - - std::optional gamma_integer(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return std::nullopt; - if (value->is_number_unsigned()) - { - const auto number = value->get(); - if (number > static_cast(std::numeric_limits::max())) - bad_field(key, "a 64-bit integer"); - return static_cast(number); - } - if (!value->is_number_integer()) bad_field(key, "an integer"); - return value->get(); - } - - std::optional gamma_bool(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return std::nullopt; - if (!value->is_boolean()) bad_field(key, "a boolean"); - return value->get(); - } - - std::vector gamma_sequence(const json &object, const char *key) - { - const auto *value = field(object, key); - if (value == nullptr) return {}; - json parsed; - const json *list = value; - if (value->is_string()) - { - parsed = json::parse(value->get_ref()); - list = &parsed; - } - if (!list->is_array()) bad_field(key, "an array or a JSON-encoded array"); - return std::vector(list->begin(), list->end()); - } - - std::vector gamma_string_list(const json &object, const char *key) - { - std::vector strings; - for (auto &item : gamma_sequence(object, key)) - { - if (!item.is_string()) bad_field(key, "a list of strings"); - strings.push_back(item.get()); - } - return strings; - } - - std::vector gamma_objects(const json &object, const char *key) - { - auto items = gamma_sequence(object, key); - for (const auto &item : items) - { - if (!item.is_object()) bad_field(key, "a list of objects"); - } - return items; - } GammaTagReference parse_gamma_tag_reference(const json &item) { GammaTagReference tag; - tag.id = gamma_required_id(item, "id"); - tag.slug = gamma_string(item, "slug"); - tag.label = gamma_string(item, "label"); - tag.force_show = gamma_bool(item, "forceShow"); - tag.force_hide = gamma_bool(item, "forceHide"); - tag.is_carousel = gamma_bool(item, "isCarousel"); - tag.requires_translation = gamma_bool(item, "requiresTranslation"); - tag.created_by = gamma_integer(item, "createdBy"); - tag.updated_by = gamma_integer(item, "updatedBy"); - tag.active_events_count = gamma_integer(item, "activeEventsCount"); - tag.published_at = gamma_string(item, "publishedAt"); - tag.created_at = gamma_string(item, "createdAt"); - tag.updated_at = gamma_string(item, "updatedAt"); + tag.id = field_required_id(item, "id"); + tag.slug = field_string(item, "slug"); + tag.label = field_string(item, "label"); + tag.force_show = field_bool(item, "forceShow"); + tag.force_hide = field_bool(item, "forceHide"); + tag.is_carousel = field_bool(item, "isCarousel"); + tag.requires_translation = field_bool(item, "requiresTranslation"); + tag.created_by = field_integer(item, "createdBy"); + tag.updated_by = field_integer(item, "updatedBy"); + tag.active_events_count = field_integer(item, "activeEventsCount"); + tag.published_at = field_string(item, "publishedAt"); + tag.created_at = field_string(item, "createdAt"); + tag.updated_at = field_string(item, "updatedAt"); tag.raw = item; return tag; } diff --git a/src/gamma_parsing.hpp b/src/gamma_parsing.hpp index 8d8e722..ef81aca 100644 --- a/src/gamma_parsing.hpp +++ b/src/gamma_parsing.hpp @@ -3,6 +3,7 @@ #include "polymarket/gamma_catalog_types.hpp" #include "polymarket/gamma_social_types.hpp" #include "polymarket/gamma_types.hpp" +#include "json_fields.hpp" #include "polymarket/pagination.hpp" #include @@ -17,22 +18,6 @@ namespace polymarket::detail { using json = nlohmann::json; - // Absent and null fields read as empty or unset. - std::string gamma_string(const json &object, const char *key); - std::string gamma_id(const json &object, const char *key); // string or integer - std::string gamma_required_id(const json &object, const char *key); - std::string gamma_hex(const json &object, const char *key); // "" or 0x-prefixed hex - std::string gamma_decimal_text(const json &object, const char *key); - std::optional gamma_number(const json &object, const char *key); // "" is unset - std::optional gamma_integer(const json &object, const char *key); - std::optional gamma_bool(const json &object, const char *key); - // A JSON array or a JSON-encoded array string. - std::vector gamma_sequence(const json &object, const char *key); - std::vector gamma_string_list(const json &object, const char *key); - std::vector gamma_objects(const json &object, const char *key); - - std::string gamma_required_string(const json &object, const char *key); - GammaTagReference parse_gamma_tag_reference(const json &item); // Nullopt for a market that does not have exactly two outcomes. std::optional parse_gamma_binary_market(const json &item); diff --git a/src/gamma_query.cpp b/src/gamma_query.cpp index e9cc03e..a77b21a 100644 --- a/src/gamma_query.cpp +++ b/src/gamma_query.cpp @@ -2,9 +2,6 @@ #include "query_encoding.hpp" -#include - -#include #include #include #include @@ -48,72 +45,11 @@ namespace polymarket::detail } } // namespace - void gamma_check_choice(const char *name, const std::string &value, - std::initializer_list choices) - { - if (value.empty()) return; - for (const auto choice : choices) - { - if (value == choice) return; - } - std::string message = std::string(name) + " must be one of:"; - for (const auto choice : choices) - message += std::string(" ") + std::string(choice); - throw std::invalid_argument(message); - } - - void GammaQuery::append(std::string_view key, std::string_view value) - { - query_ += query_.empty() ? '?' : '&'; - query_ += key; - query_ += '='; - query_ += percent_encode_query_value(value); - } - - void GammaQuery::add(std::string_view key, const std::optional &value) - { - if (value) append(key, *value ? "true" : "false"); - } - - void GammaQuery::add(std::string_view key, const std::optional &value) - { - if (!value) return; - if (!std::isfinite(*value)) - throw std::invalid_argument(std::string(key) + " must be finite"); - append(key, nlohmann::json(*value).dump()); - } - - void GammaQuery::add(std::string_view key, const std::optional &value) - { - if (value) append(key, std::to_string(*value)); - } - - void GammaQuery::add(std::string_view key, const std::string &value) - { - if (!value.empty()) append(key, value); - } - - void GammaQuery::add(std::string_view key, const std::vector &values) - { - for (const auto &value : values) - append(key, value); - } - - void GammaQuery::add(std::string_view key, const std::vector &values) - { - for (const auto value : values) - append(key, std::to_string(value)); - } - - std::string GammaQuery::str() const - { - return query_; - } std::string gamma_market_list_query(const GammaMarketFilter &filter) { - gamma_check_choice("tag_match", filter.tag_match, {"any", "all"}); - GammaQuery query; + check_choice("tag_match", filter.tag_match, {"any", "all"}); + QueryBuilder query; query.add("ascending", filter.ascending); query.add("closed", filter.closed); query.add("clob_token_ids", filter.clob_token_ids); @@ -149,9 +85,9 @@ namespace polymarket::detail std::string gamma_event_list_query(const GammaEventFilter &filter) { - gamma_check_choice("recurrence", filter.recurrence, {"daily", "weekly", "monthly"}); - gamma_check_choice("tag_match", filter.tag_match, {"any", "all"}); - GammaQuery query; + check_choice("recurrence", filter.recurrence, {"daily", "weekly", "monthly"}); + check_choice("tag_match", filter.tag_match, {"any", "all"}); + QueryBuilder query; query.add("ascending", filter.ascending); query.add("closed", filter.closed); query.add("cyom", filter.cyom); @@ -195,7 +131,7 @@ namespace polymarket::detail std::string gamma_market_lookup_query(const GammaMarketLookupOptions &options) { - GammaQuery query; + QueryBuilder query; query.add("include_tag", options.include_tag); query.add("locale", options.locale); return query.str(); @@ -203,7 +139,7 @@ namespace polymarket::detail std::string gamma_event_lookup_query(const GammaEventLookupOptions &options) { - GammaQuery query; + QueryBuilder query; query.add("include_best_lines", options.include_best_lines); query.add("include_chat", options.include_chat); query.add("include_template", options.include_template); diff --git a/src/gamma_query.hpp b/src/gamma_query.hpp index eebc1ae..0bb71f9 100644 --- a/src/gamma_query.hpp +++ b/src/gamma_query.hpp @@ -1,6 +1,7 @@ #pragma once #include "polymarket/gamma_client.hpp" +#include "query_builder.hpp" #include #include @@ -13,27 +14,6 @@ // std::invalid_argument. namespace polymarket::detail { - class GammaQuery - { - public: - void add(std::string_view key, const std::optional &value); - void add(std::string_view key, const std::optional &value); - void add(std::string_view key, const std::optional &value); - void add(std::string_view key, const std::string &value); // skipped when empty - void add(std::string_view key, const std::vector &values); - void add(std::string_view key, const std::vector &values); - // "?a=1&b=2", or "" when nothing was added. - std::string str() const; - - private: - void append(std::string_view key, std::string_view value); - std::string query_; - }; - - // Accepts "" or one of `choices`. - void gamma_check_choice(const char *name, const std::string &value, - std::initializer_list choices); - std::string gamma_market_list_query(const GammaMarketFilter &filter); std::string gamma_event_list_query(const GammaEventFilter &filter); std::string gamma_market_lookup_query(const GammaMarketLookupOptions &options); diff --git a/src/gamma_social_client.cpp b/src/gamma_social_client.cpp index 1a1df22..6a293a3 100644 --- a/src/gamma_social_client.cpp +++ b/src/gamma_social_client.cpp @@ -26,12 +26,11 @@ namespace polymarket std::string search_query(const std::string &q, const GammaSearchOptions &options) { require_nonempty("q", q); - detail::gamma_check_choice("recurrence", options.recurrence, - {"daily", "weekly", "monthly"}); - detail::gamma_check_choice("sort", options.sort, - {"volume", "volume_24hr", "liquidity", "competitive", - "closed_time", "start_date", "end_date"}); - detail::GammaQuery query; + detail::check_choice("recurrence", options.recurrence, {"daily", "weekly", "monthly"}); + detail::check_choice("sort", options.sort, + {"volume", "volume_24hr", "liquidity", "competitive", + "closed_time", "start_date", "end_date"}); + detail::QueryBuilder query; query.add("q", q); query.add("ascending", options.ascending); query.add("cache", options.cache); @@ -79,7 +78,7 @@ namespace polymarket } catch (const std::invalid_argument &ex) { - invalid = detail::gamma_invalid_argument(ex.what(), endpoint); + invalid = detail::invalid_argument_error(ex.what(), endpoint); } if (invalid) { @@ -93,8 +92,8 @@ namespace polymarket { const auto path = prefix + std::to_string(request.page) + "&limit_per_type=" + std::to_string(request.limit); - return detail::gamma_page(get(path), endpoint, - detail::parse_gamma_search_page); + return detail::rest_page(get(path), endpoint, + detail::parse_gamma_search_page); }, page_size, resume); } @@ -106,7 +105,7 @@ namespace polymarket [&] { require_nonempty("id", id); - detail::GammaQuery query; + detail::QueryBuilder query; query.add("get_positions", get_positions); return "/comments/" + detail::percent_encode_query_value(id) + query.str(); }, @@ -126,7 +125,7 @@ namespace polymarket [&] { check_comment_parent(parent_entity_id, parent_entity_type); - detail::GammaQuery query; + detail::QueryBuilder query; query.add("parent_entity_id", parent_entity_id); query.add("parent_entity_type", parent_entity_type); query.add("ascending", filter.ascending); @@ -149,7 +148,7 @@ namespace polymarket // Pinned so the first page matches the offset listing's newest-first default. const std::string order = filter.order.empty() ? "createdAt" : filter.order; const bool ascending = filter.order.empty() ? false : filter.ascending.value_or(true); - detail::GammaQuery builder; + detail::QueryBuilder builder; builder.add("parent_entity_id", parent_entity_id); builder.add("parent_entity_type", parent_entity_type); builder.add("order", order); @@ -158,11 +157,12 @@ namespace polymarket } catch (const std::invalid_argument &ex) { - invalid = detail::gamma_invalid_argument(ex.what(), endpoint); + invalid = detail::invalid_argument_error(ex.what(), endpoint); } - return detail::gamma_keyset_pager( - endpoint, query, page_size, resume, std::move(invalid), [this](const std::string &path) - { return get(path); }, detail::parse_gamma_comment_keyset_page); + return detail::keyset_pager( + endpoint, query, page_size, resume, std::move(invalid), "after_cursor", + [this](const std::string &path) { return get(path); }, + detail::parse_gamma_comment_keyset_page); } Paginator GammaClient::user_comments_pager(const std::string &address, @@ -177,12 +177,12 @@ namespace polymarket [&] { require_nonempty("address", address); - detail::GammaQuery query; + detail::QueryBuilder query; query.add("ascending", filter.ascending); query.add("order", filter.order); return query.str(); }, - detail::gamma_list_page(detail::parse_gamma_comments)); + detail::list_page(detail::parse_gamma_comments)); } Result> @@ -192,7 +192,7 @@ namespace polymarket [&] { require_nonempty("address", address); - detail::GammaQuery query; + detail::QueryBuilder query; query.add("address", address); return "/public-profile" + query.str(); }, diff --git a/src/gamma_social_parsing.cpp b/src/gamma_social_parsing.cpp index d9d5e40..bc30d95 100644 --- a/src/gamma_social_parsing.cpp +++ b/src/gamma_social_parsing.cpp @@ -11,8 +11,8 @@ namespace polymarket::detail { std::string profile_wallet(const json &item) { - if (item.contains("wallet")) return gamma_string(item, "wallet"); - return gamma_string(item, "proxyWallet"); + if (item.contains("wallet")) return field_string(item, "wallet"); + return field_string(item, "proxyWallet"); } std::optional comment_profile(const json &item, const char *key) @@ -20,30 +20,30 @@ namespace polymarket::detail if (!item.contains(key) || item.at(key).is_null()) return std::nullopt; const auto &value = item.at(key); GammaCommentProfile profile; - profile.name = gamma_string(value, "name"); - profile.pseudonym = gamma_string(value, "pseudonym"); - profile.bio = gamma_string(value, "bio"); + profile.name = field_string(value, "name"); + profile.pseudonym = field_string(value, "pseudonym"); + profile.bio = field_string(value, "bio"); profile.wallet = profile_wallet(value); - profile.base_address = gamma_string(value, "baseAddress"); - profile.profile_image = gamma_string(value, "profileImage"); - profile.display_username_public = gamma_bool(value, "displayUsernamePublic"); - profile.is_mod = gamma_bool(value, "isMod"); - profile.is_creator = gamma_bool(value, "isCreator"); - for (const auto &position : gamma_objects(value, "positions")) + profile.base_address = field_string(value, "baseAddress"); + profile.profile_image = field_string(value, "profileImage"); + profile.display_username_public = field_bool(value, "displayUsernamePublic"); + profile.is_mod = field_bool(value, "isMod"); + profile.is_creator = field_bool(value, "isCreator"); + for (const auto &position : field_objects(value, "positions")) profile.positions.push_back( - {gamma_id(position, "tokenId"), gamma_integer(position, "positionSize")}); + {field_id(position, "tokenId"), field_integer(position, "positionSize")}); return profile; } GammaReaction reaction(const json &item) { GammaReaction result; - result.id = gamma_required_id(item, "id"); - result.comment_id = gamma_integer(item, "commentID"); - result.reaction_type = gamma_string(item, "reactionType"); - result.icon = gamma_string(item, "icon"); - result.user_address = gamma_string(item, "userAddress"); - result.created_at = gamma_string(item, "createdAt"); + result.id = field_required_id(item, "id"); + result.comment_id = field_integer(item, "commentID"); + result.reaction_type = field_string(item, "reactionType"); + result.icon = field_string(item, "icon"); + result.user_address = field_string(item, "userAddress"); + result.created_at = field_string(item, "createdAt"); result.profile = comment_profile(item, "profile"); return result; } @@ -51,16 +51,16 @@ namespace polymarket::detail GammaSearchProfile search_profile(const json &item) { GammaSearchProfile profile; - profile.id = gamma_id(item, "id"); + profile.id = field_id(item, "id"); profile.wallet = profile_wallet(item); - profile.name = gamma_string(item, "name"); - profile.pseudonym = gamma_string(item, "pseudonym"); - profile.bio = gamma_string(item, "bio"); - profile.profile_image = gamma_string(item, "profileImage"); - profile.x_username = gamma_string(item, "xUsername"); - profile.display_username_public = gamma_bool(item, "displayUsernamePublic"); - profile.verified_badge = gamma_bool(item, "verifiedBadge"); - profile.created_at = gamma_string(item, "createdAt"); + profile.name = field_string(item, "name"); + profile.pseudonym = field_string(item, "pseudonym"); + profile.bio = field_string(item, "bio"); + profile.profile_image = field_string(item, "profileImage"); + profile.x_username = field_string(item, "xUsername"); + profile.display_username_public = field_bool(item, "displayUsernamePublic"); + profile.verified_badge = field_bool(item, "verifiedBadge"); + profile.created_at = field_string(item, "createdAt"); profile.raw = item; return profile; } @@ -79,21 +79,21 @@ namespace polymarket::detail GammaComment parse_gamma_comment(const json &item) { GammaComment comment; - comment.id = gamma_required_id(item, "id"); - comment.body = gamma_string(item, "body"); - comment.parent_entity_type = gamma_string(item, "parentEntityType"); - comment.parent_entity_id = gamma_id(item, "parentEntityID"); - comment.parent_comment_id = gamma_id(item, "parentCommentID"); - comment.user_address = gamma_string(item, "userAddress"); - comment.reply_address = gamma_string(item, "replyAddress"); - comment.created_at = gamma_string(item, "createdAt"); - comment.updated_at = gamma_string(item, "updatedAt"); + comment.id = field_required_id(item, "id"); + comment.body = field_string(item, "body"); + comment.parent_entity_type = field_string(item, "parentEntityType"); + comment.parent_entity_id = field_id(item, "parentEntityID"); + comment.parent_comment_id = field_id(item, "parentCommentID"); + comment.user_address = field_string(item, "userAddress"); + comment.reply_address = field_string(item, "replyAddress"); + comment.created_at = field_string(item, "createdAt"); + comment.updated_at = field_string(item, "updatedAt"); comment.profile = comment_profile(item, "profile"); - for (const auto &value : gamma_objects(item, "reactions")) + for (const auto &value : field_objects(item, "reactions")) comment.reactions.push_back(reaction(value)); - comment.report_count = gamma_integer(item, "reportCount"); - comment.reaction_count = gamma_integer(item, "reactionCount"); - comment.trade_asset = gamma_string(item, "tradeAsset"); + comment.report_count = field_integer(item, "reportCount"); + comment.reaction_count = field_integer(item, "reactionCount"); + comment.trade_asset = field_string(item, "tradeAsset"); comment.raw = item; return comment; } @@ -128,18 +128,18 @@ namespace polymarket::detail const auto item = json::parse(body); GammaPublicProfile profile; profile.wallet = profile_wallet(item); - profile.name = gamma_string(item, "name"); - profile.pseudonym = gamma_string(item, "pseudonym"); - profile.bio = gamma_string(item, "bio"); - profile.profile_image = gamma_string(item, "profileImage"); - profile.x_username = gamma_string(item, "xUsername"); - profile.display_username_public = gamma_bool(item, "displayUsernamePublic"); - profile.verified_badge = gamma_bool(item, "verifiedBadge"); - profile.created_at = gamma_string(item, "createdAt"); - for (const auto &user : gamma_objects(item, "users")) - profile.users.push_back({gamma_required_id(user, "id"), - gamma_bool(user, "communityMod"), gamma_bool(user, "creator"), - gamma_bool(user, "mod")}); + profile.name = field_string(item, "name"); + profile.pseudonym = field_string(item, "pseudonym"); + profile.bio = field_string(item, "bio"); + profile.profile_image = field_string(item, "profileImage"); + profile.x_username = field_string(item, "xUsername"); + profile.display_username_public = field_bool(item, "displayUsernamePublic"); + profile.verified_badge = field_bool(item, "verifiedBadge"); + profile.created_at = field_string(item, "createdAt"); + for (const auto &user : field_objects(item, "users")) + profile.users.push_back({field_required_id(user, "id"), + field_bool(user, "communityMod"), field_bool(user, "creator"), + field_bool(user, "mod")}); profile.raw = item; return profile; } @@ -153,8 +153,8 @@ namespace polymarket::detail for (const auto &event : optional_array(parsed, "events")) results.events.push_back(parse_gamma_event(event)); for (const auto &tag : optional_array(parsed, "tags")) - results.tags.push_back({gamma_required_id(tag, "id"), gamma_string(tag, "label"), - gamma_string(tag, "slug"), gamma_integer(tag, "event_count")}); + results.tags.push_back({field_required_id(tag, "id"), field_string(tag, "label"), + field_string(tag, "slug"), field_integer(tag, "event_count")}); for (const auto &profile : optional_array(parsed, "profiles")) results.profiles.push_back(search_profile(profile)); @@ -162,8 +162,8 @@ namespace polymarket::detail if (parsed.contains("pagination") && !parsed.at("pagination").is_null()) { const auto &pagination = parsed.at("pagination"); - page.has_more = gamma_bool(pagination, "hasMore").value_or(false); - results.total_results = gamma_integer(pagination, "totalResults"); + page.has_more = field_bool(pagination, "hasMore").value_or(false); + results.total_results = field_integer(pagination, "totalResults"); } page.items.push_back(std::move(results)); return page; diff --git a/src/json_fields.cpp b/src/json_fields.cpp new file mode 100644 index 0000000..28f1ab7 --- /dev/null +++ b/src/json_fields.cpp @@ -0,0 +1,147 @@ +#include "json_fields.hpp" + +#include "rest_numeric.hpp" + +#include +#include +#include + +namespace polymarket::detail +{ + const json *find_field(const json &object, const char *key) + { + if (!object.is_object()) throw std::invalid_argument("response value must be an object"); + const auto found = object.find(key); + if (found == object.end() || found->is_null()) return nullptr; + return &*found; + } + + void throw_bad_field(const char *key, const char *expected) + { + throw std::invalid_argument(std::string("field ") + key + " must be " + expected); + } + + std::string field_string(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return {}; + if (!value->is_string()) throw_bad_field(key, "a string"); + return value->get(); + } + + std::string field_required_string(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr || !value->is_string()) throw_bad_field(key, "a string"); + return value->get(); + } + + std::string field_id(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return {}; + if (value->is_string()) return value->get(); + if (value->is_number_unsigned()) return std::to_string(value->get()); + if (value->is_number_integer()) return std::to_string(value->get()); + throw_bad_field(key, "a string or integer ID"); + } + + std::string field_required_id(const json &object, const char *key) + { + auto id = field_id(object, key); + if (id.empty()) throw_bad_field(key, "a non-empty ID"); + return id; + } + + std::string field_hex(const json &object, const char *key) + { + auto text = field_string(object, key); + if (text.empty()) return text; + const bool hex = text.size() > 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X') && + text.find_first_not_of("0123456789abcdefABCDEF", 2) == std::string::npos; + if (!hex) throw_bad_field(key, "a 0x-prefixed hex string"); + return text; + } + + std::string field_decimal_text(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return {}; + if (value->is_number()) return value->dump(); + if (value->is_string()) + { + const auto &text = value->get_ref(); + if (text.empty() || is_decimal_number(text)) return text; + } + throw_bad_field(key, "a decimal number"); + } + + std::optional field_number(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return std::nullopt; + if (value->is_string() && value->get_ref().empty()) + return std::nullopt; + if (value->is_boolean()) throw_bad_field(key, "a number"); + return strict_json_number(*value); + } + + std::optional field_integer(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return std::nullopt; + if (value->is_number_unsigned()) + { + const auto number = value->get(); + if (number > static_cast(std::numeric_limits::max())) + throw_bad_field(key, "a 64-bit integer"); + return static_cast(number); + } + if (!value->is_number_integer()) throw_bad_field(key, "an integer"); + return value->get(); + } + + std::optional field_bool(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return std::nullopt; + if (!value->is_boolean()) throw_bad_field(key, "a boolean"); + return value->get(); + } + + std::vector field_sequence(const json &object, const char *key) + { + const auto *value = find_field(object, key); + if (value == nullptr) return {}; + json parsed; + const json *list = value; + if (value->is_string()) + { + parsed = json::parse(value->get_ref()); + list = &parsed; + } + if (!list->is_array()) throw_bad_field(key, "an array or a JSON-encoded array"); + return std::vector(list->begin(), list->end()); + } + + std::vector field_string_list(const json &object, const char *key) + { + std::vector strings; + for (auto &item : field_sequence(object, key)) + { + if (!item.is_string()) throw_bad_field(key, "a list of strings"); + strings.push_back(item.get()); + } + return strings; + } + + std::vector field_objects(const json &object, const char *key) + { + auto items = field_sequence(object, key); + for (const auto &item : items) + { + if (!item.is_object()) throw_bad_field(key, "a list of objects"); + } + return items; + } +} // namespace polymarket::detail diff --git a/src/json_fields.hpp b/src/json_fields.hpp new file mode 100644 index 0000000..390a2e2 --- /dev/null +++ b/src/json_fields.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include +#include +#include + +// Strict readers for fields of REST response objects. Malformed input throws +// std::invalid_argument naming the field. +namespace polymarket::detail +{ + using json = nlohmann::json; + + // Null when the field is absent or null; throws when `object` is not an object. + const json *find_field(const json &object, const char *key); + [[noreturn]] void throw_bad_field(const char *key, const char *expected); + + // Absent and null fields read as empty or unset. + std::string field_string(const json &object, const char *key); + std::string field_id(const json &object, const char *key); // string or integer + std::string field_required_id(const json &object, const char *key); + std::string field_hex(const json &object, const char *key); // "" or 0x-prefixed hex + std::string field_decimal_text(const json &object, const char *key); + std::optional field_number(const json &object, const char *key); // "" is unset + std::optional field_integer(const json &object, const char *key); + std::optional field_bool(const json &object, const char *key); + // A JSON array or a JSON-encoded array string. + std::vector field_sequence(const json &object, const char *key); + std::vector field_string_list(const json &object, const char *key); + std::vector field_objects(const json &object, const char *key); + + std::string field_required_string(const json &object, const char *key); +} // namespace polymarket::detail diff --git a/src/query_builder.cpp b/src/query_builder.cpp new file mode 100644 index 0000000..6510f4c --- /dev/null +++ b/src/query_builder.cpp @@ -0,0 +1,95 @@ +#include "query_builder.hpp" + +#include "query_encoding.hpp" + +#include + +#include +#include +#include + +namespace polymarket::detail +{ + void check_choice(const char *name, const std::string &value, + std::initializer_list choices) + { + if (value.empty()) return; + for (const auto choice : choices) + { + if (value == choice) return; + } + std::string message = std::string(name) + " must be one of:"; + for (const auto choice : choices) + message += std::string(" ") + std::string(choice); + throw std::invalid_argument(message); + } + + void QueryBuilder::append(std::string_view key, std::string_view value) + { + query_ += query_.empty() ? '?' : '&'; + query_ += key; + query_ += '='; + query_ += percent_encode_query_value(value); + } + + void QueryBuilder::add(std::string_view key, const std::optional &value) + { + if (value) append(key, *value ? "true" : "false"); + } + + void QueryBuilder::add(std::string_view key, const std::optional &value) + { + if (!value) return; + if (!std::isfinite(*value)) + throw std::invalid_argument(std::string(key) + " must be finite"); + append(key, nlohmann::json(*value).dump()); + } + + void QueryBuilder::add(std::string_view key, const std::optional &value) + { + if (value) append(key, std::to_string(*value)); + } + + void QueryBuilder::add(std::string_view key, const std::string &value) + { + if (!value.empty()) append(key, value); + } + + void QueryBuilder::add(std::string_view key, const std::vector &values) + { + for (const auto &value : values) + append(key, value); + } + + void QueryBuilder::add(std::string_view key, const std::vector &values) + { + for (const auto value : values) + append(key, std::to_string(value)); + } + + std::string QueryBuilder::str() const + { + return query_; + } + + void QueryBuilder::add_joined(std::string_view key, const std::vector &values) + { + if (values.empty()) return; + std::string joined; + for (const auto &value : values) + { + if (!joined.empty()) joined += ','; + joined += value; + } + append(key, joined); + } + + void QueryBuilder::add_joined(std::string_view key, const std::vector &values) + { + std::vector text; + text.reserve(values.size()); + for (const auto value : values) + text.push_back(std::to_string(value)); + add_joined(key, text); + } +} // namespace polymarket::detail diff --git a/src/query_builder.hpp b/src/query_builder.hpp new file mode 100644 index 0000000..cab03ab --- /dev/null +++ b/src/query_builder.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + // Builds a percent-encoded query string. Unset optionals, empty strings + // and empty lists are skipped; booleans are sent as true or false. + class QueryBuilder + { + public: + void add(std::string_view key, const std::optional &value); + void add(std::string_view key, const std::optional &value); + void add(std::string_view key, const std::optional &value); + void add(std::string_view key, const std::string &value); + // One key=value pair per item. + void add(std::string_view key, const std::vector &values); + void add(std::string_view key, const std::vector &values); + // One key with the items comma-joined. + void add_joined(std::string_view key, const std::vector &values); + void add_joined(std::string_view key, const std::vector &values); + // "?a=1&b=2", or "" when nothing was added. + std::string str() const; + + private: + void append(std::string_view key, std::string_view value); + std::string query_; + }; + + // Accepts "" or one of `choices`; throws std::invalid_argument otherwise. + void check_choice(const char *name, const std::string &value, + std::initializer_list choices); +} // namespace polymarket::detail diff --git a/src/rest_paging.hpp b/src/rest_paging.hpp new file mode 100644 index 0000000..2bb4e51 --- /dev/null +++ b/src/rest_paging.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include "polymarket/http_client.hpp" +#include "polymarket/pagination.hpp" +#include "polymarket/sdk_error.hpp" +#include "query_encoding.hpp" + +#include +#include +#include +#include +#include +#include + +// Request and paging plumbing shared by the REST clients. +namespace polymarket +{ + namespace detail + { + inline SdkError invalid_argument_error(const std::string &message, + const std::string &endpoint) + { + SdkError error; + error.code = SdkErrorCode::InvalidArgument; + error.message = message; + error.endpoint = endpoint; + return error; + } + + template + Result> rest_page(const HttpResponse &response, const std::string &endpoint, + Parse parse) + { + if (!response.ok()) return Result>::failure(make_sdk_error(response, endpoint)); + try + { + return Result>::success(parse(response.body)); + } + catch (const std::exception &ex) + { + return Result>::failure( + make_parse_error(ex.what(), endpoint, response.body)); + } + } + + // `query` is "" or "?a=b"; the page parameters follow the filters, and + // `cursor_param` carries the server cursor after the first page. + template + Paginator keyset_pager(const std::string &endpoint, const std::string &query, + std::size_t page_size, const PageRequest &resume, + std::optional invalid, const char *cursor_param, + Get get, ParsePage parse_page) + { + if (invalid) + { + return Paginator::keyset( + endpoint, [error = std::move(*invalid)](const PageRequest &) + { return Result>::failure(error); }, 1, resume); + } + std::string prefix = endpoint + (query.empty() ? "?" : query + "&") + + "limit=" + std::to_string(page_size); + return Paginator::keyset( + endpoint, + [endpoint, prefix = std::move(prefix), cursor_param = std::string(cursor_param), + get, parse_page](const PageRequest &request) + { + std::string path = prefix; + if (!request.cursor.empty()) + path += + "&" + cursor_param + "=" + percent_encode_query_value(request.cursor); + return rest_page(get(path), endpoint, parse_page); + }, + page_size, resume); + } + + // Parses a JSON array body into one offset page. + template auto list_page(ParseItems parse_items) + { + return [parse_items](const std::string &body) + { return Page{parse_items(body), ""}; }; + } + + // Offset pages hold at most `max_page_size` items, the server's own cap, + // and start at most at `max_offset`, past which the server rejects them. + template + Paginator offset_pager(const std::string &endpoint, const std::string &query, + std::size_t page_size, std::size_t max_page_size, + std::size_t max_offset, const PageRequest &resume, + std::optional invalid, Get get, ParsePage parse_page) + { + if (!invalid && (page_size == 0 || page_size > max_page_size)) + invalid = invalid_argument_error( + "page_size must be between 1 and " + std::to_string(max_page_size), endpoint); + if (invalid) + { + return Paginator::offset( + endpoint, [error = std::move(*invalid)](const PageRequest &) + { return Result>::failure(error); }, 1, max_offset); + } + std::string prefix = endpoint + (query.empty() ? "?" : query + "&") + + "limit=" + std::to_string(page_size) + "&offset="; + return Paginator::offset( + endpoint, + [endpoint, prefix = std::move(prefix), get, parse_page](const PageRequest &request) + { + return rest_page(get(prefix + std::to_string(request.offset)), endpoint, + parse_page); + }, + page_size, max_offset, resume.offset); + } + + // One request whose path is built by `build_path`; std::invalid_argument + // from it fails with InvalidArgument before any request is sent. + template + Result fetch_result(BuildPath build_path, Get get, Parse parse) + { + std::string path; + try + { + path = build_path(); + } + catch (const std::invalid_argument &ex) + { + return Result::failure(invalid_argument_error(ex.what(), "")); + } + const std::string endpoint = path.substr(0, path.find('?')); + const auto response = get(path); + if (!response.ok()) return Result::failure(make_sdk_error(response, endpoint)); + try + { + return Result::success(parse(response.body)); + } + catch (const std::exception &ex) + { + return Result::failure(make_parse_error(ex.what(), endpoint, response.body)); + } + } + } // namespace detail +} // namespace polymarket diff --git a/tests/data_analytics_fixtures.hpp b/tests/data_analytics_fixtures.hpp new file mode 100644 index 0000000..f5bc88e --- /dev/null +++ b/tests/data_analytics_fixtures.hpp @@ -0,0 +1,21 @@ +#pragma once + +// Responses recorded from https://data-api.polymarket.com on 2026-10-17 for one +// market and its event: /v2/holders with PnL (two holders per outcome), /v2/oi +// (two markets and the global row), /v2/live-volume (first three markets), +// /v2/prices-history and /v2/resolutions. Holder wallets, names, pseudonyms and +// avatars and resolution transaction hashes are replaced with synthetic values; +// market fields, amounts, times and cursors are as recorded. +namespace data_fixtures +{ + inline constexpr const char *data_holders_page = + R"json({"data":[{"token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","holders":[{"proxy_wallet":"0x6666666666666666666666666666666666666666","bio":"","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","pseudonym":"Example-Pseudonym","amount":8629.618709,"display_username_public":true,"outcome_index":0,"name":"Example Holder 1","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.8531,"entry_cost_usdc":7352.5012,"current_price":1.0,"current_value":8629.6187,"realized_pnl":-205.5027,"unrealized_pnl":1277.1174,"total_pnl":1071.6147},{"proxy_wallet":"0x7777777777777777777777777777777777777777","bio":"","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","pseudonym":"Example-Pseudonym","amount":6632.477528,"display_username_public":true,"outcome_index":0,"name":"Example Holder 2","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.7175,"entry_cost_usdc":4759.0745,"current_price":1.0,"current_value":6632.4775,"realized_pnl":0.0,"unrealized_pnl":1873.4029,"total_pnl":1873.4029}]},{"token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","holders":[{"proxy_wallet":"0x8888888888888888888888888888888888888888","bio":"","token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","pseudonym":"Example-Pseudonym","amount":110499.668869,"display_username_public":true,"outcome_index":1,"name":"Example Holder 3","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.5983,"entry_cost_usdc":66102.7828,"current_price":0.0,"current_value":0.0,"realized_pnl":-17.6775,"unrealized_pnl":-66102.7828,"total_pnl":-66120.4603},{"proxy_wallet":"0x9999999999999999999999999999999999999999","bio":"","token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","pseudonym":"Example-Pseudonym","amount":79930.007786,"display_username_public":true,"outcome_index":1,"name":"Example Holder 4","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.6436,"entry_cost_usdc":51154.808,"current_price":0.0,"current_value":0.0,"realized_pnl":-289.264,"unrealized_pnl":-51154.808,"total_pnl":-51444.072}]}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJob2xkZXJzIiwicGFyYW1zIjp7ImwiOjIsIm8iOjJ9fSwic2lnIjoiMzRkMDFlYzU0ZjdlZmRhMjdmZTE4ZDZmNjZjZmU0OTAifQ"}})json"; + inline constexpr const char *data_open_interests = + R"json({"data":[{"condition_id":"0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0","value":23873.989965},{"condition_id":"0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238","value":137.842267},{"condition_id":"GLOBAL","value":360163890.5159518}]})json"; + inline constexpr const char *data_live_volume = + R"json({"data":{"taker_volume_total":8227726.194698,"conditions":[{"condition_id":"0xf1013717322f7d148e2f2d41d618a774c045142173f2f1d0d85ea9c425508091","taker_volume":1950281.899588},{"condition_id":"0x6b0bd03feca294d126fac6fbaf79054122b52f084d0d8d7401403f34ab91e607","taker_volume":1884267.33962},{"condition_id":"0x8d2cc8e9d104d992e5478aa85094a5baffb704a907fbaeb1d8d097ebe9607de9","taker_volume":1510059.821007}]}})json"; + inline constexpr const char *data_price_history_page = + R"json({"data":[{"timestamp":1792197000,"price":0.51,"resolution_seconds":60},{"timestamp":1792197060,"price":0.5,"resolution_seconds":60},{"timestamp":1792197120,"price":0.5,"resolution_seconds":60}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJwcmljZXNfaGlzdG9yeSIsInBhcmFtcyI6eyJsIjozLCJ0cyI6MTc5MjE5NzEyMCwic3EiOiI2MCIsImQiOjN9fSwic2lnIjoiN2MyNDUyYWNkNzM5YThhZDc2OTBjMmQ0ZmQzNjhiODYifQ"}})json"; + inline constexpr const char *data_resolutions = + R"json({"data":[{"condition_id":"0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0","status":"resolved","extended_review":false,"was_disputed":false,"new_version_q":false,"transaction_hash":"","log_index":"","last_update_timestamp":"2026-10-17T22:23:50Z","payouts":[1000000,0],"resolution_source":"reported","was_arbitrated":false,"resolved_block":95713655,"resolved_at":"2026-10-17T22:23:50Z"},{"question_id":"0x24f034d2102744218ac250623ed0f6ac279bb6d63ecc0bc0599798a65a3e56c9","condition_id":"0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238","status":"resolved","extended_review":false,"was_disputed":false,"new_version_q":true,"proposed_price":"1000000000000000000","reproposed_price":"69","price":"1000000000000000000","transaction_hash":"0x3131313131313131313131313131313131313131313131313131313131313131","log_index":"90","last_update_timestamp":"1786579797"}]})json"; +} // namespace data_fixtures diff --git a/tests/data_fixtures.hpp b/tests/data_fixtures.hpp new file mode 100644 index 0000000..5e1d7b8 --- /dev/null +++ b/tests/data_fixtures.hpp @@ -0,0 +1,16 @@ +#pragma once + +// Responses recorded from https://data-api.polymarket.com on 2026-10-17: /v2/trades +// for one wallet, and /v2/activity rows from several leaderboard wallets (one row +// per activity type, and a final page). Wallets, transaction hashes, names, +// pseudonyms, bios and avatars are replaced with synthetic values; market fields, +// amounts, timestamps and cursors are as recorded. +namespace data_fixtures +{ + inline constexpr const char *data_trade_page = + R"json({"data":[{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266921,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"},{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266921,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"},{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266920,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJ0cmFkZXMiLCJwYXJhbXMiOnsibCI6MywidHMiOjE3OTIyNjY5MjAsInNxIjoiOTU3MDc3MTUwMDAzNDA1MjcwIiwiZCI6M319LCJzaWciOiJmYzhkMTRlMjVkMmFlZDllYWFkNGM0YzM4YmZiNDYzNSJ9"}})json"; + inline constexpr const char *data_activity_page = + R"json({"data":[{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792265849,"condition_id":"0xc5eae79d1ffe716572353962eb926b1e3964c500a4880a7a94f58408218ee76b","type":"TRADE","size":5.39,"usdc_size":0.4312,"transaction_hash":"0x0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d","price":0.08,"token_id":"103704141773947678931823410030956181918562062788486034785782641603149828893320","side":"BUY","outcome_index":0,"title":"2026 Balance of Power: R Senate, R House","slug":"2026-balance-of-power-r-senate-r-house-537","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/balance-of-power-2024-election-r-Fp-y4ONJBS.jpg","event_slug":"balance-of-power-2026-midterms","outcome":"Yes","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x3333333333333333333333333333333333333333","timestamp":1792262960,"condition_id":"0xada24d478ac478fb055d53e90c3ca964a1be1143140a927d36383ed1692a17f9","type":"MERGE","size":5.4,"usdc_size":5.4,"transaction_hash":"0x0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"Will Pete Hegseth be the first of these people impeached by December 31, 2027?","slug":"will-pete-hegseth-be-the-first-of-these-people-impeached-by-december-31-2027","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/pete-hegseth-impeached-by-june-30-dhxZQrHUmJ9x.jpg","event_slug":"which-of-these-people-will-be-impeached-first-by-the-end-of-2027","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792255512,"condition_id":"0xbfdaa64d81c94034b1c43cc52c360e959e3b097ee2c77cae004fd725686e8941","type":"REDEEM","size":49941.764515,"usdc_size":49941.764515,"transaction_hash":"0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f","price":0.0,"token_id":"","side":"","outcome_index":0,"title":"Will Liverpool FC win on 2026-10-17?","slug":"epl-bre-liv-2026-10-17-liv","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/premier+league.jpg","event_slug":"epl-bre-liv-2026-10-17","outcome":"Yes","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x3333333333333333333333333333333333333333","timestamp":1792266578,"condition_id":"0xdf7789097841b7247851032a10067c9c5618be0983d7e457d8bb5cfb87e3fe00","type":"CONVERSION","size":36.71,"usdc_size":330.39,"transaction_hash":"0x1010101010101010101010101010101010101010101010101010101010101010","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"Highest temperature in Buenos Aires on October 19?","slug":"highest-temperature-in-buenos-aires-on-october-19-2026","icon":"","event_slug":"highest-temperature-in-buenos-aires-on-october-19-2026","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x4444444444444444444444444444444444444444","timestamp":1792089877,"condition_id":"","type":"DEPOSIT","size":50000.0,"usdc_size":50000.0,"transaction_hash":"0x1111111111111111111111111111111111111111111111111111111111111111","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792203713,"condition_id":"","type":"WITHDRAWAL","size":100000.0,"usdc_size":100000.0,"transaction_hash":"0x1212121212121212121212121212121212121212121212121212121212121212","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195215,"condition_id":"","type":"REWARD","size":972.7284,"usdc_size":972.7284,"transaction_hash":"0x1313131313131313131313131313131313131313131313131313131313131313","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792197903,"condition_id":"","type":"MAKER_REBATE","size":153.8915,"usdc_size":153.8915,"transaction_hash":"0x1414141414141414141414141414141414141414141414141414141414141414","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195820,"condition_id":"","type":"TAKER_REBATE","size":1494.8434,"usdc_size":1494.8434,"transaction_hash":"0x1515151515151515151515151515151515151515151515151515151515151515","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195827,"condition_id":"","type":"YIELD","size":31.2212,"usdc_size":31.2212,"transaction_hash":"0x1616161616161616161616161616161616161616161616161616161616161616","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""}],"pagination":{"limit":500,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJhY3Rpdml0eSIsInBhcmFtcyI6eyJsIjo1MDAsInRzIjoxNzkyMTA5Njg1LCJzcSI6Ijk1NjAyOTA1MDAwMjIwMjM1MCIsImQiOjUwMH19LCJzaWciOiI1MjgzMGI2NGNiMjE3YzIwNGYyNTc3NjFmZjUwY2M3MCJ9"}})json"; + inline constexpr const char *data_activity_last_page = + R"json({"data":[{"proxy_wallet":"0x5555555555555555555555555555555555555555","timestamp":1789778706,"condition_id":"","type":"MAKER_REBATE","size":1132.6991,"usdc_size":1132.6991,"transaction_hash":"0x1717171717171717171717171717171717171717171717171717171717171717","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""}],"pagination":{"limit":500,"offset":0,"has_more":false,"next_cursor":null}})json"; +} // namespace data_fixtures diff --git a/tests/data_leaderboard_fixtures.hpp b/tests/data_leaderboard_fixtures.hpp new file mode 100644 index 0000000..caa3ce2 --- /dev/null +++ b/tests/data_leaderboard_fixtures.hpp @@ -0,0 +1,16 @@ +#pragma once + +// Responses recorded from https://data-api.polymarket.com on 2026-10-17: +// /v2/leaderboard (weekly PnL, first two traders, and one trader's standing) +// and /v2/biggest-winners (monthly, first three). Wallets, user names, avatars +// and X handles are replaced with synthetic values; market fields, amounts, +// ranks, times and cursors are as recorded. +namespace data_fixtures +{ + inline constexpr const char *data_trader_leaderboard_page = + R"json({"data":[{"rank":1,"user_id":"0x6666666666666666666666666666666666666666","pnl":3206451.405063784,"volume":19080128.404160004,"user_name":"Example Trader 1","profile_image":"","x_username":"","verified":false},{"rank":2,"user_id":"0x7777777777777777777777777777777777777777","pnl":3064135.779275291,"volume":18842357.997713,"user_name":"Example Trader 2","profile_image":"","x_username":"","verified":false}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJsZWFkZXJib2FyZCIsInBhcmFtcyI6eyJsIjoyLCJvIjoyLCJzIjoiUE5MIiwidCI6IndlZWsiLCJjIjoib3ZlcmFsbCJ9fSwic2lnIjoiN2RiZjQ2M2IxMzcwYmIwOGY1MTIzMWRkOWJhOWI4ODAifQ"}})json"; + inline constexpr const char *data_trader_standing = + R"json({"data":{"user_id":"0x2222222222222222222222222222222222222222","pnl":-66159.23301604768,"volume":6583735.1889039995,"rank_pnl":507935,"rank_volume":36,"user_name":"Example Trader","profile_image":"","x_username":"example_x","verified":false}})json"; + inline constexpr const char *data_biggest_winners_page = + R"json({"data":[{"win_rank":1,"kind":"market","user_id":"0x9999999999999999999999999999999999999999","pnl":1566890.248491,"initial_value":2641255.3755352753,"final_value":4208145.624026275,"resolved_at":1790289740,"condition_id":"0x80c7ab790a5ab37e11c79a5a658d729ac29e6e17aa2d3fc859e4a78c0d896369","position_id":"23305969272951306317157739183583008201155714184462027894470739210983097393455","event_id":1003664,"event_slug":"unl-nor-den-2026-09-24","event_title":"Norway vs. Denmark","user_name":"Example Winner 1","profile_image":""},{"win_rank":2,"kind":"market","user_id":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pnl":1557137.625996,"initial_value":474838.20906455966,"final_value":2031975.8350605597,"resolved_at":1792198740,"condition_id":"0xf815083341a35ca034fd34508fa63dd86ea7ddeb95e7e60972572d1f873ffa26","position_id":"13864490261439877402782058454800294514135228899012388119905836367840432355391","event_id":1197462,"event_slug":"lol-fur-c9-2026-10-16","event_title":"LoL: FURIA Esports vs Cloud9 (BO5) - Worlds Play-In","user_name":"Example Winner 2","profile_image":""},{"win_rank":3,"kind":"market","user_id":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","pnl":1537112.780772,"initial_value":1716193.6284954082,"final_value":3253306.4092674083,"resolved_at":1792013040,"condition_id":"0xf417fdb994941fcbfb40b8e3ed4c128e55cb64a2c430536964bc35d08efcb017","position_id":"99922256573703734427424644866042539978898018643379690678401728453693287502048","event_id":1114980,"event_slug":"ucl-rom-rma-2026-10-14","event_title":"AS Roma vs. Real Madrid CF","user_name":"Example Winner 3","profile_image":""}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJiaWdnZXN0X3dpbm5lcnMiLCJwYXJhbXMiOnsibCI6MywibyI6MywidCI6Im1vbnRoIiwiYyI6Im92ZXJhbGwifX0sInNpZyI6IjIwODlmODlhN2UwY2E0M2RmZDE2ODIxNWU3NjhmMGNkIn0"}})json"; +} // namespace data_fixtures diff --git a/tests/data_portfolio_fixtures.hpp b/tests/data_portfolio_fixtures.hpp new file mode 100644 index 0000000..c056762 --- /dev/null +++ b/tests/data_portfolio_fixtures.hpp @@ -0,0 +1,20 @@ +#pragma once + +// Responses recorded from https://data-api.polymarket.com on 2026-10-17 for one +// wallet: /v2/positions (two open rows and one closed row), /v2/value, +// /v2/user-stats, /v2/user-pnl (first two points) and /v2/user-volume. The +// wallet, names and avatars are replaced with synthetic values, and so is the +// wallet inside the position cursor; market fields and amounts are as recorded. +namespace data_fixtures +{ + inline constexpr const char *data_position_page = + R"json({"data":[{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"39926098010176473883178941508521536385676922435672429737195379406348307705828","condition_id":"0x6b0bd03feca294d126fac6fbaf79054122b52f084d0d8d7401403f34ab91e607","current_size":25541.63,"avg_price":0.5253,"entry_cost_usdc":13417.9219,"entry_fees_usdc":305.62137,"total_cost_usdc":13723.54327,"current_price":0.9995,"current_value":25528.8591,"total_size":39404.98,"realized_pnl":-109.6348,"unrealized_pnl":12110.9371,"total_pnl":12001.3023,"percent_pnl":90.2594,"percent_realized_pnl":23.3228,"status":"OPEN","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"LoL: MVK Esports vs FURIA Esports - Game 3 Winner","slug":"lol-mvk-fur-2026-10-17-game3","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/league-of-legends-61a4f083a6.png","event_id":"1202539","event_slug":"lol-mvk-fur-2026-10-17","outcome":"FURIA Esports","outcome_index":1,"opposite_outcome":"MVK Esports","opposite_token_id":"43589305603508319244498610981943623818232120862662080834034022625280805270820","end_date":"2026-10-17","last_event_at":1792269066,"first_entry_at":1792266957,"name":"Example Trader","profile_image":"","verified":false},{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","condition_id":"0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0","current_size":13774.944,"avg_price":0.6326,"entry_cost_usdc":8715.0268,"entry_fees_usdc":125.167432,"total_cost_usdc":8840.194232,"current_price":0.9995,"current_value":13768.0565,"total_size":41402.41,"realized_pnl":2257.8238,"unrealized_pnl":5053.0296,"total_pnl":7310.8534,"percent_pnl":57.9806,"percent_realized_pnl":-47.4384,"status":"OPEN","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"LoL: MVK Esports vs FURIA Esports (BO5) - Worlds Play-In","slug":"lol-mvk-fur-2026-10-17","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/league-of-legends-61a4f083a6.png","event_id":"1202539","event_slug":"lol-mvk-fur-2026-10-17","outcome":"MVK Esports","outcome_index":0,"opposite_outcome":"FURIA Esports","opposite_token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","end_date":"2026-10-18","last_event_at":1792271787,"first_entry_at":1792261224,"name":"Example Trader","profile_image":"","verified":false},{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"101885144930706737105576340105373476383506126608825983231914615563722778767317","condition_id":"0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238","current_size":0.0,"avg_price":0.1709,"entry_cost_usdc":0.0,"entry_fees_usdc":426.557202,"total_cost_usdc":426.557202,"current_price":1.0,"current_value":0.0,"total_size":229190.8358,"realized_pnl":155819.9983,"unrealized_pnl":0.0,"total_pnl":155819.9983,"percent_pnl":0.0,"percent_realized_pnl":397.8175,"status":"CLOSED","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"Will Kai and Speed beat the Minecraft challenge by August 17?","slug":"will-kai-and-speed-beat-the-minecraft-challenge-by-august-17-20260807213124706","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/kai-and-speed-beat-minecraft-challenge-byptptpt-20260805183110605-nXE-Irq8O8mt.jpg","event_id":"812929","event_slug":"kai-and-speed-beat-minecraft-challenge-byptptpt-20260807141254095","outcome":"Yes","outcome_index":0,"opposite_outcome":"No","opposite_token_id":"22233946449738721680202848885784914602141368124382016380959189140470186310672","end_date":"2026-08-17","last_event_at":1786579807,"first_entry_at":1786145020,"name":"Example Trader","profile_image":"","verified":false}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJwb3NpdGlvbnMiLCJwYXJhbXMiOnsibCI6MiwiZCI6MiwiayI6IjEzNzY4LjA1NjUiLCJ0IjoiODQ1ODYxODI1NTM2NTQ2NzYzMjQxMjg5OTkyMjk5MzcxNDQ0MjE3OTQ2MjUyMzQwNDY4OTI1MzYwMjE1NDM2MTYyNTI1ODkxNDQ5NjkiLCJhIjoidSIsImF2IjoiMHgyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyIiwic3QiOiJPUEVOIiwicyI6IkNVUlJFTlRfVkFMVUUiLCJkaXIiOiJERVNDIn19LCJzaWciOiI5ODI0MWMzODk2Y2EzMjZiYjM5ZTU1ODA3YTE0NDZhZCJ9"}})json"; + inline constexpr const char *data_portfolio_value = + R"json({"data":{"proxy_wallet":"0x2222222222222222222222222222222222222222","value":45483.5095}})json"; + inline constexpr const char *data_user_stats = + R"json({"data":{"proxy_wallet":"0x2222222222222222222222222222222222222222","trades":2795,"biggest_win":137934.308878,"views":4356,"join_date":1782617410,"all_time_pnl":{"timestamp":1792273655,"source_block":95712205,"realized_market_pnl":-79091.535325,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-23284.365597,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-79091.535325,"wallet_income":61794.0757,"position_pnl":-102375.900922,"settled_pnl":-17297.459625,"economic_pnl":-40581.825222,"trade_pnl":138548.925438,"sponsored_income":312.0471,"fees":-240924.82636,"fees_paid":-240924.82636,"cashflow_net":null,"volume":67387721.683856,"volume_usdc":18044152.087035,"trade_count":6287816}}})json"; + inline constexpr const char *data_pnl_series = + R"json({"data":{"proxy_wallet":"0x2222222222222222222222222222222222222222","interval":"1d","fidelity":"1h","source_fidelity":"1d","points":[{"timestamp":1792191600,"source_block":95657003,"realized_market_pnl":-83688.489647,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-10130.754133,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-83688.489647,"wallet_income":61794.0757,"position_pnl":-93819.24378,"settled_pnl":-21894.413947,"economic_pnl":-32025.16808,"trade_pnl":142521.90961,"sponsored_income":312.0471,"fees":-236341.15339,"fees_paid":-236341.15339,"cashflow_net":null,"volume":66451633.241392,"volume_usdc":17810064.011648,"trade_count":6205704},{"timestamp":1792195200,"source_block":95659403,"realized_market_pnl":-83078.021526,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-12712.306869,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-83078.021526,"wallet_income":61794.0757,"position_pnl":-95790.328395,"settled_pnl":-21283.945826,"economic_pnl":-33996.252695,"trade_pnl":140907.099775,"sponsored_income":312.0471,"fees":-236697.42817,"fees_paid":-236697.42817,"cashflow_net":null,"volume":66509377.301392,"volume_usdc":17836237.813248,"trade_count":6209337}]}})json"; + inline constexpr const char *data_user_volume = + R"json({"data":{"volume":68137252.68586,"volume_usdc":18235127.828172,"trade_count":6359523}})json"; +} // namespace data_fixtures diff --git a/tests/fuzz/corpus/rest_responses/data_activity_page b/tests/fuzz/corpus/rest_responses/data_activity_page new file mode 100644 index 0000000..52f5337 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_activity_page @@ -0,0 +1 @@ +{"data":[{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792265849,"condition_id":"0xc5eae79d1ffe716572353962eb926b1e3964c500a4880a7a94f58408218ee76b","type":"TRADE","size":5.39,"usdc_size":0.4312,"transaction_hash":"0x0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d","price":0.08,"token_id":"103704141773947678931823410030956181918562062788486034785782641603149828893320","side":"BUY","outcome_index":0,"title":"2026 Balance of Power: R Senate, R House","slug":"2026-balance-of-power-r-senate-r-house-537","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/balance-of-power-2024-election-r-Fp-y4ONJBS.jpg","event_slug":"balance-of-power-2026-midterms","outcome":"Yes","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x3333333333333333333333333333333333333333","timestamp":1792262960,"condition_id":"0xada24d478ac478fb055d53e90c3ca964a1be1143140a927d36383ed1692a17f9","type":"MERGE","size":5.4,"usdc_size":5.4,"transaction_hash":"0x0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"Will Pete Hegseth be the first of these people impeached by December 31, 2027?","slug":"will-pete-hegseth-be-the-first-of-these-people-impeached-by-december-31-2027","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/pete-hegseth-impeached-by-june-30-dhxZQrHUmJ9x.jpg","event_slug":"which-of-these-people-will-be-impeached-first-by-the-end-of-2027","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792255512,"condition_id":"0xbfdaa64d81c94034b1c43cc52c360e959e3b097ee2c77cae004fd725686e8941","type":"REDEEM","size":49941.764515,"usdc_size":49941.764515,"transaction_hash":"0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f","price":0.0,"token_id":"","side":"","outcome_index":0,"title":"Will Liverpool FC win on 2026-10-17?","slug":"epl-bre-liv-2026-10-17-liv","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/premier+league.jpg","event_slug":"epl-bre-liv-2026-10-17","outcome":"Yes","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x3333333333333333333333333333333333333333","timestamp":1792266578,"condition_id":"0xdf7789097841b7247851032a10067c9c5618be0983d7e457d8bb5cfb87e3fe00","type":"CONVERSION","size":36.71,"usdc_size":330.39,"transaction_hash":"0x1010101010101010101010101010101010101010101010101010101010101010","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"Highest temperature in Buenos Aires on October 19?","slug":"highest-temperature-in-buenos-aires-on-october-19-2026","icon":"","event_slug":"highest-temperature-in-buenos-aires-on-october-19-2026","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x4444444444444444444444444444444444444444","timestamp":1792089877,"condition_id":"","type":"DEPOSIT","size":50000.0,"usdc_size":50000.0,"transaction_hash":"0x1111111111111111111111111111111111111111111111111111111111111111","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792203713,"condition_id":"","type":"WITHDRAWAL","size":100000.0,"usdc_size":100000.0,"transaction_hash":"0x1212121212121212121212121212121212121212121212121212121212121212","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195215,"condition_id":"","type":"REWARD","size":972.7284,"usdc_size":972.7284,"transaction_hash":"0x1313131313131313131313131313131313131313131313131313131313131313","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792197903,"condition_id":"","type":"MAKER_REBATE","size":153.8915,"usdc_size":153.8915,"transaction_hash":"0x1414141414141414141414141414141414141414141414141414141414141414","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195820,"condition_id":"","type":"TAKER_REBATE","size":1494.8434,"usdc_size":1494.8434,"transaction_hash":"0x1515151515151515151515151515151515151515151515151515151515151515","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""},{"proxy_wallet":"0x2222222222222222222222222222222222222222","timestamp":1792195827,"condition_id":"","type":"YIELD","size":31.2212,"usdc_size":31.2212,"transaction_hash":"0x1616161616161616161616161616161616161616161616161616161616161616","price":0.0,"token_id":"","side":"","outcome_index":999,"title":"","slug":"","icon":"","event_slug":"","outcome":"","name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":""}],"pagination":{"limit":500,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJhY3Rpdml0eSIsInBhcmFtcyI6eyJsIjo1MDAsInRzIjoxNzkyMTA5Njg1LCJzcSI6Ijk1NjAyOTA1MDAwMjIwMjM1MCIsImQiOjUwMH19LCJzaWciOiI1MjgzMGI2NGNiMjE3YzIwNGYyNTc3NjFmZjUwY2M3MCJ9"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_biggest_winners_page b/tests/fuzz/corpus/rest_responses/data_biggest_winners_page new file mode 100644 index 0000000..bf0e628 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_biggest_winners_page @@ -0,0 +1 @@ +{"data":[{"win_rank":1,"kind":"market","user_id":"0x9999999999999999999999999999999999999999","pnl":1566890.248491,"initial_value":2641255.3755352753,"final_value":4208145.624026275,"resolved_at":1790289740,"condition_id":"0x80c7ab790a5ab37e11c79a5a658d729ac29e6e17aa2d3fc859e4a78c0d896369","position_id":"23305969272951306317157739183583008201155714184462027894470739210983097393455","event_id":1003664,"event_slug":"unl-nor-den-2026-09-24","event_title":"Norway vs. Denmark","user_name":"Example Winner 1","profile_image":""},{"win_rank":2,"kind":"market","user_id":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pnl":1557137.625996,"initial_value":474838.20906455966,"final_value":2031975.8350605597,"resolved_at":1792198740,"condition_id":"0xf815083341a35ca034fd34508fa63dd86ea7ddeb95e7e60972572d1f873ffa26","position_id":"13864490261439877402782058454800294514135228899012388119905836367840432355391","event_id":1197462,"event_slug":"lol-fur-c9-2026-10-16","event_title":"LoL: FURIA Esports vs Cloud9 (BO5) - Worlds Play-In","user_name":"Example Winner 2","profile_image":""},{"win_rank":3,"kind":"market","user_id":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","pnl":1537112.780772,"initial_value":1716193.6284954082,"final_value":3253306.4092674083,"resolved_at":1792013040,"condition_id":"0xf417fdb994941fcbfb40b8e3ed4c128e55cb64a2c430536964bc35d08efcb017","position_id":"99922256573703734427424644866042539978898018643379690678401728453693287502048","event_id":1114980,"event_slug":"ucl-rom-rma-2026-10-14","event_title":"AS Roma vs. Real Madrid CF","user_name":"Example Winner 3","profile_image":""}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJiaWdnZXN0X3dpbm5lcnMiLCJwYXJhbXMiOnsibCI6MywibyI6MywidCI6Im1vbnRoIiwiYyI6Im92ZXJhbGwifX0sInNpZyI6IjIwODlmODlhN2UwY2E0M2RmZDE2ODIxNWU3NjhmMGNkIn0"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_holders_page b/tests/fuzz/corpus/rest_responses/data_holders_page new file mode 100644 index 0000000..2d85efc --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_holders_page @@ -0,0 +1 @@ +{"data":[{"token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","holders":[{"proxy_wallet":"0x6666666666666666666666666666666666666666","bio":"","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","pseudonym":"Example-Pseudonym","amount":8629.618709,"display_username_public":true,"outcome_index":0,"name":"Example Holder 1","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.8531,"entry_cost_usdc":7352.5012,"current_price":1.0,"current_value":8629.6187,"realized_pnl":-205.5027,"unrealized_pnl":1277.1174,"total_pnl":1071.6147},{"proxy_wallet":"0x7777777777777777777777777777777777777777","bio":"","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","pseudonym":"Example-Pseudonym","amount":6632.477528,"display_username_public":true,"outcome_index":0,"name":"Example Holder 2","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.7175,"entry_cost_usdc":4759.0745,"current_price":1.0,"current_value":6632.4775,"realized_pnl":0.0,"unrealized_pnl":1873.4029,"total_pnl":1873.4029}]},{"token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","holders":[{"proxy_wallet":"0x8888888888888888888888888888888888888888","bio":"","token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","pseudonym":"Example-Pseudonym","amount":110499.668869,"display_username_public":true,"outcome_index":1,"name":"Example Holder 3","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.5983,"entry_cost_usdc":66102.7828,"current_price":0.0,"current_value":0.0,"realized_pnl":-17.6775,"unrealized_pnl":-66102.7828,"total_pnl":-66120.4603},{"proxy_wallet":"0x9999999999999999999999999999999999999999","bio":"","token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","pseudonym":"Example-Pseudonym","amount":79930.007786,"display_username_public":true,"outcome_index":1,"name":"Example Holder 4","profile_image":"","profile_image_optimized":"","verified":false,"avg_price":0.6436,"entry_cost_usdc":51154.808,"current_price":0.0,"current_value":0.0,"realized_pnl":-289.264,"unrealized_pnl":-51154.808,"total_pnl":-51444.072}]}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJob2xkZXJzIiwicGFyYW1zIjp7ImwiOjIsIm8iOjJ9fSwic2lnIjoiMzRkMDFlYzU0ZjdlZmRhMjdmZTE4ZDZmNjZjZmU0OTAifQ"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_pnl_series b/tests/fuzz/corpus/rest_responses/data_pnl_series new file mode 100644 index 0000000..0fda47f --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_pnl_series @@ -0,0 +1 @@ +{"data":{"proxy_wallet":"0x2222222222222222222222222222222222222222","interval":"1d","fidelity":"1h","source_fidelity":"1d","points":[{"timestamp":1792191600,"source_block":95657003,"realized_market_pnl":-83688.489647,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-10130.754133,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-83688.489647,"wallet_income":61794.0757,"position_pnl":-93819.24378,"settled_pnl":-21894.413947,"economic_pnl":-32025.16808,"trade_pnl":142521.90961,"sponsored_income":312.0471,"fees":-236341.15339,"fees_paid":-236341.15339,"cashflow_net":null,"volume":66451633.241392,"volume_usdc":17810064.011648,"trade_count":6205704},{"timestamp":1792195200,"source_block":95659403,"realized_market_pnl":-83078.021526,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-12712.306869,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-83078.021526,"wallet_income":61794.0757,"position_pnl":-95790.328395,"settled_pnl":-21283.945826,"economic_pnl":-33996.252695,"trade_pnl":140907.099775,"sponsored_income":312.0471,"fees":-236697.42817,"fees_paid":-236697.42817,"cashflow_net":null,"volume":66509377.301392,"volume_usdc":17836237.813248,"trade_count":6209337}]}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_position_page b/tests/fuzz/corpus/rest_responses/data_position_page new file mode 100644 index 0000000..11da51e --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_position_page @@ -0,0 +1 @@ +{"data":[{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"39926098010176473883178941508521536385676922435672429737195379406348307705828","condition_id":"0x6b0bd03feca294d126fac6fbaf79054122b52f084d0d8d7401403f34ab91e607","current_size":25541.63,"avg_price":0.5253,"entry_cost_usdc":13417.9219,"entry_fees_usdc":305.62137,"total_cost_usdc":13723.54327,"current_price":0.9995,"current_value":25528.8591,"total_size":39404.98,"realized_pnl":-109.6348,"unrealized_pnl":12110.9371,"total_pnl":12001.3023,"percent_pnl":90.2594,"percent_realized_pnl":23.3228,"status":"OPEN","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"LoL: MVK Esports vs FURIA Esports - Game 3 Winner","slug":"lol-mvk-fur-2026-10-17-game3","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/league-of-legends-61a4f083a6.png","event_id":"1202539","event_slug":"lol-mvk-fur-2026-10-17","outcome":"FURIA Esports","outcome_index":1,"opposite_outcome":"MVK Esports","opposite_token_id":"43589305603508319244498610981943623818232120862662080834034022625280805270820","end_date":"2026-10-17","last_event_at":1792269066,"first_entry_at":1792266957,"name":"Example Trader","profile_image":"","verified":false},{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"84586182553654676324128999229937144421794625234046892536021543616252589144969","condition_id":"0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0","current_size":13774.944,"avg_price":0.6326,"entry_cost_usdc":8715.0268,"entry_fees_usdc":125.167432,"total_cost_usdc":8840.194232,"current_price":0.9995,"current_value":13768.0565,"total_size":41402.41,"realized_pnl":2257.8238,"unrealized_pnl":5053.0296,"total_pnl":7310.8534,"percent_pnl":57.9806,"percent_realized_pnl":-47.4384,"status":"OPEN","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"LoL: MVK Esports vs FURIA Esports (BO5) - Worlds Play-In","slug":"lol-mvk-fur-2026-10-17","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/league-of-legends-61a4f083a6.png","event_id":"1202539","event_slug":"lol-mvk-fur-2026-10-17","outcome":"MVK Esports","outcome_index":0,"opposite_outcome":"FURIA Esports","opposite_token_id":"91977413179723403144014629027020625239807284808459212086805045743671880802044","end_date":"2026-10-18","last_event_at":1792271787,"first_entry_at":1792261224,"name":"Example Trader","profile_image":"","verified":false},{"proxy_wallet":"0x2222222222222222222222222222222222222222","token_id":"101885144930706737105576340105373476383506126608825983231914615563722778767317","condition_id":"0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238","current_size":0.0,"avg_price":0.1709,"entry_cost_usdc":0.0,"entry_fees_usdc":426.557202,"total_cost_usdc":426.557202,"current_price":1.0,"current_value":0.0,"total_size":229190.8358,"realized_pnl":155819.9983,"unrealized_pnl":0.0,"total_pnl":155819.9983,"percent_pnl":0.0,"percent_realized_pnl":397.8175,"status":"CLOSED","redeemable":false,"mergeable":false,"negative_risk":false,"archived":false,"title":"Will Kai and Speed beat the Minecraft challenge by August 17?","slug":"will-kai-and-speed-beat-the-minecraft-challenge-by-august-17-20260807213124706","icon":"https://polymarket-upload.s3.us-east-2.amazonaws.com/kai-and-speed-beat-minecraft-challenge-byptptpt-20260805183110605-nXE-Irq8O8mt.jpg","event_id":"812929","event_slug":"kai-and-speed-beat-minecraft-challenge-byptptpt-20260807141254095","outcome":"Yes","outcome_index":0,"opposite_outcome":"No","opposite_token_id":"22233946449738721680202848885784914602141368124382016380959189140470186310672","end_date":"2026-08-17","last_event_at":1786579807,"first_entry_at":1786145020,"name":"Example Trader","profile_image":"","verified":false}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJwb3NpdGlvbnMiLCJwYXJhbXMiOnsibCI6MiwiZCI6MiwiayI6IjEzNzY4LjA1NjUiLCJ0IjoiODQ1ODYxODI1NTM2NTQ2NzYzMjQxMjg5OTkyMjk5MzcxNDQ0MjE3OTQ2MjUyMzQwNDY4OTI1MzYwMjE1NDM2MTYyNTI1ODkxNDQ5NjkiLCJhIjoidSIsImF2IjoiMHgyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyIiwic3QiOiJPUEVOIiwicyI6IkNVUlJFTlRfVkFMVUUiLCJkaXIiOiJERVNDIn19LCJzaWciOiI5ODI0MWMzODk2Y2EzMjZiYjM5ZTU1ODA3YTE0NDZhZCJ9"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_price_history_page b/tests/fuzz/corpus/rest_responses/data_price_history_page new file mode 100644 index 0000000..3b50f50 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_price_history_page @@ -0,0 +1 @@ +{"data":[{"timestamp":1792197000,"price":0.51,"resolution_seconds":60},{"timestamp":1792197060,"price":0.5,"resolution_seconds":60},{"timestamp":1792197120,"price":0.5,"resolution_seconds":60}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJwcmljZXNfaGlzdG9yeSIsInBhcmFtcyI6eyJsIjozLCJ0cyI6MTc5MjE5NzEyMCwic3EiOiI2MCIsImQiOjN9fSwic2lnIjoiN2MyNDUyYWNkNzM5YThhZDc2OTBjMmQ0ZmQzNjhiODYifQ"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_resolutions b/tests/fuzz/corpus/rest_responses/data_resolutions new file mode 100644 index 0000000..45f7cf1 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_resolutions @@ -0,0 +1 @@ +{"data":[{"condition_id":"0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0","status":"resolved","extended_review":false,"was_disputed":false,"new_version_q":false,"transaction_hash":"","log_index":"","last_update_timestamp":"2026-10-17T22:23:50Z","payouts":[1000000,0],"resolution_source":"reported","was_arbitrated":false,"resolved_block":95713655,"resolved_at":"2026-10-17T22:23:50Z"},{"question_id":"0x24f034d2102744218ac250623ed0f6ac279bb6d63ecc0bc0599798a65a3e56c9","condition_id":"0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238","status":"resolved","extended_review":false,"was_disputed":false,"new_version_q":true,"proposed_price":"1000000000000000000","reproposed_price":"69","price":"1000000000000000000","transaction_hash":"0x3131313131313131313131313131313131313131313131313131313131313131","log_index":"90","last_update_timestamp":"1786579797"}]} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_trade_page b/tests/fuzz/corpus/rest_responses/data_trade_page new file mode 100644 index 0000000..bb25f71 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_trade_page @@ -0,0 +1 @@ +{"data":[{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266921,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"},{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266921,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"},{"proxy_wallet":"0x1111111111111111111111111111111111111111","side":"BUY","token_id":"33339798372916037220786136133406478491116150628220441951753426683441228279665","condition_id":"0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2","size":5.0,"price":0.002,"timestamp":1792266920,"title":"Will Shakhtar Donetsk win the 2026-27 UEFA Champions League Championship?","slug":"will-shakhtar-donetsk-win-the-2026-27-uefa-champions-league-championship-20260701202025578","icon":"","event_slug":"uefa-champions-league-2027-champion-20260701202025549","outcome":"Yes","outcome_index":0,"name":"Example Trader","pseudonym":"Example-Pseudonym","bio":"","profile_image":"","profile_image_optimized":"","transaction_hash":"0x0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"}],"pagination":{"limit":3,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJ0cmFkZXMiLCJwYXJhbXMiOnsibCI6MywidHMiOjE3OTIyNjY5MjAsInNxIjoiOTU3MDc3MTUwMDAzNDA1MjcwIiwiZCI6M319LCJzaWciOiJmYzhkMTRlMjVkMmFlZDllYWFkNGM0YzM4YmZiNDYzNSJ9"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_trader_leaderboard_page b/tests/fuzz/corpus/rest_responses/data_trader_leaderboard_page new file mode 100644 index 0000000..567bc8a --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_trader_leaderboard_page @@ -0,0 +1 @@ +{"data":[{"rank":1,"user_id":"0x6666666666666666666666666666666666666666","pnl":3206451.405063784,"volume":19080128.404160004,"user_name":"Example Trader 1","profile_image":"","x_username":"","verified":false},{"rank":2,"user_id":"0x7777777777777777777777777777777777777777","pnl":3064135.779275291,"volume":18842357.997713,"user_name":"Example Trader 2","profile_image":"","x_username":"","verified":false}],"pagination":{"limit":2,"offset":0,"has_more":true,"next_cursor":"eyJkYXRhIjp7InR5cGUiOiJsZWFkZXJib2FyZCIsInBhcmFtcyI6eyJsIjoyLCJvIjoyLCJzIjoiUE5MIiwidCI6IndlZWsiLCJjIjoib3ZlcmFsbCJ9fSwic2lnIjoiN2RiZjQ2M2IxMzcwYmIwOGY1MTIzMWRkOWJhOWI4ODAifQ"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/data_user_stats b/tests/fuzz/corpus/rest_responses/data_user_stats new file mode 100644 index 0000000..f8612f2 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/data_user_stats @@ -0,0 +1 @@ +{"data":{"proxy_wallet":"0x2222222222222222222222222222222222222222","trades":2795,"biggest_win":137934.308878,"views":4356,"join_date":1782617410,"all_time_pnl":{"timestamp":1792273655,"source_block":95712205,"realized_market_pnl":-79091.535325,"realized_lp_pnl":0.0,"realized_combo_pnl":0.0,"unrealized_pnl":-23284.365597,"fees_refunded":0.0,"maker_rebate":6923.7717,"taker_rebate":54558.2569,"reward_income":260.0487,"yield_income":51.9984,"referral_income":0.0,"deposits":null,"withdrawals":null,"realized_pnl":-79091.535325,"wallet_income":61794.0757,"position_pnl":-102375.900922,"settled_pnl":-17297.459625,"economic_pnl":-40581.825222,"trade_pnl":138548.925438,"sponsored_income":312.0471,"fees":-240924.82636,"fees_paid":-240924.82636,"cashflow_net":null,"volume":67387721.683856,"volume_usdc":18044152.087035,"trade_count":6287816}}} \ No newline at end of file diff --git a/tests/fuzz/fuzz_rest_responses.cpp b/tests/fuzz/fuzz_rest_responses.cpp index b90d8f8..6f175f8 100644 --- a/tests/fuzz/fuzz_rest_responses.cpp +++ b/tests/fuzz/fuzz_rest_responses.cpp @@ -1,4 +1,5 @@ #include "clob_client_internal.hpp" +#include "data_parsing.hpp" #include "gamma_parsing.hpp" #include "polymarket/market_fetcher.hpp" #include "rest_orderbook_parsing.hpp" @@ -12,7 +13,7 @@ #include #include -// Feeds one raw REST response body to every CLOB and Gamma response parser. Rejected +// Feeds one raw REST response body to every CLOB, Gamma and Data API response parser. Rejected // input must raise std::exception; an accepted order book must hold valid // levels with no repeated price on either side. namespace @@ -113,5 +114,67 @@ extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size { if (detail::parse_gamma_search_page(body).items.size() != 1) std::abort(); }); + parse_or_reject( + [&] + { + const auto page = detail::parse_data_trade_page(body); + for (const auto &trade : page.items) + { + if (trade.condition_id.empty() || (trade.side != "BUY" && trade.side != "SELL")) + std::abort(); + } + }); + parse_or_reject( + [&] + { + const auto page = detail::parse_data_activity_page(body); + for (const auto &activity : page.items) + { + if (activity.known && !activity.timestamp) std::abort(); + } + }); + parse_or_reject( + [&] + { + const auto page = detail::parse_data_position_page(body); + for (const auto &position : page.items) + { + if (position.condition_id.empty() || position.status.empty()) std::abort(); + } + }); + parse_or_reject([&] { (void)detail::parse_data_portfolio_value(body); }); + parse_or_reject([&] { (void)detail::parse_data_user_stats(body); }); + parse_or_reject([&] { (void)detail::parse_data_pnl_series(body); }); + parse_or_reject([&] { (void)detail::parse_data_user_volume(body); }); + parse_or_reject( + [&] + { + for (const auto &group : detail::parse_data_holders_page(body).items) + { + if (group.asset_id.empty()) std::abort(); + } + }); + parse_or_reject([&] { (void)detail::parse_data_open_interests(body); }); + parse_or_reject([&] { (void)detail::parse_data_live_volume(body); }); + parse_or_reject([&] { (void)detail::parse_data_price_history_page(body); }); + parse_or_reject( + [&] + { + for (const auto &resolution : detail::parse_data_resolutions(body)) + { + if (resolution.status.empty() || resolution.last_updated_at <= 0) std::abort(); + } + }); + parse_or_reject([&] { (void)detail::parse_data_trader_leaderboard_page(body); }); + parse_or_reject([&] { (void)detail::parse_data_trader_standing(body); }); + parse_or_reject( + [&] + { + for (const auto &winner : detail::parse_data_biggest_winners_page(body).items) + { + if (winner.kind == "market" ? winner.event_id.empty() : !winner.event_id.empty()) + std::abort(); + } + }); return 0; } diff --git a/tests/test_data_analytics_client.cpp b/tests/test_data_analytics_client.cpp new file mode 100644 index 0000000..578d163 --- /dev/null +++ b/tests/test_data_analytics_client.cpp @@ -0,0 +1,193 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "data_analytics_fixtures.hpp" +#include "polymarket/data_client.hpp" + +#include +#include +#include + +namespace clob_test +{ + namespace + { + const std::string condition = + "0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0"; + const std::string other = + "0x60613b262912ce2e3138ec5610a31a2f932b6300843c094053cb33aaff9f2238"; + const std::string asset = + "84586182553654676324128999229937144421794625234046892536021543616252589144969"; + constexpr const char *empty_page = + R"({"data":[],"pagination":{"has_more":false,"next_cursor":null}})"; + + Environment local_environment(const std::string &url) + { + auto environment = Environment::production(); + environment.data_url = url; + return environment; + } + + std::int64_t now() + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + } + + bool test_pagers() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_holders_page); + server.enqueue(empty_page); + server.enqueue(data_fixtures::data_price_history_page); + server.enqueue(empty_page); + server.enqueue(empty_page); + DataHolderFilter pnl; + pnl.include_pnl = true; + pnl.min_balance = 5.0; + auto holders = client.holders_pager({condition}, pnl, 2).collect(); + DataPriceHistoryFilter window; + window.start = 1792197000; + window.end = 1792197000 + 15 * 86400; + window.bucket_seconds = 60; + auto prices = client.price_history_pager(asset, window, 3).collect(); + DataPriceHistoryFilter at; + at.as_of = 1792262000; + (void)client.price_history_pager(asset, at).collect(); + const auto requests = server.requests(); + return check(holders.ok() && holders.value().size() == 2 && prices.ok() && + prices.value().size() == 3, + "analytics pagers must collect every page") && + check(requests.size() == 5 && + requests[0].target == "/v2/holders?condition_id=" + condition + + "&min_balance=5.0&include_pnl=true&limit=2", + "holders pager must send the official filters") && + check(requests[1].target.starts_with("/v2/holders?") && + requests[1].target.find("&cursor=") != std::string::npos, + "holders pager must follow the cursor") && + check(requests[2].target == "/v2/prices-history?token_id=" + asset + + "&start=1792197000&end=1793493000" + "&bucket_seconds=60&limit=3", + "price history must send a window") && + check(requests[4].target == "/v2/prices-history?token_id=" + asset + + "&as_of=1792262000&limit=10000", + "price history must send as_of with the default page size"); + } + + bool test_reads() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_open_interests); + server.enqueue(R"({"data":[]})"); + server.enqueue(data_fixtures::data_live_volume); + server.enqueue(data_fixtures::data_resolutions); + server.enqueue(R"({"data":[]})"); + auto interests = client.get_open_interests({condition, other}); + auto global = client.get_open_interests(); + auto volume = client.get_event_live_volume({1202539, 1202539}); + DataResolutionFilter by_condition; + by_condition.condition_ids = {condition, other}; + auto resolutions = client.get_resolutions(by_condition); + DataResolutionFilter by_question; + by_question.question_id = "0x24"; + auto missing = client.get_resolutions(by_question); + const auto requests = server.requests(); + const std::vector expected = { + "/v2/oi?condition_id=" + condition + "%2C" + other, + "/v2/oi", + "/v2/live-volume?event_id=1202539", + "/v2/resolutions?condition_id=" + condition + "%2C" + other, + "/v2/resolutions?question_id=0x24", + }; + bool targets = requests.size() == expected.size(); + for (std::size_t i = 0; targets && i < expected.size(); ++i) + targets = requests[i].target == expected[i]; + return check(interests.ok() && interests.value().size() == 3, "interests must parse") && + check(global.ok() && global.value().empty(), "an empty list must parse") && + check(volume.ok() && volume.value().markets.size() == 3, + "live volume must parse") && + check(resolutions.ok() && resolutions.value().size() == 2, + "resolutions must parse") && + check(missing.ok() && missing.value().empty(), "unknown rows must be omitted") && + check(targets, "analytics reads must send the official paths and filters"); + } + + bool test_invalid_arguments() + { + LocalServer server; + DataClient client(local_environment(server.url())); + const auto rejected_page = [](auto pager) + { + auto page = pager.next_page(); + return !page.ok() && page.error().code == SdkErrorCode::InvalidArgument; + }; + const auto rejected = [](const auto &result) + { return !result.ok() && result.error().code == SdkErrorCode::InvalidArgument; }; + DataHolderFilter pnl; + pnl.include_pnl = true; + DataHolderFilter negative; + negative.min_balance = -1.0; + const bool holders_rejected = + rejected_page(client.holders_pager({})) && + rejected_page(client.holders_pager({condition, other}, pnl)) && + rejected_page(client.holders_pager({condition}, pnl, 101)) && + rejected_page(client.holders_pager({condition}, negative)) && + rejected_page(client.holders_pager({condition}, {}, 1001)); + + std::vector bad(9); + bad[0] = {}; + bad[1].interval = "1d"; + bad[1].start = 5; + bad[2].interval = "2d"; + bad[3].interval = "1d"; + bad[3].end = 10; + bad[4].start = 100; + bad[4].end = 100 + 15 * 86400 + 1; + bad[5].start = 100; + bad[5].end = 99; + bad[6].start = now() - 16 * 86400; + bad[7].interval = "1w"; + bad[7].bucket_seconds = 299; + bad[8].as_of = 5; + bad[8].bucket_seconds = 600; + bool prices_rejected = rejected_page(client.price_history_pager("", {})); + for (const auto &filter : bad) + prices_rejected = + prices_rejected && rejected_page(client.price_history_pager(asset, filter)); + DataPriceHistoryFilter as_of; + as_of.as_of = 5; + prices_rejected = prices_rejected && + rejected_page(client.price_history_pager(asset, as_of, 100)) && + rejected_page(client.price_history_pager(asset, as_of, 10001)); + + DataResolutionFilter two; + two.question_id = "0x1"; + two.event_ids = {1}; + DataResolutionFilter many; + for (std::int64_t id = 1; id <= 21; ++id) + many.event_ids.push_back(id); + DataResolutionFilter combo; + combo.condition_ids = {"0x03" + condition.substr(4, 60)}; + const bool reads_rejected = + rejected(client.get_open_interests({"0x1"})) && + rejected(client.get_event_live_volume({})) && + rejected(client.get_event_live_volume({0})) && + rejected(client.get_resolutions({})) && rejected(client.get_resolutions(two)) && + rejected(client.get_resolutions(many)) && rejected(client.get_resolutions(combo)); + return check(holders_rejected, "invalid holder arguments must fail") && + check(prices_rejected, "invalid price history filters must fail") && + check(reads_rejected, "invalid analytics reads must fail") && + check(server.requests().empty(), "invalid arguments must not send requests"); + } + } // namespace +} // namespace clob_test + +int main() +{ + using namespace clob_test; + const bool passed = test_pagers() && test_reads() && test_invalid_arguments(); + if (!passed) return 1; + std::cout << "test_data_analytics_client passed\n"; + return 0; +} diff --git a/tests/test_data_analytics_parsing.cpp b/tests/test_data_analytics_parsing.cpp new file mode 100644 index 0000000..c3cf48e --- /dev/null +++ b/tests/test_data_analytics_parsing.cpp @@ -0,0 +1,173 @@ +#include "check_support.hpp" +#include "data_analytics_fixtures.hpp" +#include "data_parsing.hpp" + +#include +#include +#include + +namespace +{ + using check_support::check; + namespace detail = polymarket::detail; + + bool near(double value, double expected) + { + return std::abs(value - expected) < 1e-9 * std::max(1.0, std::abs(expected)); + } + + bool near(const std::optional &value, double expected) + { + return value && near(*value, expected); + } + + void expect_rejected(const std::string &name, const std::function &parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + return; + } + check(false, name + " must be rejected"); + } + + nlohmann::json resolution_row() + { + return nlohmann::json::parse(data_fixtures::data_resolutions).at("data").at(0); + } + + void expect_resolution_rejected(const std::string &name, const std::string &key, + const nlohmann::json &value) + { + auto row = resolution_row(); + row[key] = value; + expect_rejected(name, [&] { (void)detail::parse_data_resolution(row); }); + } + + void test_recorded_holders() + { + const auto page = detail::parse_data_holders_page(data_fixtures::data_holders_page); + check(page.items.size() == 2 && !page.next_cursor.empty(), + "recorded holder page must hold both outcomes with a cursor"); + const auto &group = page.items.at(0); + check(group.asset_id.starts_with("84586182") && group.holders.size() == 2, + "holders must group by outcome asset"); + const auto &holder = group.holders.at(0); + check(holder.asset_id == group.asset_id && near(holder.amount, 8629.618709) && + near(holder.avg_price, 0.8531) && near(holder.total_pnl, 1071.6147) && + holder.outcome_index == 0 && holder.display_username_public && !holder.verified && + holder.name == "Example Holder 1", + "recorded holder fields must parse"); + + auto plain = holder.raw; + plain.erase("avg_price"); + plain.erase("total_pnl"); + const auto without_pnl = detail::parse_data_holder(plain); + check(!without_pnl.avg_price && !without_pnl.total_pnl, + "holders without PnL must leave it unset"); + } + + void test_market_reads() + { + const auto interests = + detail::parse_data_open_interests(data_fixtures::data_open_interests); + check(interests.size() == 3 && interests.at(0).condition_id.starts_with("0x276b505d") && + near(interests.at(0).value, 23873.989965) && + interests.at(2).condition_id.empty() && interests.at(2).value > 1e8, + "open interest must read GLOBAL as an empty condition"); + + const auto volume = detail::parse_data_live_volume(data_fixtures::data_live_volume); + check(near(volume.taker_volume_total, 8227726.194698) && volume.markets.size() == 3 && + volume.markets.at(0).condition_id.starts_with("0xf1013717") && + near(volume.markets.at(0).taker_volume, 1950281.899588), + "live volume must parse its markets"); + + const auto prices = + detail::parse_data_price_history_page(data_fixtures::data_price_history_page); + check(prices.items.size() == 3 && !prices.next_cursor.empty() && + prices.items.at(0).timestamp == 1792197000 && + near(prices.items.at(0).price, 0.51) && + prices.items.at(0).resolution_seconds == 60, + "price history must parse"); + } + + void test_recorded_resolutions() + { + const auto resolutions = detail::parse_data_resolutions(data_fixtures::data_resolutions); + check(resolutions.size() == 2, "recorded resolutions must parse"); + const auto &reported = resolutions.at(0); + check(reported.status == "resolved" && reported.question_id.empty() && + reported.resolved_at == 1792275830 && reported.last_updated_at == 1792275830 && + reported.resolved_block == 95713655 && reported.payouts && + reported.payouts->first == 1.0 && reported.payouts->second == 0.0 && + reported.resolution_source == "reported" && reported.was_arbitrated == false && + reported.transaction_hash.empty() && !reported.log_index && !reported.price, + "an ISO-timed resolution must parse its payouts"); + const auto &oracle = resolutions.at(1); + check(!oracle.question_id.empty() && oracle.question_rules_updated && + oracle.last_updated_at == 1786579797 && oracle.log_index == 90 && + near(oracle.price, 1e18) && near(oracle.proposed_price, 1e18) && + !oracle.reproposed_price && !oracle.payouts && oracle.reporter.empty(), + "an oracle resolution must unset the 69 sentinel"); + + auto row = resolution_row(); + row["payouts"] = {"500000", 500000}; + row["log_index"] = 7; + row["expected_settlement_time"] = 1792300000; + row["settlement_time_basis"] = "liveness"; + const auto variant = detail::parse_data_resolution(row); + check(variant.payouts && variant.payouts->first == 0.5 && variant.payouts->second == 0.5 && + variant.log_index == 7 && variant.expected_settlement_time == 1792300000 && + variant.settlement_time_basis == "liveness", + "resolution variants must parse"); + } + + void test_malformed_input() + { + expect_resolution_rejected("an unknown status", "status", "settled"); + expect_resolution_rejected("an unknown reporter", "reporter", "ORACLE"); + expect_resolution_rejected("a single payout", "payouts", nlohmann::json::array({1})); + expect_resolution_rejected("a malformed time", "resolved_at", "17 Oct 2026"); + expect_resolution_rejected("an offset time", "last_update_timestamp", + "2026-10-17T22:23:50+02:00"); + expect_resolution_rejected("a non-numeric log index", "log_index", "9a"); + expect_resolution_rejected("a missing review flag", "extended_review", nullptr); + expect_rejected("an envelope object for a list", + [] { (void)detail::parse_data_open_interests(R"({"data":{}})"); }); + expect_rejected("a non-hex interest condition", + [] + { + (void)detail::parse_data_open_interests( + R"({"data":[{"condition_id":"global","value":1}]})"); + }); + expect_rejected( + "live volume without markets", + [] { (void)detail::parse_data_live_volume(R"({"data":{"taker_volume_total":1}})"); }); + expect_rejected( + "holders without a list", + [] + { + (void)detail::parse_data_holders_page( + R"({"data":[{"token_id":"1"}],"pagination":{"has_more":false,"next_cursor":null}})"); + }); + expect_rejected( + "a price without a resolution", + [] + { + (void)detail::parse_data_price_history_page( + R"({"data":[{"timestamp":1,"price":0.5}],"pagination":{"has_more":false,"next_cursor":null}})"); + }); + } +} // namespace + +int main() +{ + test_recorded_holders(); + test_market_reads(); + test_recorded_resolutions(); + test_malformed_input(); + return check_support::finish("test_data_analytics_parsing"); +} diff --git a/tests/test_data_client.cpp b/tests/test_data_client.cpp new file mode 100644 index 0000000..27f7620 --- /dev/null +++ b/tests/test_data_client.cpp @@ -0,0 +1,164 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "data_fixtures.hpp" +#include "polymarket/data_client.hpp" + +#include +#include + +namespace clob_test +{ + namespace + { + const std::string user = "0x1111111111111111111111111111111111111111"; + const std::string condition = + "0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2"; + constexpr const char *empty_page = + R"({"data":[],"pagination":{"has_more":false,"next_cursor":null}})"; + + Environment local_environment(const std::string &url) + { + auto environment = Environment::production(); + environment.data_url = url; + return environment; + } + + std::string upper(std::string text) + { + for (std::size_t i = 2; i < text.size(); ++i) + text[i] = static_cast(std::toupper(static_cast(text[i]))); + return text; + } + + bool test_trades_pager() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_trade_page); + server.enqueue(empty_page); + server.enqueue(empty_page); + DataTradeFilter filter; + filter.user = user; + filter.condition_ids = {condition, upper(condition)}; + filter.side = "SELL"; + filter.taker_only = true; + filter.filter_type = "CASH"; + filter.filter_amount = 10.0; + filter.start = 1700000000; + auto pager = client.trades_pager(filter, 50); + auto trades = pager.collect(); + DataTradeFilter history; + history.full_history = true; + (void)client.trades_pager(history).collect(); + const auto requests = server.requests(); + const auto cursor = nlohmann::json::parse(data_fixtures::data_trade_page) + .at("pagination") + .at("next_cursor") + .get(); + return check(trades.ok() && trades.value().size() == 3, + "trades pager must collect every page") && + check( + requests.size() == 3 && + requests[0].target == + "/v2/trades?user=" + user + "&condition_id=" + condition + + "&side=SELL&taker_only=true&filter_type=CASH&filter_amount=10.0" + "&start=1700000000&limit=50", + "trades pager must send the official filters once per distinct condition") && + check(requests[1].target.ends_with("&limit=50&cursor=" + cursor), + "trades pager must follow the cursor") && + check(requests[2].target == "/v2/trades?start=1&limit=100", + "full_history must send start=1") && + check(pager.resume_token().exhausted, + "trades pager must end when has_more is false"); + } + + bool test_activity_pager() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_activity_last_page); + DataActivityFilter filter; + filter.event_ids = {5, 5, 7}; + filter.types = {"TRADE", "REDEEM"}; + filter.sort_direction = "ASC"; + auto activity = client.activity_pager(user, filter).collect(); + const auto requests = server.requests(); + return check(activity.ok() && activity.value().size() == 1, + "activity pager must parse") && + check(requests.size() == 1 && + requests[0].target == + "/v2/activity?user=" + user + + "&event_id=5%2C7&type=TRADE%2CREDEEM&sort_direction=ASC" + "&exclude_deposits_withdrawals=false&limit=100", + "activity pager must comma-join lists and keep deposits and withdrawals"); + } + + bool test_invalid_arguments() + { + LocalServer server; + DataClient client(local_environment(server.url())); + std::vector bad_trades(8); + bad_trades[0].condition_ids = {condition}; + bad_trades[0].event_ids = {1}; + for (int i = 0; i < 21; ++i) + { + const char hex[] = "0123456789abcdef"; + bad_trades[1].condition_ids.push_back(condition.substr(0, 64) + hex[i / 16] + + hex[i % 16]); + } + bad_trades[2].condition_ids = {"0xabc"}; + bad_trades[3].event_ids = {0}; + bad_trades[4].full_history = true; + bad_trades[4].start = 5; + bad_trades[5].side = "HOLD"; + bad_trades[6].filter_amount = -1.0; + bad_trades[7].end = 253402300800; + bool all_rejected = true; + for (const auto &filter : bad_trades) + { + auto page = client.trades_pager(filter).next_page(); + all_rejected = all_rejected && !page.ok() && + page.error().code == SdkErrorCode::InvalidArgument; + } + DataActivityFilter bad_type; + bad_type.types = {"BAD"}; + auto no_user = client.activity_pager("").next_page(); + auto unknown_type = client.activity_pager(user, bad_type).next_page(); + auto oversized = client.trades_pager({}, 1001).next_page(); + auto zero = client.activity_pager(user, {}, 0).next_page(); + return check(all_rejected, "invalid trade filters must fail with InvalidArgument") && + check(!no_user.ok() && !unknown_type.ok() && !oversized.ok() && !zero.ok() && + oversized.error().code == SdkErrorCode::InvalidArgument, + "invalid activity arguments and page sizes must fail with " + "InvalidArgument") && + check(server.requests().empty(), "invalid arguments must not send requests"); + } + + bool test_failures_and_retry() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + server.enqueue(empty_page); + server.enqueue( + R"({"error":"limit must be between 0 and 1000","code":"invalid_request"})", 400); + auto retried = client.trades_pager().collect(); + auto failed = client.activity_pager(user).collect(); + return check(retried.ok() && server.requests().size() >= 2, + "a rate-limited page must be retried") && + check(!failed.ok() && failed.error().code == SdkErrorCode::ApiResponse && + failed.error().http_status == 400 && + failed.error().endpoint == "/v2/activity", + "an API error must surface with its status and endpoint"); + } + } // namespace +} // namespace clob_test + +int main() +{ + using namespace clob_test; + const bool passed = test_trades_pager() && test_activity_pager() && test_invalid_arguments() && + test_failures_and_retry(); + if (!passed) return 1; + std::cout << "test_data_client passed\n"; + return 0; +} diff --git a/tests/test_data_leaderboard_client.cpp b/tests/test_data_leaderboard_client.cpp new file mode 100644 index 0000000..a791149 --- /dev/null +++ b/tests/test_data_leaderboard_client.cpp @@ -0,0 +1,110 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "data_leaderboard_fixtures.hpp" +#include "polymarket/data_client.hpp" + +#include +#include + +namespace clob_test +{ + namespace + { + const std::string user = "0x2222222222222222222222222222222222222222"; + constexpr const char *empty_page = + R"({"data":[],"pagination":{"has_more":false,"next_cursor":null}})"; + + Environment local_environment(const std::string &url) + { + auto environment = Environment::production(); + environment.data_url = url; + return environment; + } + + bool test_leaderboards() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_trader_leaderboard_page); + server.enqueue(empty_page); + server.enqueue(data_fixtures::data_trader_standing); + server.enqueue(R"({"data":null})"); + server.enqueue(data_fixtures::data_biggest_winners_page); + server.enqueue(empty_page); + DataLeaderboardFilter weekly; + weekly.category = "SPORTS"; + weekly.window = "week"; + weekly.sort_by = "VOLUME"; + auto traders = client.trader_leaderboard_pager(weekly, 2).collect(); + DataLeaderboardFilter standing_filter; + standing_filter.window = "all"; + auto standing = client.get_trader_standing(user, standing_filter); + auto unknown = client.get_trader_standing(user); + DataLeaderboardFilter monthly; + monthly.window = "month"; + auto winners = client.biggest_winners_pager(monthly, 3).collect(); + const auto requests = server.requests(); + return check(traders.ok() && traders.value().size() == 2, + "trader leaderboard must collect every page") && + check(standing.ok() && standing.value() && standing.value()->volume_rank == 36, + "standing must parse") && + check(unknown.ok() && !unknown.value(), "an unknown trader must have none") && + check(winners.ok() && winners.value().size() == 3, + "biggest winners must collect every page") && + check(requests.size() == 6 && + requests[0].target == "/v2/leaderboard?category=SPORTS" + "&time_period=week&sort_by=VOLUME&limit=2" && + requests[1].target.find("&cursor=") != std::string::npos && + requests[2].target == + "/v2/leaderboard?user=" + user + "&time_period=all" && + requests[3].target == "/v2/leaderboard?user=" + user && + requests[4].target == + "/v2/biggest-winners?time_period=month&limit=3" && + requests[5].target.starts_with("/v2/biggest-winners?") && + requests[5].target.find("&cursor=") != std::string::npos, + "leaderboards must send the official paths and filters"); + } + + bool test_invalid_arguments() + { + LocalServer server; + DataClient client(local_environment(server.url())); + const auto rejected_page = [](auto pager) + { + auto page = pager.next_page(); + return !page.ok() && page.error().code == SdkErrorCode::InvalidArgument; + }; + DataLeaderboardFilter bad_window; + bad_window.window = "year"; + DataLeaderboardFilter bad_sort; + bad_sort.sort_by = "RANK"; + DataLeaderboardFilter sorted; + sorted.sort_by = "PNL"; + const auto standing_rejected = + [&](const std::string &who, const DataLeaderboardFilter &filter) + { + auto result = client.get_trader_standing(who, filter); + return !result.ok() && result.error().code == SdkErrorCode::InvalidArgument; + }; + const bool rejected = + rejected_page(client.trader_leaderboard_pager(bad_window)) && + rejected_page(client.trader_leaderboard_pager(bad_sort)) && + rejected_page(client.trader_leaderboard_pager({}, 1001)) && + rejected_page(client.biggest_winners_pager(bad_window)) && + rejected_page(client.biggest_winners_pager(sorted)) && + rejected_page(client.biggest_winners_pager({}, 0)) && standing_rejected("", {}) && + standing_rejected(user, bad_window) && standing_rejected(user, sorted); + return check(rejected, + "invalid leaderboard arguments must fail with InvalidArgument") && + check(server.requests().empty(), "invalid arguments must not send requests"); + } + } // namespace +} // namespace clob_test + +int main() +{ + using namespace clob_test; + const bool passed = test_leaderboards() && test_invalid_arguments(); + if (!passed) return 1; + std::cout << "test_data_leaderboard_client passed\n"; + return 0; +} diff --git a/tests/test_data_leaderboard_parsing.cpp b/tests/test_data_leaderboard_parsing.cpp new file mode 100644 index 0000000..f0f06c1 --- /dev/null +++ b/tests/test_data_leaderboard_parsing.cpp @@ -0,0 +1,139 @@ +#include "check_support.hpp" +#include "data_leaderboard_fixtures.hpp" +#include "data_parsing.hpp" + +#include +#include + +namespace +{ + using check_support::check; + namespace detail = polymarket::detail; + + void expect_rejected(const std::string &name, const std::function &parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + return; + } + check(false, name + " must be rejected"); + } + + nlohmann::json winner_row() + { + return nlohmann::json::parse(data_fixtures::data_biggest_winners_page).at("data").at(0); + } + + nlohmann::json combo_row() + { + auto row = winner_row(); + row["kind"] = "combo"; + row["condition_id"] = "0x03ABCDEF00000000000000000000000000000000000000000000000000000001"; + row["position_id"] = "4242"; + row.erase("event_id"); + row.erase("event_slug"); + return row; + } + + void expect_winner_rejected(const std::string &name, nlohmann::json row) + { + expect_rejected(name, [&] { (void)detail::parse_data_biggest_winner(row); }); + } + + void test_recorded_leaderboard() + { + const auto page = + detail::parse_data_trader_leaderboard_page(data_fixtures::data_trader_leaderboard_page); + check(page.items.size() == 2 && !page.next_cursor.empty(), + "recorded leaderboard page must parse with a cursor"); + const auto &first = page.items.at(0); + check(first.rank == 1 && first.wallet == "0x6666666666666666666666666666666666666666" && + first.pnl > 3.2e6 && first.volume > 1.9e7 && + first.user_name == "Example Trader 1" && first.x_username.empty() && + !first.verified, + "recorded leaderboard entry must parse"); + + const auto standing = + detail::parse_data_trader_standing(data_fixtures::data_trader_standing); + check(standing && standing->pnl < -66000.0 && standing->pnl_rank == 507935 && + standing->volume_rank == 36 && standing->x_username == "example_x", + "recorded standing must parse"); + check(!detail::parse_data_trader_standing(R"({"data":null})"), + "an unknown trader must have no standing"); + auto unranked = standing->raw; + unranked["rank_pnl"] = 0; + unranked.erase("rank_volume"); + const auto none = + detail::parse_data_trader_standing(nlohmann::json{{"data", unranked}}.dump()); + check(none && !none->pnl_rank && !none->volume_rank, "rank 0 must read as unranked"); + } + + void test_biggest_winners() + { + const auto page = + detail::parse_data_biggest_winners_page(data_fixtures::data_biggest_winners_page); + check(page.items.size() == 3 && !page.next_cursor.empty(), + "recorded biggest winners must parse with a cursor"); + const auto &market = page.items.at(0); + check(market.kind == "market" && market.rank == 1 && market.pnl > 1.5e6 && + market.resolved_at == 1790289740 && market.event_id == "1003664" && + market.event_slug == "unl-nor-den-2026-09-24" && + market.condition_id.starts_with("0x80c7ab79") && + market.asset_id.starts_with("2330596927"), + "a market winner must parse its market fields"); + + auto row = combo_row(); + row["resolved_at"] = "2026-10-17T22:23:50Z"; + const auto combo = detail::parse_data_biggest_winner(row); + check(combo.kind == "combo" && + combo.condition_id == + "0x03abcdef000000000000000000000000000000000000000000000000000000" && + combo.asset_id == "4242" && combo.event_id.empty() && combo.event_slug.empty() && + combo.resolved_at == 1792275830 && combo.event_title == market.event_title, + "a combo winner must keep its common fields"); + + auto text_id = winner_row(); + text_id["event_id"] = "77"; + check(detail::parse_data_biggest_winner(text_id).event_id == "77", + "a text event ID must parse"); + } + + void test_malformed_input() + { + auto kind = winner_row(); + kind["kind"] = "parlay"; + expect_winner_rejected("an unknown winner kind", kind); + auto zero = winner_row(); + zero["event_id"] = 0; + expect_winner_rejected("a zero event ID", zero); + auto missing = winner_row(); + missing.erase("event_id"); + expect_winner_rejected("a market winner without an event", missing); + auto market_combo = combo_row(); + market_combo["condition_id"] = winner_row()["condition_id"]; + expect_winner_rejected("a combo with a market condition", market_combo); + auto no_position = combo_row(); + no_position.erase("position_id"); + expect_winner_rejected("a combo without a position", no_position); + expect_rejected("an entry without a rank", + [] + { + (void)detail::parse_data_trader_entry(nlohmann::json::parse( + R"({"user_id":"0x1","pnl":1,"volume":1,"verified":false})")); + }); + expect_rejected("a standing list", + [] { (void)detail::parse_data_trader_standing(R"({"data":[]})"); }); + } +} // namespace + +int main() +{ + test_recorded_leaderboard(); + test_biggest_winners(); + test_malformed_input(); + return check_support::finish("test_data_leaderboard_parsing"); +} diff --git a/tests/test_data_parsing.cpp b/tests/test_data_parsing.cpp new file mode 100644 index 0000000..e9d9ee7 --- /dev/null +++ b/tests/test_data_parsing.cpp @@ -0,0 +1,203 @@ +#include "check_support.hpp" +#include "data_fixtures.hpp" +#include "data_parsing.hpp" + +#include +#include +#include + +namespace +{ + using check_support::check; + using check_support::expect_equal; + namespace detail = polymarket::detail; + + bool near(const std::optional &value, double expected) + { + return value && std::abs(*value - expected) < 1e-9 * std::max(1.0, std::abs(expected)); + } + + void expect_rejected(const std::string &name, const std::function &parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + return; + } + check(false, name + " must be rejected"); + } + + std::string page_of(const std::string &rows, bool has_more = false, + const std::string &cursor = "null") + { + return R"({"data":[)" + rows + R"(],"pagination":{"has_more":)" + + (has_more ? "true" : "false") + R"(,"next_cursor":)" + cursor + "}}"; + } + + const std::string condition = + "0xefa17dee3af09f69f9ddf245b969aa4efbe7c71cdf06ee49d694408bc33e2ed2"; + + std::string trade_row(const std::string &overrides) + { + return R"({"proxy_wallet":"0x1111111111111111111111111111111111111111","token_id":"7","condition_id":")" + + condition + + R"(","side":"BUY","size":5,"price":0.5,"timestamp":1792266921,"transaction_hash":"0xab")" + + overrides + "}"; + } + + void test_recorded_trades() + { + const auto page = detail::parse_data_trade_page(data_fixtures::data_trade_page); + check(page.items.size() == 3 && !page.next_cursor.empty(), + "recorded trade page must parse with a cursor"); + const auto &trade = page.items.at(0); + expect_equal("trade wallet", trade.wallet, "0x1111111111111111111111111111111111111111"); + expect_equal("trade condition", trade.condition_id, condition); + check(trade.side == "BUY" && trade.size == 5.0 && trade.price == 0.002 && + trade.timestamp == 1792266921 && trade.outcome_index == 0 && + trade.asset_id.starts_with("3333979837291603722"), + "recorded trade fields must parse"); + check(trade.raw.contains("profile_image_optimized"), "trade raw must keep the row"); + } + + void test_recorded_activity() + { + const auto page = detail::parse_data_activity_page(data_fixtures::data_activity_page); + check(page.items.size() == 10, "recorded activity page must hold one row per type"); + const auto &trade = page.items.at(0); + check(trade.type == "TRADE" && trade.known && trade.side == "BUY" && + near(trade.shares, 5.39) && near(trade.amount, 0.4312) && + near(trade.price, 0.08) && trade.outcome_index == 0 && + trade.asset_id.starts_with("10370414") && trade.timestamp == 1792265849, + "a trade activity must carry its trade fields"); + const auto &merge = page.items.at(1); + check(merge.type == "MERGE" && near(merge.amount, 5.4) && !merge.shares && !merge.price && + merge.side.empty() && !merge.outcome_index && + merge.condition_id.starts_with("0xada24d47"), + "a market event must carry its condition and amount only"); + const auto &deposit = page.items.at(4); + check(deposit.type == "DEPOSIT" && near(deposit.amount, 50000.0) && + deposit.condition_id.empty() && !deposit.shares && deposit.title.empty(), + "an account credit must carry its amount only"); + bool all_known = true; + for (const auto &row : page.items) + all_known = all_known && row.known && row.timestamp.has_value() && + !row.transaction_hash.empty(); + check(all_known, "recorded activity kinds must all be known"); + + const auto last = detail::parse_data_activity_page(data_fixtures::data_activity_last_page); + check(last.items.size() == 1 && last.next_cursor.empty(), "a final page must end paging"); + } + + void test_activity_variants() + { + const auto unknown = detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"NEW_KIND","proxy_wallet":"0x1","timestamp":0,"extra":1})")); + check(!unknown.known && unknown.type == "NEW_KIND" && !unknown.timestamp && + unknown.wallet == "0x1" && unknown.raw.at("extra") == 1, + "a newer activity kind must keep its raw row"); + const auto untyped = detail::parse_data_activity(nlohmann::json::parse(R"({"type":5})")); + check(!untyped.known && untyped.type.empty(), + "a non-string type must read as an unknown kind"); + + const auto combo = detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"TRADE","is_combo":true,"proxy_wallet":"0x1","timestamp":"1792265849","transaction_hash":"0xab",)" + R"("condition_id":"0x03ABCDEF00000000000000000000000000000000000000000000000000000001","token_id":"99",)" + R"("side":"SELL","size":"2","usdc_size":"1","price":"0.5","outcome":"Yes","outcome_index":1})")); + check(combo.is_combo && + combo.condition_id == + "0x03abcdef000000000000000000000000000000000000000000000000000000" && + combo.asset_id == "99" && combo.outcome.empty() && !combo.outcome_index && + near(combo.shares, 2.0), + "a combo trade must normalize its condition ID and drop single-market fields"); + + const auto tip = detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"TIP","proxy_wallet":"0x1","timestamp":1,"transaction_hash":"0xab","usdc_size":3,"side":"IN"})")); + check(tip.tip_side == "IN" && near(tip.amount, 3.0), "a tip must carry its side"); + + const auto none = + detail::parse_data_trade(nlohmann::json::parse(trade_row(R"(,"outcome_index":999)"))); + check(!none.outcome_index, "outcome index 999 must read as unset"); + } + + void test_malformed_input() + { + expect_rejected("has_more without a cursor", + [] { (void)detail::parse_data_trade_page(page_of("", true)); }); + expect_rejected("a cursor without has_more", + [] { (void)detail::parse_data_trade_page(page_of("", false, R"("x")")); }); + expect_rejected("an empty cursor", + [] { (void)detail::parse_data_trade_page(page_of("", true, R"("")")); }); + expect_rejected("missing pagination", + [] { (void)detail::parse_data_trade_page(R"({"data":[]})"); }); + expect_rejected("missing next_cursor", + [] + { + (void)detail::parse_data_trade_page( + R"({"data":[],"pagination":{"has_more":false}})"); + }); + expect_rejected("a non-boolean has_more", + [] + { + (void)detail::parse_data_trade_page( + R"({"data":[],"pagination":{"has_more":"no","next_cursor":null}})"); + }); + expect_rejected( + "an unknown trade side", + [] { (void)detail::parse_data_trade_page(page_of(trade_row(R"(,"side":"HOLD")"))); }); + expect_rejected("a non-hex condition", + [] + { + (void)detail::parse_data_trade_page( + page_of(trade_row(R"(,"condition_id":"abc")"))); + }); + expect_rejected( + "a boolean size", + [] { (void)detail::parse_data_trade_page(page_of(trade_row(R"(,"size":true)"))); }); + expect_rejected( + "a timestamp past 9999", [] + { (void)detail::parse_data_trade_page(page_of(trade_row(R"(,"timestamp":1e15)"))); }); + expect_rejected("a trade without a hash", + [] + { + (void)detail::parse_data_trade(nlohmann::json::parse( + R"({"proxy_wallet":"0x1","token_id":"7","side":"BUY"})")); + }); + expect_rejected( + "a combo trade with a market condition", + [] + { + (void)detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"TRADE","is_combo":true,"proxy_wallet":"0x1","timestamp":1,"transaction_hash":"0xab",)" + R"("condition_id":")" + + condition + + R"(","token_id":"9","side":"BUY","size":1,"usdc_size":1,"price":1})")); + }); + expect_rejected( + "a known kind without an amount", + [] + { + (void)detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"DEPOSIT","proxy_wallet":"0x1","timestamp":1,"transaction_hash":"0xab"})")); + }); + expect_rejected( + "an unknown tip side", + [] + { + (void)detail::parse_data_activity(nlohmann::json::parse( + R"({"type":"TIP","proxy_wallet":"0x1","timestamp":1,"transaction_hash":"0xab","usdc_size":1,"side":"UP"})")); + }); + } +} // namespace + +int main() +{ + test_recorded_trades(); + test_recorded_activity(); + test_activity_variants(); + test_malformed_input(); + return check_support::finish("test_data_parsing"); +} diff --git a/tests/test_data_portfolio_client.cpp b/tests/test_data_portfolio_client.cpp new file mode 100644 index 0000000..98a8f1d --- /dev/null +++ b/tests/test_data_portfolio_client.cpp @@ -0,0 +1,202 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "data_portfolio_fixtures.hpp" +#include "polymarket/data_client.hpp" + +#include +#include + +namespace clob_test +{ + namespace + { + const std::string user = "0x2222222222222222222222222222222222222222"; + const std::string condition = + "0x276b505d7bced5a20bf3f91d35dd425d90d3881b3daddbc49433b9f045f101e0"; + const std::string structural = + "0x01aa000000000000000000000000000000000000000000000000000000000b"; + constexpr const char *empty_page = + R"({"data":[],"pagination":{"has_more":false,"next_cursor":null}})"; + + Environment local_environment(const std::string &url) + { + auto environment = Environment::production(); + environment.data_url = url; + return environment; + } + + std::string upper(std::string text) + { + for (std::size_t i = 2; i < text.size(); ++i) + text[i] = static_cast(std::toupper(static_cast(text[i]))); + return text; + } + + bool test_positions_pager() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_position_page); + server.enqueue(empty_page); + server.enqueue(empty_page); + DataPositionFilter filter; + filter.user = user; + filter.condition_ids = {upper(condition), condition, upper(structural)}; + filter.status = "OPEN"; + filter.title = "Esports & more"; + filter.filter_type = "TOKENS"; + filter.filter_amount = 1.0; + filter.include_archived = false; + filter.sort_by = "TOTAL_PNL"; + filter.sort_direction = "DESC"; + filter.end = 1800000000; + auto pager = client.positions_pager(filter, 2); + auto positions = pager.collect(); + DataPositionFilter market; + market.condition_ids = {condition}; + market.title = " "; + (void)client.positions_pager(market).collect(); + const auto requests = server.requests(); + return check(positions.ok() && positions.value().size() == 3, + "positions pager must collect every page") && + check(requests.size() == 3 && + requests[0].target == + "/v2/positions?user=" + user + "&condition_id=" + condition + + "%2C" + structural + "00" + + "&status=OPEN&title=Esports%20%26%20more&filter_type=TOKENS" + "&filter_amount=1.0&include_archived=false&sort_by=TOTAL_PNL" + "&sort_direction=DESC&end=1800000000&limit=2", + "positions pager must send lowercased, padded and distinct market IDs") && + check(requests[1].target.find("&cursor=") != std::string::npos, + "positions pager must follow the cursor") && + check(requests[2].target == + "/v2/positions?condition_id=" + condition + "&limit=100", + "a market listing must not need a user or send a blank title"); + } + + bool test_portfolio_reads() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(data_fixtures::data_portfolio_value); + server.enqueue(data_fixtures::data_user_stats); + server.enqueue(R"({"data":null})"); + server.enqueue(data_fixtures::data_pnl_series); + server.enqueue(data_fixtures::data_user_volume); + const std::string zip("PK\x03\x04\x00\x00\xff\x01", 8); + server.enqueue(zip, 200, {{"Content-Type", "application/zip"}}); + auto value = client.get_portfolio_value(user, {condition}); + auto stats = client.get_user_stats(user); + auto none = client.get_user_stats(user); + auto pnl = client.get_user_pnl(user, "1d", "1h"); + DataTimeRange range; + range.full_history = true; + auto volume = client.get_user_volume(user, range); + auto snapshot = client.download_accounting_snapshot(user); + const auto requests = server.requests(); + const std::vector expected = { + "/v2/value?user=" + user + "&condition_id=" + condition, + "/v2/user-stats?user=" + user, + "/v2/user-stats?user=" + user, + "/v2/user-pnl?user=" + user + "&interval=1d&fidelity=1h", + "/v2/user-volume?user=" + user + "&start=1", + "/v1/accounting/snapshot?user=" + user, + }; + bool targets = requests.size() == expected.size(); + for (std::size_t i = 0; targets && i < expected.size(); ++i) + targets = requests[i].target == expected[i]; + return check(value.ok() && value.value().value > 45000.0, "value must parse") && + check(stats.ok() && stats.value() && stats.value()->views == 4356, + "stats must parse") && + check(none.ok() && !none.value(), "null stats must read as no stats") && + check(pnl.ok() && pnl.value().points.size() == 2, "PnL must parse") && + check(volume.ok() && volume.value().trade_count == 6359523, + "volume must parse") && + check(snapshot.ok() && snapshot.value() == zip, + "snapshot must keep every byte") && + check(targets, "portfolio reads must send the official paths and filters"); + } + + bool test_invalid_arguments() + { + LocalServer server; + DataClient client(local_environment(server.url())); + std::vector bad(10); + bad[0].condition_ids = {condition, "0x" + condition.substr(4)}; + bad[1].event_ids = {1}; + bad[1].condition_ids = {condition}; + bad[2].user = user; + bad[2].condition_ids = {"0x03" + condition.substr(4, 60)}; + bad[3].condition_ids = {condition}; + bad[3].status = "MERGEABLE"; + bad[4].user = user; + bad[4].status = "CLOSED"; + bad[4].include_archived = true; + bad[5].user = user; + bad[5].title = std::string(201, 'a'); + bad[6].user = user; + bad[6].sort_by = "SIZE"; + bad[7].user = user; + bad[7].start = 0; + bad[8].condition_ids = {condition}; + bad[8].event_ids = {3}; + bad[9] = {}; + bool all_rejected = true; + for (const auto &filter : bad) + { + auto page = client.positions_pager(filter).next_page(); + all_rejected = all_rejected && !page.ok() && + page.error().code == SdkErrorCode::InvalidArgument; + } + DataPositionFilter long_title; + long_title.user = user; + for (int i = 0; i < 200; ++i) + long_title.title += "\xc3\xa9"; + DataTimeRange mixed; + mixed.full_history = true; + mixed.end = 5; + const auto rejected = [](const auto &result) + { return !result.ok() && result.error().code == SdkErrorCode::InvalidArgument; }; + const bool reads_rejected = rejected(client.get_portfolio_value("")) && + rejected(client.get_portfolio_value(user, {"0x12"})) && + rejected(client.get_user_stats("")) && + rejected(client.get_user_pnl(user, "2d")) && + rejected(client.get_user_pnl(user, "", "5m")) && + rejected(client.get_user_volume(user, mixed)) && + rejected(client.download_accounting_snapshot("")); + const bool no_requests = server.requests().empty(); + server.enqueue(empty_page); + auto accented = client.positions_pager(long_title).next_page(); + return check(all_rejected, "invalid position filters must fail with InvalidArgument") && + check(reads_rejected, + "invalid portfolio reads must fail with InvalidArgument") && + check(no_requests, "invalid arguments must not send requests") && + check(accented.ok(), "a title limit must count characters, not bytes"); + } + + bool test_failures() + { + LocalServer server; + DataClient client(local_environment(server.url())); + server.enqueue(R"({"error":"user is invalid"})", 400); + server.enqueue(R"({"data":{"volume":1}})"); + auto failed = client.download_accounting_snapshot(user); + auto malformed = client.get_user_volume(user); + return check(!failed.ok() && failed.error().code == SdkErrorCode::ApiResponse && + failed.error().http_status == 400 && + failed.error().endpoint == "/v1/accounting/snapshot", + "a failed download must surface its status and endpoint") && + check(!malformed.ok() && malformed.error().code == SdkErrorCode::Parse, + "a malformed read must fail with Parse"); + } + } // namespace +} // namespace clob_test + +int main() +{ + using namespace clob_test; + const bool passed = test_positions_pager() && test_portfolio_reads() && + test_invalid_arguments() && test_failures(); + if (!passed) return 1; + std::cout << "test_data_portfolio_client passed\n"; + return 0; +} diff --git a/tests/test_data_portfolio_parsing.cpp b/tests/test_data_portfolio_parsing.cpp new file mode 100644 index 0000000..fb8cfcb --- /dev/null +++ b/tests/test_data_portfolio_parsing.cpp @@ -0,0 +1,180 @@ +#include "check_support.hpp" +#include "data_parsing.hpp" +#include "data_portfolio_fixtures.hpp" + +#include +#include +#include + +namespace +{ + using check_support::check; + using check_support::expect_equal; + namespace detail = polymarket::detail; + + bool near(double value, double expected) + { + return std::abs(value - expected) < 1e-9 * std::max(1.0, std::abs(expected)); + } + + bool near(const std::optional &value, double expected) + { + return value && near(*value, expected); + } + + void expect_rejected(const std::string &name, const std::function &parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + return; + } + check(false, name + " must be rejected"); + } + + nlohmann::json recorded_position() + { + return nlohmann::json::parse(data_fixtures::data_position_page).at("data").at(1); + } + + void expect_position_rejected(const std::string &name, const std::string &key, + const nlohmann::json &value) + { + auto row = recorded_position(); + row[key] = value; + expect_rejected(name, [&] { (void)detail::parse_data_position(row); }); + } + + void test_recorded_positions() + { + const auto page = detail::parse_data_position_page(data_fixtures::data_position_page); + check(page.items.size() == 3 && !page.next_cursor.empty(), + "recorded position page must parse with a cursor"); + const auto &open = page.items.at(1); + expect_equal("position wallet", open.wallet, "0x2222222222222222222222222222222222222222"); + check(open.condition_id.starts_with("0x276b505d") && open.status == "OPEN" && + near(open.current_size, 13774.944) && near(open.realized_pnl, 2257.8238) && + open.event_id == "1202539" && open.outcome_index == 0 && + open.end_date == "2026-10-18" && open.first_entry_at == 1792261224 && + !open.opposite_asset_id.empty() && !open.redeemable && !open.verified, + "recorded open position fields must parse"); + const auto &closed = page.items.at(2); + check(closed.status == "CLOSED" && closed.current_size == 0.0 && + near(closed.realized_pnl, 155819.9983), + "recorded closed position must parse"); + check(open.raw.contains("percent_realized_pnl"), "position raw must keep the row"); + } + + void test_position_variants() + { + auto row = recorded_position(); + row["event_id"] = 0; + row["end_date"] = "1970-01-01"; + row["outcome_index"] = 999; + row["last_event_at"] = "0"; + row["first_entry_at"] = nullptr; + row["opposite_outcome"] = ""; + row["current_size"] = "12.5"; + const auto position = detail::parse_data_position(row); + check(position.event_id.empty() && position.end_date.empty() && !position.outcome_index && + !position.last_event_at && !position.first_entry_at && + position.opposite_outcome.empty() && position.current_size == 12.5, + "Data API placeholders must read as unset"); + row["event_id"] = 77; + check(detail::parse_data_position(row).event_id == "77", + "an integer event ID must read as text"); + } + + void test_portfolio_reads() + { + const auto value = detail::parse_data_portfolio_value(data_fixtures::data_portfolio_value); + check(value.wallet == "0x2222222222222222222222222222222222222222" && + near(value.value, 45483.5095), + "portfolio value must parse"); + + const auto stats = detail::parse_data_user_stats(data_fixtures::data_user_stats); + check(stats && stats->traded_market_count == 2795 && + near(stats->biggest_win, 137934.308878) && stats->views == 4356 && + stats->join_date == 1782617410 && stats->all_time_pnl, + "user stats must parse"); + const auto &pnl = *stats->all_time_pnl; + check(pnl.timestamp == 1792273655 && pnl.source_block == 95712205 && + pnl.trade_count == 6287816 && near(pnl.realized_pnl, -79091.535325) && + near(pnl.unrealized_pnl, -23284.365597) && near(pnl.fees_paid, -240924.82636) && + !pnl.deposits && !pnl.cashflow_net, + "all-time PnL must keep missing amounts unset"); + + const auto empty = detail::parse_data_user_stats( + R"({"data":{"proxy_wallet":"0x1","trades":0,"biggest_win":0.0,"views":0,"join_date":null,"all_time_pnl":null}})"); + check(empty && !empty->join_date && !empty->all_time_pnl, + "a wallet without history must have no PnL"); + check(!detail::parse_data_user_stats(R"({"data":null})"), + "null stats must read as no stats"); + + const auto series = detail::parse_data_pnl_series(data_fixtures::data_pnl_series); + check(series.interval == "1d" && series.fidelity == "1h" && + series.source_fidelity == "1d" && series.points.size() == 2 && + series.points.at(0).timestamp == 1792191600 && + series.points.at(1).timestamp == 1792195200, + "PnL series must parse"); + + const auto volume = detail::parse_data_user_volume(data_fixtures::data_user_volume); + check(near(volume.volume, 68137252.68586) && near(volume.volume_usdc, 18235127.828172) && + volume.trade_count == 6359523, + "user volume must parse"); + } + + void test_malformed_input() + { + expect_position_rejected("an unknown position status", "status", "MERGEABLE"); + expect_position_rejected("a string boolean", "redeemable", "false"); + expect_position_rejected("a missing boolean", "verified", nullptr); + expect_position_rejected("a missing amount", "total_pnl", nullptr); + expect_position_rejected("a non-hex condition", "condition_id", "abc"); + expect_position_rejected("a malformed end date", "end_date", "18/10/2026"); + expect_position_rejected("a fractional event ID", "event_id", 1.5); + expect_rejected("a value without data", + [] { (void)detail::parse_data_portfolio_value(R"({"value":1})"); }); + expect_rejected("a list value", + [] { (void)detail::parse_data_portfolio_value(R"({"data":[]})"); }); + expect_rejected( + "an unknown PnL fidelity", + [] + { + (void)detail::parse_data_pnl_series( + R"({"data":{"proxy_wallet":"0x1","interval":"1d","fidelity":"5m","source_fidelity":"1d","points":[]}})"); + }); + expect_rejected( + "PnL without points", + [] + { + (void)detail::parse_data_pnl_series( + R"({"data":{"proxy_wallet":"0x1","interval":"1d","fidelity":"1h","source_fidelity":"1d"}})"); + }); + expect_rejected("a fractional trade count", + [] + { + (void)detail::parse_data_user_volume( + R"({"data":{"volume":1,"volume_usdc":1,"trade_count":1.5}})"); + }); + expect_rejected( + "stats with a malformed PnL", + [] + { + (void)detail::parse_data_user_stats( + R"({"data":{"proxy_wallet":"0x1","trades":0,"biggest_win":0,"views":0,"all_time_pnl":{"timestamp":1}}})"); + }); + } +} // namespace + +int main() +{ + test_recorded_positions(); + test_position_variants(); + test_portfolio_reads(); + test_malformed_input(); + return check_support::finish("test_data_portfolio_parsing"); +}