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.

Unreleased ​

0.6.0 - 2026-10-08 ​

Added ​

  • The vendor's binomial Greeks endpoints are now served, on every surface. The vendor computes a second set of Greeks with a Leisen-Reimer binomial tree that allows early exercise at every node, so the values are American-style rather than the European values its Black-Scholes routes carry, and it serves them on thirteen routes: option/snapshot/binomial_greeks/ all, first_order, second_order and third_order; option/history/binomial_greeks/ eod, all, first_order, second_order and third_order; and option/history/binomial_trade_greeks/ all, first_order, second_order and third_order. This SDK served none of them, so anyone who wanted them had to run the terminal beside it. Each route takes the parameters of its non-binomial counterpart plus binomial_steps, the number of steps in the tree, and returns the same columns as that counterpart. The SDK sends the vendor's default of 101 steps when the caller leaves the parameter unset; the vendor accepts 5 to 201, rounds an even value up to the next odd one, and caps a request for every expiration at 101. The routes reach Rust, Python, TypeScript, C++ and the C ABI, the local server answers them on the vendor's own paths, and the MCP server advertises one tool for each.

  • The option trade Greeks history endpoints take perf_boost_intraday. The vendor answers a current-day query faster when it may look the underlying price up with 1-second precision instead of millisecond precision. This SDK had no way to ask for that: no binding exposed the parameter and the local server did not recognise the name, so a request carrying perf_boost_intraday=true was answered at millisecond precision without saying so. It is now an optional boolean on option/history/trade_greeks/ all, first_order, second_order, third_order and implied_volatility, and on the binomial trade Greeks routes, from every binding and through the local server. The SDK sends false when the caller leaves it unset, which is what the terminal sends, so existing calls are unchanged.

  • A dropped stream says which way it ended. Disconnected, Reconnecting and ReconnectsExhausted carried only reason, which is the server's own disconnect vocabulary, and that vocabulary has no word for the ways a connection ends without the server saying anything. Three of them, the server closing the socket with no message, a read failing and a write failing, all arrived as Unspecified, and the client's own read deadline arrived as TimedOut, indistinguishable from the server sending code 4. The log lines said which of these had happened, but the event a program dispatches on did not, so a handler watching a session that logged in and then dropped had nothing to act on. Each of the three events now carries a cause beside the reason: ServerSent (the server sent a disconnect message and reason is its code), ClosedByServer, ReadFailed, WriteFailed and ReadTimeout. A read or write failure names the I/O error kind it failed with, for example ConnectionReset, and a read timeout reports the deadline that expired. The read deadline now reports the same way whether or not part of a frame had arrived when the peer went silent: a stall part-way through a frame was reported as a read failure with reason Unspecified, and is now ReadTimeout with reason TimedOut, like a deadline that expires between frames. Every other reason value is unchanged, so code matching on them keeps working. The fields are cause / cause_name / cause_io_error_kind / cause_timeout_ms in Python and cause / causeName / causeIoErrorKind / causeTimeoutMs in TypeScript. The C ABI event structs carry cause, cause_io_error_kind and cause_timeout_ms; C++ resolves the name with thetadatadx::cause_name, and the C header documents the cause codes and the callback lifetime of cause_io_error_kind beside the structs. Rust carries a DisconnectCause value. The cause also goes on the log lines that already report a disconnect, so a log and an event agree on which way a session ended.

Changed ​

  • A streaming login that runs out its deadline raises the deadline error on every surface. Starting the stream (start_streaming in Rust and Python, startStreaming in TypeScript, registering the streaming callback in C++ and the C ABI) raised the network error when the server accepted the connection and never answered the login (Error::Io in Rust, NetworkError in Python, TypeScript and C++, THETADATADX_ERR_NETWORK on the C ABI), and the generic stream error when it went quiet part-way through a frame, so one failure gave two answers. Both now raise the deadline error: Error::Stream { kind: Timeout } in Rust, DeadlineExceededError in Python, TypeScript and C++, and THETADATADX_ERR_DEADLINE_EXCEEDED on the C ABI. A handler that caught the network error around starting the stream must also catch the deadline error.

  • The C ABI request-options struct carries the two new parameters, so a C or C++ program has to be rebuilt against the new header. ThetaDataDxEndpointRequestOptions gains binomial_steps and perf_boost_intraday with the presence flag every optional field on it carries. The binomial_steps pair sits ahead of underlyer_use_nbbo and the perf_boost_intraday pair just after it, so underlyer_use_nbbo moves from offset 184 to 192, the per-call deadline from 192 to 208, and the struct grows from 208 to 224 bytes on LP64. The caller fills this struct and the library reads it, so a program compiled against the previous header and linked against the new library hands over a 208-byte object the library reads at the new offsets: it takes the moved members from the wrong bytes and reads the deadline past the end of the object. Rebuild against the new header.

  • The Greeks tick structs carry the underlying's date, which breaks Rust code that builds or exhaustively destructures them and C or C++ code built against the previous header. Every Greeks and trade-Greeks tick gains underlying_date beside its date. In Rust the eleven structs are constructible and carry no #[non_exhaustive], so a struct literal or a pattern that names every field has to add underlying_date, or ... This is a breaking change to the Rust API. The Python and TypeScript surfaces only gain a field. In C and C++ the new member moves the contract identity members that follow it and changes the size of several of the structs, so a program compiled against the previous header and linked against the new library would read those members from the offsets they used to sit at; rebuild against the new header.

  • The three disconnect events carry one more field in Rust. StreamControl::Disconnected, StreamControl::Reconnecting and StreamControl::ReconnectsExhausted each gained cause: DisconnectCause beside reason, so a match arm that names their fields exhaustively no longer compiles until it adds cause or .., and code that builds one of these events must now supply cause. The variants were left constructible rather than marked #[non_exhaustive], because that would also have stopped any crate outside the core from building one of these events, which a test harness for an event handler legitimately does. This is a breaking change to the Rust API. The Python and TypeScript surfaces only gain fields; in C and C++ the three event structs carry the cause fields beside reason, which moves the fields after it, so a C or C++ program has to be rebuilt against the new header.

