Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.4.0 - 2026-08-08

Removed

  • MarketDataClient::bulk_fetch_plan, and the public ShardPlan and ShardQuery types. Bulk-fetch sharding is automatic on the history builders. This Rust-only method handed back the shard plan for you to run the sub-requests yourself; it had no counterpart on the other bindings and exposed the internal planner types on the public API, which kept the sharding strategy from being a free-to-change implementation detail. The automatic sharding is unchanged. This is a breaking change to the Rust API.
  • The [grpc] max_message_size_mb config-file key. The inbound gRPC message ceiling is set in bytes via [market_data] max_message_size; the megabyte-denominated duplicate that mirrored it is gone. A config file that still sets max_message_size_mb now fails to load. This is a breaking change to the configuration file.

Changed

  • The public API surface is tightened to what the SDK exposes for use. Internal modules that were reachable by path (auth beyond Credentials, backoff beyond JitterMode, util) and a few internal decode helpers are now crate-private or hidden from the documented surface. The user-facing types stay exported at the crate root exactly as before (Credentials, JitterMode), so ordinary code is unaffected; only code that reached into the auth or backoff module path directly switches to those root re-exports.

  • Multi-day option-chain pulls now fan the date shard axis out by call/put. A both-rights chain (right = "both", strike = "*") pulled over a date range shorter than the request pool cut one shard per day and left the rest of the pool idle; it now also splits each date band into a call band and a put band, so more lanes run at once and the pull finishes sooner. A tick chain is served one day per band, so its date bands split by right only while the resulting call and put single-day bands still fit the request pool. The merged rows are exactly the single stream's, in the SDK's canonical chain order (ascending expiration, strike, right). The ShardBand::Date band (surfaced only through Error::PartialShardFetch) gains a right field; code that matches or builds it updates for the new field.

  • TypeScript date and time arguments take a wire-format string only, not a JS Date. A calendar date (startDate, endDate, expiration) or an intraday time (startTime, endTime, timeOfDay) was accepted either as the "YYYYMMDD" / "HH:MM:SS" wire string or as a JS Date. A Date is an instant, not a calendar day, so the SDK rendered it in UTC and shifted the requested day for a caller in a non-UTC zone (new Date(2024, 0, 1) in Central Europe queried 20231231). These parameters now accept the wire-format string exclusively. This is a breaking change to the TypeScript surface; format a Date yourself before passing it.

  • The TypeScript TCP-keepalive setters name their argument secs, not ms. setStreamingKeepaliveIdleSecs and setStreamingKeepaliveIntervalSecs store seconds, but the parameter was labeled ms, so an editor hint invited a 1000× unit mistake. The label is now secs, matching the Python binding; positional callers are unaffected.

  • StreamingClient.streaming() returns a StandaloneStreamingSession. The standalone streaming client's session was typed as StreamingSession (a Client and StreamView), so session.marketData, session.flatFiles, and session.close() type-checked but were undefined at runtime. The standalone session is now its own type extending only StreamingClient, so the type surface matches what the object exposes.

