|
kalshi-cpp 0.6.2
C++23 client for Kalshi's Predictions API
|
All notable changes to kalshi-cpp are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning.
>Unreleased
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.WebSocketClient relied on libwebsockets ignoring SIGPIPE process-wide, which an application that restores the default handler undoes. Its sockets now set SO_NOSIGPIPE where the OS has it (macOS and the BSDs, which signal the whole process), and its network thread blocks SIGPIPE (Linux, which signals the writing thread). Callbacks on that thread see SIGPIPE blocked.allocs and alloc_bytes no longer count the allocation that recording the instructions counter makes.KALSHI_NATIVE_ARCH stops at configure time when the compiler rejects -march=native for the target, as in universal macOS builds, instead of failing mid-build.make lint, make format, and make tidy find Homebrew's keg-only llvm@18 without PATH changes.-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.http_status 0, since no server answered; it reported 429.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 rebuilds the SDK from Kalshi's published specs. KalshiClient covers all 117 Predictions REST operations and WebSocketClient every channel, with the specs' names and types. The WebSocket client reconnects and resubscribes on its own, and Ed25519 keys, retries, and rate limits are new. This release breaks the 0.5 API; "Migrating from 0.5" below lists what changed.
make docs and published to https://reddimus.github.io/kalshi-cpp/ for each release.WebSocketClient covers every channel and command in Kalshi's AsyncAPI 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 maps channels to them.ws::Subscription handles survive reconnects, and every ws::Update names its subscription.sid.on_error, and order book gaps request fresh snapshots (WsConfig::resync_on_gap).WsConfig::connect_timeout, ping_interval, idle_timeout, and max_reconnect_delay. A silent connection is pinged and then dropped, which triggers a reconnect.WsError carries the failed command's id, sid, seq, and subscription, and WsState reports connecting, connected, reconnecting, and disconnected.KalshiClient covers all 117 operations in Kalshi Predictions OpenAPI 3.31.0, 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 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.ErrorCode::NotFound and Error::api_code (Kalshi's machine-readable code). HTTP errors map to specific codes and keep Kalshi's error message.null where Kalshi's spec says a field cannot be null, and unknown enum values read as Unknown instead of failing.collect_pages() follows cursors for any list operation. to_cents(), to_contracts(), and parse_timestamp() convert exact wire values.Signer picks RSA-PSS or Ed25519 from the loaded key and reports it through key_type().HttpClient(ClientConfig) sends unauthenticated requests for public market data, so no API key is needed to read markets.Environment::{Production, Demo} with rest_base_url(), websocket_url(), ClientConfig::for_environment(), and WsConfig::for_environment().RetryingTransport, a transport decorator that retries network errors, 429s, and 5xx responses with backoff. Writes (POST, PUT, DELETE) retry only on 429 unless RetryPolicy::retry_writes is set, so an order is never placed twice and a canceled order never reports a spurious failure. Retry-After is honored up to max_delay.TokenBucket and RateLimitedTransport, which pace requests to Kalshi's Read and Write token budgets. Waiters reserve tokens, so large batches are served in order instead of being starved. Defaults match the Basic tier, and rate_limit_config() builds the account's real budgets from get_account_api_limits() and get_account_endpoint_costs().HttpResponse::header() for case-insensitive header lookup.kalshiConfig.cmake finds it the same way and reports kalshi-cpp as not found, instead of stopping, when it is missing.KALSHI_USE_SYSTEM_GLAZE=ON. Glaze 8.3 or newer is enforced at compile time.-march=x86-64-v3 or LTO. Both are opt-in through KALSHI_NATIVE_ARCH and KALSHI_ENABLE_LTO and apply only to kalshi-cpp's own targets.SameMinorVersion compatibility, because 0.x minor releases may break the API.KALSHI_USE_SYSTEM_GLAZE=ON to build against an installed Glaze 8.3 or newer.make bench runs a Google Benchmark suite (KALSHI_BUILD_BENCHMARKS) in place of tools/bench.sh, which timed examples that exit immediately without credentials.market_data, portfolio, place_and_cancel_order (demo only), and stream_orderbook, built as example_NAME. make run-NAME loads .env but no longer decodes KALSHI_API_PRIVATE_KEY; point KALSHI_API_KEY_FILE at a PEM file instead.make configure-debug and make bench-compare are gone. Use make debug, and compare make bench runs with Google Benchmark's compare.py.kalshi/detail/ws_json.hpp), classify_lifecycle_event(), and the integer cent and contract fields on WebSocket messages; use to_cents() and to_contracts().kalshi/models/), and the deprecated methods for routes Kalshi removed (announcements, search, live data by ticker, bundle lookup, generic communications).RateLimiter and ScopedRateLimit. They counted one token per millisecond-resolution interval and could not express Kalshi's budgets; use TokenBucket.RetryingClient, with_retry(), calculate_retry_delay(), and RetryResult. Use RetryingTransport and retry_delay(). should_retry() now takes the HTTP method.build_paginated_query(), which did not percent-encode the cursor.subscribe(ws::Channel, ws::SubscribeParams) replaces subscribe_orderbook() and the other per-channel methods and returns a ws::Subscription. Messages arrive as ws::Update<T>, such as ws::Update<ws::OrderbookDelta>, with the payload in msg and field names from the spec. on_state_change receives a WsState, and max_reconnect_attempts now defaults to 0 (keep trying).connect(); they are sent once connected. disconnect() forgets them.kalshi/models.hpp. For example, get_markets() returns GetMarketsResponse{markets, cursor}, orders use CreateOrderV2Request, and Order::ticker replaces Order::market_ticker. Responses with one field return that field directly (get_market() returns Market).parse_timestamp() converts them. The integer cent and contract views are gone; to_cents() and to_contracts() convert exactly or fail.create_order, cancel_order, batch_create_orders). Methods for routes Kalshi removed are gone.InvalidRequest instead of silently calling a different route.ErrorCode::Ok, Error::ok(), and Error::is_ok() are gone; a Result holds either a value or an error. ErrorCode::NotFound is new, so the enumerators' numeric values changed. Any other 4xx is InvalidRequest, and 408 is NetworkError.KalshiClient takes a std::shared_ptr<const HttpTransport>, and http_client() and the non-const transport() are gone.collect_pages() replaces PaginatedIterator, Cursor, PaginationParams, and PaginatedResponse.derive_outcome_side() and derive_book_side() are now outcome_side() and book_side(), in kalshi/helpers.hpp. They return Unknown when the side or action is Unknown.Signer is copyable, and WebSocketClient stores its own copy, so the signer no longer has to outlive the client.HttpResponse::status_code is an int. ClientConfig::timeout is in milliseconds (assigning std::chrono::seconds still works), and ClientConfig::connect_timeout is new.HttpClient's constructors are explicit.ErrorCode::InvalidKey.message key Kalshi does not send and always fell back to a generic name.update_subscription no longer sends an undocumented channel parameter, and fill action values such as sell_to_close no longer read as buy.connect() returns the actual failure at once instead of waiting 10 seconds and reporting a timeout.WebSocketClient inside one of its own callbacks is safe.create_subaccount()) no longer makes libcurl read the request body from standard input.long values to libcurl; the int64 passed before was undefined behavior on Windows.CURLOPT_NOSIGNAL, a connect timeout, TCP keepalive, gzip, and a kalshi-cpp/<version> User-Agent.export() file is gone; use cmake --install or FetchContent.make uses every CPU on macOS, and make install-hooks works in worktrees.InvalidRequest instead of throwing from std::stoi or reaching the network.connect() or disconnect() while another thread queued a subscription was a data race, and could hand libwebsockets a handle from a destroyed context.Signer and HttpClient objects safe to inspect and reject work through them with typed errors.noexcept HttpClient::config() and WebSocketClient::config() accessors. Their moved-from sentinels were function-local statics whose construction could throw, which would call std::terminate rather than return.tools/bench.sh; piping the prior loop into sort discarded the totals in a subshell.series_ticker route.std::string_view without copying the full frame or allocating a quoted search string for each field. The receive benchmark now guards this path alongside request serialization.kalshi::kalshi target available to FetchContent consumers, matching the installed package interface.get_events_response() for event-list milestone expansions and preserved arbitrary Series product metadata as exact JSON.discard_legacy_direction=true when they intentionally replace the default legacy Yes/Buy direction.discard_legacy_direction=true to acknowledge that the current RFQ contract has no direction fields.InvalidRequest instead of calling stale routes.FixedPoint for checked, exact decimal conversion.CreateOrderParams::book_side, count_fp, price_dollars, time_in_force, and self_trade_prevention_type for V2 order creation.series_ticker, start_ts, and end_ts for candlesticks.*_dollars and *_fp fields instead of assuming whole cents or contracts.PublicTrade::is_block_trade — parsed from GET /markets/trades (Kalshi changelog 2026-05-29: public trade responses now flag block trades and support filtering by block status). Block trades are large negotiated prints routed off the central order book; exposing the flag lets trade-flow / microstructure consumers exclude them. The field is absent on older payloads and defaults to false (forward/backward compatible — no behavioural change for existing consumers).GET /markets/trades body parser out of KalshiClient::get_trades into a testable api_detail::parse_trades_response, matching the existing parse_deposits_response / parse_withdrawals_response convention. Adds unit coverage for trade parsing (incl. the new is_block_trade flag).INT32_MIN or above INT32_MAX, and oversized non-negative order-book price and quantity entries clamp at INT32_MAX. Both scanners previously accumulated directly into int32_t, so malformed or hostile values could trigger signed-overflow undefined behaviour; all in-range values continue to parse unchanged.Market::status now preserves unknown / future Kalshi status strings in the parsed model instead of silently mapping them to a default enum. Consumers that introspect status_raw can keep trading against new states the SDK hasn't yet enumerated; the bracket vs tail routing on the trader side is unaffected.get_account_api_limits() and get_endpoint_costs() for Kalshi's authenticated account API metadata endpoints. These expose account usage tier, read/write token-bucket budgets, default endpoint token cost, and non-default endpoint cost overrides.current lifecycle fields on Market (current_open_ts, current_close_ts, current_settle_ts, current_expire_ts) so consumers can read the in-flight contract's effective lifecycle timestamps without re-deriving them.unsubscribe, add_markets, and remove_markets now translate the SDK's client command id to Kalshi's server-assigned subscription id after a subscribed acknowledgement. This prevents hourly resubscribe flows from sending stale client ids and receiving Unknown subscription ID errors from the live WebSocket API.get_market_orderbooks(tickers) for Kalshi's documented GET /markets/orderbooks endpoint. The SDK sends repeated tickers query params, matching the live API shape, and enforces the documented 1-100 ticker request size.orderbook_fp fixed-point-dollar response shape (yes_dollars / no_dollars) while preserving the legacy integer-cent yes / no arrays. This also fixes a single-orderbook parser bug that skipped the first nested [price, qty] pair in legacy payloads.CancelOrderV2Params, OrderCancelResult, cancel_order_v2(params), and batch_cancel_orders_v2(request). These call Kalshi's /portfolio/events/orders/{order_id} and /portfolio/events/orders/batched endpoints and preserve V2 response fields including reduced_by, ts_ms, client_order_id, and per-order error payloads.batch_cancel_orders now sends the JSON body required by Kalshi's DELETE /portfolio/orders/batched endpoint instead of dropping the serialized body. Existing BatchCancelRequest::order_ids callers are preserved and emitted as the current orders selector shape; callers that need subaccount-scoped cancellation can populate BatchCancelRequest::orders directly.CreateOrderParams now exposes the current optional create-order request fields documented by Kalshi: count_fp, fixed-point dollar prices, time_in_force, post_only, reduce_only, self_trade_prevention_type, order_group_id, cancel_order_on_pause, subaccount, and exchange_index. Existing integer-count / cent-price callers are unchanged.https://external-api.kalshi.com/trade-api/v2 and wss://external-api-ws.kalshi.com/trade-api/ws/v2. The older api.elections.kalshi.com hosts remain supported by Kalshi and can still be supplied explicitly in ClientConfig / WsConfig.get_balance() now parses balance / available_balance via extract_cents_or_dollars instead of extract_int. Kalshi's 2026-05-21 changelog adds balance_dollars (fixed-point dollar string) to GET /portfolio/balance alongside the integer-cent field; this picks up the _dollars shape when present and falls back to raw cents otherwise — same v2 wire-format handling already used across the other REST response parsers (CHANGELOG v0.0.8). No public API change; Balance struct layout is unchanged (still int64 cents)..editorconfig (fleet-standard: tabs, 4-width, LF, UTF-8, 100-col max for C++; spaces, 2-width for YAML/JSON). Sibling to .clang-format. Covers editors that don't read .clang-format (Vim, VS Code without the extension). Matches the file already shipped in infra-cpp (#41).WsError.message was returning the literal string "code" for error frames that lacked an explicit message field (e.g. code 7 = "Unknown subscription ID"). The previous fallback extract_string("msg") returned the first quoted token inside the msg object — the "code" key name — because the hand-rolled scanner doesn't distinguish object-value vs nested-key occurrences. Now falls back to the documented ws_error_code_name(err.code) when no message field is present, so consumers see WS error: code=7 message="Unknown subscription ID" instead of message="code". Caught in 2026-05-15 production logs against kalshi-websocket (#45).kalshi::Deposit + kalshi::Withdrawal structs (same wire shape, distinct types for call-site clarity), kalshi::GetPortfolioMovementParams (limit + cursor), and two cursor-paginated client methods: KalshiClient::get_deposits(...) and KalshiClient::get_withdrawals(...). Kalshi added the GET /portfolio/deposits and GET /portfolio/withdrawals endpoints on 2026-05-05. Schema per https://docs.kalshi.com/api-reference/portfolio: id, status (pending | applied | failed | returned), type (ach | wire | crypto | debit | apm), amount_cents, fee_cents, created_ts, finalized_ts (nullable). Parsers live in src/api/response_parsers.hpp as parse_deposits_response / parse_withdrawals_response; a shared extract_nullable_int helper handles the nullable finalized_ts field. Four new tests in tests/test_response_parsers.cpp cover the finalized + pending shapes for both endpoints.kalshi::OutcomeSide (Yes/No) and kalshi::BookSide (Bid/Ask) enums, plus derive_outcome_side(side, action) and derive_book_side(side, action) constexpr helpers. Kalshi added normalized outcome_side / book_side directional fields to Order / Fill / Trade response objects on 2026-05-06; the upstream spec calls these out as the eventual replacement for (side, action). The derivation helpers let consumers normalize today without waiting for a response-field parse path. See tests/test_models.cpp for the truth table.CreateQuoteParams::post_only (std::optional<bool>). Kalshi added the post_only quote flag on 2026-05-05 — when true the quote never takes resting orders or pays taker fees; it auto-cancels at execution if it would have matched. Serialized after expires_at per the glz::meta<ser::QuoteBody> field order; omitted from the body when nullopt (preserves byte-equivalence with pre-flag calls).OrderGroup::subaccount_number field. Kalshi added this to the order-group response surface on 2026-05-07 (the subaccount that owns the group). Parsers in create_order_group, get_order_group, get_order_groups, and reset_order_group now populate it. Empty string when the server omits the field (back-compat).GetQuotesParams::rfq_user_filter (std::optional<std::string>). Kalshi added the parameter on 2026-05-07 — restricts listed quotes to those that responded to RFQs the authenticated user created. Appended to the query string by build_quotes_query when set; omitted when nullopt (preserves byte-equivalence with pre-filter calls).MarketLifecycle::yes_sub_title (std::optional<std::string>). Kalshi added this field to the v2 metadata_updated lifecycle sub-event on 2026-05-11 — emitted only when the yes-side subtitle changes. The hand-rolled dispatcher in src/ws/websocket.cpp extracts it when non-empty; nullopt for the open/close/determination/settlement sub-events.kalshi::ws_error_code_name(code) constexpr helper — maps the Kalshi WS error code (WsError::code) to its canonical name per the AsyncAPI spec, including code 25 (Subscription buffer overflow) added 2026-05-12. Falls back to "Unknown error code" for codes outside the documented 1-22 + 25 range so consumers can log unknown codes without branching themselves.Order::mutation_ts_ms (std::optional<std::int64_t>). Kalshi added a top-level ts_ms field to V2 order-mutating endpoint responses on 2026-05-05 — the matching-engine wall-clock timestamp (Unix epoch ms) at which the request was processed. parse_order extracts it from the response top-level (sibling to the wrapped order object) and surfaces it on the returned Order. Populated by create_order, amend_order, decrease_order, batch_create_orders, batch_cancel_orders; nullopt for get_order / get_orders reads and pre-2026-05-05 servers.kalshi::LifecycleEventType enum and classify_lifecycle_event(const MarketLifecycle&) constexpr helper. Kalshi's market_lifecycle_v2 channel flat-encodes 9+ sub-events (created/activated/deactivated/determined/settled/metadata_updated/ fractional_trading_updated/price_level_structure_updated/ close_date_updated) onto one frame shape with no explicit event_type field. The helper inspects which fields are populated and returns the best-fit LifecycleEventType (Settled > Determined > Deactivated > MetadataUpdated > OpenOrCreated > Unknown). Consumers needing the finer 9-way distinction can branch on the underlying fields directly.market_lifecycle_v2 channel (Kalshi deprecated market_lifecycle). The dispatcher still accepts v1 frames for the transition window so older subscriptions don't break.KALSHI-ACCESS-KEY, KALSHI-ACCESS-SIGNATURE, KALSHI-ACCESS-TIMESTAMP) now include the trailing : required by lws_add_http_header_by_name. Previously the authenticated upgrade silently sent malformed header lines.Origin header on Kalshi upgrades. Kalshi rejects upgrades that include one with HTTP 403; the documented Python client sends no Origin and succeeds.percent_encode_query_value helper. Cursor + category values containing ,/spaces/= now survive intact through append_query_param.GET /markets/.../candlesticks response parser now handles Kalshi's current price.*_dollars / volume_fp string-decimal schema alongside the legacy raw-cent ints. Adds a small extract_fixed_point_int helper.price.*_dollars / volume_fp schema, the legacy raw-cent schema, and the alternate candlesticks array key. Pins the parser behavior that downstream market-data ingestion depends on for nonzero OHLC backtest rows.GET /series percent-encoding through a new pure free function (kalshi::api_detail::build_series_query_string). The test routes through the free function instead of poking the private member method via #define private public; MSVC encodes access modifiers in mangled symbol names, so the hack broke build-windows on PR #20 until this refactor.Makefile: new pre-commit and install-hooks targets. make pre-commit runs format then lint in one shot. make install-hooks drops a .git/hooks/pre-commit shim that fires make pre-commit on every git commit (idempotent). Mirrors the pattern adopted across the C++ SDK family.Migrate outgoing-JSON serialization from nlohmann/json to Glaze v7.6.0. Affects the WS subscribe / unsubscribe / update_subscription frame builders and the dozen KalshiClient::serialize_* REST request body methods. Public API and ABI unchanged; downstream consumers (kalshi-trader) link without modification. Glaze is FetchContent-only and never appears in the install / export sets — it stays a TU-private implementation detail.
Recorded benchmark (x86_64-v3, GCC 13.3, -O3 -DNDEBUG, 50-order batch-create payload, 1000 iters):
nlohmann::ordered_json v3.11.3 : ~162 us/op (pre-migration) glaze v7.6.0 : ~3 us/op (post-migration) speedup : ~55-60x
See tests/parse_benchmark.cpp for the regression guard (cap = 500 us/op, ctest –timeout = 30s).
handle_message + the kalshi/detail/ws_json.hpp scanners) is deliberately untouched — it was stripped of nlohmann in v0.0.7 / v0.0.8 for perf and v2-schema correctness. See feedback_find_first_json_scanner memory note for rationale. The REST response parsers (still hand-rolled extract_* in src/api/client.cpp) are likewise unchanged.tests/test_json_serialize.cpp (25 cases) — byte-equivalence regression gate for every migrated glz::meta-driven serializer against the pre-migration nlohmann::ordered_json::dump() output. Kalshi's API rejects unordered payloads on the order-management routes and the WS server rejects unordered subscribe frames; any key-reorder or whitespace change in the emitted bytes is a production breakage.tests/parse_benchmark.cpp — serialize-throughput regression guard wired via add_test + set_tests_properties(... TIMEOUT 30).kalshi/version.hpp (introduced in v0.1.0). The configure-time generated header lives at ${PROJECT_BINARY_DIR}/include/kalshi/version.hpp but the target_include_directories(kalshi_core PUBLIC ...) was pointing at ${CMAKE_BINARY_DIR}/include — under FetchContent that resolves to the consumer's top-level build dir (where the header is NOT), not the kalshi-cpp subproject dir (where it IS). Using PROJECT_BINARY_DIR pins to the kalshi-cpp project's own build dir under either standalone or FetchContent layouts.pm::api::Subaccount** — Kalshi subaccount endpoint suite (create, list, get, update, disable). Returns std::expected<Subaccount, Error> on all operations; mirrors the Account surface for primary-account callers. Unblocks the multi-account separation Kalshi recommends for capital partitioning across strategies. (PR #12)WebSocketClient move-from null-guard — null-check all pimpl accessors (is_connected(), last_error(), etc.) so a moved-from instance is safe to destroy without UB. Caught by the same crash mode (terminate called without an active exception) v0.0.9 fixed in the reconnect path; this closes the move-destruction sibling case. (PR #13)Signer.SignProducesHeaders previously embedded an invalid TEST_RSA_KEY placeholder string; the test silently skipped its assertions. Replaced with a real PKCS#8-encoded private key so the test actually runs and pins the signer's header output shape. (PR #14)build-windows job added via vcpkg — parity with the rest of the SDK family (alpaca-markets-cpp, ncei-cpp, nws-cpp, open-meteo-cpp, polymarket-cpp all now have Linux + macOS + Windows). Zero source changes needed; the codebase was already POSIX-isms-free. (PR #11)actions/checkout@v6 for Node 24 runtime parity with sibling SDKs.cpp_auto_audit.py walks --cached + --others so new test files in a feature branch trigger the audit during local lint (previously only --cached was checked).SECURITY.md — canonical contact path for reporting vulnerabilities in the auth / signer / WebSocket paths. (PR #15)WebSocketClient::connect() reaps the previous service thread + libwebsockets context before starting a new connection. After a LWS_CALLBACK_CLIENT_CONNECTION_ERROR the thread stays joinable (the callback only sets connected = false), and the caller's typical reconnect-on-error loop then move-assigned a new std::thread onto the still-joinable handle, hitting std::terminate with terminate called without an active exception and exiting 139 (SIGSEGV). Production observed ~5 crashes/day on kalshi-websocket; supervisor auto-restart masked it but each crash dropped ~10s of WS data.kalshi::VERSION from CMake PROJECT_VERSION via configure_file() so the C++ constexpr can no longer drift from the FetchContent tag. Previously kalshi.hpp hardcoded "0.1.0" while CMakeLists.txt said 0.0.8. The new include/kalshi/version.hpp is generated; do not edit by hand..markdownlint-cli2.yaml mirroring the open-meteo-cpp config — disables MD013 (line-length) and other style-noise rules but keeps MD022/MD031/MD032 formatting hygiene enforced.tests/test_version.cpp pinning kalshi::VERSION to semver and round-tripping through VERSION_MAJOR/MINOR/PATCH._dollars and quoted-int schema fields. (48eaff1)yes_price_dollars / no_price_dollars (string decimal dollars), count_fp / delta_fp (string floats), and ISO-8601 orderbook_delta.ts instead of unix ints. (2a3a8e0)>0.0.6
09c5dc1)nlohmann::json instead of hand-rolled string concatenation. (e9c6f03)>0.0.5
KALSHI_ENABLE_SANITIZERS, KALSHI_ENABLE_COVERAGE). (452b3d4)-march=x86-64-v3 (2017+ baseline: AVX2, BMI2, FMA) when KALSHI_NATIVE_ARCH=OFF. Matches the service Dockerfiles so FetchContent-consumed builds produce identical codegen. (7956824)2bd34d6)