Fixed ​

  • A request sent as its connection closes now fails at once instead of waiting. When the market-data connection ended at the instant a request was being queued on it, that request could miss the connection's final cleanup and stay queued until another call reached the same channel. With the per-call deadline disabled (timeout_ms = 0, or with_deadline(Duration::ZERO) in Rust) nothing else bounded that wait, so a call on an otherwise idle client could hang indefinitely; with a deadline it failed only when the deadline expired. Each connection's request queue is now released the moment the connection ends, so such a request fails immediately with a connection-closed error, which the retry path already treats as transient and re-sends on a fresh connection. Flow-control windows, keepalive, deadlines and error classification are unchanged.

  • Building the C ABI from source takes a fraction of the time and memory it did. Every generated endpoint entry wrapped the shared endpoint dispatch in an async block of its own, so the runtime's block_on and the dispatch future, which spans every endpoint, were compiled once per endpoint, and the streaming entries did the same with a handler type of their own. The cost grew with the square of the endpoint count, enough for the release build to exhaust the memory of a typical CI runner. Every entry now calls one shared function per dispatch kind, so the cost grows with the endpoint count instead. Nothing changes at the C ABI.

  • The Greeks rows publish the date the vendor sent for the underlying price instead of borrowing the option's. The vendor sends underlying_timestamp as a full moment, the instant of the underlying price the Greeks were computed from. The decoder kept only its time of day and dropped its date, so the only date any consumer had was the option row's, and the local server rebuilt the published timestamp from that. The two are not the same day whenever the option quote and the underlying price come from different sessions, which is every snapshot taken outside the option's own: the option carries its last quote from the previous session and the underlying its latest price from the current one. The published moment then carried the vendor's time of day on the option's date, a day early, and no binding could correct it because the underlying's date never reached them. Each Greeks tick now carries underlying_date beside underlying_ms_of_day on every surface, the Rust, Python, TypeScript, C++ and C ABI rows, the Arrow, Polars and pandas frames and the MCP tool rows; the underlying_timestamp_ms accessor on those ticks now combines the underlying's own date rather than the row's; and the local server builds underlying_timestamp from the underlying's date in every output format. A response that omits the column leaves the date unset rather than standing in a fabricated one.

  • A client no longer rate-limits itself out of the vendor's services. The vendor blocks a public address that opens about 20 new connections to its streaming or market-data services within 2 seconds, and the block can last an hour. While it holds, every new connection from that address times out, so a client cannot recover until it clears. Nothing here paced new connections, and two ordinary paths reached that rate on their own: a market-data client opened its whole channel pool back to back, 8 connections on the Pro tier and more when max_concurrent_requests is raised, and during a short outage every retry of a request whose connection had gone dialled again, with one retry per pool channel running at a time. A server-side failure of a few seconds could therefore end in an hour-long block, far longer than the fault that caused it. New connections now take a slot in one process-wide budget shared by the market-data pool, the streaming client and flat-file downloads, which hands out at most 12 in any 2-second window and opens the first few immediately, so a pool opens over about a second rather than all at once. Waiting for a slot stays inside the caller's own deadline and cancellation, and the connect timeout still bounds the connect itself rather than the wait for a slot. A slot is spent only when a connection is opened, so an attempt abandoned while it waits costs nothing, and a streaming client that is dropped stops waiting at once. Requests that queue behind a dial on the same channel now share its outcome, so one failed dial fails them all instead of each opening another connection against a peer that has just refused one. The connect timeout also covers the HTTP/2 session setup now, so no phase of a dial to a stalled peer is unbounded. Established connections never touch the budget, so steady-state request handling is unchanged.

  • The published REST contract no longer demands parameters the API treats as optional. It marked strike and right required on every option route, and symbol required on both spellings of the contract listing, so a client generated from it could not ask for a whole chain, for both sides of one, or for every contract traded on a date without being made to supply a value the server does not need. All three are optional now and each records the value an omitted request takes: every strike, both sides, and the full contract universe for the date. The contract also carries the defaults and the permitted values it had been leaving out, on start_time, end_time, interval, venue, exclusive, rate_type, version, use_market_value, underlyer_use_nbbo and request_type, so a reader can tell what each one does when it is left out; request_type no longer offers OPEN_INTEREST, which no route serves.

  • Both spellings of the option list-dates route publish the same rule for expiration. /v3/option/list/dates documented a parameter that rejects the * wildcard while its path-segment form documented one that accepts it, so a client generated from the contract carried two methods for one endpoint that disagreed on whether a chain-wide request was valid. Both now carry the rule the vendor publishes for that route, which rejects the wildcard.

  • Every code example in the published contract is a call that works as written. Most of them passed the optional strike and right as positional arguments, which no binding accepts: the methods take symbol and expiration positionally and every optional by name, so a reader who copied a snippet got a call that does not compile or raises a type error. The examples are now rendered from the endpoint registry, which records which parameters a call takes positionally and which are optional, in the shape each binding exposes: a Rust builder chain, Python keyword arguments, and a C++ options object with fluent setters. A history example carries a date: the vendor marks date and the date range optional on those routes and then refuses a request that gives neither, so an example without one could only fail. A multi-symbol example names its vector rather than passing a bare braced list, which is ambiguous between the single-symbol and multi-symbol overloads in C++ and does not compile. Every C++ example now compiles against the shipped headers, and the values the examples pass are the ones the generated live validators send.

  • The package pages count the endpoints the SDK exposes. Every README advertised 64 typed endpoints, which counted the four streaming builders as endpoints of their own. They are the callback delivery mode of endpoints already counted, on the same paths, so the pages now state 73: one per endpoint a caller can call, the binomial Greeks routes included.

  • The published contract states the subscription a route really needs. The contract listed the option contract listing and three calendar routes as available on the free tier, while the vendor sells them at the value tier and the reference page for each already showed that badge, so the site disagreed with itself and a reader could plan against a route their account cannot call. All of them now publish the tier the vendor states.

0.5.0 - 2026-10-01 ​