Fixed

  • Rejected stream subscriptions now log at warn instead of debug. A subscribe the server rejects (MaxStreamsReached, InvalidPerms, or a generic error) is dropped; recording that only at debug left a capped or unentitled stream looking live under a default log subscriber. It now logs at warn with the rejection reason, so hitting the concurrent-stream cap or an entitlement limit is visible without lowering the log level.

  • A tick option-chain history pull spanning more days than the request pool now shards one band per day. The server serves a tick option-chain (strike = "*" or expiration = "*", no bar interval) only one day at a time. When such a pull covered more days than the pool width, the date-axis fan-out capped the band count at the pool width and produced multi-day bands the server rejects; it now takes one band per day, with bands past the pool width queued. Stock and single-contract tick pulls, bar pulls, and at_time chains still band multi-day and cap at the pool width.

  • Retry and reconnect backoff no longer overshoots its configured wall-clock envelope. RetryPolicy::max_elapsed — and the FPSS reconnect envelope — bounds when a retry sequence schedules its next attempt, but it was checked only before each backoff sleep, so a long backoff could sleep well past the envelope before the check fired. The backoff is now clipped to the time left and the loop stops scheduling once the envelope is spent. An individual attempt's own duration remains bounded by the per-request deadline (with_deadline / timeout_ms), not by the retry envelope.

  • A stream delivering only unrecognized frames now reconnects instead of going silent. The FPSS reader skips a frame with an unknown type code to stay aligned, and an unknown frame deliberately does not refresh the data-plane liveness clock. A peer sending nothing but unknown frames kept each read returning before its per-read timeout, so the no-frame reconnect deadline never fired and the session stayed authenticated while delivering no usable events. The read deadline is now enforced in the skip path too: no recognized frame — data, control, or heartbeat — within the read timeout drops the session and reconnects.

  • A synchronous market-data call from inside a Python streaming delivery handler fails fast with HandlerReentrancy. The core already rejects a same-client reentrant request when the pool is committed, but the Python binding runs a delivery handler on a blocking-pool thread (async streaming) or inside the runtime's block_on (synchronous streaming), where a reentrant synchronous call would deadlock on the held request permit or abort the runtime with a nested block_on before reaching the core guard. The binding now detects the re-entry on that thread and raises HandlerReentrancy immediately; issue the request outside the handler (for example from a background thread).

  • Per-call timeout_ms = 0 disables the deadline on every endpoint family. On the Python and TypeScript list endpoints (option_list_expirations, stock_list_symbols, and the like) a zero timeout_ms / timeoutMs raced the call against a zero-duration timer and failed immediately, while the history and snapshot endpoints read zero as "no deadline" (matching with_deadline(Duration::ZERO)). Zero now means "no per-call deadline" uniformly; any other value runs under the configured request timeout.

  • The TypeScript numeric config setters reject an out-of-range value instead of silently wrapping it. napi decoded these arguments with V8 ToUint32, so setMaxConcurrentRequests(-1) wrapped to 4294967295 and drove a multi-billion-entry pool allocation, 1.5 truncated to 1, and 2**32 wrapped to 0. The setters now take the value as a number and reject a non-finite, negative, fractional, or over-u32 input with InvalidParameterError; the port setters keep their 0..=65535 range check on top.

  • A rejected BigInt config value names its actual cause. A negative or over-u64 BigInt passed to a *Ms config setter reported "magnitude must fit in u64", which is wrong for a negative value — the magnitude fits, the sign does not. The message now names the cause (a negative sign or an over-u64 magnitude). The error stays a plain Error, matching the built-in OverflowError the Python binding raises for the same input.

  • A C++ index subscription subscribes to the index, not a stock. The C ABI ignored the subscription's sec_type and treated every non-option contract as a stock, so a C++ Contract::index(...) request (for example VIX) subscribed to the wrong instrument while still rendering as an index. The C ABI now maps sec_type (STOCK / INDEX, a null or empty value defaulting to stock for compatibility, any other value rejected as THETADATADX_ERR_INVALID_PARAMETER) and the C++ wrapper carries it for underlier subscriptions.

  • A synchronous C++ streaming handler exception is surfaced, not swallowed. The shared trampoline caught and discarded every exception a _stream(...) handler threw, and the wrapper checked only the FFI return code, so a decode, storage, or application failure in the handler lost a chunk while the call reported success. The trampoline now retains the first exception, stops delivering further chunks, and rethrows it after the drain; an empty handler is rejected before the stream starts.

  • Python streaming callbacks are validated when set, not when they first fire. A non-callable passed to start_streaming or assigned to reconnect_callback was accepted and only failed later on a delivery thread as an unraisable TypeError. Both now raise InvalidParameterError synchronously.

  • Contract.option(right=...) accepts a Right enum. The Python contract builder took right as a plain str, so passing the SDK's own Right.CALL raised a TypeError; it now accepts a Right (through its .value) as well as a string.

  • Client.from_dotenv keeps the file's environment and host settings when a single channel selector is overridden. Passing only market_data_type= (or streaming_type=) reset the streaming environment and any custom hosts to the production defaults before applying the one override, contradicting the documented field-level override. The file is now the base configuration and the selector overrides only the channel it names.

  • AsyncClient surfaces the StreamView health getters and the flat-file async terminals. The async proxy's allowlist omitted is_authenticated, millis_since_last_event, last_event_received_at_unix_nanos, last_connected_addr, and batches, so those valid operations raised AttributeError, and every *_async name routed to market_data, so flatfile_to_path_async could not resolve. The health getters resolve like their sibling diagnostics, and a flat-file *_async name falls back to the flat_files namespace.

  • Stream::batches rejects a negative linger. A negative linger on the C++ Arrow batch reader silently became an immediate flush; it now throws InvalidParameterError, matching the Python and TypeScript bindings.

  • A malformed endpoint argument is reported as THETADATADX_ERR_INVALID_PARAMETER on the C ABI. A rejected request-option argument (for example a boolean that is neither 0 nor 1) set the untyped THETADATADX_ERR_OTHER, so the C++ wrapper threw the generic base exception instead of InvalidParameterError; it now sets the typed code.

  • Documentation corrections. The TypeScript reconnect callback is documented as receiving a single { reason, attempt } object — the runtime and .d.ts type — not the positional (reason, attempt) the prose showed. The pull-based Arrow batch reader is documented as opened before subscribing (building the reader starts the session; a subscribe before it errors), and "block" backpressure as preventing queue-side drops rather than "lossless", across Python, TypeScript, and C/C++. The C++ reconnect user_data lifetime and the tick-chunk callback's possible worker-thread affinity are stated, the Python from_env helpers name the InvalidParameterError a missing THETADATA_API_KEY actually raises rather than ConfigError, and the streaming shutdown docs no longer claim a repeated void shutdown returns -1.

