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
23 changes: 20 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.6.1] - 2026-09-25

### Fixed

- Public headers compile without warnings under `-Wall -Wextra -Wpedantic`,
so projects that build with `-Werror` can include them. `Error` values in
`error.hpp` and `fixed_point.hpp` left a field uninitialized, which
`-Wmissing-field-initializers` reported. The consumer check now builds with
those flags.
- A request refused by the local rate limiter reports `http_status` 0, since
no server answered; it reported 429.
- README's examples copy the `Signer` instead of moving it, so later snippets
still have a key, and its Ubuntu setup installs a compiler and a new enough
CMake. The docs, comments, and pre-commit hook were corrected where the
final review found them out of date.

## [0.6.0] - 2026-09-25

0.6.0 rebuilds the SDK from Kalshi's published specs. `KalshiClient` covers all
Expand All @@ -22,7 +38,7 @@ the 0.5 API; "Migrating from 0.5" below lists what changed.
document: all 13 channels, `update_subscription` for markets, snapshots, CF
Benchmarks indices, and Pyth underlyings, and `list_subscriptions`. Message
types in `kalshi/ws_models.hpp` are generated from `spec/asyncapi.yaml`, and
[docs/channels.md](docs/channels.md) maps channels to them.
[docs/channels.md](https://github.com/Reddimus/kalshi-cpp/blob/main/docs/channels.md) maps channels to them.
- The WebSocket client reconnects with backoff after a dropped connection,
signs each attempt, and resubscribes with each subscription's current
markets. `ws::Subscription` handles survive reconnects, and every
Expand All @@ -40,7 +56,7 @@ the 0.5 API; "Migrating from 0.5" below lists what changed.
up from 70. New areas include historical data, live data, fee changes, event
candlesticks and forecasts, order-group triggers and limits, intra-exchange
transfers, target balance allocation, block trades, API usage levels, search
filters, and FCM. [docs/operations.md](docs/operations.md) lists every one.
filters, and FCM. [docs/operations.md](https://github.com/Reddimus/kalshi-cpp/blob/main/docs/operations.md) lists every one.
- `tools/codegen/generate.py` generates the client, models, route tests, and
operation list from the vendored `spec/openapi.yaml`. CI fails if they drift.
- Requests are validated before sending: required fields must be set and
Expand Down Expand Up @@ -763,7 +779,8 @@ the 0.5 API; "Migrating from 0.5" below lists what changed.

## [0.0.2] — initial public release

[Unreleased]: https://github.com/Reddimus/kalshi-cpp/compare/v0.6.0...HEAD
[Unreleased]: https://github.com/Reddimus/kalshi-cpp/compare/v0.6.1...HEAD
[0.6.1]: https://github.com/Reddimus/kalshi-cpp/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Reddimus/kalshi-cpp/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/Reddimus/kalshi-cpp/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/Reddimus/kalshi-cpp/compare/v0.5.0...v0.5.1
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ User-visible changes also require a `CHANGELOG.md` entry. The CI workflow is
the source of truth for Linux, macOS, Windows, sanitizer, clang-tidy, Markdown,
and consumer gates.

Examples make authenticated network calls. Keep automated tests offline by
using an injected `HttpTransport`.
Examples call the live API, and most need a key. Keep automated tests offline
by injecting an `HttpTransport` or using the local servers in `tests/`.

## Architecture

Expand All @@ -33,8 +33,9 @@ using an injected `HttpTransport`.

## Conventions

- Use explicit local types. The permitted `auto` cases are recorded in
`tools/cpp_auto_allowlist.txt` and enforced by `tools/cpp_auto_audit.py`.
- Use explicit local types. `tools/cpp_auto_audit.py` allows `auto` for
structured bindings, lambdas, and iterators; anything else needs an
`// auto-ok: reason` comment.
- `tools/codegen/generate.py` writes the REST client from `spec/openapi.yaml`
and the WebSocket types from `spec/asyncapi.yaml`. Edit a spec, the
generator, or a `tools/codegen/*.in` template, then run `make codegen`;
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Fetching Glaze needs CMake 3.31; an installed Glaze works with 3.21.
cmake_minimum_required(VERSION 3.21...4.4)
project(kalshi-cpp
VERSION 0.6.0
VERSION 0.6.1
DESCRIPTION "C++23 client for the Kalshi Predictions API"
HOMEPAGE_URL "https://github.com/Reddimus/kalshi-cpp"
LANGUAGES CXX)
Expand Down
19 changes: 15 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ make docs # Doxygen API reference in build-docs/html (needs Doxygen)
make bench # Google Benchmark suite
make consumers # Build installed and FetchContent consumers
make coverage # lcov report (needs lcov)
make lint-docs # markdownlint (needs markdownlint-cli2)
```

Run `make format lint test` before pushing. `make install-hooks` runs format
and lint on every commit. CI adds the sanitizers, clang-tidy, and the consumer
check, on Linux, macOS, and Windows.
Run `make format lint test` before pushing, and `make lint-docs` (needs
`markdownlint-cli2`) after editing Markdown. `make install-hooks` runs
`make lint` on every commit. CI builds and tests on Linux, macOS, and Windows,
and runs the sanitizers, clang-tidy, and the consumer check on Linux.

`make tidy` needs clang-tidy 18. On Ubuntu, build with the matching clang and
libc++, as CI does:

```bash
make tidy CMAKE_ARGS="-DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_CXX_FLAGS=-stdlib=libc++"
```

## Generated code

Expand Down Expand Up @@ -90,7 +99,9 @@ explains how to refresh the specs.
## Releases

1. Set `VERSION` in `CMakeLists.txt`, move the `[Unreleased]` notes into a new
`[X.Y.Z]` section, and update the `GIT_TAG` in `README.md`.
`[X.Y.Z]` section, update the compare links at the bottom of
`CHANGELOG.md`, and update the `GIT_TAG` in `README.md`. Release notes
come from that section, so link with absolute URLs.
2. Merge that change to `main`.
3. Run `git tag vX.Y.Z && git push origin vX.Y.Z`. `release.yml` publishes the
GitHub release after CI passes on the tagged commit, and `docs.yml`
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ format:

pre-commit: format lint

# Installs a hook that runs `make pre-commit`. Works in worktrees too.
# Installs a hook that runs `make lint` on every commit, so unformatted code
# fails instead of being formatted after it is staged. Works in worktrees too.
install-hooks:
@hook="$$(git rev-parse --git-path hooks)/pre-commit"; \
printf '#!/bin/sh\nexec make pre-commit\n' > "$$hook" && chmod +x "$$hook" && \
printf '#!/bin/sh\nexec make lint\n' > "$$hook" && chmod +x "$$hook" && \
echo "Installed $$hook"

# Needs lcov and genhtml.
Expand Down Expand Up @@ -113,7 +114,7 @@ help:
@echo "make lint-docs markdownlint"
@echo "make format Format C++ sources in place"
@echo "make pre-commit format + lint"
@echo "make install-hooks Run pre-commit on every git commit"
@echo "make install-hooks Run make lint on every git commit"
@echo "make coverage lcov report in build-coverage/html"
@echo "make run-NAME Run examples/NAME.cpp with .env loaded (ARGS=...)"
@echo "make clean Remove build directories"
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ scope.
## Build

You need a C++23 compiler, CMake 3.31+, OpenSSL 3, libcurl, and libwebsockets.
Ubuntu 24.04's apt CMake is older; `pipx install cmake` gets a current one.

```bash
brew install cmake openssl curl libwebsockets pkg-config # macOS
sudo apt install cmake libssl-dev libcurl4-openssl-dev libwebsockets-dev pkg-config # Ubuntu
# macOS
brew install cmake openssl curl libwebsockets pkg-config

# Ubuntu 24.04, whose apt CMake is older than 3.31
sudo apt install build-essential pkg-config pipx libssl-dev libcurl4-openssl-dev libwebsockets-dev
pipx install cmake && pipx ensurepath # then open a new shell

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
Expand All @@ -35,7 +38,7 @@ ctest --test-dir build --output-on-failure
include(FetchContent)
FetchContent_Declare(kalshi
GIT_REPOSITORY https://github.com/Reddimus/kalshi-cpp.git
GIT_TAG v0.6.0)
GIT_TAG v0.6.1)
FetchContent_MakeAvailable(kalshi)
target_link_libraries(myapp PRIVATE kalshi::kalshi)
```
Expand Down Expand Up @@ -73,9 +76,10 @@ int main() {

## Authenticate

Create a key under API keys at <https://kalshi.com/account/profile>. Kalshi
recommends Ed25519; generating the pair yourself keeps the private key off the
website:
Create a key under API keys at <https://kalshi.com/account/profile> and save
the private key file Kalshi gives you. Kalshi recommends Ed25519 keys. If the
page offers to use your own public key, you can generate the pair locally so
the private key never leaves your machine:

```bash
openssl genpkey -algorithm ed25519 -out kalshi.key
Expand All @@ -84,7 +88,11 @@ openssl pkey -in kalshi.key -pubout # paste this public key into Kalshi

```cpp
kalshi::Result<kalshi::Signer> signer = kalshi::Signer::from_pem_file(key_id, "kalshi.key");
kalshi::KalshiClient client{kalshi::HttpClient{std::move(*signer)}};
if (!signer) {
std::cerr << signer.error().message << '\n';
return 1;
}
kalshi::KalshiClient client{kalshi::HttpClient{*signer}}; // Signer is cheap to copy
kalshi::Result<kalshi::GetBalanceResponse> balance = client.get_balance();
```

Expand Down Expand Up @@ -121,7 +129,7 @@ Kalshi's status and error code, and `Error::message` includes its explanation.
Both are transports you stack under the client:

```cpp
std::shared_ptr<kalshi::HttpClient> http = std::make_shared<kalshi::HttpClient>(std::move(*signer));
std::shared_ptr<kalshi::HttpClient> http = std::make_shared<kalshi::HttpClient>(*signer);
std::shared_ptr<kalshi::RateLimitedTransport> paced =
std::make_shared<kalshi::RateLimitedTransport>(http, kalshi::RateLimitConfig{});
kalshi::KalshiClient client{std::make_shared<kalshi::RetryingTransport>(paced)};
Expand Down
2 changes: 2 additions & 0 deletions include/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
| `kalshi/error.hpp` | `Error`, `ErrorCode`, and `Result<T>` |
| `kalshi/environment.hpp` | Production and demo URLs |
| `kalshi/fixed_point.hpp` | Exact decimal parsing |
| `kalshi/raw_json.hpp` | `RawJson`, free-form JSON kept as text |
| `kalshi/version.hpp` | `kalshi::VERSION` (generated at configure time) |

Headers under `kalshi/detail/` support the implementation and tests. They are
not a stable interface.
5 changes: 3 additions & 2 deletions include/kalshi/api.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ namespace kalshi {
/// operation in Kalshi's OpenAPI document, named after its operationId.
///
/// Failures come back as `Error`: `InvalidRequest` for a request the SDK
/// rejects before sending (or HTTP 400/409/422), `AuthenticationError`,
/// `NotFound`, `RateLimited`, `ServerError`, `NetworkError`, or `ParseError`.
/// rejects before sending or another HTTP 4xx, `AuthenticationError`,
/// `NotFound`, `RateLimited`, `ServerError`, `NetworkError`, `ParseError`,
/// or `SigningError`.
/// `Error::api_code` carries Kalshi's machine-readable code when it sends one.
class KalshiClient {
public:
Expand Down
2 changes: 1 addition & 1 deletion include/kalshi/detail/http_path.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace kalshi::detail {
return result;
}

/// Build the URL path covered by Kalshi's RSA-PSS signature.
/// Build the URL path covered by Kalshi's request signature.
///
/// Kalshi signs the full path from the host root, including the base URL's
/// `/trade-api/v2` prefix, but excludes the query string.
Expand Down
8 changes: 4 additions & 4 deletions include/kalshi/error.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,19 @@ struct Error {
std::string api_code;

[[nodiscard]] static Error network(std::string msg) {
return {ErrorCode::NetworkError, std::move(msg)};
return {ErrorCode::NetworkError, std::move(msg), 0, {}};
}

[[nodiscard]] static Error auth(std::string msg) {
return {ErrorCode::AuthenticationError, std::move(msg)};
return {ErrorCode::AuthenticationError, std::move(msg), 0, {}};
}

[[nodiscard]] static Error parse(std::string msg) {
return {ErrorCode::ParseError, std::move(msg)};
return {ErrorCode::ParseError, std::move(msg), 0, {}};
}

[[nodiscard]] static Error signing(std::string msg) {
return {ErrorCode::SigningError, std::move(msg)};
return {ErrorCode::SigningError, std::move(msg), 0, {}};
}
};

Expand Down
21 changes: 13 additions & 8 deletions include/kalshi/fixed_point.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ class FixedPoint {
public:
[[nodiscard]] static Result<FixedPoint> parse(std::string_view value) {
if (value.empty()) {
return std::unexpected(Error{ErrorCode::InvalidRequest, "fixed-point value is empty"});
return std::unexpected(
Error{ErrorCode::InvalidRequest, "fixed-point value is empty", 0, {}});
}

std::size_t index = value.front() == '-' ? 1 : 0;
if (index == value.size()) {
return std::unexpected(
Error{ErrorCode::InvalidRequest, "fixed-point value has no digits"});
Error{ErrorCode::InvalidRequest, "fixed-point value has no digits", 0, {}});
}

bool saw_digit = false;
Expand All @@ -37,10 +38,12 @@ class FixedPoint {
saw_decimal = true;
continue;
}
return std::unexpected(Error{ErrorCode::InvalidRequest, "invalid fixed-point value"});
return std::unexpected(
Error{ErrorCode::InvalidRequest, "invalid fixed-point value", 0, {}});
}
if (!saw_digit || (saw_decimal && !fractional_digit)) {
return std::unexpected(Error{ErrorCode::InvalidRequest, "invalid fixed-point value"});
return std::unexpected(
Error{ErrorCode::InvalidRequest, "invalid fixed-point value", 0, {}});
}
return FixedPoint(std::string(value));
}
Expand All @@ -51,8 +54,8 @@ class FixedPoint {
/// Returns InvalidRequest instead of rounding or saturating.
[[nodiscard]] Result<std::int64_t> scaled_integer(std::uint8_t scale) const {
if (scale > 18) {
return std::unexpected(
Error{ErrorCode::InvalidRequest, "fixed-point scale exceeds int64 capacity"});
return std::unexpected(Error{
ErrorCode::InvalidRequest, "fixed-point scale exceeds int64 capacity", 0, {}});
}

const bool negative = wire_.front() == '-';
Expand All @@ -67,7 +70,9 @@ class FixedPoint {
for (std::size_t i = fractional_start + scale; i < wire_.size(); ++i) {
if (wire_[i] != '0') {
return std::unexpected(Error{ErrorCode::InvalidRequest,
"fixed-point conversion would lose precision"});
"fixed-point conversion would lose precision",
0,
{}});
}
}
}
Expand Down Expand Up @@ -109,7 +114,7 @@ class FixedPoint {

[[nodiscard]] static Result<std::int64_t> overflow_error() {
return std::unexpected(
Error{ErrorCode::InvalidRequest, "fixed-point value does not fit in int64"});
Error{ErrorCode::InvalidRequest, "fixed-point value does not fit in int64", 0, {}});
}

std::string wire_;
Expand Down
15 changes: 3 additions & 12 deletions include/kalshi/version.hpp.in
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
#pragma once

/// @file version.hpp
/// @brief Version constants for the Kalshi C++ SDK.
///
/// This header is generated at CMake configure time from
/// `version.hpp.in` via `configure_file()`. The substituted values
/// come from CMakeLists.txt's `project(kalshi-cpp VERSION ...)`
/// declaration — that's the single source of truth.
///
/// Do NOT edit the generated copy in the build directory. Edit
/// CMakeLists.txt to change the version; this header re-generates.
/// @brief The SDK version, generated from `project(... VERSION)` in CMakeLists.txt.

namespace kalshi {

/// Full SDK version, "MAJOR.MINOR.PATCH".
constexpr const char* VERSION = "@PROJECT_VERSION@";

/// Major version component (incompatible API changes).
constexpr int VERSION_MAJOR = @PROJECT_VERSION_MAJOR@;

/// Minor version component (backwards-compatible additions).
/// While VERSION_MAJOR is 0, a new minor version may break the API.
constexpr int VERSION_MINOR = @PROJECT_VERSION_MINOR@;

/// Patch version component (backwards-compatible fixes).
/// Patch releases fix bugs without changing the API.
constexpr int VERSION_PATCH = @PROJECT_VERSION_PATCH@;

} // namespace kalshi
5 changes: 3 additions & 2 deletions src/http/rate_limited_transport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ Result<HttpResponse> RateLimitedTransport::request(HttpMethod method, std::strin
std::to_string(bucket.config().capacity) + "; split the batch"});
}
if (!bucket.acquire_for(tokens, config_.max_wait)) {
return std::unexpected(Error{ErrorCode::RateLimited,
"Rate-limit tokens would not refill within max_wait", 429});
// No server answered, so http_status stays 0.
return std::unexpected(Error{
ErrorCode::RateLimited, "Rate-limit tokens would not refill within max_wait", 0, {}});
}
return inner_->request(method, path, body);
}
Expand Down
5 changes: 5 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ memory.
| `test_http_client.cpp` | The libcurl transport, POSIX only |
| `test_transports.cpp` | Retries, token buckets, rate limiting |
| `test_signer.cpp` | Ed25519 and RSA-PSS signing |
| `test_helpers.cpp` | Cents, contracts, timestamps, order directions, pagination |
| `test_fixed_point.cpp` | Exact decimal parsing and scaling |
| `test_features.cpp` | Defaults for configs and enums |
| `test_version.cpp` | `kalshi::VERSION` and its components |
| `parse_benchmark.cpp` | A coarse throughput guard, run as its own test |
| `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
7 changes: 2 additions & 5 deletions tests/parse_benchmark.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ int main(int argc, char** argv) {
}

if (glaze_checksum == 0) {
std::fprintf(stderr, "checksum is zero — render_body emitted nothing\n");
std::fprintf(stderr, "checksum is zero: encode() produced nothing\n");
return 1;
}

Expand All @@ -73,10 +73,7 @@ int main(int argc, char** argv) {
std::printf("parse_benchmark: payload=%zuB iters=%d\n", sample.size(), kIterations);
std::printf(" glaze (serialize): %8.3f ms total (%8.3f us/op)\n", glaze_ms, us_per_op);

// Regression guard: at migration time, Glaze rendered a 50-order
// batch in ~30-60 us/op on x86_64-v3 / -O3 / LTO. Cap at 500 us/op
// — that's ~10x the measured baseline and accounts for slower CI
// runners, Debug builds, and AddressSanitizer overhead.
// About 10x the measured cost of encoding a 50-order batch, to absorb slow runners.
constexpr double kMaxUsPerOp = 500.0;
if (check_timing && us_per_op > kMaxUsPerOp) {
std::fprintf(stderr, "REGRESSION: %.3f us/op exceeds cap of %.0f us/op\n", us_per_op,
Expand Down
Loading
Loading