Added ​

  • option_list_dates takes the strike and right filters the vendor applies. The terminal narrows this route to one strike and one side and returns only the matching dates; this SDK declared both parameters but pinned them to * and both on the wire, so a filtered request came back as the list for the whole chain. Both now reach the wire from every binding and from the local server, which no longer refuses them. Setting neither sends the vendor's defaults, so an unfiltered call is unchanged.

  • An index market value arrives on its own event, with the figure the vendor serves. The vendor publishes a market value for an index, carrying ms_of_day, date and market_price and nothing else: an index has no NBBO, so there is no bid or ask beside the price and no midpoint between them. This SDK decoded every market-value frame as the eleven-field quote layout, while an index carries the eight-field trade layout, so an index market-value subscription reported itself active and never emitted a tick. It now decodes the layout the feed sends and delivers a distinct IndexMarketValue event rather than a MarketValue carrying two fields that do not apply. The vendor sells index market value as an indicative figure: its gateway serves the index level moved by a random 1 to 5 cents up or down, never exact, on the stream and on the snapshot alike, and the exact level is the index price subscription. The stream now serves that same figure, so an index market value reads the same here as from the vendor's own gateway. The local server forwards it on the WebSocket as a MARKET_VALUE frame carrying date, ms_of_day and market_price.

  • Streaming can target the vendor's staging cluster. The staging cluster runs the server build bound for production on the live feed and restarts often, so it is for validating against pre-release server changes rather than for data a caller depends on. Select it with THETADATA_STREAMING_TYPE=STAGE or with the streaming environment setting each binding exposes (StreamingEnvironment::Stage in Rust, streaming_type="STAGE" in Python, streamingType: "STAGE" in TypeScript, streaming_environment("STAGE") in C++, selector 2 in the C ABI); the Python stub types Config.streaming_environment as Literal["PROD", "STAGE", "DEV"]. The market-data staging preset still moves only the market-data channel.

  • The MCP server can report what the account is entitled to. A new entitlements tool returns the subscription tier for each asset class. Tools are advertised per asset class, so a class the account holds no tier for is withheld from tools/list entirely and a caller sees a whole family of tools missing with no way to learn why. This turns "those tools do not exist" into "you hold no options tier". Within a class every tool is advertised, so an individual endpoint can still refuse a call that needs a higher tier than the one held.

  • The MCP server speaks the 2026-07-28 revision of the Model Context Protocol. That revision drops the handshake: a client declares the revision it speaks on every request, in _meta, rather than agreeing one once at initialize. The server implements server/discover, the mandatory RPC that reports the revisions it speaks, its capabilities and its identity in a single call, so a client can pick a revision up front instead of probing. A request that declares a revision the server does not speak is refused with -32022 and the list of revisions it does speak, so the client can retry without a second round trip. Results carry resultType, and tools/list carries the ttlMs freshness hint and a cacheScope of private, because the advertised tool set depends on the authenticated account's subscription and a shared cache must never hand one caller's list to another.

  • The server accepts format=json_new and format=json_legacy. Both are names the vendor serves. json_new is the JSON shape the server already produced; json_legacy is one array per column keyed by column name, with no envelope. They used to be refused with a 400.

Removed ​

  • Three flag accessors are gone from the trade tick. trade_condition_no_last returned condition_flags & 1, price_condition_set_last returned price_flags & 1, and is_incremental_volume returned volume_type == 0, each naming a meaning for the value it read. The vendor does not define those three columns: it has told subscribers so directly, and its published documentation marks them reserved. Observed rows carry price_flags values of 0, 1 and 3 and condition_flags of 7, so these are multi-bit fields of which only the low bit was being named, under names chosen here. The columns themselves stay on the tick exactly as the vendor sends them, so a caller who has worked the bits out can still read them. Removed from the Rust, Python, TypeScript and C++ surfaces.

  • Two more flag accessors are gone from the trade tick. regular_trading_hours compared ms_of_day against two constants chosen here, hardcoding one venue's session into a field served for every market, so it was already wrong for an early close or a holiday; the calendar endpoint answers that question per date and per market. is_seller read condition 12, which is the vendor's SELLER settlement term, a delivery option of up to sixty days that the NYSE stopped accepting in 2017, not the side that took liquidity, so signed volume built on it was reading a delivery term as an aggressor side. Removed from the Rust, Python, TypeScript and C++ surfaces, along with the regularTradingHours and isSeller properties on the TypeScript tick object. The columns they read, ms_of_day and ext_condition1, stay on the tick exactly as the vendor sends them.

  • The metrics port is no longer a setter on the bindings. One schema row generated a getter and setter into the C ABI, C++, Python and TypeScript. The only reader of that value is the Prometheus exporter, whose body sits behind a cargo feature that no binding crate, no CI job and no release artifact enables, so setting the port succeeded on every binding, reported no error and could never take effect. The configuration field stays for an embedder who compiles the feature in and sets it directly.

  • 0 is no longer a wildcard for expiration or strike. The vendor defines * for every expiration and every strike, and the terminal refuses expiration=0 with HTTP 400. The SDK translated 0 to * before sending, so it accepted a form the vendor does not define. Every surface, the local server and the MCP tools now refuse 0 in either parameter; use *.

  • The server no longer rewrites a CSV text cell that starts with =, +, -, @ or a tab. It prefixed such a cell with ', so a reader decoded a different value than the one the vendor sent, and the terminal does not do it. Cells are written as sent, with RFC 4180 quoting.

  • Error::NoData is gone. It was documented as the error for a request that returned no rows, but nothing in the SDK raised it. A query with no rows arrives as Error::Grpc with kind NotFound (NotFoundError in every binding, with NoDataFoundError kept as its Python alias), and an empty stream returns an empty table, so a branch matching the variant could never run. This is a breaking change to the Rust API.