0.3.0 - 2026-07-31

Changed

  • The request pool now defaults to the account's subscription tier, and the new market_data.max_concurrent_requests config field overrides it with no client-side cap. The SDK previously treated the tier's 2^tier concurrency (Free 1 / Value 2 / Standard 4 / Pro 8) as a hard client-side ceiling, which also held back accounts whose server-side allowance had been raised above their base tier — an account boosted to 32 concurrent requests was still capped at 8 by its own client. The tier now only seeds the default: leaving max_concurrent_requests unset sizes the gRPC channel pool and the request semaphore to the tier's allowance exactly as before, while an explicit value is used verbatim on every binding (cfg.max_concurrent_requests = 32 in Python, setMaxConcurrentRequests in TypeScript, set_max_concurrent_requests in C++ and the C ABI, [market_data] max_concurrent_requests in the TOML config file), and bulk-fetch sharding fans out across the full resolved pool — a boosted account now shards past the old 2^tier ceiling. The server remains the enforcement point: requests past the account's real allowance are rejected as ResourceExhausted and retried with backoff before surfacing an error.

  • Option-chain pulls now shard by right (call / put), not by time. A chain (strike = "*") is a contract cross-product whose cost is the server assembling its contracts, so slicing the requested window by time — the previous behavior — made every band re-enumerate the whole chain. A both-rights tick chain now splits into a call half and a put half, each band assembling half the contracts, and the tier's remaining lanes slice each half by time (at Pro width 8, call × 4 time bands + put × 4). The right split rides the intraday time axis, so it applies to tick chains; a bounded-interval chain stays a single stream on one day and fans out across date bands (both rights together) over a multi-day range. Rows come back in the same canonical order as before (grouped by expiration, strike, right; call before put; time-ascending within a contract), so buffered output is unchanged — only the fan-out is smarter. A chain already pinned to one right (a strike wildcard with right = "call" / "put") has nothing to divide on this axis and keeps the equal time split.

  • Streaming history builders (*_stream()) now shard concurrently like the buffered path, and shard sizing cuts the requested time or date range into equal concurrent bands from the request shape. Under bulk_fetch = "auto" (the default), a large chunk-streaming history pull fans out across the same equal bands as its buffered sibling: every band streams as its own concurrent request, and each band's chunks reach the handler as they arrive — every chunk exactly once, with chunks from different bands interleaved in arrival order (use the buffered builder, or bulk_fetch = "off", when the single stream's exact order matters). The split is decided from the request shape alone: a multi-day range cuts into equal date bands, and a single day with an intraday window cuts into equal time bands for a tick pull. A bounded-interval (bar) pull is never split by intraday time, because its bars accumulate from the request's window start (cumulative vwap on OHLC, last-tick carry on quotes and greeks), so a mid-window seam would hand back different bar values than a single stream even when the row count matches; such a pull stays a single stream on one day and still fans out across date bands over a multi-day range, where the server computes each day independently. There is no density-probe request and no per-endpoint tuning, and provably small pulls run as a single stream.

  • MarketDataClient::bulk_fetch_plan is now fn(&self, endpoint, query) -> Option<ShardPlan> (was async fn -> Result<Option<ShardPlan>, Error>). The plan is pure computation on the request shape, so there is nothing to await and no error to surface; call sites drop the .await and the Result handling. This is a breaking change to the Rust API.

  • A failed band no longer takes down a large sharded pull. A buffered .await band that dies mid-collection re-fetches from scratch within the standard retry budget — its rows had not reached the caller, so the replay is invisible and duplicate-free — and only a band that spends its whole budget fails the query. On a chunk-streaming .stream / .stream_async pull, a band that fails terminally no longer cancels its siblings: the surviving bands drain to completion (every one of their chunks reaches the handler), and the call returns the new Error::PartialShardFetch naming the failed band window(s) — the start/end of each lost date or time band — so you can re-pull exactly those slices instead of restarting the whole pull. A streaming band that fails before delivering any chunk still retries transparently, a pull where no chunk reached the handler at all still fails with the underlying error like a single stream, and the call deadline still cancels every band at once, so size with_deadline / timeout_ms to the whole pull. Mapped to StreamError / THETADATADX_ERR_STREAM on the Python, TypeScript, C++, and C surfaces.

  • A market-data request made from inside a streaming delivery handler on the same client fails fast instead of blocking. A sharded streaming pull holds its request permit(s) until the handler returns, so a blocking market-data call awaited inside the handler on that same client could otherwise wait on permits that only free once the handler completes. When the client's request pool is fully committed such a call now returns the new Error::HandlerReentrancy immediately, with guidance to issue the request outside the handler or from a second client; when the pool has headroom it proceeds unchanged, and any request made outside a handler keeps the ordinary wait.

