Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- `RateLimitedTransport` counted a batch's orders past the end of the body
it was given. A truncated body was read one byte beyond its end, and a
view over the start of a longer buffer was counted from the bytes after
it. Counting also no longer copies each order: 20 orders now take 6
allocations instead of 26.

## [0.6.1] - 2026-09-25

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions src/http/rate_limited_transport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace http_detail {

// Glaze reflection needs a type with linkage, so this is not in an unnamed namespace.
struct BatchBody {
std::vector<glz::raw_json> orders;
std::vector<glz::skip> orders; // Only the count matters, so nothing is copied.
};

} // namespace http_detail
Expand All @@ -23,7 +23,8 @@ std::size_t batch_items(std::string_view path, std::string_view body) {
return 1;
}
http_detail::BatchBody batch;
constexpr glz::opts options{.error_on_unknown_keys = false};
// `body` is a view, so no terminator is guaranteed after it.
constexpr glz::opts options{.null_terminated = false, .error_on_unknown_keys = false};
if (glz::read<options>(batch, body) || batch.orders.empty()) {
return 1;
}
Expand Down
5 changes: 3 additions & 2 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ gtest_discover_tests(kalshi_tests DISCOVERY_MODE PRE_TEST DISCOVERY_TIMEOUT 30)

# Its own binary, because it replaces operator new for the whole process.
if(TARGET kalshi_allocation_counter)
add_executable(kalshi_allocation_tests test_allocation_counter.cpp)
target_link_libraries(kalshi_allocation_tests PRIVATE kalshi_allocation_counter
add_executable(kalshi_allocation_tests test_allocation_counter.cpp
test_allocation_budgets.cpp)
target_link_libraries(kalshi_allocation_tests PRIVATE kalshi_allocation_counter kalshi::kalshi
GTest::gtest_main)
kalshi_configure_target(kalshi_allocation_tests)
gtest_discover_tests(kalshi_allocation_tests DISCOVERY_MODE PRE_TEST)
Expand Down
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ memory.
| `test_version.cpp` | `kalshi::VERSION` and its components |
| `parse_benchmark.cpp` | A coarse throughput guard, run as its own test |
| `test_allocation_counter.cpp` | The `operator new` counter in `support/`, in its own binary |
| `test_allocation_budgets.cpp` | Hot paths whose allocations must not grow with the size of each item |
| `test_ws_messages.cpp` | Every example frame in the AsyncAPI spec (generated) |
| `test_ws_frames.cpp` | Control frames, discriminated types, nulls, unknown values |
| `test_ws_subscriptions.cpp` | Command frames, held commands, resubscribing, gaps |
Expand Down
44 changes: 44 additions & 0 deletions tests/test_allocation_budgets.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Allocation budgets for hot paths. Each test compares two calls that should
// allocate the same, so it holds on every standard library.

#include "kalshi/rate_limit.hpp"

#include <cstdint>
#include <gtest/gtest.h>
#include <string>
#include <string_view>

#include "support/allocation_counter.hpp"

namespace {

using kalshi::test::AllocationProbe;

std::string batch_of(int orders, std::string_view order) {
std::string body = R"({"orders":[)";
for (int i = 0; i < orders; ++i) {
body += i == 0 ? "" : ",";
body += order;
}
body += "]}";
return body;
}

std::uint64_t cost_allocations(const kalshi::RateLimitedTransport& transport,
const std::string& body) {
const AllocationProbe probe;
(void)transport.cost(kalshi::HttpMethod::POST, "/portfolio/events/orders/batched", body);
return probe.count().count;
}

TEST(AllocationBudget, CountingABatchDoesNotCopyItsOrders) {
const kalshi::RateLimitedTransport transport(nullptr, kalshi::RateLimitConfig{});
const std::string empty_orders = batch_of(20, "{}");
const std::string real_orders =
batch_of(20, R"({"ticker":"KXHIGHNY-26SEP25-T75","side":"yes","action":"buy","count":10,)"
R"("yes_price":42,"client_order_id":"5f0c7c1e-8d5a-4f4e-9b1a-2f6d3c9e7a10"})");

EXPECT_EQ(cost_allocations(transport, real_orders), cost_allocations(transport, empty_orders));
}

} // namespace
36 changes: 36 additions & 0 deletions tests/test_transports.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,42 @@ TEST(RateLimitedTransport, FailsFastInsteadOfWaitingPastMaxWait) {
EXPECT_EQ(inner->calls, 1);
}

TEST(RateLimitedTransport, CountsBatchItemsWhateverTheyContain) {
const kalshi::RateLimitedTransport transport(std::make_shared<ScriptedTransport>(),
kalshi::RateLimitConfig{});
const std::string body =
R"({"orders":[{"ticker":"A,]}","tags":[1,[2,3]],"note":"say \"hi\""},{},)"
R"({"ticker":"B","nested":{"orders":[{},{}]}}],"extra":{"orders":[]}})";

EXPECT_DOUBLE_EQ(
transport.cost(kalshi::HttpMethod::POST, "/portfolio/events/orders/batched", body), 30.0);
}

TEST(RateLimitedTransport, CountsOnlyTheBodyItIsGiven) {
// The view stops before the closing brace, so it is truncated JSON and
// costs one unit. Reading past the view would find the brace and count two.
const std::string buffer = R"({"orders":[{},{}]})";
const kalshi::RateLimitedTransport transport(std::make_shared<ScriptedTransport>(),
kalshi::RateLimitConfig{});

EXPECT_DOUBLE_EQ(transport.cost(kalshi::HttpMethod::POST, "/portfolio/events/orders/batched",
std::string_view{buffer.data(), buffer.size() - 1}),
10.0);
}

TEST(RateLimitedTransport, CountsBatchItemsWithoutReadingPastTheBody) {
// A truncated body that fills its buffer exactly, so a read past the view is
// a read past the allocation, which the sanitizer build reports.
const std::string_view text = R"({"orders":[{"ticker":"A"},{"ticker":"B"})";
const std::vector<char> buffer(text.begin(), text.end());
const kalshi::RateLimitedTransport transport(std::make_shared<ScriptedTransport>(),
kalshi::RateLimitConfig{});

EXPECT_DOUBLE_EQ(transport.cost(kalshi::HttpMethod::POST, "/portfolio/events/orders/batched",
std::string_view{buffer.data(), buffer.size()}),
10.0);
}

TEST(RateLimitedTransport, BatchesLargerThanTheBucketFailClearly) {
const std::shared_ptr<ScriptedTransport> inner = std::make_shared<ScriptedTransport>();
const kalshi::RateLimitedTransport transport(inner, kalshi::RateLimitConfig{});
Expand Down
Loading