diff --git a/AGENTS.md b/AGENTS.md index 708c7d0..4c2290e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,16 +17,16 @@ and untracked change and create logical commit groups. ## Repository map -| Area | Public headers (`include/polymarket/`) | Implementation (`src/`) | -| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| HTTP transport | `http_client.hpp`, `network.hpp` | `http_client*.cpp`, `http_global.cpp`, `network_route.cpp` | -| WebSocket transport | `websocket_client.hpp` | `websocket_client*.cpp`, `websocket_resilience.cpp`, `websocket_tunnel.cpp` | -| CLOB REST and trading | `clob_client.hpp`, `clob_types.hpp`, `geoblock.hpp` | `clob_*.cpp`, `order_execution.cpp`, `order_amounts.cpp`, `geoblock.cpp` | -| Order signing | `order_signer.hpp`, `decimal_math.hpp` | `order_signer*.cpp`, `decimal_math.cpp` | -| Market data streams | `orderbook.hpp`, `types.hpp` | `orderbook*.cpp`, `websocket_market_data.cpp`, `arb_*.cpp` | -| User stream | `user_stream.hpp` | `user_stream*.cpp`, `websocket_user_data.cpp` | -| On-chain positions | `position_client.hpp`, `trading_approvals.hpp`, `evm_*.hpp` | `position_*.cpp`, `approval_calls.cpp`, `safe_relayer.cpp`, `evm_*.cpp` | -| Polygon indexing | `json_rpc_client.hpp`, `evm_event_indexer.hpp`, `oracle_watcher.hpp` | `json_rpc_*.cpp`, `evm_*indexer*.cpp`, `oracle_*.cpp`, `polymarket_events.cpp` | +| Area | Public headers (`include/polymarket/`) | Implementation (`src/`) | +| --------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| HTTP transport | `http_client.hpp`, `network.hpp` | `http_client*.cpp`, `http_global.cpp`, `network_route.cpp` | +| WebSocket transport | `websocket_client.hpp` | `websocket_client*.cpp`, `websocket_resilience.cpp`, `websocket_tunnel.cpp` | +| CLOB REST and trading | `clob_client.hpp`, `clob_types.hpp`, `market_price.hpp`, `geoblock.hpp` | `clob_*.cpp`, `order_execution.cpp`, `order_amounts.cpp`, `market_price*.cpp`, `geoblock.cpp` | +| Order signing | `order_signer.hpp`, `decimal_math.hpp` | `order_signer*.cpp`, `decimal_math.cpp` | +| Market data streams | `orderbook.hpp`, `types.hpp` | `orderbook*.cpp`, `websocket_market_data.cpp`, `arb_*.cpp` | +| User stream | `user_stream.hpp` | `user_stream*.cpp`, `websocket_user_data.cpp` | +| On-chain positions | `position_client.hpp`, `trading_approvals.hpp`, `evm_*.hpp` | `position_*.cpp`, `approval_calls.cpp`, `safe_relayer.cpp`, `evm_*.cpp` | +| Polygon indexing | `json_rpc_client.hpp`, `evm_event_indexer.hpp`, `oracle_watcher.hpp` | `json_rpc_*.cpp`, `evm_*indexer*.cpp`, `oracle_*.cpp`, `polymarket_events.cpp` | Tests live in `tests/` (fixtures in `*_fixture.hpp` and `*_test_support.hpp`); some contract tests live in `src/*_contract_tests.cpp`. Benchmarks live in diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ac3f72..7a4e03f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,7 +148,11 @@ set(POLYMARKET_CLIENT_SOURCES src/order_amounts.cpp src/clob_order_execution.cpp src/clob_order_submission.cpp + src/clob_order_placement.cpp + src/clob_order_settlement.cpp src/order_execution.cpp + src/market_price.cpp + src/market_price_client.cpp src/polymarket_events.cpp src/polymarket_contracts.cpp src/environment.cpp diff --git a/README.md b/README.md index 21cd191..980a65f 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ int main() client.warm_connection(); // open TCP + TLS before the first order - CreateOrderParams order; + PlaceLimitOrderParams order; order.token_id = ""; order.price = 0.42; order.size = 10; order.side = OrderSide::BUY; - const auto response = client.create_and_post_order(order); // GTC by default - std::cout << (response.success ? response.order_id : response.error_msg) << "\n"; + const auto placed = client.place_limit_order(order); // GTC unless expiration is set + std::cout << (placed ? placed.value().order_id : placed.error().message) << "\n"; } ``` @@ -70,13 +70,14 @@ Tick size and neg-risk metadata are resolved and cached for you. See | Area | What you get | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | **Trading** | CLOB V2 EIP-712 order signing (EOA, proxy, Safe, `POLY_1271` deposit wallets), limit and market orders, batch posting, cancels, tick-size rounding | +| **Order flow** | Book-walk fill estimates, one-call limit and market orders with post-only, GTD, and worst-price bounds, on-chain settlement waits | | **Market data** | REST books, prices, midpoints, markets, trades; WebSocket orderbook streaming with reconnect, subscription replay, gap detection, backpressure | | **User stream** | Authenticated `UserStream` with typed order/trade events, gap callbacks, and REST reconciliation hooks | | **On-chain positions** | `PositionClient` split/merge/redeem for CTF and Protocol V2, trading approvals, gasless Safe operations through the Polymarket relayer | | **Polygon indexing** | JSON-RPC HTTP catch-up + WebSocket subscriptions, persistent `EvmEventIndexer`, UMA and Conditional Tokens event decoders | | **Networking** | HTTP/HTTPS/SOCKS proxies and VPN interface binding for REST **and** WebSockets, one process-wide route, fail-closed, geoblock eligibility check | | **Low latency** | Warm keep-alive connections, heartbeat, `TCP_NODELAY`, DNS caching, metadata caches, per-request metrics | -| **Errors** | Opt-in `Result` APIs with typed `SdkError` (transport, API, auth, rate limit, parse, signing) and request IDs | +| **Errors** | Opt-in `Result` APIs with typed `SdkError` (transport, API, auth, rate limit, parse, signing, liquidity, timeout) and request IDs | | **Neg-risk markets** | Automatic exchange and collateral-adapter selection | ## Requirements @@ -186,6 +187,31 @@ std::cout << "avg latency: " << client.get_connection_stats().avg_latency_ms << Order helpers round prices, sizes, and maker/taker amounts to the market's tick size. Leave `tick_size` empty to resolve it from the client's metadata cache. +### Preview, place, and settle orders + +```cpp +using namespace polymarket; + +// Walk the live book: worst price, average price, and shares for a $250 BUY. +auto estimate = client.estimate_market_price(token_id, OrderSide::BUY, 250.0); +if (!estimate) return; // InsufficientLiquidity when a FOK cannot fill + +PlaceMarketOrderParams order; +order.token_id = token_id; +order.amount = 250.0; +order.worst_price = estimate.value().price; // never fill worse than the preview +auto placed = client.place_market_order(order); // FAK by default +if (!placed) return; + +// Matched fills are final only once their transaction confirms on-chain. +auto settlement = client.wait_for_order_fill_settlement(placed.value()); +``` + +`place_limit_order` posts GTC, or GTD when `expiration` is set, and supports +`post_only`. `estimate_market_price` also takes a book you already hold, such as +one from `OrderbookManager`, without a request. See +[docs/order-flow.md](docs/order-flow.md). + ### Select an environment `Environment` holds every endpoint and contract for one deployment. Pass it to @@ -298,19 +324,20 @@ exchange; `PositionClient` picks the neg-risk collateral adapter the same way. Build with `-DPOLYMARKET_CLIENT_BUILD_EXAMPLES=ON` and run from `build/`. -| Example | What it does | -| ---------------------------- | ------------------------------------------------------------------------------------ | -| `rest_example` | Public markets and books; balances and open orders with credentials | -| `sign_example` | Signs a dummy order (`PRIVATE_KEY`) | -| `ws_example` | Streams market-channel orderbook messages | -| `user_stream_example` | Streams your own order and trade events (`PRIVATE_KEY`) | -| `position_example` | Split, merge, or redeem from an EOA or Safe; dry run unless `--execute` | -| `approvals_example` | Lists and grants missing trading approvals; dry run unless `--execute` | -| `uma_oracle_watch` | Streams UMA adapter lifecycle events over Polygon JSON-RPC | -| `condition_resolution_watch` | Streams Conditional Tokens resolution and redemption events | -| `evm_event_indexer_example` | Persistent HTTP catch-up + live WebSocket indexer with a cursor file | -| `feed_latency_benchmark` | Compares receive timing across the Polymarket market WS and a Polygon RPC WS | -| `polymarket_arb` | Analysis-only scan of complementary YES/NO books (`--15m --symbol btc --fetch-only`) | +| Example | What it does | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| `rest_example` | Public markets and books; balances and open orders with credentials | +| `sign_example` | Signs a dummy order (`PRIVATE_KEY`) | +| `ws_example` | Streams market-channel orderbook messages | +| `user_stream_example` | Streams your own order and trade events (`PRIVATE_KEY`) | +| `order_flow_example` | Estimates, places, and settles a market or post-only limit order; dry run unless `--execute` | +| `position_example` | Split, merge, or redeem from an EOA or Safe; dry run unless `--execute` | +| `approvals_example` | Lists and grants missing trading approvals; dry run unless `--execute` | +| `uma_oracle_watch` | Streams UMA adapter lifecycle events over Polygon JSON-RPC | +| `condition_resolution_watch` | Streams Conditional Tokens resolution and redemption events | +| `evm_event_indexer_example` | Persistent HTTP catch-up + live WebSocket indexer with a cursor file | +| `feed_latency_benchmark` | Compares receive timing across the Polymarket market WS and a Polygon RPC WS | +| `polymarket_arb` | Analysis-only scan of complementary YES/NO books (`--15m --symbol btc --fetch-only`) | Examples that call Polymarket services, and `order_test`, read `POLYMARKET_ENV` (`production` by default, or `preproduction`). @@ -336,6 +363,7 @@ Methodology and how to compare branches: [docs/benchmarks.md](docs/benchmarks.md | Guide | Topic | | ---------------------------------------------------- | ------------------------------------------------------ | +| [Order flow](docs/order-flow.md) | Fill estimates, one-call orders, settlement waits | | [Networking](docs/networking.md) | Proxies, VPN interfaces, WebSocket routing, geoblock | | [Position operations](docs/position-operations.md) | Split, merge, redeem, approvals, Safe relayer | | [Polygon indexing](docs/polygon-indexing.md) | JSON-RPC watchers, persistent indexer, reorg handling | diff --git a/cmake/PolymarketExamples.cmake b/cmake/PolymarketExamples.cmake index eee44b6..40884e7 100644 --- a/cmake/PolymarketExamples.cmake +++ b/cmake/PolymarketExamples.cmake @@ -39,6 +39,9 @@ if(POLYMARKET_CLIENT_BUILD_EXAMPLES) add_executable(user_stream_example examples/user_stream_example.cpp) target_link_libraries(user_stream_example PRIVATE polymarket::client) + add_executable(order_flow_example examples/order_flow_example.cpp) + target_link_libraries(order_flow_example PRIVATE polymarket::client) + add_executable(position_example examples/position_example.cpp) target_link_libraries(position_example PRIVATE polymarket::client) diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 5bf8cdd..17f2cec 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -146,6 +146,23 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_order_execution PRIVATE polymarket::client) add_test(NAME test_order_execution COMMAND test_order_execution) + add_executable(test_market_price tests/test_market_price.cpp) + target_include_directories(test_market_price PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_market_price PRIVATE polymarket::client) + add_test(NAME test_market_price COMMAND test_market_price) + + add_executable(test_order_placement tests/test_order_placement.cpp) + target_link_libraries(test_order_placement PRIVATE polymarket::client) + add_test(NAME test_order_placement COMMAND test_order_placement) + + add_executable(test_market_order_placement tests/test_market_order_placement.cpp) + target_link_libraries(test_market_order_placement PRIVATE polymarket::client) + add_test(NAME test_market_order_placement COMMAND test_market_order_placement) + + add_executable(test_order_settlement tests/test_order_settlement.cpp) + target_link_libraries(test_order_settlement PRIVATE polymarket::client) + add_test(NAME test_order_settlement COMMAND test_order_settlement) + add_executable(test_order_type_serialization tests/test_order_type_serialization.cpp) target_link_libraries(test_order_type_serialization PRIVATE polymarket::client) add_test(NAME test_order_type_serialization COMMAND test_order_type_serialization) diff --git a/docs/order-flow.md b/docs/order-flow.md new file mode 100644 index 0000000..3b8ef2f --- /dev/null +++ b/docs/order-flow.md @@ -0,0 +1,148 @@ +# Order-Flow Helpers + +Four `ClobClient` methods cover the usual trading loop: preview a fill, place +the order in one call, and wait until its fills settle on-chain. They match +`estimate_market_price`, `place_limit_order`, `place_market_order` and +`wait_for_order_fill_settlement` in the official Python SDK. All four return +`Result` and never throw for API, input or liquidity failures. + +| Method | Network | Result | +| ---------------------------------------------------------------------------------------- | -------------------------------------------- | --------------------- | +| `estimate_market_price(token_id, side, amount, order_type = FOK)` | tick size + book | `MarketPriceEstimate` | +| `estimate_market_price(book, side, amount, tick_size, order_type = FOK)` (free function) | none | `MarketPriceEstimate` | +| `place_limit_order(PlaceLimitOrderParams)` | metadata + `POST /order` | `OrderResponse` | +| `place_market_order(PlaceMarketOrderParams)` | metadata, book unless bounded, `POST /order` | `OrderResponse` | +| `wait_for_order_fill_settlement(order, timeout = 30s, poll_interval = 250ms)` | polls `/data/trades?id=` | `OrderSettlement` | + +`create_order`, `create_market_order`, `post_order` and the `create_and_post_*` +methods are unchanged. Use them when you need a `SignedOrder` before posting, +for example to batch it with `post_orders`. + +## Estimate a market order + +```cpp +auto estimate = client.estimate_market_price(token_id, polymarket::OrderSide::BUY, 250.0); +if (estimate) + std::cout << "worst " << estimate.value().price << ", average " + << estimate.value().average_price << ", shares " + << estimate.value().share_units / 1e6 << "\n"; +``` + +`amount` is collateral to spend for BUY and shares to sell for SELL. The walk +starts at the best price and stops at the first level whose cumulative depth +covers the order that would be signed at that level's price. + +| Field | Meaning | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `price` | Deepest level reached; `create_market_order` signs at exactly this price for the same book | +| `average_price` | Collateral per share across the simulated fill | +| `share_units`, `collateral_units` | Simulated fill in 6-decimal base units, rounded against you by under one unit per level | +| `levels` | Price levels the fill touches | +| `fully_fillable` | `false` when the book runs out; FOK then fails with `InsufficientLiquidity`, FAK returns the partial estimate | + +The free function takes a book you already hold, such as one from +`OrderbookManager::get_orderbook`, and makes no request. Books may list levels +best-first (stream) or best-last (REST); both give the same estimate. + +## Place a limit order + +```cpp +polymarket::PlaceLimitOrderParams order; +order.token_id = token_id; +order.price = 0.42; +order.size = 10; +order.side = polymarket::OrderSide::BUY; +order.post_only = true; // rest on the book or be rejected +order.expiration = unix_now + 3600; // GTD; leave unset for GTC + +auto placed = client.place_limit_order(order); +``` + +- No `expiration` posts GTC. An `expiration` posts GTD and must be at least + 180 seconds ahead, to allow for latency and clock skew. +- `post_only` is sent as `postOnly`, so the server rejects the order instead of + matching it on arrival. +- When `tick_size` is empty, a price that is off the cached tick grid + refreshes the market metadata once before signing, because Polymarket + narrows the tick near 0 and 1. A price still off the grid is rejected + without posting. + +## Place a market order + +```cpp +polymarket::PlaceMarketOrderParams order; +order.token_id = token_id; +order.side = polymarket::OrderSide::BUY; +order.amount = 25.0; // collateral for BUY, shares for SELL +order.worst_price = 0.47; // optional bound +order.order_type = polymarket::OrderType::FOK; // FAK by default + +auto placed = client.place_market_order(order); +``` + +- With `worst_price`, the order is signed at that price without fetching the + book. A BUY never pays more than `worst_price` per share and a SELL never + receives less. The server enforces FAK or FOK. A BUY's signed maker and + taker amounts encode exactly `worst_price`, so a finer tick cannot lift a + higher ask. +- Without `worst_price`, the price comes from walking the current book as + `estimate_market_price` does. A FOK the book cannot fill fails with + `InsufficientLiquidity` before signing. +- The tick refresh works as for limit orders. + +## Wait for settlement + +A matched order is not final. Each fill moves through `MATCHED`, `MINED` and +`CONFIRMED`, or ends `FAILED` when its transaction reverts. Wait before +treating the shares or collateral as yours: + +```cpp +auto settlement = client.wait_for_order_fill_settlement(placed.value()); +if (settlement) + for (const auto &hash : settlement.value().transaction_hashes) + std::cout << "settled in " << hash << "\n"; +``` + +- Only the fills listed in `OrderResponse::trade_ids` are covered: those + matched when the order was posted. Later fills of a remainder resting on the + book are not. +- An order without fills returns at once with the order's own hashes. +- `transaction_hashes` holds the unique hashes of fills that did not fail. + `trades` holds every fill's final `Trade`, both in `trade_ids` order. +- The wait blocks the calling thread. It polls with a monotonic deadline, + never sleeps past it, and polls once more at the deadline. A 429 whose + Retry-After would end past the deadline is returned as `RateLimit` instead + of being retried. +- The deadline bounds sleeps, not requests. A trade lookup already in flight + at the deadline runs until the HTTP client's own timeout. +- `get_trade(id)` and `get_trade_result(id)` read one trade directly. They + return an empty value while the trade is not visible yet. + +For push updates instead of polling, subscribe a `UserStream` and watch +`UserTradeEvent::status`. + +## Errors + +| Code | When | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `InvalidArgument` | Bad input, such as a price outside (0, 1), a resting type for a market order, an early GTD expiration, or a price off the tick grid | +| `InsufficientLiquidity` | FOK the book cannot fill, or an empty book side | +| `HttpTransport` | Tick size, neg-risk, or book request did not complete (retryable) | +| `Parse` | Tick size, neg-risk, or book response was malformed | +| `ApiResponse` | The server rejected the order (the message is its `errorMsg`) or a metadata or book lookup (retryable for 5xx) | +| `Timeout` | Fills still settling at the deadline (retryable; the order is unaffected) | +| `RateLimit` | A trade lookup got HTTP 429 and its Retry-After does not fit in the remaining wait (retryable) | +| `TransactionFailed` | Every fill of the order failed | + +Validation errors are returned before any request is sent. Writes are never +retried; see [Rate limits](../README.md#rate-limits). + +## Differences from the official SDKs + +- `estimate_market_price` returns the full simulated fill, not only the price, + and has an offline overload for a book you already hold. +- `place_market_order` takes one `worst_price` instead of `max_price` for BUY + and `min_price` for SELL. +- `max_spend` (a fee-inclusive spend target) is not supported yet. +- Orders rejected for insufficient allowance are not approved and retried + automatically. Grant approvals once with `PositionClient::setup_trading_approvals`. diff --git a/examples/order_flow_example.cpp b/examples/order_flow_example.cpp new file mode 100644 index 0000000..39ae761 --- /dev/null +++ b/examples/order_flow_example.cpp @@ -0,0 +1,177 @@ +#include "example_environment.hpp" +#include "polymarket/clob_client.hpp" + +#include +#include +#include +#include +#include +#include + +// Preview, place and settle orders with the order-flow helpers. +// +// order_flow_example market [--execute] +// order_flow_example limit [--execute] +// +// market: estimates a BUY of from the live book. With --execute +// it places a FAK BUY bounded at the estimated worst price, so it never fills +// worse than the preview, then waits until its fills settle on-chain. +// +// limit: places a post-only GTD BUY that expires in one hour, so it rests on +// the book or is rejected; it never matches on arrival. Only with --execute. +// +// Without --execute nothing is signed or sent. Environment: PRIVATE_KEY is +// required for --execute. With POLYMARKET_PROXY_ADDRESS set, orders are made +// for that Gnosis Safe. POLYMARKET_ENV selects the deployment (default +// production). Orders placed with --execute use real funds. + +namespace +{ + std::string env(const char *name) + { + const char *value = std::getenv(name); + return value ? value : ""; + } + + int usage() + { + std::cerr << "usage: order_flow_example market [--execute]\n" + " order_flow_example limit [--execute]\n"; + return 2; + } + + void print_error(const char *step, const polymarket::SdkError &error) + { + std::cerr << step << " failed: " << polymarket::sdk_error_code_to_string(error.code) << ' ' + << error.message << '\n'; + } + + std::uint64_t unix_now_seconds() + { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); + } +} // namespace + +int main(int argc, char **argv) +{ + using namespace polymarket; + + bool execute = false; + if (argc > 1 && std::strcmp(argv[argc - 1], "--execute") == 0) + { + execute = true; + --argc; + } + if (argc < 2) return usage(); + const std::string mode = argv[1]; + if (!((mode == "market" && argc == 4) || (mode == "limit" && argc == 5))) return usage(); + const std::string token_id = argv[2]; + + try + { + http_global_init(); + const auto environment = polymarket_example::selected_environment(); + const bool market = mode == "market"; + double amount = 0.0; + double worst_price = 0.0; + + if (market) + { + amount = std::stod(argv[3]); + ClobClient public_client(environment); + const auto estimate = public_client.estimate_market_price(token_id, OrderSide::BUY, + amount, OrderType::FAK); + if (!estimate) + { + print_error("estimate", estimate.error()); + return 1; + } + const auto &value = estimate.value(); + std::cout << "BUY $" << amount << ": worst price " << value.price << ", average " + << value.average_price << ", " << value.share_units / 1e6 << " shares across " + << value.levels << " level(s)" + << (value.fully_fillable ? "" : " (book too shallow; FAK fills part)") + << '\n'; + worst_price = value.price; + } + else + { + std::cout << "post-only GTD BUY " << argv[4] << " shares @ " << argv[3] + << ", expiring in one hour\n"; + } + if (!execute) + { + std::cout << "dry run: pass --execute to place the order\n"; + return 0; + } + + const auto private_key = env("PRIVATE_KEY"); + if (private_key.empty()) + { + std::cerr << "PRIVATE_KEY is required for --execute\n"; + return 1; + } + const auto funder = env("POLYMARKET_PROXY_ADDRESS"); + const auto signature_type = + funder.empty() ? SignatureType::EOA : SignatureType::POLY_GNOSIS_SAFE; + // Derive L2 API credentials from the signer (no orders are placed). + const auto credentials = + ClobClient(environment, private_key, signature_type, funder).create_or_derive_api_key(); + ClobClient client(environment, private_key, credentials, signature_type, funder); + + const auto geoblock = client.get_geoblock_status(); + if (!geoblock || geoblock.value().blocked) + { + std::cerr << "order placement is not available from this location\n"; + return 1; + } + + const auto placed = [&] + { + if (market) + { + PlaceMarketOrderParams order; + order.token_id = token_id; + order.side = OrderSide::BUY; + order.amount = amount; + order.worst_price = worst_price; // never fill worse than the preview + return client.place_market_order(order); + } + PlaceLimitOrderParams order; + order.token_id = token_id; + order.price = std::stod(argv[3]); + order.size = std::stod(argv[4]); + order.side = OrderSide::BUY; + order.post_only = true; + order.expiration = unix_now_seconds() + 3600; + return client.place_limit_order(order); + }(); + if (!placed) + { + print_error("place order", placed.error()); + return 1; + } + std::cout << "order " << placed.value().order_id << ' ' << placed.value().status << ", " + << placed.value().trade_ids.size() << " fill(s)\n"; + + const auto settlement = client.wait_for_order_fill_settlement(placed.value()); + if (!settlement) + { + print_error("settlement", settlement.error()); + return 1; + } + for (const auto &trade : settlement.value().trades) + std::cout << "fill " << trade.id << ' ' << trade.status << ' ' << trade.size << " @ " + << trade.price << '\n'; + for (const auto &hash : settlement.value().transaction_hashes) + std::cout << "settled in " << hash << '\n'; + } + catch (const std::exception &error) + { + std::cerr << "error: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/include/polymarket/clob_client.hpp b/include/polymarket/clob_client.hpp index 65e86f7..5419b83 100644 --- a/include/polymarket/clob_client.hpp +++ b/include/polymarket/clob_client.hpp @@ -3,6 +3,7 @@ #include "polymarket/clob_types.hpp" #include "polymarket/environment.hpp" #include "polymarket/geoblock.hpp" +#include "polymarket/market_price.hpp" #include "polymarket/types.hpp" #include "polymarket/http_client.hpp" #include "polymarket/order_signer.hpp" @@ -101,6 +102,13 @@ namespace polymarket std::optional get_neg_risk(const std::string &token_id); void clear_market_metadata_cache(const std::string &token_id = ""); + // Fetches the book and the market tick size, then simulates a market + // order of `amount` (collateral for BUY, shares for SELL). The estimated + // price is the one create_market_order would sign against the same book. + Result estimate_market_price(const std::string &token_id, + OrderSide side, double amount, + OrderType order_type = OrderType::FOK); + // Prices history using PriceHistoryPoint = ::polymarket::PriceHistoryPoint; std::vector get_prices_history(const std::string &token_id, @@ -152,6 +160,18 @@ namespace polymarket OrderResponse create_and_post_market_order(const CreateMarketOrderParams ¶ms, OrderType order_type = OrderType::FAK); + // Validates, signs and posts a limit order: GTC, or GTD when an + // expiration is set. When the tick size comes from the market, a price + // off the cached grid refreshes it once, since ticks change near 0 and 1. + // Nothing is posted if validation or signing fails. + Result place_limit_order(const PlaceLimitOrderParams ¶ms); + + // Validates, signs and posts a FAK or FOK market order. Without a + // worst_price it walks the book like estimate_market_price, so a FOK + // the book cannot fill fails with InsufficientLiquidity before signing. + // The market tick size is refreshed once if it rejects the price. + Result place_market_order(const PlaceMarketOrderParams ¶ms); + // Order management bool cancel_order(const std::string &order_id); Result cancel_order_result(const std::string &order_id); @@ -165,6 +185,20 @@ namespace polymarket std::vector get_open_orders(const std::string &market = ""); Result> get_open_orders_result(const std::string &market = ""); std::vector get_trades(const std::string &next_cursor = ""); + // Empty when the account has no trade with this ID (yet). + std::optional get_trade(const std::string &trade_id); + Result> get_trade_result(const std::string &trade_id); + + // Blocks until every fill in `order.trade_ids` is CONFIRMED or FAILED, + // polling get_trade_result. Covers only the fills matched when the + // order was posted, not later fills of a remainder resting on the book. + // Fails with Timeout while fills are still settling (the order itself + // is unaffected), TransactionFailed when every fill failed, or the + // error of the first trade lookup that fails. + Result wait_for_order_fill_settlement( + const OrderResponse &order, + std::chrono::milliseconds timeout = std::chrono::seconds(30), + std::chrono::milliseconds poll_interval = std::chrono::milliseconds(250)); // Balance and allowance std::optional get_balance_allowance( @@ -318,6 +352,9 @@ namespace polymarket std::set metadata_cache_in_flight_; std::map> tick_size_cache_; std::map> neg_risk_cache_; + Result tick_size_result(const std::string &token_id); + Result neg_risk_result(const std::string &token_id); + Result order_book_result(const std::string &token_id); using RateLimitListener = std::function; mutable std::mutex rate_limit_mutex_; @@ -327,7 +364,13 @@ namespace polymarket std::optional rate_limit_retry() const; // Runs a read request under the retry policy; attempt() must rebuild // signed headers. Defined in clob_client_internal.hpp. - template HttpResponse read(Attempt &&attempt) const; + template + HttpResponse + read(Attempt &&attempt, + std::optional deadline = std::nullopt) const; + Result> + lookup_trade(const std::string &trade_id, + std::optional deadline); // Helper methods std::map get_l2_headers(const std::string &method, @@ -336,6 +379,8 @@ namespace polymarket std::string order_type_to_string(OrderType type); std::string order_side_to_string(OrderSide side); + Result post_signed_order(const SignedOrder &order, OrderType order_type, + bool post_only); // JSON parsing helpers std::vector parse_markets(const std::string &json); diff --git a/include/polymarket/clob_types.hpp b/include/polymarket/clob_types.hpp index eaa10c2..175b6ef 100644 --- a/include/polymarket/clob_types.hpp +++ b/include/polymarket/clob_types.hpp @@ -92,6 +92,15 @@ namespace polymarket std::optional error_msg; }; + // Final state of the fills an order matched when it was posted. + struct OrderSettlement + { + // Unique hashes of fills that did not fail, in trade_ids order. + std::vector transaction_hashes; + // Each fill once CONFIRMED or FAILED, in trade_ids order. + std::vector trades; + }; + // Balance/Allowance info struct BalanceAllowance { @@ -165,6 +174,43 @@ namespace polymarket std::optional neg_risk; // If set, skips API call to fetch neg_risk }; + // Limit order for ClobClient::place_limit_order. + struct PlaceLimitOrderParams + { + std::string token_id; + double price{0.0}; + double size{0.0}; + OrderSide side{OrderSide::BUY}; + // Rejected instead of matched if it would cross the book on arrival. + bool post_only{false}; + // Unix seconds. Set: posted as GTD, at least 180 s ahead to allow for + // latency and clock skew. Unset: posted as GTC. + std::optional expiration; + std::string tick_size; // Empty uses the market minimum + std::string metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"; + std::string builder_code = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + std::optional neg_risk; // If set, skips API call to fetch neg_risk + }; + + // Market order for ClobClient::place_market_order. + struct PlaceMarketOrderParams + { + std::string token_id; + OrderSide side{OrderSide::BUY}; + double amount{0.0}; // Collateral to spend for BUY, shares to sell for SELL + // Most a BUY pays, or least a SELL accepts, per share. Set: the order + // is signed at this price without fetching the book. Unset: the price + // comes from walking the current book. + std::optional worst_price; + OrderType order_type{OrderType::FAK}; // FAK or FOK + std::string tick_size; // Empty uses the market minimum + std::string metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"; + std::string builder_code = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + std::optional neg_risk; // If set, skips API call to fetch neg_risk + }; + // Signed order paired with the execution policy used when posting it. struct PreparedOrder { diff --git a/include/polymarket/market_price.hpp b/include/polymarket/market_price.hpp new file mode 100644 index 0000000..2337bc5 --- /dev/null +++ b/include/polymarket/market_price.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "polymarket/clob_types.hpp" +#include "polymarket/order_signer.hpp" +#include "polymarket/sdk_error.hpp" +#include "polymarket/types.hpp" + +#include +#include +#include + +namespace polymarket +{ + // Simulated fill of a market order against one order book snapshot. + // Amounts use 6-decimal base units, like signed order amounts. + struct MarketPriceEstimate + { + // Deepest level the order reaches; create_market_order signs at this price. + double price{0.0}; + // Collateral per share across the simulated fill. + double average_price{0.0}; + // Shares bought or sold, and collateral paid or received. Each level + // is rounded against the caller by less than one base unit. + std::uint64_t share_units{0}; + std::uint64_t collateral_units{0}; + // Price levels the fill touches. + std::size_t levels{0}; + // False when the book cannot absorb the whole amount; a FAK order + // then fills only the simulated part. + bool fully_fillable{false}; + }; + + // Walks `book` for a market order of `amount` (collateral for BUY, shares + // for SELL) without network access, so a book from OrderbookManager can be + // used directly. Levels may be listed best-first or best-last. + // FOK fails with InsufficientLiquidity when the book cannot fill the whole + // amount; FAK returns the partial estimate. + Result estimate_market_price(const Orderbook &book, OrderSide side, + double amount, const std::string &tick_size, + OrderType order_type = OrderType::FOK); +} // namespace polymarket diff --git a/include/polymarket/sdk_error.hpp b/include/polymarket/sdk_error.hpp index 37a17a5..800a07e 100644 --- a/include/polymarket/sdk_error.hpp +++ b/include/polymarket/sdk_error.hpp @@ -18,7 +18,10 @@ namespace polymarket RateLimit, Parse, Signing, - InvalidArgument + InvalidArgument, + InsufficientLiquidity, + Timeout, + TransactionFailed }; struct SdkError diff --git a/src/clob_books.cpp b/src/clob_books.cpp index 4b0bb9e..22aaece 100644 --- a/src/clob_books.cpp +++ b/src/clob_books.cpp @@ -1,5 +1,6 @@ #include "polymarket/clob_client.hpp" #include "clob_client_internal.hpp" +#include "rest_orderbook_parsing.hpp" #include #include @@ -13,13 +14,30 @@ namespace polymarket std::optional ClobClient::get_order_book(const std::string &token_id) { - if (token_id.empty()) return std::nullopt; + auto result = order_book_result(token_id); + if (!result) return std::nullopt; + return std::move(result.value()); + } + + Result ClobClient::order_book_result(const std::string &token_id) + { + constexpr const char *endpoint = "/book"; + if (token_id.empty()) + return Result::failure({SdkErrorCode::InvalidArgument, + "token_id is required", endpoint, 0, "", "", false}); auto response = read( [&] { return http_.get("/book?token_id=" + percent_encode_query_value(token_id)); }); - if (!response.ok()) - return std::nullopt; + if (!response.ok()) return Result::failure(make_sdk_error(response, endpoint)); - return parse_orderbook(response.body, token_id); + try + { + return Result::success( + detail::parse_rest_orderbook_json(response.body, token_id)); + } + catch (const std::exception &ex) + { + return Result::failure(make_parse_error(ex.what(), endpoint, response.body)); + } } std::map ClobClient::get_order_books(const std::vector &token_ids) diff --git a/src/clob_client_internal.hpp b/src/clob_client_internal.hpp index b42dddc..3e76b75 100644 --- a/src/clob_client_internal.hpp +++ b/src/clob_client_internal.hpp @@ -83,8 +83,11 @@ namespace polymarket::detail namespace polymarket { - template HttpResponse ClobClient::read(Attempt &&attempt) const + template + HttpResponse + ClobClient::read(Attempt &&attempt, + std::optional deadline) const { - return detail::retry_rate_limited(rate_limit_retry(), attempt); + return detail::retry_rate_limited(rate_limit_retry(), attempt, deadline); } } // namespace polymarket diff --git a/src/clob_market_metadata.cpp b/src/clob_market_metadata.cpp index 0b681ed..b004488 100644 --- a/src/clob_market_metadata.cpp +++ b/src/clob_market_metadata.cpp @@ -48,6 +48,14 @@ namespace polymarket std::optional ClobClient::get_tick_size(const std::string &token_id) { + auto result = tick_size_result(token_id); + if (!result) return std::nullopt; + return std::move(result.value()); + } + + Result ClobClient::tick_size_result(const std::string &token_id) + { + constexpr const char *endpoint = "/tick-size"; const std::string in_flight_key = "tick:" + token_id; { std::unique_lock lock(metadata_cache_mutex_); @@ -57,7 +65,7 @@ namespace polymarket if (cached != tick_size_cache_.end()) { if (std::chrono::steady_clock::now() < cached->second.expires_at) - return cached->second.value; + return Result::success(cached->second.value); tick_size_cache_.erase(cached); } if (metadata_cache_in_flight_.insert(in_flight_key).second) @@ -73,32 +81,44 @@ namespace polymarket [&] { return http_.get("/tick-size?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) - return std::nullopt; + return Result::failure(make_sdk_error(response, endpoint)); try { auto j = json::parse(response.body); if (!j.is_object() || !j.contains("minimum_tick_size") || j["minimum_tick_size"].is_null()) - return std::nullopt; + return Result::failure( + make_parse_error("missing minimum_tick_size", endpoint, response.body)); TickSizeInfo info; info.minimum_tick_size = json_scalar_string(j["minimum_tick_size"]); (void)json_orderbook_price(j["minimum_tick_size"]); std::lock_guard lock(metadata_cache_mutex_); - return tick_size_cache_ - .insert_or_assign(token_id, - MetadataCacheEntry{std::move(info), - std::chrono::steady_clock::now() + METADATA_CACHE_TTL}) - .first->second.value; + return Result::success( + tick_size_cache_ + .insert_or_assign( + token_id, + MetadataCacheEntry{ + std::move(info), std::chrono::steady_clock::now() + METADATA_CACHE_TTL}) + .first->second.value); } - catch (...) + catch (const std::exception &ex) { - return std::nullopt; + return Result::failure( + make_parse_error(ex.what(), endpoint, response.body)); } } std::optional ClobClient::get_neg_risk(const std::string &token_id) { + auto result = neg_risk_result(token_id); + if (!result) return std::nullopt; + return result.value(); + } + + Result ClobClient::neg_risk_result(const std::string &token_id) + { + constexpr const char *endpoint = "/neg-risk"; const std::string in_flight_key = "neg-risk:" + token_id; { std::unique_lock lock(metadata_cache_mutex_); @@ -108,7 +128,7 @@ namespace polymarket if (cached != neg_risk_cache_.end()) { if (std::chrono::steady_clock::now() < cached->second.expires_at) - return cached->second.value; + return Result::success(cached->second.value); neg_risk_cache_.erase(cached); } if (metadata_cache_in_flight_.insert(in_flight_key).second) @@ -123,27 +143,29 @@ namespace polymarket auto response = read( [&] { return http_.get("/neg-risk?token_id=" + percent_encode_query_value(token_id)); }); - if (!response.ok()) - return std::nullopt; + if (!response.ok()) return Result::failure(make_sdk_error(response, endpoint)); try { auto j = json::parse(response.body); if (!j.is_object() || !j.contains("neg_risk") || !j["neg_risk"].is_boolean()) - return std::nullopt; + return Result::failure( + make_parse_error("missing boolean neg_risk", endpoint, response.body)); NegRiskInfo info; info.neg_risk = j["neg_risk"].get(); std::lock_guard lock(metadata_cache_mutex_); - return neg_risk_cache_ - .insert_or_assign(token_id, - MetadataCacheEntry{ - info, std::chrono::steady_clock::time_point::max()}) - .first->second.value; + return Result::success( + neg_risk_cache_ + .insert_or_assign(token_id, + MetadataCacheEntry{ + info, std::chrono::steady_clock::time_point::max()}) + .first->second.value); } - catch (...) + catch (const std::exception &ex) { - return std::nullopt; + return Result::failure( + make_parse_error(ex.what(), endpoint, response.body)); } } diff --git a/src/clob_order_contract_tests.cpp b/src/clob_order_contract_tests.cpp index 837958d..3b8a396 100644 --- a/src/clob_order_contract_tests.cpp +++ b/src/clob_order_contract_tests.cpp @@ -52,20 +52,36 @@ namespace clob_test const auto missing_tick = client.create_market_order_result(params, OrderType::FOK); const auto requests = server.requests(); - return check(fok && fok.value().order.maker_amount == "1999998" && fok.value().order.taker_amount == "3333330", "FOK must use an exact executable worst-depth ratio") && - check(fak && fak.value().order.maker_amount == "8999998" && fak.value().order.taker_amount == "12857140", "FAK must use an exact shallow-book limit ratio") && - check(!missing_quote && missing_quote.error().code == SdkErrorCode::HttpTransport, - "missing market quote must be a metadata transport failure") && - check(!missing_neg_risk && missing_neg_risk.error().code == SdkErrorCode::HttpTransport, - "missing neg-risk metadata must be a metadata transport failure") && - check(!missing_tick && missing_tick.error().code == SdkErrorCode::HttpTransport, - "missing tick metadata must be a metadata transport failure") && + return check(fok && fok.value().order.maker_amount == "1999998" && + fok.value().order.taker_amount == "3333330", + "FOK must use an exact executable worst-depth ratio") && + check(fak && fak.value().order.maker_amount == "8999998" && + fak.value().order.taker_amount == "12857140", + "FAK must use an exact shallow-book limit ratio") && + check(!missing_quote && missing_quote.error().code == SdkErrorCode::ApiResponse && + missing_quote.error().http_status == 404 && + !missing_quote.error().retryable && + missing_quote.error().endpoint == "/book", + "a missing market quote must keep the book 404") && + check(!missing_neg_risk && + missing_neg_risk.error().code == SdkErrorCode::ApiResponse && + missing_neg_risk.error().http_status == 500 && + missing_neg_risk.error().retryable, + "a neg-risk server error must keep its HTTP status") && + check(!missing_tick && missing_tick.error().code == SdkErrorCode::ApiResponse && + missing_tick.error().http_status == 500 && missing_tick.error().retryable, + "a tick size server error must keep its HTTP status") && check(requests.size() == 6, "market-order metadata request count mismatch") && - check(requests[0].target == "/tick-size?token_id=123" && requests[1].target == "/book?token_id=123", "FOK must fetch tick and full book") && - check(requests[2].target == "/book?token_id=123", "FAK must reuse cached tick metadata") && + check(requests[0].target == "/tick-size?token_id=123" && + requests[1].target == "/book?token_id=123", + "FOK must fetch tick and full book") && + check(requests[2].target == "/book?token_id=123", + "FAK must reuse cached tick metadata") && check(requests[3].target == "/book?token_id=123", "missing quote path mismatch") && - check(requests[4].target == "/neg-risk?token_id=123", "neg-risk lookup path mismatch") && - check(requests[5].target == "/tick-size?token_id=456", "tick failure should stop before signing"); + check(requests[4].target == "/neg-risk?token_id=123", + "neg-risk lookup path mismatch") && + check(requests[5].target == "/tick-size?token_id=456", + "tick failure should stop before signing"); } bool test_limit_order_fail_closed_metadata() @@ -109,21 +125,28 @@ namespace clob_test resolved_tick.value().taker_amount == "8040000", "empty tick must resolve to the market minimum (maker=" + (resolved_tick ? resolved_tick.value().maker_amount : "error") + ")") && - check(!too_small_tick && too_small_tick.error().code == SdkErrorCode::InvalidArgument, + check(!too_small_tick && + too_small_tick.error().code == SdkErrorCode::InvalidArgument, "caller tick below the market minimum must be InvalidArgument") && - check(!missing_tick && missing_tick.error().code == SdkErrorCode::HttpTransport && - !malformed_tick && malformed_tick.error().code == SdkErrorCode::HttpTransport, - "missing or malformed tick metadata must be transport failures") && - check(!missing_neg_risk && missing_neg_risk.error().code == SdkErrorCode::HttpTransport && - !malformed_neg_risk && malformed_neg_risk.error().code == SdkErrorCode::HttpTransport, - "missing or malformed neg-risk metadata must be transport failures") && + check(!missing_tick && missing_tick.error().code == SdkErrorCode::ApiResponse && + missing_tick.error().http_status == 500 && !malformed_tick && + malformed_tick.error().code == SdkErrorCode::Parse, + "tick metadata failures must keep their HTTP or parse error") && + check(!missing_neg_risk && + missing_neg_risk.error().code == SdkErrorCode::ApiResponse && + missing_neg_risk.error().http_status == 500 && !malformed_neg_risk && + malformed_neg_risk.error().code == SdkErrorCode::Parse && + malformed_neg_risk.error().endpoint == "/neg-risk", + "neg-risk metadata failures must keep their HTTP or parse error") && check(requests.size() == 7, "limit-order metadata request count mismatch") && check(requests[0].target == "/tick-size?token_id=123" && requests[1].target == "/tick-size?token_id=missing-tick" && requests[2].target == "/tick-size?token_id=malformed-tick", "limit orders must resolve uncached tick metadata before signing") && - check(requests[3].target == "/tick-size?token_id=missing-neg" && requests[4].target == "/neg-risk?token_id=missing-neg" && - requests[5].target == "/tick-size?token_id=malformed-neg" && requests[6].target == "/neg-risk?token_id=malformed-neg", + check(requests[3].target == "/tick-size?token_id=missing-neg" && + requests[4].target == "/neg-risk?token_id=missing-neg" && + requests[5].target == "/tick-size?token_id=malformed-neg" && + requests[6].target == "/neg-risk?token_id=malformed-neg", "limit orders must resolve neg-risk metadata after tick validation"); } diff --git a/src/clob_order_execution.cpp b/src/clob_order_execution.cpp index 4ecc279..a2a3e87 100644 --- a/src/clob_order_execution.cpp +++ b/src/clob_order_execution.cpp @@ -2,6 +2,8 @@ #include "order_execution.hpp" #include "polymarket/order_signer.hpp" #include +#include +#include namespace polymarket { @@ -9,10 +11,14 @@ namespace polymarket { constexpr const char *ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; - class MetadataResolutionError : public std::runtime_error + struct MetadataResolutionError : std::runtime_error { - public: - using std::runtime_error::runtime_error; + MetadataResolutionError(const std::string &context, SdkError cause) + : std::runtime_error(context + ": " + cause.message), error(std::move(cause)) + { + } + + SdkError error; }; constexpr bool is_market_order_type(OrderType order_type) @@ -41,21 +47,20 @@ namespace polymarket return scale; } - std::string resolve_tick_size(ClobClient &client, - const std::string &token_id, + std::string resolve_tick_size(const Result &tick_info, const std::string &requested_tick_size) { - const auto tick_info = client.get_tick_size(token_id); - if (!tick_info || tick_info->minimum_tick_size.empty()) + if (!tick_info) { - throw MetadataResolutionError("could not resolve market tick size"); + throw MetadataResolutionError("could not resolve market tick size", + tick_info.error()); } - const auto minimum = detail::rounding_config_for_tick_size( - tick_info->minimum_tick_size); + const auto &market_tick_size = tick_info.value().minimum_tick_size; + const auto minimum = detail::rounding_config_for_tick_size(market_tick_size); if (requested_tick_size.empty()) { - return tick_info->minimum_tick_size; + return market_tick_size; } const auto requested = detail::rounding_config_for_tick_size( requested_tick_size); @@ -69,21 +74,14 @@ namespace polymarket return requested_tick_size; } - bool resolve_neg_risk(ClobClient &client, - const std::string &token_id, - const std::optional &requested_neg_risk) + bool resolve_neg_risk(const Result &neg_risk_info) { - if (requested_neg_risk) - { - return *requested_neg_risk; - } - - const auto neg_risk_info = client.get_neg_risk(token_id); if (!neg_risk_info) { - throw MetadataResolutionError("could not resolve neg-risk metadata"); + throw MetadataResolutionError("could not resolve neg-risk metadata", + neg_risk_info.error()); } - return neg_risk_info->neg_risk; + return neg_risk_info.value().neg_risk; } } @@ -94,10 +92,12 @@ namespace polymarket throw std::runtime_error("Client not authenticated"); } - const std::string tick_size = resolve_tick_size(*this, params.token_id, params.tick_size); + const std::string tick_size = + resolve_tick_size(tick_size_result(params.token_id), params.tick_size); const auto validated_price = detail::validate_order_price( params.price, tick_size); - const bool is_neg_risk = resolve_neg_risk(*this, params.token_id, params.neg_risk); + const bool is_neg_risk = + params.neg_risk ? *params.neg_risk : resolve_neg_risk(neg_risk_result(params.token_id)); const auto context = build_execution_context(*this, *order_signer_, funder_address_, sig_type_); const auto amounts = detail::calculate_limit_order_amounts( @@ -152,7 +152,7 @@ namespace polymarket } catch (const MetadataResolutionError &ex) { - return Result::failure({SdkErrorCode::HttpTransport, ex.what(), "/order", 0, "", "", true}); + return Result::failure(ex.error); } catch (const std::exception &ex) { @@ -175,7 +175,8 @@ namespace polymarket throw std::runtime_error("Client not authenticated"); } - const std::string tick_size = resolve_tick_size(*this, params.token_id, params.tick_size); + const std::string tick_size = + resolve_tick_size(tick_size_result(params.token_id), params.tick_size); double price; if (params.price) { @@ -183,17 +184,19 @@ namespace polymarket } else { - const auto book = get_order_book(params.token_id); + const auto book = order_book_result(params.token_id); if (!book) { - throw MetadataResolutionError("could not resolve executable market price"); + throw MetadataResolutionError("could not resolve executable market price", + book.error()); } - price = detail::calculate_market_price( - *book, params.side, params.amount, order_type, tick_size); + price = detail::calculate_market_price(book.value(), params.side, params.amount, + order_type, tick_size); } const auto validated_price = detail::validate_order_price(price, tick_size); - const bool is_neg_risk = resolve_neg_risk(*this, params.token_id, params.neg_risk); + const bool is_neg_risk = + params.neg_risk ? *params.neg_risk : resolve_neg_risk(neg_risk_result(params.token_id)); const auto context = build_execution_context(*this, *order_signer_, funder_address_, sig_type_); const auto amounts = detail::calculate_market_order_amounts( @@ -260,7 +263,7 @@ namespace polymarket } catch (const MetadataResolutionError &ex) { - return Result::failure({SdkErrorCode::HttpTransport, ex.what(), "/order", 0, "", "", true}); + return Result::failure(ex.error); } catch (const std::exception &ex) { diff --git a/src/clob_order_placement.cpp b/src/clob_order_placement.cpp new file mode 100644 index 0000000..0a75c6a --- /dev/null +++ b/src/clob_order_placement.cpp @@ -0,0 +1,149 @@ +#include "polymarket/clob_client.hpp" +#include "order_execution.hpp" + +#include +#include +#include +#include +#include +#include + +namespace polymarket +{ + namespace + { + // Same buffer as py-sdk: the server rejects GTD orders that expire too + // soon, so leave room for latency and clock skew. + constexpr std::uint64_t min_expiration_lead_seconds = 180; + + Result invalid_order(const std::string &message) + { + return Result::failure( + {SdkErrorCode::InvalidArgument, message, "/order", 0, "", "", false}); + } + + std::uint64_t unix_now_seconds() + { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); + } + + bool on_tick_grid(double price, const std::string &tick_size) + { + try + { + (void)detail::validate_order_price(price, tick_size); + return true; + } + catch (const std::exception &) + { + return false; + } + } + } // namespace + + Result ClobClient::place_limit_order(const PlaceLimitOrderParams ¶ms) + { + if (!is_authenticated()) + return Result::failure( + make_auth_error("Client not authenticated", "/order")); + if (params.token_id.empty()) return invalid_order("token_id is required"); + if (!std::isfinite(params.price) || params.price <= 0.0 || params.price >= 1.0) + return invalid_order("limit price must be between 0 and 1"); + if (!std::isfinite(params.size) || params.size <= 0.0) + return invalid_order("limit size must be finite and positive"); + if (params.expiration && + *params.expiration < unix_now_seconds() + min_expiration_lead_seconds) + return invalid_order("expiration must be at least 180 seconds in the future"); + + if (params.tick_size.empty()) + { + const auto cached = tick_size_result(params.token_id); + if (!cached) return Result::failure(cached.error()); + if (!on_tick_grid(params.price, cached.value().minimum_tick_size)) + clear_market_metadata_cache(params.token_id); + } + + CreateOrderParams order; + order.token_id = params.token_id; + order.price = params.price; + order.size = params.size; + order.side = params.side; + order.tick_size = params.tick_size; + order.expiration = params.expiration ? std::to_string(*params.expiration) : "0"; + order.metadata = params.metadata; + order.builder_code = params.builder_code; + order.neg_risk = params.neg_risk; + + const auto signed_order = create_order_result(order); + if (!signed_order) return Result::failure(signed_order.error()); + return post_signed_order(signed_order.value(), + params.expiration ? OrderType::GTD : OrderType::GTC, + params.post_only); + } + + Result ClobClient::place_market_order(const PlaceMarketOrderParams ¶ms) + { + if (!is_authenticated()) + return Result::failure( + make_auth_error("Client not authenticated", "/order")); + if (params.token_id.empty()) return invalid_order("token_id is required"); + if (params.order_type != OrderType::FAK && params.order_type != OrderType::FOK) + return invalid_order("Market orders require FAK or FOK"); + if (!std::isfinite(params.amount) || params.amount <= 0.0) + return invalid_order("market order amount must be finite and positive"); + if (params.worst_price && (!std::isfinite(*params.worst_price) || + *params.worst_price <= 0.0 || *params.worst_price >= 1.0)) + return invalid_order("worst price must be between 0 and 1"); + + const bool uses_market_tick = params.tick_size.empty(); + std::string tick_size = params.tick_size; + if (uses_market_tick) + { + const auto info = tick_size_result(params.token_id); + if (!info) return Result::failure(info.error()); + tick_size = info.value().minimum_tick_size; + } + + CreateMarketOrderParams order; + order.token_id = params.token_id; + order.amount = params.amount; + order.side = params.side; + order.tick_size = params.tick_size; + order.metadata = params.metadata; + order.builder_code = params.builder_code; + order.neg_risk = params.neg_risk; + + if (params.worst_price) + { + if (uses_market_tick && !on_tick_grid(*params.worst_price, tick_size)) + clear_market_metadata_cache(params.token_id); + order.price = params.worst_price; + } + else + { + const auto book = order_book_result(params.token_id); + if (!book) return Result::failure(book.error()); + auto estimate = polymarket::estimate_market_price( + book.value(), params.side, params.amount, tick_size, params.order_type); + // Book levels on a finer grid than the cached tick mean the tick changed. + if (!estimate && estimate.error().code == SdkErrorCode::InvalidArgument && + uses_market_tick) + { + clear_market_metadata_cache(params.token_id); + const auto refreshed = tick_size_result(params.token_id); + if (!refreshed) return Result::failure(refreshed.error()); + tick_size = refreshed.value().minimum_tick_size; + estimate = polymarket::estimate_market_price( + book.value(), params.side, params.amount, tick_size, params.order_type); + } + if (!estimate) return Result::failure(estimate.error()); + order.price = estimate.value().price; + } + + const auto prepared = create_market_order_result(order, params.order_type); + if (!prepared) return Result::failure(prepared.error()); + return post_signed_order(prepared.value().order, prepared.value().order_type, false); + } +} // namespace polymarket diff --git a/src/clob_order_settlement.cpp b/src/clob_order_settlement.cpp new file mode 100644 index 0000000..0ab28d6 --- /dev/null +++ b/src/clob_order_settlement.cpp @@ -0,0 +1,172 @@ +#include "polymarket/clob_client.hpp" +#include "clob_client_internal.hpp" +#include "poll_deadline.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace polymarket +{ + namespace + { + constexpr const char *trades_endpoint = "/data/trades"; + + std::string normalized_status(const Trade &trade) + { + constexpr std::string_view prefix = "TRADE_STATUS_"; + std::string status = trade.status; + std::transform(status.begin(), status.end(), status.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + if (status.starts_with(prefix)) status.erase(0, prefix.size()); + return status; + } + + // CONFIRMED is final on-chain; FAILED never will be. Earlier statuses + // (MATCHED, MINED, RETRYING) can still change their transaction hash. + bool is_settled(const Trade &trade) + { + const auto status = normalized_status(trade); + return status == "CONFIRMED" || status == "FAILED"; + } + + bool is_failed(const Trade &trade) + { + return normalized_status(trade) == "FAILED"; + } + + void append_unique(std::vector &values, const std::string &value) + { + if (!value.empty() && std::find(values.begin(), values.end(), value) == values.end()) + values.push_back(value); + } + + std::string join(const std::vector &values) + { + std::string result; + for (const auto &value : values) + result += (result.empty() ? "" : ", ") + value; + return result; + } + + SdkError settlement_error(SdkErrorCode code, const std::string &message, + bool retryable = false) + { + return {code, message, trades_endpoint, 0, "", "", retryable}; + } + } // namespace + + std::optional ClobClient::get_trade(const std::string &trade_id) + { + auto result = get_trade_result(trade_id); + return result ? result.value() : std::nullopt; + } + + Result> ClobClient::get_trade_result(const std::string &trade_id) + { + return lookup_trade(trade_id, std::nullopt); + } + + Result> + ClobClient::lookup_trade(const std::string &trade_id, + std::optional deadline) + { + if (!order_signer_ || !api_creds_) + return Result>::failure( + make_auth_error("Client not authenticated", trades_endpoint)); + if (trade_id.empty()) + return Result>::failure( + settlement_error(SdkErrorCode::InvalidArgument, "trade_id is required")); + + const std::string path = + std::string(trades_endpoint) + "?id=" + detail::percent_encode_query_value(trade_id); + auto response = read( + [&] { return http_.get(path, get_l2_headers("GET", trades_endpoint, "")); }, deadline); + if (!response.ok()) + return Result>::failure(make_sdk_error(response, trades_endpoint)); + + try + { + auto page = detail::parse_trade_page_json(response.body); + for (auto &trade : page.trades) + { + if (trade.id == trade_id) + return Result>::success(std::move(trade)); + } + return Result>::success(std::nullopt); + } + catch (const std::exception &ex) + { + return Result>::failure( + make_parse_error(ex.what(), trades_endpoint, response.body)); + } + } + + Result + ClobClient::wait_for_order_fill_settlement(const OrderResponse &order, + std::chrono::milliseconds timeout, + std::chrono::milliseconds poll_interval) + { + if (timeout < std::chrono::milliseconds::zero() || + poll_interval <= std::chrono::milliseconds::zero()) + return Result::failure(settlement_error( + SdkErrorCode::InvalidArgument, + "timeout must not be negative and poll_interval must be positive")); + + std::vector trade_ids; + for (const auto &trade_id : order.trade_ids) + append_unique(trade_ids, trade_id); + + OrderSettlement settlement; + if (trade_ids.empty()) + { + // Nothing matched on arrival, so there are no fills to poll. + for (const auto &hash : order.transaction_hashes) + append_unique(settlement.transaction_hashes, hash); + return Result::success(std::move(settlement)); + } + + std::vector> settled(trade_ids.size()); + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (true) + { + std::vector pending; + for (std::size_t index = 0; index < trade_ids.size(); ++index) + { + if (settled[index]) continue; + auto trade = lookup_trade(trade_ids[index], deadline); + if (!trade) return Result::failure(trade.error()); + if (trade.value() && is_settled(*trade.value())) + settled[index] = std::move(trade.value()); + else + pending.push_back(trade_ids[index]); + } + if (pending.empty()) break; + if (!detail::sleep_before_next_poll(deadline, poll_interval)) + return Result::failure(settlement_error( + SdkErrorCode::Timeout, + "timed out waiting for trades to settle: " + join(pending), true)); + } + + bool all_failed = true; + settlement.trades.reserve(settled.size()); + for (auto &trade : settled) + { + if (!is_failed(*trade)) + { + all_failed = false; + append_unique(settlement.transaction_hashes, trade->transaction_hash); + } + settlement.trades.push_back(std::move(*trade)); + } + if (all_failed) + return Result::failure(settlement_error( + SdkErrorCode::TransactionFailed, + "every fill of order " + order.order_id + " failed execution: " + join(trade_ids))); + return Result::success(std::move(settlement)); + } +} // namespace polymarket diff --git a/src/clob_order_submission.cpp b/src/clob_order_submission.cpp index f21f98d..5f7b486 100644 --- a/src/clob_order_submission.cpp +++ b/src/clob_order_submission.cpp @@ -97,6 +97,12 @@ namespace polymarket } Result ClobClient::post_order_result(const SignedOrder &order, OrderType order_type) + { + return post_signed_order(order, order_type, false); + } + + Result ClobClient::post_signed_order(const SignedOrder &order, + OrderType order_type, bool post_only) { std::string serialized_type; try @@ -114,9 +120,8 @@ namespace polymarket return Result::failure(make_auth_error("Client not authenticated", "/order")); } - const auto body = detail::order_payload_json(order, - api_creds_ ? api_creds_->api_key : "", - serialized_type); + const auto body = detail::order_payload_json(order, api_creds_ ? api_creds_->api_key : "", + serialized_type, post_only); const std::string body_str = body.dump(); auto headers = get_l2_headers("POST", "/order", body_str); auto response = http_.post("/order", body_str, headers); diff --git a/src/clob_trade_contract_tests.cpp b/src/clob_trade_contract_tests.cpp index f8c10f1..b415c09 100644 --- a/src/clob_trade_contract_tests.cpp +++ b/src/clob_trade_contract_tests.cpp @@ -88,7 +88,7 @@ namespace clob_test item.erase("error_msg"); auto null_item = trade(); null_item["maker_orders"] = nullptr; - null_item["transaction_hash"] = ""; + null_item["transaction_hash"] = nullptr; null_item["error_msg"] = nullptr; server.enqueue( page(json::array({std::move(item), std::move(null_item)}), "LTE=").dump()); diff --git a/src/clob_trade_parsing.cpp b/src/clob_trade_parsing.cpp index 5b45fcc..60cab1e 100644 --- a/src/clob_trade_parsing.cpp +++ b/src/clob_trade_parsing.cpp @@ -90,12 +90,10 @@ namespace polymarket::detail std::string transaction_hash(const json &trade) { const auto hash = trade.find("transaction_hash"); - if (hash == trade.end()) - return {}; + if (hash == trade.end() || hash->is_null()) return {}; if (!hash->is_string()) { - throw std::invalid_argument( - "trade transaction_hash must be a string when present"); + throw std::invalid_argument("trade transaction_hash must be a string or null"); } return hash->get(); } diff --git a/src/market_price.cpp b/src/market_price.cpp new file mode 100644 index 0000000..2bd0e82 --- /dev/null +++ b/src/market_price.cpp @@ -0,0 +1,280 @@ +#include "order_execution.hpp" +#include "polymarket/clob_client.hpp" +#include "polymarket/decimal_math.hpp" + +#include +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + namespace + { + constexpr int depth_decimals = 6; + constexpr std::uint64_t depth_scale = 1'000'000; + constexpr auto uint64_max = std::numeric_limits::max(); + + struct AccumulatedNotional + { + std::uint64_t units{0}; + std::uint64_t fractional_units{0}; + bool saturated{false}; + + bool covers(std::uint64_t target) const { return saturated || units >= target; } + }; + + struct AccumulatedSize + { + std::uint64_t units{0}; + bool saturated{false}; + + bool covers(std::uint64_t target) const { return saturated || units >= target; } + }; + + struct LevelUnits + { + std::uint64_t price{0}; + std::uint64_t size{0}; + }; + + struct DepthLimit + { + double price{0.0}; + bool fully_fillable{false}; + }; + + std::uint64_t exact_depth_units(double value) + { + return exact_decimal_to_scaled_uint64(value, depth_decimals); + } + + LevelUnits level_units(const PriceLevel &level) + { + if (!std::isfinite(level.price) || !std::isfinite(level.size) || level.price <= 0.0 || + level.price >= 1.0 || level.size < 0.0) + { + throw std::invalid_argument("orderbook level is invalid"); + } + const auto size_units = exact_depth_units(level.size); + const auto price_units = exact_depth_units(level.price); + if (price_units == 0 || price_units >= depth_scale) + throw std::invalid_argument("orderbook level is invalid"); + return {price_units, size_units}; + } + + void add_notional(AccumulatedNotional &total, std::uint64_t size_units, + std::uint64_t price_units) + { + if (total.saturated) return; + + const auto size_whole = size_units / depth_scale; + const auto size_fraction = size_units % depth_scale; + const auto fractional_product = size_fraction * price_units; + const auto fractional_whole = fractional_product / depth_scale; + const auto contribution_fraction = fractional_product % depth_scale; + if (price_units != 0 && size_whole > (uint64_max - fractional_whole) / price_units) + { + total.saturated = true; + return; + } + const auto contribution_units = size_whole * price_units + fractional_whole; + if (contribution_units > uint64_max - total.units) + { + total.saturated = true; + return; + } + total.units += contribution_units; + total.fractional_units += contribution_fraction; + if (total.fractional_units < depth_scale) return; + + total.fractional_units -= depth_scale; + if (total.units == uint64_max) + { + total.saturated = true; + return; + } + ++total.units; + } + + void add_size(AccumulatedSize &total, std::uint64_t size_units) + { + if (total.saturated) return; + if (size_units > uint64_max - total.units) + { + total.saturated = true; + return; + } + total.units += size_units; + } + + std::uint64_t checked_add(std::uint64_t left, std::uint64_t right) + { + if (right > uint64_max - left) + throw std::out_of_range("simulated fill exceeds uint64 range"); + return left + right; + } + + // Collateral for `share_units` at one price. + std::uint64_t collateral_for(std::uint64_t share_units, std::uint64_t price_units, + bool round_up) + { + const auto whole = share_units / depth_scale; + const auto fractional_product = (share_units % depth_scale) * price_units; + if (whole > (uint64_max - depth_scale) / price_units) + throw std::out_of_range("simulated fill exceeds uint64 range"); + auto units = whole * price_units + fractional_product / depth_scale; + if (round_up && fractional_product % depth_scale != 0) ++units; + return units; + } + + // Shares that `collateral_units` buys at one price, rounded down. + std::uint64_t shares_for(std::uint64_t collateral_units, std::uint64_t price_units) + { + const auto whole = collateral_units / price_units; + const auto remainder = collateral_units % price_units; + if (whole > (uint64_max - depth_scale) / depth_scale) + throw std::out_of_range("simulated fill exceeds uint64 range"); + return whole * depth_scale + remainder * depth_scale / price_units; + } + + // Calls visit(level) from the best price outward until it returns true. + // REST books list the best price last; OrderbookManager lists it first. + template + void visit_best_first(const std::vector &levels, OrderSide side, Visit &&visit) + { + // Finite prices keep the comparison a strict weak ordering. + for (const auto &level : levels) + { + if (!std::isfinite(level.price)) + throw std::invalid_argument("orderbook level is invalid"); + } + const auto better = [side](const PriceLevel &left, const PriceLevel &right) + { + return side == OrderSide::BUY ? left.price < right.price : left.price > right.price; + }; + if (std::is_sorted(levels.begin(), levels.end(), better)) + { + for (const auto &level : levels) + if (visit(level)) return; + return; + } + if (std::is_sorted(levels.rbegin(), levels.rend(), better)) + { + for (auto level = levels.rbegin(); level != levels.rend(); ++level) + if (visit(*level)) return; + return; + } + auto sorted = levels; + std::sort(sorted.begin(), sorted.end(), better); + for (const auto &level : sorted) + if (visit(level)) return; + } + + // Finds the first level whose cumulative depth covers the order that + // would be signed at that level's price, or the worst level otherwise. + DepthLimit find_depth_limit(const Orderbook &book, OrderSide side, double amount, + const std::string &tick_size) + { + if (!std::isfinite(amount) || amount <= 0.0) + { + throw std::invalid_argument("market order amount must be finite and positive"); + } + const auto &levels = side == OrderSide::BUY ? book.asks : book.bids; + if (levels.empty()) + { + throw std::runtime_error("no matching orders"); + } + + AccumulatedNotional available_notional; + AccumulatedSize available_size; + DepthLimit limit; + visit_best_first(levels, side, + [&](const PriceLevel &level) + { + const auto units = level_units(level); + const auto candidate_amounts = calculate_market_order_amounts( + side, amount, validate_order_price(level.price, tick_size)); + if (side == OrderSide::BUY) + add_notional(available_notional, units.size, units.price); + else + add_size(available_size, units.size); + limit.price = level.price; + limit.fully_fillable = + side == OrderSide::BUY + ? available_notional.covers(candidate_amounts.maker) + : available_size.covers(candidate_amounts.maker); + return limit.fully_fillable; + }); + return limit; + } + } // namespace + + double calculate_market_price(const Orderbook &book, OrderSide side, double amount, + OrderType order_type, const std::string &tick_size) + { + const auto limit = find_depth_limit(book, side, amount, tick_size); + if (!limit.fully_fillable && order_type == OrderType::FOK) + { + throw std::runtime_error("insufficient orderbook depth for FOK order"); + } + return limit.price; + } + + MarketPriceEstimate estimate_market_depth(const Orderbook &book, OrderSide side, double amount, + const std::string &tick_size) + { + const auto limit = find_depth_limit(book, side, amount, tick_size); + // Fill the amounts create_market_order would sign at the limit price: + // a collateral budget for BUY, shares for SELL. + const auto order = calculate_market_order_amounts( + side, amount, validate_order_price(limit.price, tick_size)); + auto remaining = order.maker; + + MarketPriceEstimate estimate; + estimate.price = limit.price; + estimate.fully_fillable = limit.fully_fillable; + const auto &levels = side == OrderSide::BUY ? book.asks : book.bids; + visit_best_first( + levels, side, + [&](const PriceLevel &level) + { + if (side == OrderSide::BUY ? level.price > limit.price : level.price < limit.price) + return true; + const auto units = level_units(level); + std::uint64_t shares = 0; + std::uint64_t collateral = 0; + bool partial = false; + if (side == OrderSide::BUY) + { + const auto level_cost = collateral_for(units.size, units.price, true); + partial = remaining < level_cost; + shares = partial ? shares_for(remaining, units.price) : units.size; + collateral = partial ? collateral_for(shares, units.price, true) : level_cost; + remaining -= collateral; + } + else + { + shares = std::min(remaining, units.size); + collateral = collateral_for(shares, units.price, false); + remaining -= shares; + } + if (shares != 0) + { + ++estimate.levels; + estimate.share_units = checked_add(estimate.share_units, shares); + estimate.collateral_units = checked_add(estimate.collateral_units, collateral); + } + return partial || remaining == 0; + }); + + if (estimate.share_units != 0) + { + estimate.average_price = static_cast(estimate.collateral_units) / + static_cast(estimate.share_units); + } + return estimate; + } +} // namespace polymarket::detail diff --git a/src/market_price_client.cpp b/src/market_price_client.cpp new file mode 100644 index 0000000..997d38c --- /dev/null +++ b/src/market_price_client.cpp @@ -0,0 +1,82 @@ +#include "order_execution.hpp" +#include "polymarket/clob_client.hpp" +#include "polymarket/market_price.hpp" + +#include +#include +#include +#include +#include + +namespace polymarket +{ + namespace + { + SdkError estimate_error(SdkErrorCode code, const std::string &message, + const std::string &endpoint = "", bool retryable = false) + { + return {code, message, endpoint, 0, "", "", retryable}; + } + + std::optional validate_estimate_request(OrderSide side, double amount, + OrderType order_type) + { + if (side != OrderSide::BUY && side != OrderSide::SELL) + return estimate_error(SdkErrorCode::InvalidArgument, + "order side must be BUY or SELL"); + if (order_type != OrderType::FAK && order_type != OrderType::FOK) + return estimate_error(SdkErrorCode::InvalidArgument, + "Market orders require FAK or FOK"); + if (!std::isfinite(amount) || amount <= 0.0) + return estimate_error(SdkErrorCode::InvalidArgument, + "market order amount must be finite and positive"); + return std::nullopt; + } + } // namespace + + Result estimate_market_price(const Orderbook &book, OrderSide side, + double amount, const std::string &tick_size, + OrderType order_type) + { + if (auto error = validate_estimate_request(side, amount, order_type)) + return Result::failure(std::move(*error)); + if ((side == OrderSide::BUY ? book.asks : book.bids).empty()) + return Result::failure( + estimate_error(SdkErrorCode::InsufficientLiquidity, "no matching orders")); + + try + { + auto estimate = detail::estimate_market_depth(book, side, amount, tick_size); + if (!estimate.fully_fillable && order_type == OrderType::FOK) + { + return Result::failure( + estimate_error(SdkErrorCode::InsufficientLiquidity, + "insufficient orderbook depth for FOK order")); + } + return Result::success(estimate); + } + catch (const std::logic_error &ex) + { + return Result::failure( + estimate_error(SdkErrorCode::InvalidArgument, ex.what())); + } + } + + Result ClobClient::estimate_market_price(const std::string &token_id, + OrderSide side, double amount, + OrderType order_type) + { + if (token_id.empty()) + return Result::failure( + estimate_error(SdkErrorCode::InvalidArgument, "token_id is required", "/book")); + if (auto error = validate_estimate_request(side, amount, order_type)) + return Result::failure(std::move(*error)); + + const auto tick_info = tick_size_result(token_id); + if (!tick_info) return Result::failure(tick_info.error()); + const auto book = order_book_result(token_id); + if (!book) return Result::failure(book.error()); + return polymarket::estimate_market_price(book.value(), side, amount, + tick_info.value().minimum_tick_size, order_type); + } +} // namespace polymarket diff --git a/src/order_execution.cpp b/src/order_execution.cpp index 55829c1..fc7be02 100644 --- a/src/order_execution.cpp +++ b/src/order_execution.cpp @@ -1,97 +1,9 @@ #include "order_execution.hpp" -#include "polymarket/decimal_math.hpp" -#include -#include -#include #include namespace polymarket::detail { - namespace - { - constexpr int depth_decimals = 6; - constexpr std::uint64_t depth_scale = 1'000'000; - - struct AccumulatedNotional - { - std::uint64_t units{0}; - std::uint64_t fractional_units{0}; - bool saturated{false}; - - bool covers(std::uint64_t target) const - { - return saturated || units >= target; - } - }; - - struct AccumulatedSize - { - std::uint64_t units{0}; - bool saturated{false}; - - bool covers(std::uint64_t target) const - { - return saturated || units >= target; - } - }; - - std::uint64_t exact_depth_units(double value) - { - return exact_decimal_to_scaled_uint64(value, depth_decimals); - } - - void add_notional(AccumulatedNotional &total, - std::uint64_t size_units, - std::uint64_t price_units) - { - if (total.saturated) return; - - const auto size_whole = size_units / depth_scale; - const auto size_fraction = size_units % depth_scale; - const auto fractional_product = size_fraction * price_units; - const auto fractional_whole = fractional_product / depth_scale; - const auto contribution_fraction = fractional_product % depth_scale; - constexpr auto maximum = std::numeric_limits::max(); - if (price_units != 0 && - size_whole > (maximum - fractional_whole) / price_units) - { - total.saturated = true; - return; - } - const auto contribution_units = - size_whole * price_units + fractional_whole; - if (contribution_units > maximum - total.units) - { - total.saturated = true; - return; - } - total.units += contribution_units; - total.fractional_units += contribution_fraction; - if (total.fractional_units < depth_scale) return; - - total.fractional_units -= depth_scale; - if (total.units == maximum) - { - total.saturated = true; - return; - } - ++total.units; - } - - void add_size(AccumulatedSize &total, std::uint64_t size_units) - { - if (total.saturated) return; - constexpr auto maximum = std::numeric_limits::max(); - if (size_units > maximum - total.units) - { - total.saturated = true; - return; - } - total.units += size_units; - } - } - std::string OrderExecutionContext::maker_address() const { if (funder_address.empty() && signature_type != SignatureType::EOA) @@ -110,60 +22,6 @@ namespace polymarket::detail return neg_risk ? neg_risk_exchange_address : standard_exchange_address; } - double calculate_market_price(const Orderbook &book, - OrderSide side, - double amount, - OrderType order_type, - const std::string &tick_size) - { - if (!std::isfinite(amount) || amount <= 0.0) - { - throw std::invalid_argument( - "market order amount must be finite and positive"); - } - const auto &levels = side == OrderSide::BUY ? book.asks : book.bids; - if (levels.empty()) - { - throw std::runtime_error("no matching orders"); - } - - AccumulatedNotional available_notional; - AccumulatedSize available_size; - for (auto level = levels.rbegin(); level != levels.rend(); ++level) - { - if (!std::isfinite(level->price) || !std::isfinite(level->size) || - level->price <= 0.0 || level->price >= 1.0 || level->size < 0.0) - { - throw std::invalid_argument("orderbook level is invalid"); - } - const auto size_units = exact_depth_units(level->size); - const auto price_units = exact_depth_units(level->price); - if (price_units == 0 || price_units >= depth_scale) - throw std::invalid_argument("orderbook level is invalid"); - const auto candidate_price = validate_order_price( - level->price, tick_size); - const auto candidate_amounts = calculate_market_order_amounts( - side, amount, candidate_price); - if (side == OrderSide::BUY) - add_notional(available_notional, size_units, price_units); - else - add_size(available_size, size_units); - if ((side == OrderSide::BUY && - available_notional.covers(candidate_amounts.maker)) || - (side == OrderSide::SELL && - available_size.covers(candidate_amounts.maker))) - { - return level->price; - } - } - - if (order_type == OrderType::FOK) - { - throw std::runtime_error("insufficient orderbook depth for FOK order"); - } - return levels.front().price; - } - nlohmann::json signed_order_json(const SignedOrder &order) { return { diff --git a/src/order_execution.hpp b/src/order_execution.hpp index 39715e1..41694cb 100644 --- a/src/order_execution.hpp +++ b/src/order_execution.hpp @@ -64,6 +64,11 @@ namespace polymarket::detail double amount, OrderType order_type, const std::string &tick_size); + // Same walk as calculate_market_price, plus the simulated fill. Throws + // std::logic_error subclasses for invalid input and std::runtime_error + // for an empty side; never rejects a shallow book. + MarketPriceEstimate estimate_market_depth(const Orderbook &book, OrderSide side, double amount, + const std::string &tick_size); nlohmann::json signed_order_json(const SignedOrder &order); nlohmann::json order_payload_json(const SignedOrder &order, const std::string &owner, diff --git a/src/poll_deadline.hpp b/src/poll_deadline.hpp new file mode 100644 index 0000000..c9807dd --- /dev/null +++ b/src/poll_deadline.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace polymarket::detail +{ + inline std::chrono::milliseconds remaining(std::chrono::steady_clock::time_point deadline) + { + return std::max(std::chrono::milliseconds::zero(), + std::chrono::ceil( + deadline - std::chrono::steady_clock::now())); + } + + // Sleeps until the next poll, never past the deadline, so a timeout + // shorter than the poll interval still gets a final poll at the + // deadline. Returns false once the deadline has passed. + inline bool sleep_before_next_poll(std::chrono::steady_clock::time_point deadline, + std::chrono::milliseconds poll_interval) + { + if (std::chrono::steady_clock::now() >= deadline) return false; + std::this_thread::sleep_for(std::min(poll_interval, remaining(deadline))); + return true; + } +} // namespace polymarket::detail diff --git a/src/rate_limit_internal.hpp b/src/rate_limit_internal.hpp index 3ce1664..8ede48c 100644 --- a/src/rate_limit_internal.hpp +++ b/src/rate_limit_internal.hpp @@ -30,7 +30,9 @@ namespace polymarket::detail // the server-requested delay between attempts. attempt() must rebuild any // signed headers so each retry carries a fresh timestamp. template - HttpResponse retry_rate_limited(const std::optional &policy, Attempt &&attempt) + HttpResponse + retry_rate_limited(const std::optional &policy, Attempt &&attempt, + std::optional deadline = std::nullopt) { for (int retry = 0;; ++retry) { @@ -38,6 +40,7 @@ namespace polymarket::detail if (response.status_code != 429 || !policy || retry >= policy->retries) return response; const auto delay = rate_limit_delay(response); if (delay > policy->max_delay) return response; + if (deadline && std::chrono::steady_clock::now() + delay > *deadline) return response; std::this_thread::sleep_for(delay); } } diff --git a/src/sdk_error.cpp b/src/sdk_error.cpp index 46a621d..fb3f2ae 100644 --- a/src/sdk_error.cpp +++ b/src/sdk_error.cpp @@ -179,6 +179,12 @@ namespace polymarket return "signing"; case SdkErrorCode::InvalidArgument: return "invalid_argument"; + case SdkErrorCode::InsufficientLiquidity: + return "insufficient_liquidity"; + case SdkErrorCode::Timeout: + return "timeout"; + case SdkErrorCode::TransactionFailed: + return "transaction_failed"; } return "unknown"; } diff --git a/src/transaction_handle.cpp b/src/transaction_handle.cpp index 016ed28..21759aa 100644 --- a/src/transaction_handle.cpp +++ b/src/transaction_handle.cpp @@ -1,30 +1,8 @@ #include "transaction_waiters.hpp" -#include -#include +#include "poll_deadline.hpp" namespace polymarket { - namespace - { - std::chrono::milliseconds remaining(std::chrono::steady_clock::time_point deadline) - { - return std::max(std::chrono::milliseconds::zero(), - std::chrono::ceil(deadline - std::chrono::steady_clock::now())); - } - - // Sleeps until the next poll, never past the deadline, so a timeout - // shorter than the poll interval still gets a final poll at the - // deadline. Returns false once the deadline has passed. - bool sleep_before_next_poll(std::chrono::steady_clock::time_point deadline, - std::chrono::milliseconds poll_interval) - { - if (std::chrono::steady_clock::now() >= deadline) - return false; - std::this_thread::sleep_for(std::min(poll_interval, remaining(deadline))); - return true; - } - } // namespace - namespace detail { TransactionOutcome wait_for_receipt(EvmJsonRpcHttpClient &rpc, const std::string &transaction_hash, diff --git a/tests/order_placement_test_support.hpp b/tests/order_placement_test_support.hpp new file mode 100644 index 0000000..d3885c4 --- /dev/null +++ b/tests/order_placement_test_support.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include "check_support.hpp" +#include "polymarket/clob_client.hpp" + +#include +#include +#include + +// Metadata failures shared by the limit and market placement tests. +namespace order_placement_test +{ + enum class MetadataBranch : std::uint8_t + { + TickRefresh, + ExplicitTick, + NegRisk + }; + + struct MetadataFailure + { + int status; + const char *body; + const char *name; + }; + + inline const std::vector metadata_branches = { + MetadataBranch::TickRefresh, MetadataBranch::ExplicitTick, MetadataBranch::NegRisk}; + inline const std::vector metadata_failures = { + {404, R"({"error":"market not found"})", "404"}, {200, "not json", "malformed 200"}}; + + inline std::string branch_name(MetadataBranch branch) + { + switch (branch) + { + case MetadataBranch::TickRefresh: + return "tick refresh"; + case MetadataBranch::ExplicitTick: + return "explicit tick"; + case MetadataBranch::NegRisk: + return "neg-risk"; + } + return "unknown"; + } + + inline std::string failed_endpoint(MetadataBranch branch) + { + return branch == MetadataBranch::NegRisk ? "/neg-risk" : "/tick-size"; + } + + inline void check_preserved_error(const polymarket::Result &result, + MetadataBranch branch, const MetadataFailure &failure, + const std::string &helper) + { + using polymarket::SdkErrorCode; + const auto label = helper + " " + branch_name(branch) + " " + failure.name; + if (result) + { + check_support::check(false, label + ": the order must not be placed"); + return; + } + const auto &error = result.error(); + check_support::check(error.endpoint == failed_endpoint(branch), + label + ": the error must name the failed lookup"); + if (failure.status == 404) + check_support::check( + error.code == SdkErrorCode::ApiResponse && error.http_status == 404 && + !error.retryable && + error.response_body_excerpt.find("market not found") != std::string::npos, + label + ": a 404 must stay a non-retryable API rejection with its body"); + else + check_support::check(error.code == SdkErrorCode::Parse && !error.retryable, + label + ": a malformed body must stay a parse failure"); + } +} // namespace order_placement_test diff --git a/tests/test_market_order_placement.cpp b/tests/test_market_order_placement.cpp new file mode 100644 index 0000000..d235cb9 --- /dev/null +++ b/tests/test_market_order_placement.cpp @@ -0,0 +1,250 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "check_support.hpp" +#include "order_placement_test_support.hpp" + +#include + +#include +#include + +using namespace polymarket; +using check_support::check; + +namespace +{ + constexpr const char *accepted_order = + R"({"success":true,"orderID":"order-1","status":"matched"})"; + // REST order: best ask last. + constexpr const char *ask_book = + R"({"asset_id":"123","bids":[],"asks":[{"price":"0.70","size":"1"},{"price":"0.60","size":"3"},{"price":"0.50","size":"2"}]})"; + + PlaceMarketOrderParams market_order(OrderSide side, double amount) + { + PlaceMarketOrderParams params; + params.token_id = "123"; + params.side = side; + params.amount = amount; + return params; + } + + std::vector targets(const std::vector &requests) + { + std::vector result; + result.reserve(requests.size()); + for (const auto &request : requests) + result.push_back(request.method + " " + request.target); + return result; + } + + nlohmann::json order_body(const clob_test::Request &request) + { + return nlohmann::json::parse(request.body); + } + + void test_book_walk_prices_unbounded_orders() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(ask_book); + server.enqueue(R"({"neg_risk":false})"); + server.enqueue(accepted_order); + const auto placed = client.place_market_order(market_order(OrderSide::BUY, 2.0)); + + auto fok = market_order(OrderSide::BUY, 9.0); + fok.order_type = OrderType::FOK; + server.enqueue(ask_book); + const auto shallow = client.place_market_order(fok); + const auto requests = server.requests(); + + check(placed.ok(), "a BUY the book can fill must be posted"); + check(!shallow && shallow.error().code == SdkErrorCode::InsufficientLiquidity, + "a FOK the book cannot fill must fail before signing"); + check(targets(requests) == + std::vector{"GET /tick-size?token_id=123", "GET /book?token_id=123", + "GET /neg-risk?token_id=123", "POST /order", + "GET /book?token_id=123"}, + "unbounded orders must fetch tick and book, and never post a shallow FOK"); + if (requests.size() != 5) return; + const auto body = order_body(requests[3]); + // Signed at the 0.60 level: the same ratio create_market_order produces. + check(body["orderType"] == "FAK" && body["order"]["makerAmount"] == "1999998" && + body["order"]["takerAmount"] == "3333330", + "unbounded BUY must post FAK at the walked price"); + } + + void test_worst_price_skips_the_book() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + auto buy = market_order(OrderSide::BUY, 2.0); + buy.worst_price = 0.65; + buy.order_type = OrderType::FOK; + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"neg_risk":false})"); + server.enqueue(accepted_order); + const auto bounded_buy = client.place_market_order(buy); + + auto sell = market_order(OrderSide::SELL, 5.0); + sell.worst_price = 0.4; + server.enqueue(accepted_order); + const auto bounded_sell = client.place_market_order(sell); + const auto requests = server.requests(); + + check(bounded_buy && bounded_sell, "bounded orders must be posted"); + check(targets(requests) == std::vector{"GET /tick-size?token_id=123", + "GET /neg-risk?token_id=123", + "POST /order", "POST /order"}, + "bounded orders must not fetch the book"); + if (requests.size() != 4) return; + const auto buy_body = order_body(requests[2]); + const auto maker = std::stoull(buy_body["order"]["makerAmount"].get()); + const auto taker = std::stoull(buy_body["order"]["takerAmount"].get()); + check(buy_body["orderType"] == "FOK" && maker == 1'999'998 && taker == 3'076'920 && + maker * 100 == taker * 65, + "bounded BUY must sign exactly at worst_price within the budget"); + const auto sell_body = order_body(requests[3]); + check(sell_body["orderType"] == "FAK" && sell_body["order"]["makerAmount"] == "5000000" && + sell_body["order"]["takerAmount"] == "2000000", + "bounded SELL must sign the shares at worst_price"); + } + + void test_stale_tick_refreshes_once() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + auto bounded = market_order(OrderSide::BUY, 1.0); + bounded.worst_price = 0.965; + bounded.neg_risk = false; + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"minimum_tick_size":"0.001"})"); + server.enqueue(accepted_order); + const auto refreshed_bound = client.place_market_order(bounded); + + // The book already rests on a 0.0001 grid the fetched 0.01 tick cannot represent. + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"asset_id":"456","bids":[],"asks":[{"price":"0.9655","size":"10"}]})"); + server.enqueue(R"({"minimum_tick_size":"0.0001"})"); + auto unbounded = market_order(OrderSide::BUY, 1.0); + unbounded.token_id = "456"; + unbounded.neg_risk = false; + server.enqueue(accepted_order); + const auto refreshed_walk = client.place_market_order(unbounded); + const auto requests = server.requests(); + + check(refreshed_bound.ok(), "a bound on the refreshed grid must be accepted"); + check(refreshed_walk.ok(), "a book walk on the refreshed grid must be accepted"); + check(targets(requests) == + std::vector{"GET /tick-size?token_id=123", + "GET /tick-size?token_id=123", "POST /order", + "GET /tick-size?token_id=456", "GET /book?token_id=456", + "GET /tick-size?token_id=456", "POST /order"}, + "a stale tick must be refetched once before signing"); + } + + void test_preserves_metadata_and_book_errors() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"error":"No orderbook exists for the requested token id"})", 404); + const auto missing_book = client.place_market_order(market_order(OrderSide::BUY, 2.0)); + server.enqueue("not json"); + const auto malformed_book = client.place_market_order(market_order(OrderSide::BUY, 2.0)); + auto other = market_order(OrderSide::BUY, 2.0); + other.token_id = "456"; + server.enqueue("not json"); + const auto malformed_tick = client.place_market_order(other); + + check(!missing_book && missing_book.error().code == SdkErrorCode::ApiResponse && + missing_book.error().http_status == 404 && !missing_book.error().retryable && + !missing_book.error().response_body_excerpt.empty(), + "a book 404 must surface as a non-retryable API rejection"); + check(!malformed_book && malformed_book.error().code == SdkErrorCode::Parse && + !malformed_book.error().retryable, + "a malformed book must surface as a parse failure"); + check(!malformed_tick && malformed_tick.error().code == SdkErrorCode::Parse && + malformed_tick.error().endpoint == "/tick-size", + "a malformed tick size must surface as a parse failure"); + check(server.requests().size() == 4, "lookup failures must stop before posting"); + } + + void test_preserves_metadata_errors_while_signing() + { + using namespace order_placement_test; + for (const auto branch : metadata_branches) + for (const auto &failure : metadata_failures) + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + auto params = market_order(OrderSide::BUY, 1.0); + params.worst_price = 0.5; + params.neg_risk = false; + int warmup_posts = 0; + if (branch == MetadataBranch::TickRefresh) + { + // Caches 0.01, so a 0.965 bound forces a refetch inside order creation. + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(accepted_order); + (void)client.place_market_order(params); + params.worst_price = 0.965; + warmup_posts = 1; + } + else if (branch == MetadataBranch::ExplicitTick) + { + params.tick_size = "0.01"; + } + else + { + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + params.neg_risk.reset(); + } + server.enqueue(failure.body, failure.status); + const auto placed = client.place_market_order(params); + + check_preserved_error(placed, branch, failure, "place_market_order"); + int posts = 0; + for (const auto &request : server.requests()) + posts += request.target == "/order"; + check(posts == warmup_posts, "a failed metadata lookup must not post the order"); + } + } + + void test_rejects_before_any_request() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + const auto invalid = [](const Result &result) + { return !result && result.error().code == SdkErrorCode::InvalidArgument; }; + + auto gtc = market_order(OrderSide::BUY, 1.0); + gtc.order_type = OrderType::GTC; + check(invalid(client.place_market_order(gtc)), "resting order types must be rejected"); + check(invalid(client.place_market_order(market_order(OrderSide::SELL, 0.0))), + "zero amount must be rejected"); + auto bad_bound = market_order(OrderSide::BUY, 1.0); + bad_bound.worst_price = 1.0; + check(invalid(client.place_market_order(bad_bound)), "worst price 1 must be rejected"); + auto no_token = market_order(OrderSide::BUY, 1.0); + no_token.token_id.clear(); + check(invalid(client.place_market_order(no_token)), "empty token_id must be rejected"); + + ClobClient public_client(server.url(), 137); + const auto unauthenticated = + public_client.place_market_order(market_order(OrderSide::BUY, 1.0)); + check(!unauthenticated && unauthenticated.error().code == SdkErrorCode::Auth, + "a public client must not place orders"); + check(server.requests().empty(), "invalid orders must not reach the server"); + } +} // namespace + +int main() +{ + test_book_walk_prices_unbounded_orders(); + test_worst_price_skips_the_book(); + test_stale_tick_refreshes_once(); + test_preserves_metadata_and_book_errors(); + test_preserves_metadata_errors_while_signing(); + test_rejects_before_any_request(); + return check_support::finish("test_market_order_placement"); +} diff --git a/tests/test_market_price.cpp b/tests/test_market_price.cpp new file mode 100644 index 0000000..a168672 --- /dev/null +++ b/tests/test_market_price.cpp @@ -0,0 +1,239 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "check_support.hpp" +#include "order_execution.hpp" + +#include "polymarket/market_price.hpp" + +#include +#include +#include +#include +#include + +using namespace polymarket; +using check_support::check; + +namespace +{ + // Best price first, as OrderbookManager stores books. + Orderbook stream_book() + { + Orderbook book; + book.asset_id = "123"; + book.asks = {{0.5, 2.0}, {0.6, 3.0}, {0.7, 1.0}}; + book.bids = {{0.5, 2.0}, {0.4, 3.0}, {0.3, 1.0}}; + return book; + } + + // Best price last, as the REST /book endpoint returns books. + Orderbook rest_book() + { + auto book = stream_book(); + std::reverse(book.asks.begin(), book.asks.end()); + std::reverse(book.bids.begin(), book.bids.end()); + return book; + } + + Orderbook shuffled_book() + { + auto book = stream_book(); + std::swap(book.asks[0], book.asks[1]); + std::swap(book.bids[1], book.bids[2]); + return book; + } + + bool same_estimate(const MarketPriceEstimate &left, const MarketPriceEstimate &right) + { + return left.price == right.price && left.average_price == right.average_price && + left.share_units == right.share_units && + left.collateral_units == right.collateral_units && left.levels == right.levels && + left.fully_fillable == right.fully_fillable; + } + + void test_buy_simulates_fill_across_levels() + { + const auto estimate = estimate_market_price(stream_book(), OrderSide::BUY, 2.0, "0.1"); + check(estimate.ok(), "BUY estimate must succeed"); + if (!estimate) return; + const auto &value = estimate.value(); + // The signed budget at 0.6 is 1999998; 0.5 takes 1000000 for 2 shares and + // the rest buys 1.666663 shares at 0.6 for 999998. + check(value.price == 0.6, "BUY limit must be the first level that covers the budget"); + check(value.share_units == 3'666'663, "BUY shares mismatch"); + check(value.collateral_units == 1'999'998, "BUY collateral mismatch"); + check(value.levels == 2, "BUY must touch two levels"); + check(value.fully_fillable, "BUY must be fully fillable"); + check(std::abs(value.average_price - 1'999'998.0 / 3'666'663.0) < 1e-15, + "BUY average price mismatch"); + } + + void test_sell_simulates_fill_across_levels() + { + const auto estimate = estimate_market_price(stream_book(), OrderSide::SELL, 4.0, "0.1"); + check(estimate.ok(), "SELL estimate must succeed"); + if (!estimate) return; + const auto &value = estimate.value(); + check(value.price == 0.4, "SELL limit must be the first level that covers the shares"); + check(value.share_units == 4'000'000, "SELL shares mismatch"); + check(value.collateral_units == 1'800'000, "SELL collateral mismatch"); + check(value.levels == 2, "SELL must touch two levels"); + check(value.average_price == 0.45, "SELL average price mismatch"); + } + + void test_book_order_does_not_change_estimate() + { + for (const auto side : {OrderSide::BUY, OrderSide::SELL}) + { + for (const double amount : {0.5, 2.0, 4.0, 9.0}) + { + const auto stream = + estimate_market_price(stream_book(), side, amount, "0.1", OrderType::FAK); + const auto rest = + estimate_market_price(rest_book(), side, amount, "0.1", OrderType::FAK); + const auto shuffled = + estimate_market_price(shuffled_book(), side, amount, "0.1", OrderType::FAK); + const auto label = + std::string(side == OrderSide::BUY ? "BUY " : "SELL ") + std::to_string(amount); + check(stream && rest && shuffled, label + " estimates must succeed"); + if (!stream || !rest || !shuffled) continue; + check(same_estimate(stream.value(), rest.value()) && + same_estimate(stream.value(), shuffled.value()), + label + " must not depend on level order"); + check(stream.value().price == detail::calculate_market_price(stream_book(), side, + amount, OrderType::FAK, + "0.1"), + label + " must match the price create_market_order signs"); + } + } + // A best-first book used to be walked from its worst level. + check(detail::calculate_market_price(stream_book(), OrderSide::BUY, 0.5, OrderType::FOK, + "0.1") == 0.5, + "best-first asks must be walked from the lowest price"); + check(detail::calculate_market_price(stream_book(), OrderSide::SELL, 1.0, OrderType::FOK, + "0.1") == 0.5, + "best-first bids must be walked from the highest price"); + } + + void test_shallow_book() + { + const auto fak = + estimate_market_price(stream_book(), OrderSide::BUY, 9.0, "0.1", OrderType::FAK); + check(fak.ok(), "FAK must estimate a partial fill"); + if (fak) + { + check(!fak.value().fully_fillable, "FAK partial fill must be reported"); + check(fak.value().price == 0.7, "FAK limit must be the worst level"); + check(fak.value().share_units == 6'000'000 && + fak.value().collateral_units == 3'500'000 && fak.value().levels == 3, + "FAK must take every level"); + } + + const auto fok = estimate_market_price(stream_book(), OrderSide::BUY, 9.0, "0.1"); + check(!fok && fok.error().code == SdkErrorCode::InsufficientLiquidity, + "FOK must reject a shallow book"); + + Orderbook empty; + const auto no_asks = + estimate_market_price(empty, OrderSide::BUY, 1.0, "0.1", OrderType::FAK); + check(!no_asks && no_asks.error().code == SdkErrorCode::InsufficientLiquidity, + "an empty side must be insufficient liquidity"); + } + + void test_invalid_input() + { + const auto invalid = [](const Result &result) + { return !result && result.error().code == SdkErrorCode::InvalidArgument; }; + const auto book = stream_book(); + check(invalid(estimate_market_price(book, OrderSide::BUY, 1.0, "0.1", OrderType::GTC)), + "limit order types must be rejected"); + check(invalid(estimate_market_price(book, OrderSide::BUY, 0.0, "0.1")), + "zero amount must be rejected"); + const auto bad_side = estimate_market_price(book, static_cast(2), 1.0, "0.1"); + check(invalid(bad_side) && bad_side.error().message == "order side must be BUY or SELL", + "an unknown side must be rejected"); + check(invalid(estimate_market_price(book, OrderSide::BUY, + std::numeric_limits::quiet_NaN(), "0.1")), + "NaN amount must be rejected"); + check(invalid(estimate_market_price(book, OrderSide::BUY, 1.0, "0.3")), + "unsupported tick size must be rejected"); + + auto off_tick = book; + off_tick.asks[0].price = 0.55; + check(invalid(estimate_market_price(off_tick, OrderSide::BUY, 0.5, "0.1")), + "a level off the tick grid must be rejected"); + + auto nan_price = shuffled_book(); + nan_price.asks[2].price = std::numeric_limits::quiet_NaN(); + check(invalid(estimate_market_price(nan_price, OrderSide::BUY, 0.5, "0.1")), + "a NaN price must be rejected before ordering levels"); + } + + void test_client_fetches_tick_and_book() + { + clob_test::LocalServer server; + ClobClient client(server.url(), 137); + server.enqueue(R"({"minimum_tick_size":"0.1"})"); + server.enqueue( + R"({"asset_id":"123","bids":[],"asks":[{"price":"0.7","size":"1"},{"price":"0.6","size":"3"},{"price":"0.5","size":"2"}]})"); + const auto estimate = client.estimate_market_price("123", OrderSide::BUY, 2.0); + server.enqueue(R"({"error":"no tick"})", 500); + const auto missing_tick = client.estimate_market_price("456", OrderSide::BUY, 2.0); + const auto rejected = + client.estimate_market_price("123", OrderSide::BUY, 2.0, OrderType::GTD); + const auto bad_side = client.estimate_market_price("123", static_cast(2), 2.0); + const auto requests = server.requests(); + + check(estimate && estimate.value().price == 0.6 && + estimate.value().share_units == 3'666'663, + "client estimate must walk the REST book"); + check(!missing_tick && missing_tick.error().code == SdkErrorCode::ApiResponse && + missing_tick.error().http_status == 500 && missing_tick.error().retryable && + missing_tick.error().endpoint == "/tick-size", + "a tick size server error must keep its HTTP status"); + check(!rejected && rejected.error().code == SdkErrorCode::InvalidArgument, + "client must reject limit order types"); + check(!bad_side && bad_side.error().code == SdkErrorCode::InvalidArgument, + "client must reject an unknown side"); + check(requests.size() == 3 && requests[0].target == "/tick-size?token_id=123" && + requests[1].target == "/book?token_id=123" && + requests[2].target == "/tick-size?token_id=456", + "client must fetch tick then book, stop on tick failure, and validate before I/O"); + } + + void test_client_preserves_book_errors() + { + clob_test::LocalServer server; + ClobClient client(server.url(), 137); + server.enqueue(R"({"minimum_tick_size":"0.1"})"); + server.enqueue(R"({"error":"No orderbook exists for the requested token id"})", 404); + const auto missing = client.estimate_market_price("123", OrderSide::BUY, 2.0); + server.enqueue(R"({"asset_id":"123","bids":[)"); + const auto malformed = client.estimate_market_price("123", OrderSide::BUY, 2.0); + server.enqueue(R"({"minimum_tick_size":null})"); + const auto no_tick = client.estimate_market_price("456", OrderSide::BUY, 2.0); + + check(!missing && missing.error().code == SdkErrorCode::ApiResponse && + missing.error().http_status == 404 && !missing.error().retryable && + missing.error().endpoint == "/book" && + missing.error().response_body_excerpt.find("No orderbook") != std::string::npos, + "a book 404 must surface as a non-retryable API rejection"); + check(!malformed && malformed.error().code == SdkErrorCode::Parse && + !malformed.error().retryable && malformed.error().endpoint == "/book", + "a malformed book must surface as a parse failure"); + check(!no_tick && no_tick.error().code == SdkErrorCode::Parse && + no_tick.error().endpoint == "/tick-size", + "a tick size response without a value must be a parse failure"); + } +} // namespace + +int main() +{ + test_buy_simulates_fill_across_levels(); + test_sell_simulates_fill_across_levels(); + test_book_order_does_not_change_estimate(); + test_shallow_book(); + test_invalid_input(); + test_client_fetches_tick_and_book(); + test_client_preserves_book_errors(); + return check_support::finish("test_market_price"); +} diff --git a/tests/test_order_placement.cpp b/tests/test_order_placement.cpp new file mode 100644 index 0000000..ccd9642 --- /dev/null +++ b/tests/test_order_placement.cpp @@ -0,0 +1,220 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "check_support.hpp" +#include "order_placement_test_support.hpp" + +#include + +#include +#include +#include +#include + +using namespace polymarket; +using check_support::check; + +namespace +{ + constexpr const char *accepted_order = + R"({"success":true,"orderID":"order-1","status":"live"})"; + + std::uint64_t unix_now_seconds() + { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); + } + + PlaceLimitOrderParams limit_order(double price) + { + PlaceLimitOrderParams params; + params.token_id = "123"; + params.price = price; + params.size = 10.0; + params.side = OrderSide::BUY; + return params; + } + + std::vector targets(const std::vector &requests) + { + std::vector result; + result.reserve(requests.size()); + for (const auto &request : requests) + result.push_back(request.method + " " + request.target); + return result; + } + + void test_gtc_and_gtd_payloads() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"neg_risk":false})"); + server.enqueue(accepted_order); + const auto gtc = client.place_limit_order(limit_order(0.5)); + + auto gtd_params = limit_order(0.5); + gtd_params.post_only = true; + gtd_params.expiration = unix_now_seconds() + 3600; + server.enqueue(accepted_order); + const auto gtd = client.place_limit_order(gtd_params); + const auto requests = server.requests(); + + check(gtc && gtd, "limit orders must be accepted"); + check(targets(requests) == std::vector{"GET /tick-size?token_id=123", + "GET /neg-risk?token_id=123", + "POST /order", "POST /order"}, + "limit orders must resolve metadata once and post each order"); + if (requests.size() != 4) return; + + const auto gtc_body = nlohmann::json::parse(requests[2].body); + check(gtc_body["orderType"] == "GTC" && gtc_body["postOnly"] == false && + gtc_body["order"]["expiration"] == "0", + "an order without expiration must post as GTC with expiration 0"); + check(gtc_body["order"]["makerAmount"] == "5000000" && + gtc_body["order"]["takerAmount"] == "10000000", + "limit BUY amounts mismatch"); + + const auto gtd_body = nlohmann::json::parse(requests[3].body); + check(gtd_body["orderType"] == "GTD" && gtd_body["postOnly"] == true && + gtd_body["order"]["expiration"] == std::to_string(*gtd_params.expiration), + "an order with expiration must post as post-only GTD with that expiration"); + } + + void test_off_grid_price_refreshes_tick_once() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(R"({"neg_risk":false})"); + server.enqueue(accepted_order); + (void)client.place_limit_order(limit_order(0.5)); + + // The market moved to a 0.001 tick after the cached 0.01 was fetched. + server.enqueue(R"({"minimum_tick_size":"0.001"})"); + server.enqueue(R"({"neg_risk":false})"); + server.enqueue(accepted_order); + const auto refreshed = client.place_limit_order(limit_order(0.965)); + + // Still off the refreshed grid: fail without posting. + server.enqueue(R"({"minimum_tick_size":"0.001"})"); + const auto off_grid = client.place_limit_order(limit_order(0.9655)); + const auto requests = server.requests(); + + check(refreshed.ok(), "a price on the refreshed grid must be accepted"); + check(!off_grid && off_grid.error().code == SdkErrorCode::InvalidArgument, + "a price off the refreshed grid must be rejected"); + check(targets(requests) == + std::vector{ + "GET /tick-size?token_id=123", "GET /neg-risk?token_id=123", "POST /order", + "GET /tick-size?token_id=123", "GET /neg-risk?token_id=123", "POST /order", + "GET /tick-size?token_id=123"}, + "off-grid prices must refetch metadata once and never post"); + if (requests.size() != 7) return; + const auto body = nlohmann::json::parse(requests[5].body); + check(body["order"]["makerAmount"] == "9650000", + "refreshed order must be signed on the finer grid"); + } + + void test_preserves_metadata_errors_while_signing() + { + using namespace order_placement_test; + for (const auto branch : metadata_branches) + for (const auto &failure : metadata_failures) + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + auto params = limit_order(0.5); + params.neg_risk = false; + int warmup_posts = 0; + if (branch == MetadataBranch::TickRefresh) + { + // Caches 0.01, so 0.965 forces a refetch inside order creation. + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue(accepted_order); + (void)client.place_limit_order(params); + params.price = 0.965; + warmup_posts = 1; + } + else if (branch == MetadataBranch::ExplicitTick) + { + params.tick_size = "0.01"; + } + else + { + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + params.neg_risk.reset(); + } + server.enqueue(failure.body, failure.status); + const auto placed = client.place_limit_order(params); + + check_preserved_error(placed, branch, failure, "place_limit_order"); + int posts = 0; + for (const auto &request : server.requests()) + posts += request.target == "/order"; + check(posts == warmup_posts, "a failed metadata lookup must not post the order"); + } + } + + void test_rejects_before_any_request() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + const auto invalid = [](const Result &result) + { return !result && result.error().code == SdkErrorCode::InvalidArgument; }; + + auto soon = limit_order(0.5); + soon.expiration = unix_now_seconds() + 60; + check(invalid(client.place_limit_order(soon)), + "an expiration under 180 s ahead must be rejected"); + auto zero_expiration = limit_order(0.5); + zero_expiration.expiration = 0; + check(invalid(client.place_limit_order(zero_expiration)), + "a GTD expiration of 0 must be rejected"); + check(invalid(client.place_limit_order(limit_order(1.0))), "price 1 must be rejected"); + auto no_size = limit_order(0.5); + no_size.size = 0.0; + check(invalid(client.place_limit_order(no_size)), "zero size must be rejected"); + auto no_token = limit_order(0.5); + no_token.token_id.clear(); + check(invalid(client.place_limit_order(no_token)), "empty token_id must be rejected"); + + ClobClient public_client(server.url(), 137); + const auto unauthenticated = public_client.place_limit_order(limit_order(0.5)); + check(!unauthenticated && unauthenticated.error().code == SdkErrorCode::Auth, + "a public client must not place orders"); + check(server.requests().empty(), "invalid orders must not reach the server"); + } + + void test_reports_server_rejection() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + auto params = limit_order(0.5); + params.neg_risk = false; + server.enqueue(R"({"minimum_tick_size":"0.01"})"); + server.enqueue( + R"({"success":false,"orderID":"","status":"","errorMsg":"not enough balance"})"); + const auto rejected = client.place_limit_order(params); + server.enqueue(R"({"error":"no tick"})", 500); + params.token_id = "456"; + const auto missing_tick = client.place_limit_order(params); + + check(!rejected && rejected.error().code == SdkErrorCode::ApiResponse && + rejected.error().message == "not enough balance", + "a rejected order must surface the server message"); + check(!missing_tick && missing_tick.error().code == SdkErrorCode::ApiResponse && + missing_tick.error().http_status == 500 && missing_tick.error().retryable, + "a tick size server error must keep its HTTP status"); + check(server.requests().size() == 3, "tick failure must stop before posting"); + } +} // namespace + +int main() +{ + test_gtc_and_gtd_payloads(); + test_off_grid_price_refreshes_tick_once(); + test_rejects_before_any_request(); + test_reports_server_rejection(); + test_preserves_metadata_errors_while_signing(); + return check_support::finish("test_order_placement"); +} diff --git a/tests/test_order_settlement.cpp b/tests/test_order_settlement.cpp new file mode 100644 index 0000000..2ae7c56 --- /dev/null +++ b/tests/test_order_settlement.cpp @@ -0,0 +1,280 @@ +#include "../src/clob_client_test_fixture.hpp" +#include "check_support.hpp" + +#include + +#include +#include +#include + +using namespace polymarket; +using check_support::check; +using namespace std::chrono_literals; + +namespace +{ + std::string trade_page(const std::string &id, const std::string &status, + const nlohmann::json &hash = "") + { + nlohmann::json trade = { + {"id", id}, + {"taker_order_id", "order-1"}, + {"market", "0x000000000000000000000000000000000000000000000000000000006d61726b"}, + {"asset_id", "123"}, + {"side", "BUY"}, + {"size", "10"}, + {"fee_rate_bps", "0"}, + {"price", "0.5"}, + {"status", status}, + {"match_time", "1705322096"}, + {"last_update", "1705322130"}, + {"outcome", "YES"}, + {"bucket_index", 0}, + {"owner", "test-key"}, + {"maker_address", "0x2222222222222222222222222222222222222222"}, + {"transaction_hash", hash}, + {"trader_side", "TAKER"}, + }; + return nlohmann::json{{"data", nlohmann::json::array({trade})}, {"next_cursor", "LTE="}} + .dump(); + } + + constexpr const char *empty_page = R"({"data":[],"next_cursor":"LTE="})"; + + OrderResponse matched_order(std::vector trade_ids) + { + OrderResponse order; + order.success = true; + order.order_id = "order-1"; + order.status = "matched"; + order.trade_ids = std::move(trade_ids); + return order; + } + + std::vector targets(const std::vector &requests) + { + std::vector result; + result.reserve(requests.size()); + for (const auto &request : requests) + result.push_back(request.target); + return result; + } + + void test_orders_without_fills_do_not_poll() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + const auto resting = client.wait_for_order_fill_settlement(matched_order({})); + auto with_hashes = matched_order({}); + with_hashes.transaction_hashes = {"0xaa", "0xbb", "0xaa"}; + const auto hashed = client.wait_for_order_fill_settlement(with_hashes); + + check(resting && resting.value().transaction_hashes.empty() && + resting.value().trades.empty(), + "an order with no fills must settle immediately with nothing"); + check(hashed && + hashed.value().transaction_hashes == std::vector{"0xaa", "0xbb"}, + "an order with no trade IDs must return its unique hashes"); + check(server.requests().empty(), "orders without trade IDs must not poll"); + } + + void test_polls_until_every_fill_confirms() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("t1", "MATCHED")); + server.enqueue(trade_page("t2", "CONFIRMED", "0x22")); + server.enqueue(empty_page); + server.enqueue(trade_page("t1", "CONFIRMED", "0x11")); + const auto settled = + client.wait_for_order_fill_settlement(matched_order({"t1", "t2", "t1"}), 5s, 1ms); + const auto requests = server.requests(); + + check(settled.ok(), "fills that confirm must settle"); + if (settled) + { + check(settled.value().transaction_hashes == std::vector{"0x11", "0x22"}, + "hashes must follow trade_ids order"); + check(settled.value().trades.size() == 2 && settled.value().trades[0].id == "t1" && + settled.value().trades[0].status == "CONFIRMED", + "settled trades must follow trade_ids order"); + } + check(targets(requests) == + std::vector{"/data/trades?id=t1", "/data/trades?id=t2", + "/data/trades?id=t1", "/data/trades?id=t1"}, + "only unsettled, unique trade IDs must be polled, including unseen ones"); + } + + void test_failed_fills() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("t1", "FAILED", "0x11")); + server.enqueue(trade_page("t2", "CONFIRMED", "0x22")); + const auto partial = client.wait_for_order_fill_settlement(matched_order({"t1", "t2"})); + server.enqueue(trade_page("t3", "FAILED")); + const auto failed = client.wait_for_order_fill_settlement(matched_order({"t3"})); + + check(partial && partial.value().transaction_hashes == std::vector{"0x22"} && + partial.value().trades.size() == 2, + "failed fills must keep their trade but contribute no hash"); + check(!failed && failed.error().code == SdkErrorCode::TransactionFailed && + failed.error().message.find("t3") != std::string::npos, + "an order whose every fill failed must report TransactionFailed"); + } + + void test_prefixed_rest_statuses() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("t1", "TRADE_STATUS_MINED")); + server.enqueue(trade_page("t1", "TRADE_STATUS_CONFIRMED", "0x11")); + const auto confirmed = + client.wait_for_order_fill_settlement(matched_order({"t1"}), 5s, 1ms); + server.enqueue(trade_page("t2", "TRADE_STATUS_FAILED", "0x22")); + const auto failed = client.wait_for_order_fill_settlement(matched_order({"t2"}), 5s, 1ms); + server.enqueue(trade_page("t3", "TRADE_STATUS_FAILED", "0x33")); + server.enqueue(trade_page("t4", "TRADE_STATUS_CONFIRMED", "0x44")); + const auto mixed = + client.wait_for_order_fill_settlement(matched_order({"t3", "t4"}), 5s, 1ms); + + check(confirmed && + confirmed.value().transaction_hashes == std::vector{"0x11"} && + confirmed.value().trades.size() == 1 && + confirmed.value().trades[0].status == "TRADE_STATUS_CONFIRMED", + "a prefixed confirmed fill must settle and keep its raw status"); + check(!failed && failed.error().code == SdkErrorCode::TransactionFailed && + failed.error().message.find("t2") != std::string::npos, + "a prefixed failed fill must report TransactionFailed"); + check(mixed && mixed.value().transaction_hashes == std::vector{"0x44"} && + mixed.value().trades.size() == 2, + "prefixed mixed fills must keep both trades and only the confirmed hash"); + check(server.requests().size() == 5, "prefixed statuses polling request count mismatch"); + } + + void test_null_hash_keeps_polling() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("t1", "MATCHED_NOT_BROADCASTED", nullptr)); + server.enqueue(trade_page("t1", "CONFIRMED", "0x11")); + const auto settled = client.wait_for_order_fill_settlement(matched_order({"t1"}), 5s, 1ms); + + check(settled && settled.value().transaction_hashes == std::vector{"0x11"}, + "a pending fill with a null hash must keep polling until it confirms"); + check(server.requests().size() == 2, "null hash polling request count mismatch"); + } + + void test_deadline() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("t1", "MINED")); + const auto immediate = client.wait_for_order_fill_settlement(matched_order({"t1"}), 0ms); + + // A timeout shorter than the poll interval still polls once at the deadline. + server.enqueue(trade_page("t1", "MINED")); + server.enqueue(trade_page("t1", "MINED")); + const auto started = std::chrono::steady_clock::now(); + const auto late = client.wait_for_order_fill_settlement(matched_order({"t1"}), 200ms, 10s); + const auto elapsed = std::chrono::steady_clock::now() - started; + + server.enqueue(trade_page("t1", "MINED")); + server.enqueue(trade_page("t1", "CONFIRMED", "0x11")); + const auto at_deadline = + client.wait_for_order_fill_settlement(matched_order({"t1"}), 200ms, 10s); + + check(!immediate && immediate.error().code == SdkErrorCode::Timeout && + immediate.error().retryable && + immediate.error().message.find("t1") != std::string::npos, + "a zero timeout must poll once and report the pending trade"); + check(!late && late.error().code == SdkErrorCode::Timeout, "unsettled fills must time out"); + check(elapsed < 5s, "the wait must not sleep a full poll interval past the deadline"); + check(at_deadline && + at_deadline.value().transaction_hashes == std::vector{"0x11"}, + "a fill that confirms at the deadline must settle"); + check(server.requests().size() == 5, "deadline polling request count mismatch"); + } + + void test_rate_limit_retry_respects_deadline() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0.3"}}); + const auto started = std::chrono::steady_clock::now(); + const auto limited = client.wait_for_order_fill_settlement(matched_order({"t1"}), 0ms); + const auto elapsed = std::chrono::steady_clock::now() - started; + + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0.05"}}); + server.enqueue(trade_page("t1", "CONFIRMED", "0x11")); + const auto retried = client.wait_for_order_fill_settlement(matched_order({"t1"}), 5s, 1ms); + + check(!limited && limited.error().code == SdkErrorCode::RateLimit && + limited.error().retryable, + "a Retry-After past the deadline must return the 429 as RateLimit"); + check(elapsed < 250ms, "a rate-limit retry must not sleep past the deadline"); + check(retried && retried.value().transaction_hashes == std::vector{"0x11"}, + "a Retry-After within the deadline must still be retried"); + check(server.requests().size() == 3, "deadline rate-limit request count mismatch"); + } + + void test_lookup_failures() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(R"({"error":"boom"})", 500); + const auto failed_lookup = client.wait_for_order_fill_settlement(matched_order({"t1"})); + server.enqueue(R"({"data":"oops","next_cursor":"LTE="})"); + const auto malformed = client.get_trade_result("t1"); + const auto bad_interval = + client.wait_for_order_fill_settlement(matched_order({"t1"}), 1s, 0ms); + + ClobClient public_client(server.url(), 137); + const auto unauthenticated = + public_client.wait_for_order_fill_settlement(matched_order({"t1"})); + + check(!failed_lookup && failed_lookup.error().http_status == 500, + "a failed trade lookup must stop the wait with its error"); + check(!malformed && malformed.error().code == SdkErrorCode::Parse, + "a malformed trade page must be a parse error"); + check(!bad_interval && bad_interval.error().code == SdkErrorCode::InvalidArgument, + "a zero poll interval must be rejected"); + check(!unauthenticated && unauthenticated.error().code == SdkErrorCode::Auth, + "a public client must not read account trades"); + check(server.requests().size() == 2, "rejected waits must not reach the server"); + } + + void test_get_trade_matches_id() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + server.enqueue(trade_page("other", "CONFIRMED")); + const auto unrelated = client.get_trade_result("t1"); + server.enqueue(trade_page("a+b/c", "CONFIRMED")); + const auto encoded = client.get_trade("a+b/c"); + const auto requests = server.requests(); + + check(unrelated && !unrelated.value(), "rows for other trade IDs must be ignored"); + check(encoded && encoded->id == "a+b/c", "a matching trade must be returned"); + check(requests.size() == 2 && requests[1].target == "/data/trades?id=a%2Bb%2Fc", + "trade IDs must be query-encoded"); + if (requests.size() == 2) + check(requests[1].headers.at("poly_signature") == + clob_test::expected_signature(requests[1], "/data/trades"), + "the trade lookup must sign the path without its query"); + } +} // namespace + +int main() +{ + test_orders_without_fills_do_not_poll(); + test_polls_until_every_fill_confirms(); + test_failed_fills(); + test_prefixed_rest_statuses(); + test_null_hash_keeps_polling(); + test_deadline(); + test_rate_limit_retry_respects_deadline(); + test_lookup_failures(); + test_get_trade_matches_id(); + return check_support::finish("test_order_settlement"); +}