Added

  • Shard-decision logging. The bulk-fetch planner now logs at debug why a pull did NOT shard (endpoint outside the shardable set, no cut axis in the request shape, a bounded interval on the intraday time axis, fan-out width under two, an unparsable band window, a provably small bar grid, or a window too narrow for two bands) next to the existing "sharded" line, and every band's work is tagged with its window: retry warnings carry the band span, and each band emits a completion line with its row count and duration, so concurrent bands stay distinguishable in the log stream.

  • Whole-market at_time history now shards along the date axis. stock_at_time_trade, stock_at_time_quote, and index_at_time_price return one row per day and are dominated by per-day server compute rather than transfer, so a multi-day date range now splits into equal concurrent bands like the other bandable history endpoints — cutting a wide as-of pull to a fraction of its single-stream wall time. Buffered output is byte-identical to the single stream and date-ordered; the server computes each day independently (no last-tick carry across days), so band seams never diverge. A multi-day .stream() now fans out, delivering each band's chunks as they arrive and so interleaved across bands in arrival order rather than strictly date-ascending — use the buffered path, or bulk_fetch = "off", when strict date order matters. A * option-chain at_time (option_at_time_trade / option_at_time_quote) shards the same way — every strike as-of a time each day is the same dense per-day compute — and reproduces the single-stream row set even at a carry-sensitive as-of time, because an option's prior-day carry resolves from the calendar day before the pull, not from a band's start date, so band seams never diverge. A concrete single-contract option at_time is sparse over a wide range and stays a single stream.

Fixed

  • The dedicated *_stream() builders now share the .stream(handler) chunk-delivery path. The four dedicated streaming builders (stock_history_trade_stream, stock_history_quote_stream, option_history_trade_stream, option_history_quote_stream) previously marked their no-replay guard on every parsed chunk — including empty keepalives — and kept draining the stream after a decode failure, while the .stream(handler) methods on the same endpoints marked the guard only once rows actually reached the handler and stopped at the first decode failure. Both surfaces now route through the same delivery primitive, so a transient error that arrives after only empty keepalive chunks retries instead of surfacing terminal, and a decode failure ends the drain immediately — identically on both.

0.2.0 - 2026-07-16