Changed ​

  • The TypeScript drain barrier says what it guarantees. The README told a reader that once awaitDrain resolved, the callback had stopped firing and any state it closed over could be released. It waits for the consumer thread to stop reading events and calling the callback; the consumer hands each event to the JavaScript event loop, so callbacks already queued can still run afterwards. The call also resolves false on timeout, which the sentence read as success. Releasing callback-owned state on that signal followed the documentation and outlived nothing.

  • The C ABI describes an index market price correctly. Its documentation is keyed on the column name, so market_price carried one description everywhere it appears. On the index market value, which has no bid and no ask, the C header called it the midpoint of two fields the event does not have. A column whose meaning is particular to its event now carries its own description in the schema, and a doc naming a sibling the event does not carry fails the build.

  • A calendar day must be given its classification. The Python constructor defaulted status to full_close, so CalendarDay(date=...) reported the market shut that day when the caller had said nothing. full_close is one of the vendor's four real day types, not a way to say unknown, and the same shape as the other substitutions removed this cycle. The argument is required now.

  • A tick column says when a zero may not be a value. Quote and trade condition codes and exchange codes all start at zero and the vendor assigns that code a meaning: quote condition 0 is REGULAR and exchange 0 is the composite. A response shape that carries no such column leaves the field at zero, which reads the same as a reported one. The field documentation now says so and points at columns(), which records what the response actually carried.

  • A renamed column carries the vendor's name for it. Five column names here resolve onto four of the vendor's: implied_volatility, ask_implied_volatility and bid_implied_volatility map to IMPLIED_VOL, ASK_IMPLIED_VOL and BID_IMPLIED_VOL, and underlying_ms_of_day and quote_ms_of_day are one vendor column, MS_OF_DAY2, under two names depending on which tick holds it. Each field now records the vendor's own name, so a reader of the reference pages can resolve it without guessing.

  • The 0.1.0 notes below no longer describe a slow-callback watchdog. They named slow_callback_count() and a microsecond threshold setter. Neither shipped in 0.1.0: the watchdog was removed before that tag, and the entry still described it.

  • A subscription the feed does not publish is refused. An index has no quote stream and a stock has no open-interest stream: the server accepts either subscribe, answers that it is subscribed, and then never sends a tick, so the caller waits on a book that stays silent for the life of the connection. Both are now refused at the subscribe boundary, naming what that contract kind does publish. The streaming guide offered both and now says what each kind carries.

  • The error reference describes the TypeScript error hierarchy the binding ships. It told TypeScript callers to recognise a failure from the message text because there was no class tree. The binding throws a typed subclass, the same hierarchy the other bindings expose, with the retry hint on the rate-limit error, so an instance check works and a pattern over the message was never needed.

  • The README states the subscription tier streaming actually requires. It drew the line between free and paid. The vendor draws it between Value and Standard, so a Value subscriber, who is paying, followed the front page to a streaming client whose login is refused.

  • The streaming delta decoder no longer keeps a second per-contract map. It recorded a field width that was always the width the caller had just passed in, read it back through a fallback that could not fire, and returned it to callers that all discarded it, at the cost of a hash lookup per tick on a path that runs per message.

  • The exchange symbol column is described as what it is. It was documented in three places as a MIC identifier. It is the vendor's short symbol: code 5 is CBOE here and XCBO as a MIC, and the vendor carries the MIC as a separate field. A caller who read the documentation and joined the column against ISO 10383 got no matches and no error. The value and the field name are unchanged.

  • An option tick documents its contract fields as the vendor fills them. expiration, strike and right were documented as populated on a wildcard query only, reading 0 on a single-contract query. The vendor sends all three on a single-contract response too: history trade, quote, OHLC, end of day, first-order greeks and open interest each return the contract's expiration, strike and right, the same values a wildcard query returns for it. A caller who followed the documentation skipped fields that are always filled. They are now documented as populated whenever the response carries them, and 0 only when it does not.

  • A shipped C++ header no longer names an internal transport. The layout-guard header, which reaches every C++ consumer through the public include path, described the pinned structs using the wire protocol's internal name rather than the channel vocabulary the rest of the surface uses.

  • trade_quote pairs each trade with the quote strictly before it by default. The exclusive argument on option_history_trade_quote and stock_history_trade_quote defaulted to false, which is the documented default in the vendor's protocol definition. The terminal does not use it: when the value is omitted the terminal sends true, pairing each trade with the last NBBO quote strictly before the trade rather than one that may carry the trade's own timestamp. A caller who omitted the argument got a different quote against the same trade than the terminal returned for the same request, a systematic divergence in the paired ask and size. The default is true on every surface now, so an omitted exclusive matches the terminal. Pass it as false for the previous pairing.

  • The MCP server still answers 2025-11-25 and 2024-11-05 clients unchanged. The initialize handshake, the negotiated protocolVersion in its result and the existing tool surface all behave as before; an older client sends no revision in _meta and is not asked to. Nothing in the tool set, the argument shapes or the returned rows changes with this revision.

  • Arrow moves to 60 and polars to 0.55. TickColumns::to_arrow returns an arrow_array::RecordBatch and to_polars a polars::prelude::DataFrame, so both crates are part of this SDK's public signature and a caller has to move with them. Recompiling against the previous major produces a type mismatch at those two call sites and nowhere else.

  • A condition or exchange cell the vendor left empty is published as empty, not as code zero. Zero is not a spare value on these columns: quote condition 0 is REGULAR and exchange 0 is the composite, so an empty cell used to read as a firm quote or a composite print the vendor never reported. The sixty-four condition and exchange columns are now int | None in Python, number | null in TypeScript, an Arrow null in every columnar reader, null in the server's JSON and the MCP tools' rows, and an empty field in the server's CSV. In C and C++ each carries a bool has_<column> beside its value, which changes the size of several tick structs; rebuild against the new header.

  • panic_count() and dropped_event_count() keep their count after the session ends. Both are documented as cumulative and were read off the live session alone, so they answered zero from the moment stop_streaming() returned and a reconnect discarded whatever the previous session had recorded. They now count across every session the client has run, including faults and drops recorded while a stopping session drains.

  • The standalone Python StreamingClient.start_streaming refuses a callback that is not callable. It raises InvalidParameterError at the call site, as the unified client already did. It used to connect and then fail on the first event, off the calling thread, so the caller saw a stream that came up and never delivered.

  • Every server timestamp carries three fraction digits. A whole second renders .000 and trailing zeros stay, which is how the terminal formats it; the fraction used to be dropped or trimmed.

  • Option contract blocks come back in the order the rows arrived. The server sorted them by the rendered contract identity, which compares the strike as text, so strike 1000 came back ahead of strike 90.

  • The minimum supported Rust is 1.94. Every crate declared 1.88, but the polars and frames features have not compiled on anything older than 1.94, so a user on 1.88 to 1.93 who enabled either one, as the crate README shows, got a compile error from inside a dependency rather than a clear refusal. Every crate now declares 1.94, the lowest release on which the full feature set builds, and Cargo names the required toolchain up front. CI now builds with that toolchain, so the floor cannot drift again unnoticed.

  • Error::Grpc carries the HTTP status the service attached to a failure. The new http_status_code field holds the status from the service's http_status_code trailer, which is the status the terminal answers a failed request with, and the server now answers with it (see Fixed). This is a breaking change to the Rust API: code that builds Error::Grpc or destructures it without .. must add the field.

  • An MCP tool call returns at most 50,000 rows and refuses a larger result by name. Nothing bounded a tool result before, so a default-argument call such as a same-day SPY quote history at every strike could return millions of rows, take gigabytes to serialise and stall every other request. The refusal states the row count and how to narrow the request, including a symbol filter for a contract list.

  • split_date_range accepts the same years as every endpoint. It used its own date validator and accepted years 0000 to 9999, while every request built from its chunks only accepts 1900 to 2100. It now applies the SDK's single calendar rule and refuses a year outside that range up front.

  • The TypeScript startStreaming documentation says which events an overflow drops. It said the oldest events are dropped once the delivery queue and the event ring fill. The incoming event is the one dropped and counted, so a consumer that falls behind drains the older backlog first and loses the most recent quotes and trades.

  • The WebSocket guide says that OHLC and TRADE share one subscription. It said OHLC bars are not subscribed to separately, yet req_type: OHLC installs the contract's trade subscription, so removing it with add: false also stops that contract's TRADE frames. There is one trade subscription per contract and it carries both.