Added

  • Selectable streaming consumer wait mode (wait_mode) with a manual park interval, across every binding. The event-ring consumer's idle-wait strategy is now selectable: spin (default, unchanged — an adaptive busy-spin then a yield ramp, never sleeps), busyspin (pure spin, no yield, tightest re-poll and lowest jitter), park (spin/yield ramp then sleep, low but fixed idle CPU), and backoff (spins while events flow and, after a short idle window, sleeps until events resume, then snaps back to spinning — low latency when active, low CPU when idle, the hands-free choice for a 24/7 consumer). spin and busyspin both hold ~100% of one core and differ only in jitter; only park and backoff lower idle CPU. park and backoff sleep for park_interval_us microseconds (default 1000 = 1 ms, validated [50, 1_000_000]); the OS timer honors sleeps down to ~50 us (below that kernel timer slack dominates, so 50 is the floor) and a 100 us park is a valid low-latency option that measured a few percent of a core in live premarket; the client pings roughly every 100 ms, so a park longer than the ping cadence adds delivery latency without saving further CPU. Exposed on Rust (StreamingConfig::wait_mode / park_interval_us, builder .wait_mode() / .park_interval_us()), Python (Config.wait_mode / park_interval_us), TypeScript (waitMode / setWaitMode, parkIntervalUs / setParkIntervalUs), C++ (set_wait_mode / get_wait_mode, set_park_interval_us / get_park_interval_us), and the C ABI (thetadatadx_config_set_wait_mode / _get_wait_mode, _set_park_interval_us / _get_park_interval_us), and configurable in config.toml under [streaming]. The default is byte-for-byte the previous fixed low-latency wait, so callers who do not set it are unaffected.

  • Automatic bulk-fetch sharding for large history pulls, buffered and streaming. A large history pull — a buffered .await on a history builder, or a chunk-streaming .stream / .stream_async call — is now sized with a cheap density probe and, when the response is big enough to benefit, split into balanced concurrent sub-requests across the account's concurrent-request budget: a multiplied bulk-download rate with no change to the call site. Buffered pulls merge the shards back into exactly the rows of the single-stream response. Row order: single-contract, stock, and index pulls keep the exact single-stream order; option-chain pulls come back in a deterministic canonical order — grouped by (expiration, strike, right) ascending, calls before puts, time-ascending within each contract — because the server enumerates chain contracts in an internal order no client can reproduce. Streaming pulls forward each band's chunks to the handler as they arrive — no merge, no buffering, the full fan-out throughput — so every chunk is delivered exactly once but chunks from different bands interleave in arrival order rather than the single stream's order; each band of a single-contract, stock, or index pull is internally time-ascending. Configure with market_data.bulk_fetch ("auto" default; "off" runs every query as a single stream in the server's own row and chunk order — use it, or the buffered path, when the single-stream order matters) and market_data.shard_concurrency (fan-out cap; default the account's full budget), exposed across every binding and settable in the config file under [market_data] (bulk_fetch = "auto"|"off", shard_concurrency, connect_timeout_secs, request_timeout_secs, warn_on_buffered_threshold_bytes). Rust power users can fetch the same plan via MarketDataClient::bulk_fetch_plan and run the bands under their own concurrency.

Changed

  • ping_interval_ms default is now 100, matching the terminal. The client heartbeat previously defaulted to 250 ms; the Theta Terminal pings on a fixed 100 ms period, and with flush_mode removed the ping is the sole flush trigger for queued outbound control frames, so the default now matches the terminal exactly (subscribe / unsubscribe reach the server within one 100 ms interval instead of 250 ms). The knob and its [100, 300_000] range are unchanged; override it if you prefer the old cadence.

  • HTTP/2 flow-control window sizes are tunable across every binding, and the market-data window_size_kb field is renamed to stream_window_size_kb. The per-stream and per-connection HTTP/2 windows (stream_window_size_kb, connection_window_size_kb) are now exposed through the Python, TypeScript, C++, and C bindings, not just Rust. The validation clamp is raised from [64, 1024] to [64, 2_097_151] KB — the largest whole-KB value under the HTTP/2 2^31 - 1 byte window cap — so bulk pulls are no longer throttled by a 1 MiB per-stream ceiling, and the defaults are raised to stream_window_size_kb = 8192 / connection_window_size_kb = 16384. The Rust field MarketDataConfig::window_size_kb and the [grpc] window_size_kb config-file key are renamed to stream_window_size_kb; because the grpc config section rejects unknown keys, an existing config.toml using the old key now fails to load until it is renamed. This is a breaking change to the configuration surface.

Removed

  • flush_mode streaming write-flush knob. The flush_mode setting is removed from every binding (Rust StreamingConfig::flush_mode, Python Config.flush_mode, TypeScript Config.flushMode / setFlushMode, C++ set_flush_mode / get_flush_mode, C ABI thetadatadx_config_set_flush_mode / _get_flush_mode). Outbound streaming writes now always coalesce and flush on the ping heartbeat, so a subscription burst leaves as fewer, larger packets — the terminal's own behavior — with received-data latency unaffected as before. The "immediate" per-frame-flush mode existed only to defeat that server-friendly coalescing and is gone. This is a breaking change to the configuration surface.

  • host_selection / host_shuffle_seed streaming host-ordering knobs. Removed from every binding (Rust StreamingConfig::host_selection / host_shuffle_seed, Python Config.streaming_host_selection / streaming_host_shuffle_seed, TypeScript Config.streamingHostSelection / setStreamingHostSelection and the shuffle-seed pair, C++ set_streaming_host_selection / get_streaming_host_selection and the shuffle-seed pair, C ABI thetadatadx_config_*_streaming_host_selection / _host_shuffle_seed), along with the HostSelectionPolicy enum. The client now cycles the declared host list left to right — the terminal's own behavior — and a reconnect tries the last-known-good host first. The per-client fault-domain shuffle and its seed existed only in the SDK, with no terminal counterpart. This is a breaking change to the configuration surface.

  • reconnect_jitter trimmed to "full" / "none". The "equal" and "decorrelated" jitter variants are removed from every binding (the JitterMode enum drops them, and the C-ABI integer encoding is now 0 = Full, 1 = None). Full jitter — sampling uniformly from [0, delay] — stays the default and is the right choice for a recovering fleet; "none" (deterministic delays) remains for tests. The two removed variants were unused academic backoff modes with no operational benefit over full jitter. Setting reconnect_jitter to "equal" or "decorrelated" is now rejected. The C-ABI integer encoding renumbered — 1 now means None (it was Equal), and 2 / 3 are rejected — so a C caller that passed a numeric jitter code must be re-audited: passing 1 now selects no jitter rather than erroring. This is a breaking change to the configuration surface.

  • Config-file migration. A config.toml that still sets flush_mode, streaming.host_selection, streaming.host_shuffle_seed, or a reconnect_jitter of "equal" / "decorrelated" now fails to load with a typed error rather than being silently ignored — remove those keys / values when upgrading.

  • QuoteTick.midpoint derived column. Removed from every binding (Python QuoteTick.midpoint, TypeScript QuoteTick.midpoint, C ThetaDataDxQuoteTick.midpoint, and the Arrow / Polars midpoint column). The quote wire carries bid and ask; midpoint was an SDK-computed (bid + ask) / 2 column the server never sends and the terminal never exposes — compute it from bid / ask at the call site if you need it. The IvTick.midpoint field is a real wire column and is unaffected. This is a breaking change to the quote tick schema.

  • List endpoints now preserve wire order. list_* results (roots / symbols, expirations, strikes, dates) come back in the server's row order rather than re-sorted ascending by the SDK. The terminal streams these lists in wire order untouched; the SDK's numeric-aware ascending sort had no terminal counterpart. Sort client-side if you need a specific order. This is a behavioral change to the list_* returns.

  • interval is forwarded verbatim. The interval argument is no longer snapped to the nearest preset: a raw-millisecond value such as "250" or "60000" is now rejected client-side rather than silently mapped to "500ms" / "1m". The server accepts only the closed preset enum (tick, 10ms, 100ms, 500ms, 1s, 5s, 10s, 15s, 30s, 1m, 5m, 10m, 15m, 30m, 1h); the SDK now forwards the string as-is and validates against that set. Pass an explicit preset. This is a breaking change to the interval parameter.

  • CalendarDay.is_open derived boolean. Removed from every binding (Python CalendarDay.is_open, TypeScript CalendarDay.isOpen, C ThetaDataDxCalendarDay.is_open, and the Arrow / Polars is_open column). The calendar wire carries a single day-type column; is_open was an SDK-derived boolean (status in {open, early_close}) that the terminal never exposes. Read status instead — it carries the full open / early_close / full_close / weekend vocabulary — and treat open / early_close as trading days. This is a breaking change to the calendar tick schema.

Fixed

  • A normal cargo build of the Rust crate no longer requires a system protoc. The crate now ships the pre-generated gRPC client code (committed at proto/beta_endpoints.snapshot.rs and included directly), so a default build never shells out to protoc — fixing build failures for users who lack a runnable protoc. The proto compile and its byte-for-byte drift check against mdds.proto are gated behind the grpc-codegen cargo feature (and prost-build moves out of the default build graph); regenerate the snapshot with cargo run -p thetadatadx-rs --bin refresh_grpc_snapshot --features grpc-codegen. CI still drift-checks the snapshot under that feature, so an edited mdds.proto with a stale snapshot fails the build.

  • Python sync snapshot endpoints no longer carry a hardcoded 5-second cap. The Python sync snapshot methods wrapped every call in a fixed 5-second timeout that fired before the caller's timeout_ms and the configured request timeout, making large snapshots (bulk greeks, wide option chains) unusable from the sync API. Snapshots now route through the normal request path and honor the request's own deadline. TypeScript, C++, and Rust were unaffected.

[0.1.1] - 2026-07-07

Added

  • Flat files on the standalone MarketDataClient. The market-data-only client now exposes the full flat-file surface — the flat_files() namespace view plus the flatfile_request / flatfile_request_decoded entries and the five per-dataset convenience methods — matching the unified Client method-for-method. Flat files are account-authenticated market data with no streaming leg, so they belong on the market-data handle; a market-data-only workflow no longer needs the unified client to pull whole-universe per-day distributions. Reached the same way on every binding: Rust and C++ MarketDataClient::flat_files(), and the Python and TypeScript market-data clients, all backed by the same flat-file engine as the unified client.

Fixed

  • C++ flat-file view lifetime. The C++ FlatFiles view now co-owns the client handle (shared_ptr) instead of borrowing it, so it stays valid if the originating client is closed or destroyed while the view — or an in-flight call on it — is still alive. The accessor is now plain const (safe on a temporary), matching the market_data() view; the handle is released only once the last owner drops.

0.1.0 - 2026-07-07

The first public release of ThetaDataDx: a terminal-exact, drop-in market-data SDK across Rust, Python, TypeScript, and C++, plus a bundled HTTP + WebSocket server and an MCP server, all over one Rust engine. It connects straight to ThetaData with nothing to install and run locally, and delivers US stock, option, index, and interest-rate data three ways from a single authenticated client: point-in-time history, real-time streaming, and whole-universe flat files.

The SDK ships under per-language package names: thetadatadx-rs (crates.io), thetadatadx-py (PyPI), and thetadatadx-ts (npm, with its platform packages); thetadatadx-cpp is the in-repo CMake/header target; the MCP server is thetadatadx-mcp-server on npm. The import surface is unchanged — use thetadatadx::… in Rust, import thetadatadx in Python, the thetadatadx namespace in C++.

Added

  • One client, three ways to the data. A single authenticated Client (with Python's async companion AsyncClient) exposes point-in-time history under market_data, real-time streaming under stream, and whole-universe flat files on the client directly (client.market_data.stock_history_eod(...), client.stream.subscribe(...)). Single-purpose MarketDataClient and StreamingClient are available on every binding. API-key authentication is supplied inline, read from THETADATA_API_KEY, or loaded from a .env file, and authenticates both channels; email and password authentication is also supported.
  • The bundled server speaks the ThetaData v3 terminal contract, 1:1. The HTTP server serves the v3 REST and WebSocket surface: v3 {response} bodies, one ISO local-datetime per row, CALL / PUT rights, option rows grouped under {expiration, strike, right}, CSV default with the v3 column order, and plain-text error status. It exposes exactly the terminal's three unauthenticated GET /v3/terminal/* routes (shutdown, fpss/status, mdds/status, one-word channel health) and reports all four connectivity states (CONNECTED / UNVERIFIED / DISCONNECTED / ERROR). REST data endpoints accept format=html; flat-file downloads accept csv (default), json, ndjson / jsonl, and html on the terminal's GET /v3/{sec_type}/flat_file/{req_type} route. On the WebSocket, an option strike defaults to the terminal's 1/10-cent integer (--strike-format dollars switches to a dollar value), and every message header carries the streaming status.
  • MCP server on npm. The thetadatadx-mcp-server server runs with npx -y thetadatadx-mcp-server (no Rust toolchain required; cargo install stays for Rust users), and once authenticated it advertises only the tools the account's per-asset-class subscription grants.
  • History serves a single date or a date range on the same route. Every intraday history endpoint (stock and option ohlc / trade / quote / trade_quote, option open_interest, the intraday greeks and trade-greeks families, and index price) takes an optional date plus optional start_date / end_date: supply date for a single day, or a range on the base route, matching the terminal.
  • Unambiguous field units and semantics. strike means dollars on every typed surface (float / double), with the exact wire integer reachable under the unit-named strike_thousandths (a $550.00 strike is 550000). The option right is the logical character ('C' / 'P'; a uint32_t Unicode scalar in C). EodTick time columns are created_ms_of_day and last_trade_ms_of_day. CalendarDay.is_open is a boolean and status carries the open / early_close / full_close / weekend vocabulary. Absent contract identity is None / undefined (Arrow null) in Python and TypeScript; the C-layout rows carry documented fills with has_contract_id(). Contract.option(...) takes the leg as one named OptionLeg { expiration, strike, right }.
  • Columnar and Arrow across every binding. A pull-based Arrow RecordBatch reader, batches(), delivers decoded streaming events as Arrow record batches on a schedule you control (Rust futures::Stream + .blocking(), Python sync/async iterable over the Arrow C-Data interface, TypeScript AsyncIterable<RecordBatch>, C++ arrow::RecordBatchReader), tunable by batch_size and a linger flush with Block or DropOldest backpressure and a dropped() / ring_dropped() loss signal. Every history result also emits a projected Arrow-IPC frame with exactly the columns the wire carried, drivable from a live call via TypeScript's <method>WithColumns and the C / C++ column-presence out-param; multi-symbol snapshots attribute each row to its own symbol. Python DataFrame conversion releases the GIL and hands off zero-copy over the Arrow PyCapsule interface.
  • Live market value. A per-contract theoretical bid / ask computed from the real-time quote, delivered as StreamData::MarketValue with market_bid / market_ask / integer-midpoint market_price, subscribed via Contract::market_value().
  • Async query surfaces on every binding. TypeScript is fully asynchronous — every network entry point returns a Promise (connect factories, all history methods, the streaming lifecycle, flat files), each endpoint taking its required parameters positionally followed by one optional trailing options object (stockHistoryEOD("AAPL", { startDate, endDate, timeoutMs })). Python AsyncClient gains awaitable constructors and *_async flat-file twins; C++ gains an <endpoint>_async(...) companion returning std::future for every buffered query; and the C ABI and C++ stream market-data results through a tick-chunk callback so peak memory tracks one chunk.
  • Typed, unified error taxonomy. One branded hierarchy across every binding: a ConfigError leaf for environmental faults, InvalidParameterError for rejected configuration, sequence, and flat-file inputs (and, in Python, a subclass of the built-in ValueError), and StreamError for streaming faults. Python adds NotFoundError / DeadlineExceededError / UnavailableError (with NoDataFoundError / TimeoutError aliases). Rate-limit errors expose the decoded server back-off (retry_after / retryAfter / retry_after(); the C-ABI thetadatadx_last_error_retry_after_ms()).
  • Observability and delivery controls. A slow-callback watchdog (slow_callback_count() plus a microsecond threshold setter) counts over-budget callback invocations without ever cancelling one; an optional consumer_cpu knob pins the streaming consumer thread for low-jitter delivery; and epoch-instant accessors (*_timestamp_ms, plus thetadatadx::time::date_ms_to_epoch_ms) compute DST-aware Unix milliseconds on read from any row carrying a date and a milliseconds-of-day column.
  • Robust streaming lifecycle. The reconnect budget resets only after a stable connected window so a flapping connection cannot reconnect forever; reconnect marks the session live only after replay succeeds and prefers the last-known-good host; an in-session server rate-limit or restart signal reaches the reconnect classifier; and delta-decode state resets on reconnect so the first post-reconnect ticks decode against a fresh baseline. Subscriptions are de-duplicated and reference-counted so a duplicate subscribe, or a subscribe racing an in-flight reconnect under command-queue backpressure, stays tracked and replayed exactly once. Read, write, and connect timeouts bound every socket operation, and a panicking I/O thread surfaces as a failure to callback, columnar, and pull consumers alike rather than a false "still streaming".
  • Memory-safe teardown across the bindings. The C++ destructor routes through the drained teardown path (stopping a stream from inside its own callback can no longer read a destroyed callback, and async market-data views are lvalue-only so a call on a dangling temporary is a compile error); the Node.js client never deadlocks on close behind a slow callback nor leaves a silently dead stream after reconnect(); and the Python client runs teardown off the GIL. A retired streaming callback is dropped only after the consumer thread confirms quiescence.
  • Decode and data integrity. Truncated or drifted flat-file and EOD responses fail loud with a typed decode error instead of emitting garbage or zero-filled rows; a wrong-width streaming row is rejected before it poisons the per-contract field cache; market-value arithmetic saturates at the integer extremes instead of panicking on an adversarial quote; OHLCVC volume and count decode as unsigned; projected frames keep the trading date; and OCC-21 parsing and FIT integer runs never panic or drop digits on hostile input.
  • Configuration and connect hygiene. Config invariants run at the single connect funnel every path routes through; the loader rejects a misspelled or unknown section at load time; a server retry hint is clamped to the configured ceiling; the C ABI installs its TLS crypto provider from every connect entrypoint; and the embedded async runtime honors the worker_threads knob. Config sections are market_data and streaming, each channel selected independently (MarketDataEnvironment PROD / STAGE, StreamingEnvironment PROD / DEV) via per-binding setters and the THETADATA_MARKET_DATA_* / THETADATA_STREAMING_* environment variables; an unrecognized or cross-channel value is a hard error naming the key and the valid set.
  • Flat-file formats and parity. FlatFileFormat covers Csv, Jsonl, Json (a single JSON array), and Html (an HTML table) on the SDK surface, restricted to the datasets actually served — option trade_quote / open_interest / eod and stock trade_quote / eod, with an unsupported pair rejected by a typed error before any network round-trip. A machine-checked cross-binding parity contract (parity.toml) covers value-field types and the per-endpoint async and streaming families. A from_file client-construction convenience defaults to the production configuration, list-endpoint results are sorted, and every binding carries generated trade-flag accessors.
  • C ABI and layout. ThetaDataDxContract.strike is double dollars with a trailing int32_t strike_thousandths; per-tick right fields are uint32_t Unicode scalars; and ThetaDataDxCalendarDay.is_open is a C99 bool. The workspace builds and publishes a single thetadatadx artifact — every row carries its price as decoded f64 dollars, and time-and-calendar support is a private internal module.

Security

  • Every client TLS configuration is built with an explicit ring crypto provider and explicit protocol versions rather than a process-global default, so ring is the sole provider in the graph and a connect never depends on an installed default.
  • The streaming login wipes the account password from memory the moment the login frame is sent, so the cleartext password is not retained in released heap or a buffered protocol frame after authentication, on the first connect and every reconnect.
  • Authentication errors carry only the HTTP status and never the upstream response body, the auth client does not follow redirects, and session UUIDs are redacted from Debug output.

Released under the Apache-2.0 License.