Fixed ​

  • A Python time argument keeps its milliseconds. The extractor formatted a time or datetime with strftime("%H:%M:%S"), so sub-second precision was dropped before the value reached the core. time(9, 30, 0, 123000) asked the server for 09:30:00.000 while the string "09:30:00.123" asked for the instant it names, and an at-time query answers with the tick nearest what it was given, so two equivalent inputs returned different rows with nothing to show why. The core and the vendor both carry milliseconds; only this extractor discarded them.

  • A tick flag predicate cannot be built from a missing operand. The schema defaulted the bounds of a range and the mask of a bit test to zero, so an entry omitting one still generated. Dropping the lower bound of the cancelled-trade range widened it from 40..=44 to 0..=44, which covers REGULAR, and every ordinary trade would have reported itself cancelled on four surfaces. A bit test with no mask became field & 0 == 0, true for every input. Each operand is required for the kind that uses it, and the build stops with the accessor named.

  • An oversized wire integer decodes to the number the terminal reads. The FIT decoder accumulated digits into a 64-bit value and then clamped to the signed 32-bit range, while the terminal reads the same digits with 32-bit wrapping, so the ten-digit token 4294967295 decoded to 2147483647 here and -1 in the terminal. The value does not stop at the caller, because it seeds the delta baseline for that contract, so every later row for the field accumulated onto a number the terminal never held. The accumulator wraps now, matching it exactly. A digit run longer than ten no longer produces a saturated value either: the terminal does not deliver such a row, so the row is marked unusable here and rejected the same way a truncated one is.

  • A server row is labelled with the contract the wire sent. The server took each row's symbol from the request parameter, which for the snapshot family is a comma-separated list, so a six-symbol request returned six rows each labelled with the whole list; and an option row the wire did not identify had its expiration, strike and right filled in from the query. The decoder keeps the symbol the response carried, one per row when a response spans several underlyings and a single constant when it covers one, and the server now reads both. A value the wire did not send is left out, as the terminal leaves it out, instead of being filled in from the request.

  • The MCP tool list waits for the connect before it answers. The server answers initialize at once and connects in the background, because a client that waits on the handshake times out. tools/list answered from whatever the connection held the moment the request arrived, so a client that lists once at startup, as most do, got the offline set of one tool and kept it for the session with no way to learn why. The documented diagnosis named rejected credentials, which was rarely the cause. The listing now waits up to five seconds for the connect to settle, and falls back to the offline set beyond that, which is the honest answer while there is no connection.

  • A market value arrives while a quote stream is running. A contract's quote and market-value streams share one delta baseline upstream: the server encodes both against a single per-contract row. This SDK kept one baseline per message type, so a market-value row for a contract whose quote stream was already running arrived as a partial delta with no baseline of its own, was taken for a first absolute row, failed the exact-width check and was dropped. Every later market-value row for that contract did the same, so the stream went silent for the rest of the session while the quote stream went on accumulating onto a row the server had moved past, publishing prices that drifted further from the feed with no error and no counter. Both streams now accumulate onto the contract's single baseline.

  • Exchange codes 74 and 76 no longer name a venue. The exchange table reproduces the vendor's own table, including its oddities, but these two rows did not come from it: the vendor publishes both as unassigned placeholder slots, and they had been filled in locally with the names of two real venues. Because the lookup is a dense index, a populated row can never fall through to the unknown result, so a tick carrying either code was attributed to a venue the feed never identified. Both now return the vendor's own placeholder value.

  • Daylight saving before 1987 resolves to the rule that governed each year. Eastern-time conversion applied the first-Sunday-of-April rule to every year before 2007. That rule began in 1987; the Uniform Time Act put the start on the last Sunday of April, and 1974 and 1975 began in January and February under a separate act. Any April timestamp between 1967 and 1986 resolved an hour early. Each era now uses its own boundary, and the module states that no federal rule governed the years before 1967.

  • A calendar day type arrives as the text the vendor sends. The decoder also accepted an integer and resolved it through the SDK enum's declaration order, which is not a vendor coding: the calendar wire carries the day class as text and the vendor publishes no integer for it. A number is now refused rather than read under an ordering chosen here.

  • An unreadable disconnect on a flat-file stream is retried rather than reported as an authentication failure. A DISCONNECTED frame whose payload is too short to carry a reason had its reason substituted with zero, which is the vendor's live ordinal for invalid credentials and classifies as permanent, so a mid-frame reset stopped the retry and surfaced as an auth fault. The substitution is now the vendor's own unspecified reason, which classifies as transient.

  • connection_status() now answers when a session has stopped trying. A streaming session that exhausts its reconnect budget publishes ReconnectsExhausted and leaves its loop, but the status went on reporting Reconnecting for ever. The two states follow the same disconnect and want opposite responses: one is wait, the other is this session is over and a caller that wants a feed has to start another. Telling them apart meant watching the event stream and latching a flag, which every consumer that cared had to reinvent. ConnectionStatus has a terminal ReconnectsExhausted variant.

  • A Python market-data call no longer reacquires the GIL once per log event. The logging bridge acquired the GIL to ask Python whether each tracing event was enabled, including every event it then dropped. That is invisible on an idle interpreter and multiplies a call's wall time next to a busy Python thread, on the path that is meant to have released the GIL. The level is now cached per logger for 250 ms and checked first; a level you set takes effect within that window.

  • A flat-file login interrupted mid-frame retries instead of reporting bad credentials. A disconnect too short to state its reason was read as reason 0, which is InvalidCredentials and permanent, so the retry stopped. It now reads as Unspecified on the login path, as it already did on the download path.

  • Daylight saving time applies before 1970. A summer instant in 1967 to 1969 resolved to standard time and came back an hour early.

  • The packaged crate builds with every feature on. It listed the repository's maintenance binaries, which reach generator code the package does not carry, so cargo build --all-features against the published crate failed.

  • The C++ streaming example compiles under C++17. The README and the Contract::option doc used C++20 designated initializers and a missing include, against a project that requires C++17.

  • A failed reconnect() can be retried without losing subscriptions. A reconnect saved the live subscriptions, tore the old session down and started a new one. When the new session failed to start, for example while the network was still down, the saved set was dropped with the error, and the teardown had already cleared the old session's lists. The next reconnect() then succeeded on a session with no subscriptions: it reported itself as streaming while no market data arrived. A reconnect whose restart fails now keeps the subscriptions, and the next reconnect restores them. An explicit stop still drops them. The C and C++ reconnects now report subscriptions they could not restore with THETADATADX_ERR_STREAM (C++ StreamError) instead of returning success, as Python and TypeScript already did.

  • A columnar batch stream ends with an error when the streaming session will not reconnect. When the server ended the session for good, or the reconnect budget ran out, the batch stream closed exactly as it does after the caller's own close, so a for batch in batches loop exited without error on a dead feed. The stream now ends with a disconnected error, after every queued batch has been read.

  • Streaming failover moves past a host that accepts the TCP connection but cannot complete TLS. The dial loop chose the first host that accepted TCP and left the TLS handshake to the first write. A host whose server process was hung was therefore chosen every time, the handshake timed out, and reconnects spent their whole budget on that one host while the others were healthy. A cold connect failed outright the same way. The handshake now completes inside each host's attempt, so a TLS failure moves on to the next host.

  • A reconnect callback can stop the stream. The callback set through the reconnect policy runs on a streaming thread. When it stopped the stream or closed the client, the teardown waited on that same thread, so the call never returned, a core stayed busy for the life of the process and the connection leaked. In TypeScript, a stop issued while a decision was pending froze the Node event loop for 30 seconds. A stop from inside the callback now returns, a pending decision gives way to a stop within about 100 ms, and the session winds down without reconnecting.

  • ReconnectsExhausted is the last event of a session in every case. When the server ended a session mid-stream with a reason that is never retried, such as the account already being connected elsewhere, ReconnectsExhausted arrived before the Disconnected event that carried the reason. A consumer that stopped reading on the terminal event missed it.

  • A throttled streaming login raises the rate-limit error. When the server answered the initial streaming login with TOO_MANY_REQUESTS, the connect failed with a generic disconnected error, which every binding reports as a network error. The documented RateLimitError (THETADATADX_ERR_RATE_LIMIT in C) could never come from a streaming connect, and a caller retrying network errors redialled straight into the throttle.

  • A full-stream open-interest subscription is refused for anything other than options. Open interest counts option contracts outstanding. The full-stream subscribe accepted a stock request, tracked it and replayed it on every reconnect, but the server never sends a tick for it. It is now refused with an error saying open interest is published only for options.

  • TypeScript reconnect() keeps the events still waiting for the callback. The teardown aborted the callback after 250 ms even when the Node main thread was free and still delivering, so a session with a large backlog lost the queued events without counting them in droppedEventCount(), and the reconnect then failed with "streaming callback is closed" without restoring its subscriptions. The callback is now aborted only when the stop runs on the main thread, where the queue cannot drain.

  • Two flat-file requests for the same output path no longer corrupt each other. A request derived its raw download and temporary files from the output path alone, so two requests for the same path at overlapping times shared them, which is easy to reach through the MCP tool's fixed default path. The second request truncated the first one's temporary file mid-write, the first then published it with a zero-filled gap and reported success, and the second failed on its own rename. Each request now writes to scratch files of its own.

  • A flat-file login moves on to the next host when one is restarting. A host that answered the login with a restart or a login timeout stopped the attempt at that host, and every retry started again on the same host, so the whole retry budget could be spent on one host while the others were serving. Such a refusal now moves the login on to the next host. Refused credentials, a missing dataset and the account's request-rate limit still stop at once, the last waiting for the retry backoff because the limit applies on every host.

  • A date rejected by a typed endpoint names the argument that was wrong. Every date failure was reported against a parameter called date, so on a range endpoint such as stock_history_eod the message named a parameter the caller never passed and could not say which of the two dates failed.

  • The config template says what request_timeout_secs = 0 does. It said 0 disables the per-request market-data deadline. The SDK treats 0 as the 300-second default, so that a silent server cannot hang a request, and a long pull configured that way still timed out at five minutes. The template now says so and points at with_deadline(0), which lifts the deadline for a single request.

  • A C or C++ endpoint call always requests the symbol and dates passed as its arguments. The options bag is shared by every endpoint, so it has symbol, date, start_date and end_date fields. When one of these was set on a bag reused from another call, it replaced the endpoint's own argument of the same name: stock_history_eod("AAPL", ...) with a bag carrying another symbol returned that symbol's bars, and nothing in the rows showed it. The positional arguments now take precedence.

  • C++ Stream::set_callback throws StreamError on a live session instead of silently dropping its subscriptions. Registering a callback starts a new session, so replacing a live one left the stream running with no subscriptions while the call returned normally. The C API, Python and TypeScript already refused this. Replacing the callback after stop_streaming() still works.

  • Opening a C or C++ batch reader can no longer let a reconnect revive an old callback. A stopped callback session kept its callback registered for reconnect, so reconnecting after a batch reader was opened replaced the reader with a callback session that called the old callback, which the caller was allowed to have freed once stop and drain returned. Opening a reader now drops that registration.

  • An unknown flat-file security type, request type or format is reported the same way everywhere. The C ABI used the generic error code, so C++ threw the base ThetaDataError and an InvalidParameterError handler missed it, and the MCP tools wrote JSON Lines for json where every other surface writes one JSON array. All surfaces now share one parser and one message naming the parameter and the tokens it accepts (csv, json, jsonl or ndjson, and html for the format).

  • A bad THETADATA_MARKET_DATA_TYPE or THETADATA_STREAMING_TYPE raises an error you can catch. The TypeScript Config.production(), dev() and stage() factories, and every connect that falls back to the production configuration, panicked on an unrecognised environment selector, and a panic cannot leave a native Node callback, so a typo such as THETADATA_MARKET_DATA_TYPE=production aborted the whole Node process. In Python the same presets raised a PanicException, which except Exception does not catch. Both now raise InvalidParameterError naming the variable, its value and the valid set.

  • An exception raised by a history stream handler stops the stream. The Python .stream(handler) documentation promised this, but the handler had no way to stop the download: the rest of the range was still fetched and decoded, holding a request slot until the range ended or the deadline fired, and the first Ctrl+C inside the handler did not stop it. Python's stream_async and TypeScript's *Stream methods drained the same way. The first handler error now ends the request, including every sub-request of a fanned-out pull, and is raised from the call.

  • A timezone-aware datetime passed to a Python date or time argument asks for the instant it names. These arguments were formatted with strftime, which ignores tzinfo, so datetime(2024, 3, 15, 13, 30, tzinfo=timezone.utc), 09:30 in New York, asked for 13:30 Eastern. An aware datetime is now converted to the Eastern date and time of its instant, and an aware time, which cannot be placed in Eastern Time without a date, raises ValueError. Naive values are read as Eastern wall-clock time, as before.

  • Cancelling a Python await reader.__anext__() no longer loses a batch, and Ctrl+C interrupts for batch in reader. The async pull ran on a blocking thread that cannot be cancelled, so after an asyncio.wait_for timeout on a quiet stream that thread took the next batch for a result nobody read, and rows vanished without an error or a drop count. The sync pull did not check for signals, so Ctrl+C did nothing until a batch arrived.

  • The C, C++ and TypeScript streaming ring-size setters reject a size above 2^24. Each setter applied only part of the rule a connect applies, so a power of two such as 2^25 was accepted by a setter documented to reject invalid values, and then failed at connect.

  • timeout_ms on the Python and TypeScript list endpoints means what it means everywhere else. On the symbol, date, expiration and strike lists, 0 ran under the configured 300-second request timeout instead of disabling the deadline (TypeScript's optionListDates already treated it as no deadline), and a larger value was still cut off by that timeout. Unset now means the configured timeout, 0 means none, and any other value is the deadline.

  • TypeScript rejects a value its target cannot hold instead of rewriting it. A hand-built tick row passed to a <tick>ToArrowIpc export with a field outside its 32-bit column (for example msOfDay: 2 ** 32 + 50_040_000, 1.5 or NaN) was wrapped or truncated into the Arrow output, and client.stream.batches({ batchSize, lingerMs, capacity }) turned NaN and Infinity into 0. Both now raise InvalidParameterError, as Python does.

  • AsyncClient.flatfile_to_path_async resolves. The async client looked the name up only on the flat-file namespace, where it does not live, and raised AttributeError.

  • TypeScript reports values in the documented shape. FlatFileRowList.toJson() sorted every row's keys alphabetically; it now lists symbol, expiration, strike, right, then the vendor's columns in file order, as Python's to_list() does. activeFullSubscriptions() returned "Option" and "Stock" instead of the declared "OPTION" and "STOCK". A partial restore after the standalone StreamingClient.reconnect() rejected as a plain Error that neither instanceof StreamError nor instanceof ThetaDataError matched.

  • Repeating symbol on a snapshot route returns every symbol you asked for. GET /v3/stock/snapshot/quote?symbol=AAPL&symbol=MSFT answered with MSFT's row alone. That is how an OpenAPI client, or requests given a list, sends the symbol array these routes declare, so the caller silently lost part of its data. Like the terminal, the server now collects every value of a list parameter, and a repeated single-valued parameter such as date uses its first occurrence rather than its last.

  • A comma-separated symbol list on a list endpoint is answered as the vendor answers it, or refused before it is sent. The vendor documents symbol=AAPL,SPY on the contract, expiration, strike and date lists. The SDK sent the list as a single symbol, so the request came back as no data on every surface. Each symbol now goes on the wire as its own value. The contract list, and the stock and index date lists, return the union, as the terminal does. The expiration and strike lists return plain values that cannot say which symbol each belongs to, so a request naming more than one symbol there is refused before anything is sent, with an error that says why; request those one symbol at a time. A filter that names no symbol at all, such as symbol=,, is refused rather than sent as no filter, which on the contract list would have returned every contract for the date. The server answers these refusals, and any other input the SDK refuses, with 400 rather than 500, and the MCP tools answer them as invalid params rather than as a server error.

  • The server answers an upstream rejection with the HTTP status the service attaches to it. Every upstream failure other than capacity exhaustion came back as HTTP 500 with a body prefixed gRPC status <kind>:, so a query with no rows, an entitlement rejection and a bad argument all looked like a server crash, and clients that retry 5xx retried requests that could never succeed. Like the terminal, the server now answers with the status the service attaches (472 for a query with no rows) and the service's description as a plain-text body, and with 500 only when no status is attached.

  • Flat-file downloads through the server no longer fill the temp directory. Each download decoded the whole file into the OS temp directory and left it there, so every new dataset, date and format left another multi-gigabyte file behind until the disk ran out. The server now streams from a scratch file that the OS reclaims as soon as the response ends.

  • FULL_TRADES and FULL_OPEN_INTEREST work over the WebSocket as documented, with no contract. The server checked the contract symbol before it looked at the request type, so every documented full-stream command was answered ERROR with 'symbol' must be non-empty. A full stream is now planned from sec_type alone.

  • The WebSocket accepts every option expiration the REST routes accept. A separate range check refused expirations from 2100-01-02 through 2100-12-31, which the REST validators accept, with a different error message.

  • The MCP server's rows say which symbol each one belongs to. A multi-symbol snapshot such as stock_snapshot_quote with AAPL,MSFT returned rows that named no symbol, in the vendor's order and without a row for a symbol with no data, so a model could not tell which quote belonged to which ticker and tended to match them by request order. Every row now carries the symbol the response attributed it to, as the server's rows do.

  • The MCP server returns the columns the response carried, no more and no fewer. Columns the vendor does not send were filled with zeros, such as exchange: 0 on every stock_snapshot_trade row, a 0.0 bid and ask beside an index's market price, and date: 0 on the single-day calendar tools, while columns it does send were dropped: the bid, ask, midpoint and underlying of the implied-volatility tools, vwap on OHLC history, and the extended trade conditions. Rows now follow the response's own column set, and a closed day's open and close are null.

  • An MCP tool call with an argument the tool does not declare is refused by name. A misspelled filter such as start_tim was dropped and the endpoint answered the wider request, for example a full day of quotes instead of the window asked for, with nothing to say a filter had been ignored. The refusal lists the arguments the tool accepts.

  • MCP tool calls made while the server is still connecting wait for the connection. tools/list already waited for the startup connect, but tools/call did not, so a call in that window was told to set credentials that were already set.

  • The bundled OpenAPI file describes what the server accepts and returns. It modelled every route as a bare array of integer ms_of_day and date rows, had no format parameter, rejected the ISO YYYY-MM-DD dates the server takes, and described one JSON error body for every failure. It now declares format on every route, the response envelope and its legacy columnar form, the ISO date form, and which failures carry a JSON body.

  • The README and reference examples run as written. The first-order Greeks quick starts read a gamma column the endpoint does not return, the root full-trade example used SecType without importing it, the Python handler raised on every stock event, examples used dates on which nothing expires, the C++ tab on every reference page included a header path the SDK does not ship, the symbology page gave the server's WebSocket strike in dollars, and the READMEs counted 65 endpoints where the SDK ships 64.

  • The Windows downloads run without the Visual C++ runtime. The server, the MCP server package, the TypeScript addon and the C ABI DLL linked the MSVC C runtime dynamically, so each needed VCRUNTIME140.dll from the Visual C++ redistributable. On a clean Windows install the server and npx -y thetadatadx-mcp-server failed to start with a missing-DLL error, and require('thetadatadx-ts') could not load the addon. They now link the runtime statically, so nothing else needs installing, as the documentation already said.

  • The TypeScript addon loads on Linux distributions older than the build machine. The linux-x64-gnu addon required glibc 2.34, while npm installs it on any glibc host Node runs on. On RHEL 8, Debian 11 or Ubuntu 20.04 the install succeeded and the first require failed because GLIBC_2.34 was not found. It is now linked against glibc 2.17.

  • The free-threaded Python wheel installs on glibc 2.28 and newer. The cp314t wheel required glibc 2.34, so CPython 3.14t on RHEL 8, Debian 11 or Ubuntu 20.04 found no wheel and pip fell back to building from source, which needs a Rust toolchain and protoc. It is now built for manylinux_2_28.

  • The Windows C ABI libraries ship under the names their import libraries expect. The release page renamed the DLLs, but the import libraries beside them still name thetadatadx_ffi.dll, so a program linked against them failed to start until the DLL was renamed back. Each toolchain now ships as one archive, thetadatadx_ffi-windows-msvc-x86_64.zip or thetadatadx_ffi-windows-gnu-x86_64.zip, holding the DLL and its libraries under their original names, which are also the names the C++ CMake target looks for.

  • The documented MCP setup works for a final release. The docs-site page ran thetadatadx-mcp-server@next, a tag a final release never publishes, so npx failed to find a matching version. It now runs the latest release.

  • TypeScript leaves the attempt-budget ranges to connect, like every other binding. setReconnectMaxAttempts, setReconnectMaxRateLimitedAttempts, setReconnectMaxServerRestartAttempts, setReconnectReplayBurstSize, setRetryMaxAttempts and setFlatfilesMaxAttempts refused 0 at once but accepted any larger value, which connect then refused if it was above the ceiling, so the same mistake failed at a different point depending on the binding. Every out-of-range value is now refused when the configuration is validated, naming the field, as in Python, C++ and the C ABI. Negative, fractional and non-finite values are still refused at the setter.

  • A standalone streaming client validates its configuration when it is built. The standalone StreamingClient in Python, TypeScript and the C ABI skipped the validation the unified client's connect runs, so an out-of-range value such as a reconnect budget of 0 reached the reconnect driver instead of being refused. It is now refused at construction, naming the field.

  • The Linux C ABI library on the release page loads on older distributions. libthetadatadx_ffi.so was linked against the build machine's glibc, so a program using it on RHEL 8, Debian 11 or Ubuntu 20.04 failed to load it. It now links against glibc 2.17, the same floor as the TypeScript addon, and the release build fails if it ever requires anything newer.

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. 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.