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_orderandthird_order;option/history/binomial_greeks/eod,all,first_order,second_orderandthird_order; andoption/history/binomial_trade_greeks/all,first_order,second_orderandthird_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 plusbinomial_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 carryingperf_boost_intraday=truewas answered at millisecond precision without saying so. It is now an optional boolean onoption/history/trade_greeks/all,first_order,second_order,third_orderandimplied_volatility, and on the binomial trade Greeks routes, from every binding and through the local server. The SDK sendsfalsewhen 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,ReconnectingandReconnectsExhaustedcarried onlyreason, 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 asUnspecified, and the client's own read deadline arrived asTimedOut, 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 andreasonis its code),ClosedByServer,ReadFailed,WriteFailedandReadTimeout. A read or write failure names the I/O error kind it failed with, for exampleConnectionReset, 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 reasonUnspecified, and is nowReadTimeoutwith reasonTimedOut, like a deadline that expires between frames. Every otherreasonvalue is unchanged, so code matching on them keeps working. The fields arecause/cause_name/cause_io_error_kind/cause_timeout_msin Python andcause/causeName/causeIoErrorKind/causeTimeoutMsin TypeScript. The C ABI event structs carrycause,cause_io_error_kindandcause_timeout_ms; C++ resolves the name withthetadatadx::cause_name, and the C header documents the cause codes and the callback lifetime ofcause_io_error_kindbeside the structs. Rust carries aDisconnectCausevalue. 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_streamingin Rust and Python,startStreamingin 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::Ioin Rust,NetworkErrorin Python, TypeScript and C++,THETADATADX_ERR_NETWORKon 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,DeadlineExceededErrorin Python, TypeScript and C++, andTHETADATADX_ERR_DEADLINE_EXCEEDEDon 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.
ThetaDataDxEndpointRequestOptionsgainsbinomial_stepsandperf_boost_intradaywith the presence flag every optional field on it carries. Thebinomial_stepspair sits ahead ofunderlyer_use_nbboand theperf_boost_intradaypair just after it, sounderlyer_use_nbbomoves 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_datebeside itsdate. 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 addunderlying_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::ReconnectingandStreamControl::ReconnectsExhaustedeach gainedcause: DisconnectCausebesidereason, so amatcharm that names their fields exhaustively no longer compiles until it addscauseor.., and code that builds one of these events must now supplycause. 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 besidereason, 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, orwith_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
asyncblock of its own, so the runtime'sblock_onand 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_timestampas 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 carriesunderlying_datebesideunderlying_ms_of_dayon every surface, the Rust, Python, TypeScript, C++ and C ABI rows, the Arrow, Polars and pandas frames and the MCP tool rows; theunderlying_timestamp_msaccessor on those ticks now combines the underlying's own date rather than the row's; and the local server buildsunderlying_timestampfrom 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_requestsis 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
strikeandrightrequired on every option route, andsymbolrequired 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, onstart_time,end_time,interval,venue,exclusive,rate_type,version,use_market_value,underlyer_use_nbboandrequest_type, so a reader can tell what each one does when it is left out;request_typeno longer offersOPEN_INTEREST, which no route serves.Both spellings of the option list-dates route publish the same rule for
expiration./v3/option/list/datesdocumented 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
strikeandrightas positional arguments, which no binding accepts: the methods takesymbolandexpirationpositionally 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 marksdateand 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_datestakes thestrikeandrightfilters 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*andbothon 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,dateandmarket_priceand 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 distinctIndexMarketValueevent rather than aMarketValuecarrying 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 aMARKET_VALUEframe carryingdate,ms_of_dayandmarket_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=STAGEor with the streaming environment setting each binding exposes (StreamingEnvironment::Stagein Rust,streaming_type="STAGE"in Python,streamingType: "STAGE"in TypeScript,streaming_environment("STAGE")in C++, selector2in the C ABI); the Python stub typesConfig.streaming_environmentasLiteral["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
entitlementstool 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 fromtools/listentirely 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-28revision 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 atinitialize. The server implementsserver/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-32022and the list of revisions it does speak, so the client can retry without a second round trip. Results carryresultType, andtools/listcarries thettlMsfreshness hint and acacheScopeofprivate, 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_newandformat=json_legacy. Both are names the vendor serves.json_newis the JSON shape the server already produced;json_legacyis 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_lastreturnedcondition_flags & 1,price_condition_set_lastreturnedprice_flags & 1, andis_incremental_volumereturnedvolume_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 carryprice_flagsvalues of 0, 1 and 3 andcondition_flagsof 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_hourscomparedms_of_dayagainst 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_sellerread 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 theregularTradingHoursandisSellerproperties on the TypeScript tick object. The columns they read,ms_of_dayandext_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.
0is no longer a wildcard forexpirationorstrike. The vendor defines*for every expiration and every strike, and the terminal refusesexpiration=0with HTTP 400. The SDK translated0to*before sending, so it accepted a form the vendor does not define. Every surface, the local server and the MCP tools now refuse0in 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::NoDatais 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 asError::Grpcwith kindNotFound(NotFoundErrorin every binding, withNoDataFoundErrorkept 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
awaitDrainresolved, 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 resolvesfalseon 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_pricecarried 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
statustofull_close, soCalendarDay(date=...)reported the market shut that day when the caller had said nothing.full_closeis 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
REGULARand 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 atcolumns(), 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_volatilityandbid_implied_volatilitymap toIMPLIED_VOL,ASK_IMPLIED_VOLandBID_IMPLIED_VOL, andunderlying_ms_of_dayandquote_ms_of_dayare 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
symbolcolumn 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 isCBOEhere andXCBOas 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,strikeandrightwere documented as populated on a wildcard query only, reading0on 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, and0only 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_quotepairs each trade with the quote strictly before it by default. Theexclusiveargument onoption_history_trade_quoteandstock_history_trade_quotedefaulted 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 omittedexclusivematches the terminal. Pass it as false for the previous pairing.The MCP server still answers
2025-11-25and2024-11-05clients unchanged. Theinitializehandshake, the negotiatedprotocolVersionin its result and the existing tool surface all behave as before; an older client sends no revision in_metaand 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_arrowreturns anarrow_array::RecordBatchandto_polarsapolars::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
REGULARand 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 nowint | Nonein Python,number | nullin TypeScript, an Arrow null in every columnar reader,nullin the server's JSON and the MCP tools' rows, and an empty field in the server's CSV. In C and C++ each carries abool has_<column>beside its value, which changes the size of several tick structs; rebuild against the new header.panic_count()anddropped_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 momentstop_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_streamingrefuses a callback that is not callable. It raisesInvalidParameterErrorat 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
.000and 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
polarsandframesfeatures 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::Grpccarries the HTTP status the service attached to a failure. The newhttp_status_codefield holds the status from the service'shttp_status_codetrailer, 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 buildsError::Grpcor 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_rangeaccepts 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
startStreamingdocumentation 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
OHLCandTRADEshare one subscription. It said OHLC bars are not subscribed to separately, yetreq_type: OHLCinstalls the contract's trade subscription, so removing it withadd: falsealso stops that contract'sTRADEframes. There is one trade subscription per contract and it carries both.
Fixed
A Python
timeargument keeps its milliseconds. The extractor formatted atimeordatetimewithstrftime("%H:%M:%S"), so sub-second precision was dropped before the value reached the core.time(9, 30, 0, 123000)asked the server for09:30:00.000while 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 becamefield & 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
4294967295decoded to2147483647here and-1in 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
symbolfrom 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 itsexpiration,strikeandrightfilled 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
initializeat once and connects in the background, because a client that waits on the handshake times out.tools/listanswered 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
DISCONNECTEDframe 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 publishesReconnectsExhaustedand leaves its loop, but the status went on reportingReconnectingfor 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.ConnectionStatushas a terminalReconnectsExhaustedvariant.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
tracingevent 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
InvalidCredentialsand permanent, so the retry stopped. It now reads asUnspecifiedon 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-featuresagainst the published crate failed.The C++ streaming example compiles under C++17. The README and the
Contract::optiondoc 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 nextreconnect()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 withTHETADATADX_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 batchesloop 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.
ReconnectsExhaustedis 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,ReconnectsExhaustedarrived before theDisconnectedevent 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_LIMITin 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 indroppedEventCount(), 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 asstock_history_eodthe 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 = 0does. 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 atwith_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_dateandend_datefields. 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_callbackthrowsStreamErroron 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 afterstop_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
ThetaDataErrorand anInvalidParameterErrorhandler missed it, and the MCP tools wrote JSON Lines forjsonwhere 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_TYPEorTHETADATA_STREAMING_TYPEraises an error you can catch. The TypeScriptConfig.production(),dev()andstage()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 asTHETADATA_MARKET_DATA_TYPE=productionaborted the whole Node process. In Python the same presets raised aPanicException, whichexcept Exceptiondoes not catch. Both now raiseInvalidParameterErrornaming 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'sstream_asyncand TypeScript's*Streammethods 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
datetimepassed to a Python date or time argument asks for the instant it names. These arguments were formatted withstrftime, which ignorestzinfo, sodatetime(2024, 3, 15, 13, 30, tzinfo=timezone.utc), 09:30 in New York, asked for 13:30 Eastern. An awaredatetimeis now converted to the Eastern date and time of its instant, and an awaretime, which cannot be placed in Eastern Time without a date, raisesValueError. 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 interruptsfor batch in reader. The async pull ran on a blocking thread that cannot be cancelled, so after anasyncio.wait_fortimeout 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_mson the Python and TypeScript list endpoints means what it means everywhere else. On the symbol, date, expiration and strike lists,0ran under the configured 300-second request timeout instead of disabling the deadline (TypeScript'soptionListDatesalready treated it as no deadline), and a larger value was still cut off by that timeout. Unset now means the configured timeout,0means 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>ToArrowIpcexport with a field outside its 32-bit column (for examplemsOfDay: 2 ** 32 + 50_040_000,1.5orNaN) was wrapped or truncated into the Arrow output, andclient.stream.batches({ batchSize, lingerMs, capacity })turnedNaNandInfinityinto 0. Both now raiseInvalidParameterError, as Python does.AsyncClient.flatfile_to_path_asyncresolves. The async client looked the name up only on the flat-file namespace, where it does not live, and raisedAttributeError.TypeScript reports values in the documented shape.
FlatFileRowList.toJson()sorted every row's keys alphabetically; it now listssymbol,expiration,strike,right, then the vendor's columns in file order, as Python'sto_list()does.activeFullSubscriptions()returned"Option"and"Stock"instead of the declared"OPTION"and"STOCK". A partial restore after the standaloneStreamingClient.reconnect()rejected as a plainErrorthat neitherinstanceof StreamErrornorinstanceof ThetaDataErrormatched.Repeating
symbolon a snapshot route returns every symbol you asked for.GET /v3/stock/snapshot/quote?symbol=AAPL&symbol=MSFTanswered with MSFT's row alone. That is how an OpenAPI client, orrequestsgiven a list, sends thesymbolarray 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 asdateuses 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,SPYon 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 assymbol=,, 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_TRADESandFULL_OPEN_INTERESTwork over the WebSocket as documented, with nocontract. The server checked the contract symbol before it looked at the request type, so every documented full-stream command was answeredERRORwith'symbol' must be non-empty. A full stream is now planned fromsec_typealone.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_quotewithAAPL,MSFTreturned 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: 0on everystock_snapshot_traderow, a 0.0 bid and ask beside an index's market price, anddate: 0on the single-day calendar tools, while columns it does send were dropped: the bid, ask, midpoint and underlying of the implied-volatility tools,vwapon OHLC history, and the extended trade conditions. Rows now follow the response's own column set, and a closed day's open and close arenull.An MCP tool call with an argument the tool does not declare is refused by name. A misspelled filter such as
start_timwas 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/listalready waited for the startup connect, buttools/calldid 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_dayanddaterows, had noformatparameter, rejected the ISOYYYY-MM-DDdates the server takes, and described one JSON error body for every failure. It now declaresformaton 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
gammacolumn the endpoint does not return, the root full-trade example usedSecTypewithout 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-serverfailed to start with a missing-DLL error, andrequire('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
requirefailed 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.ziporthetadatadx_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,setRetryMaxAttemptsandsetFlatfilesMaxAttemptsrefused0at 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
StreamingClientin 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.sowas 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 publicShardPlanandShardQuerytypes. 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_mbconfig-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 setsmax_message_size_mbnow 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 (
authbeyondCredentials,backoffbeyondJitterMode,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 theauthorbackoffmodule 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). TheShardBand::Dateband (surfaced only throughError::PartialShardFetch) gains arightfield; 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 JSDate. ADateis 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 queried20231231). These parameters now accept the wire-format string exclusively. This is a breaking change to the TypeScript surface; format aDateyourself before passing it.The TypeScript TCP-keepalive setters name their argument
secs, notms.setStreamingKeepaliveIdleSecsandsetStreamingKeepaliveIntervalSecsstore seconds, but the parameter was labeledms, so an editor hint invited a 1000× unit mistake. The label is nowsecs, matching the Python binding; positional callers are unaffected.StreamingClient.streaming()returns aStandaloneStreamingSession. The standalone streaming client's session was typed asStreamingSession(aClientandStreamView), sosession.marketData,session.flatFiles, andsession.close()type-checked but wereundefinedat runtime. The standalone session is now its own type extending onlyStreamingClient, so the type surface matches what the object exposes.
Fixed
Rejected stream subscriptions now log at
warninstead ofdebug. A subscribe the server rejects (MaxStreamsReached,InvalidPerms, or a generic error) is dropped; recording that only atdebugleft a capped or unentitled stream looking live under a default log subscriber. It now logs atwarnwith 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 = "*"orexpiration = "*", 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, andat_timechains 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'sblock_on(synchronous streaming), where a reentrant synchronous call would deadlock on the held request permit or abort the runtime with a nestedblock_onbefore reaching the core guard. The binding now detects the re-entry on that thread and raisesHandlerReentrancyimmediately; issue the request outside the handler (for example from a background thread).Per-call
timeout_ms = 0disables the deadline on every endpoint family. On the Python and TypeScript list endpoints (option_list_expirations,stock_list_symbols, and the like) a zerotimeout_ms/timeoutMsraced the call against a zero-duration timer and failed immediately, while the history and snapshot endpoints read zero as "no deadline" (matchingwith_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, sosetMaxConcurrentRequests(-1)wrapped to4294967295and drove a multi-billion-entry pool allocation,1.5truncated to1, and2**32wrapped to0. The setters now take the value as a number and reject a non-finite, negative, fractional, or over-u32input withInvalidParameterError; the port setters keep their0..=65535range check on top.A rejected BigInt config value names its actual cause. A negative or over-
u64BigInt passed to a*Msconfig 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-u64magnitude). The error stays a plainError, matching the built-inOverflowErrorthe 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_typeand 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 mapssec_type(STOCK/INDEX, a null or empty value defaulting to stock for compatibility, any other value rejected asTHETADATADX_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_streamingor assigned toreconnect_callbackwas accepted and only failed later on a delivery thread as an unraisableTypeError. Both now raiseInvalidParameterErrorsynchronously.Contract.option(right=...)accepts aRightenum. The Python contract builder tookrightas a plainstr, so passing the SDK's ownRight.CALLraised aTypeError; it now accepts aRight(through its.value) as well as a string.Client.from_dotenvkeeps the file's environment and host settings when a single channel selector is overridden. Passing onlymarket_data_type=(orstreaming_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.AsyncClientsurfaces the StreamView health getters and the flat-file async terminals. The async proxy's allowlist omittedis_authenticated,millis_since_last_event,last_event_received_at_unix_nanos,last_connected_addr, andbatches, so those valid operations raisedAttributeError, and every*_asyncname routed tomarket_data, soflatfile_to_path_asynccould not resolve. The health getters resolve like their sibling diagnostics, and a flat-file*_asyncname falls back to theflat_filesnamespace.Stream::batchesrejects a negative linger. A negativelingeron the C++ Arrow batch reader silently became an immediate flush; it now throwsInvalidParameterError, matching the Python and TypeScript bindings.A malformed endpoint argument is reported as
THETADATADX_ERR_INVALID_PARAMETERon the C ABI. A rejected request-option argument (for example a boolean that is neither0nor1) set the untypedTHETADATADX_ERR_OTHER, so the C++ wrapper threw the generic base exception instead ofInvalidParameterError; 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.tstype — 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++ reconnectuser_datalifetime and the tick-chunk callback's possible worker-thread affinity are stated, the Pythonfrom_envhelpers name theInvalidParameterErrora missingTHETADATA_API_KEYactually raises rather thanConfigError, and the streaming shutdown docs no longer claim a repeatedvoidshutdown 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_requestsconfig field overrides it with no client-side cap. The SDK previously treated the tier's2^tierconcurrency (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: leavingmax_concurrent_requestsunset 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 = 32in Python,setMaxConcurrentRequestsin TypeScript,set_max_concurrent_requestsin C++ and the C ABI,[market_data] max_concurrent_requestsin the TOML config file), and bulk-fetch sharding fans out across the full resolved pool — a boosted account now shards past the old2^tierceiling. The server remains the enforcement point: requests past the account's real allowance are rejected asResourceExhaustedand 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 withright = "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. Underbulk_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, orbulk_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_planis nowfn(&self, endpoint, query) -> Option<ShardPlan>(wasasync 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.awaitand theResulthandling. This is a breaking change to the Rust API.A failed band no longer takes down a large sharded pull. A buffered
.awaitband 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_asyncpull, 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 newError::PartialShardFetchnaming 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 sizewith_deadline/timeout_msto the whole pull. Mapped toStreamError/THETADATADX_ERR_STREAMon 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::HandlerReentrancyimmediately, 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
debugwhy 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_timehistory now shards along the date axis.stock_at_time_trade,stock_at_time_quote, andindex_at_time_pricereturn 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, orbulk_fetch = "off", when strict date order matters. A*option-chainat_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 optionat_timeis 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), andbackoff(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).spinandbusyspinboth hold ~100% of one core and differ only in jitter; onlyparkandbackofflower idle CPU.parkandbackoffsleep forpark_interval_usmicroseconds (default1000= 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 inconfig.tomlunder[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
.awaiton a history builder, or a chunk-streaming.stream/.stream_asynccall — 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 withmarket_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) andmarket_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 viaMarketDataClient::bulk_fetch_planand run the bands under their own concurrency.
Changed
ping_interval_msdefault is now100, matching the terminal. The client heartbeat previously defaulted to250 ms; the Theta Terminal pings on a fixed100 msperiod, and withflush_moderemoved 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 one100 msinterval instead of250 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_kbfield is renamed tostream_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/22^31 - 1byte window cap — so bulk pulls are no longer throttled by a 1 MiB per-stream ceiling, and the defaults are raised tostream_window_size_kb = 8192/connection_window_size_kb = 16384. The Rust fieldMarketDataConfig::window_size_kband the[grpc] window_size_kbconfig-file key are renamed tostream_window_size_kb; because the grpc config section rejects unknown keys, an existingconfig.tomlusing the old key now fails to load until it is renamed. This is a breaking change to the configuration surface.
Removed
flush_modestreaming write-flush knob. Theflush_modesetting is removed from every binding (RustStreamingConfig::flush_mode, PythonConfig.flush_mode, TypeScriptConfig.flushMode/setFlushMode, C++set_flush_mode/get_flush_mode, C ABIthetadatadx_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_seedstreaming host-ordering knobs. Removed from every binding (RustStreamingConfig::host_selection/host_shuffle_seed, PythonConfig.streaming_host_selection/streaming_host_shuffle_seed, TypeScriptConfig.streamingHostSelection/setStreamingHostSelectionand the shuffle-seed pair, C++set_streaming_host_selection/get_streaming_host_selectionand the shuffle-seed pair, C ABIthetadatadx_config_*_streaming_host_selection/_host_shuffle_seed), along with theHostSelectionPolicyenum. 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_jittertrimmed to"full"/"none". The"equal"and"decorrelated"jitter variants are removed from every binding (theJitterModeenum drops them, and the C-ABI integer encoding is now0 = 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. Settingreconnect_jitterto"equal"or"decorrelated"is now rejected. The C-ABI integer encoding renumbered —1now meansNone(it wasEqual), and2/3are rejected — so a C caller that passed a numeric jitter code must be re-audited: passing1now selects no jitter rather than erroring. This is a breaking change to the configuration surface.Config-file migration. A
config.tomlthat still setsflush_mode,streaming.host_selection,streaming.host_shuffle_seed, or areconnect_jitterof"equal"/"decorrelated"now fails to load with a typed error rather than being silently ignored — remove those keys / values when upgrading.QuoteTick.midpointderived column. Removed from every binding (PythonQuoteTick.midpoint, TypeScriptQuoteTick.midpoint, CThetaDataDxQuoteTick.midpoint, and the Arrow / Polarsmidpointcolumn). The quote wire carriesbidandask;midpointwas an SDK-computed(bid + ask) / 2column the server never sends and the terminal never exposes — compute it frombid/askat the call site if you need it. TheIvTick.midpointfield 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 thelist_*returns.intervalis forwarded verbatim. Theintervalargument 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 theintervalparameter.CalendarDay.is_openderived boolean. Removed from every binding (PythonCalendarDay.is_open, TypeScriptCalendarDay.isOpen, CThetaDataDxCalendarDay.is_open, and the Arrow / Polarsis_opencolumn). The calendar wire carries a single day-type column;is_openwas an SDK-derived boolean (status in {open, early_close}) that the terminal never exposes. Readstatusinstead — it carries the fullopen/early_close/full_close/weekendvocabulary — and treatopen/early_closeas trading days. This is a breaking change to the calendar tick schema.
Fixed
A normal
cargo buildof the Rust crate no longer requires a systemprotoc. The crate now ships the pre-generated gRPC client code (committed atproto/beta_endpoints.snapshot.rsand included directly), so a default build never shells out toprotoc— fixing build failures for users who lack a runnableprotoc. The proto compile and its byte-for-byte drift check againstmdds.protoare gated behind thegrpc-codegencargo feature (andprost-buildmoves out of the default build graph); regenerate the snapshot withcargo run -p thetadatadx-rs --bin refresh_grpc_snapshot --features grpc-codegen. CI still drift-checks the snapshot under that feature, so an editedmdds.protowith 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_msand 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 — theflat_files()namespace view plus theflatfile_request/flatfile_request_decodedentries and the five per-dataset convenience methods — matching the unifiedClientmethod-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++
FlatFilesview 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 plainconst(safe on a temporary), matching themarket_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 companionAsyncClient) exposes point-in-time history undermarket_data, real-time streaming understream, and whole-universe flat files on the client directly (client.market_data.stock_history_eod(...),client.stream.subscribe(...)). Single-purposeMarketDataClientandStreamingClientare available on every binding. API-key authentication is supplied inline, read fromTHETADATA_API_KEY, or loaded from a.envfile, 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/PUTrights, 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 unauthenticatedGET /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 acceptformat=html; flat-file downloads acceptcsv(default),json,ndjson/jsonl, andhtmlon the terminal'sGET /v3/{sec_type}/flat_file/{req_type}route. On the WebSocket, an optionstrikedefaults to the terminal's 1/10-cent integer (--strike-format dollarsswitches to a dollar value), and every message header carries the streamingstatus. - MCP server on npm. The
thetadatadx-mcp-serverserver runs withnpx -y thetadatadx-mcp-server(no Rust toolchain required;cargo installstays 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, optionopen_interest, the intraday greeks and trade-greeks families, and indexprice) takes an optionaldateplus optionalstart_date/end_date: supplydatefor a single day, or a range on the base route, matching the terminal. - Unambiguous field units and semantics.
strikemeans dollars on every typed surface (float/double), with the exact wire integer reachable under the unit-namedstrike_thousandths(a$550.00strike is550000). The optionrightis the logical character ('C'/'P'; auint32_tUnicode scalar in C).EodTicktime columns arecreated_ms_of_dayandlast_trade_ms_of_day.CalendarDay.is_openis a boolean andstatuscarries theopen/early_close/full_close/weekendvocabulary. Absent contract identity isNone/undefined(Arrow null) in Python and TypeScript; the C-layout rows carry documented fills withhas_contract_id().Contract.option(...)takes the leg as one namedOptionLeg { expiration, strike, right }. - Columnar and Arrow across every binding. A pull-based Arrow
RecordBatchreader,batches(), delivers decoded streaming events as Arrow record batches on a schedule you control (Rustfutures::Stream+.blocking(), Python sync/async iterable over the Arrow C-Data interface, TypeScriptAsyncIterable<RecordBatch>, C++arrow::RecordBatchReader), tunable bybatch_sizeand alingerflush withBlockorDropOldestbackpressure and adropped()/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>WithColumnsand the C / C++ column-presence out-param; multi-symbol snapshots attribute each row to its ownsymbol. 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::MarketValuewithmarket_bid/market_ask/ integer-midpointmarket_price, subscribed viaContract::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 })). PythonAsyncClientgains awaitable constructors and*_asyncflat-file twins; C++ gains an<endpoint>_async(...)companion returningstd::futurefor 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
ConfigErrorleaf for environmental faults,InvalidParameterErrorfor rejected configuration, sequence, and flat-file inputs (and, in Python, a subclass of the built-inValueError), andStreamErrorfor streaming faults. Python addsNotFoundError/DeadlineExceededError/UnavailableError(withNoDataFoundError/TimeoutErroraliases). Rate-limit errors expose the decoded server back-off (retry_after/retryAfter/retry_after(); the C-ABIthetadatadx_last_error_retry_after_ms()). - Observability and delivery controls. An optional
consumer_cpuknob pins the streaming consumer thread for low-jitter delivery, and epoch-instant accessors (*_timestamp_ms, plusthetadatadx::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_threadsknob. Config sections aremarket_dataandstreaming, each channel selected independently (MarketDataEnvironmentPROD/STAGE,StreamingEnvironmentPROD/DEV) via per-binding setters and theTHETADATA_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.
FlatFileFormatcoversCsv,Jsonl,Json(a single JSON array), andHtml(an HTML table) on the SDK surface, restricted to the datasets actually served — optiontrade_quote/open_interest/eodand stocktrade_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. Afrom_fileclient-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.strikeisdoubledollars with a trailingint32_t strike_thousandths; per-tickrightfields areuint32_tUnicode scalars; andThetaDataDxCalendarDay.is_openis a C99bool. The workspace builds and publishes a singlethetadatadxartifact — every row carries its price as decodedf64dollars, and time-and-calendar support is a private internal module.
Security
- Every client TLS configuration is built with an explicit
ringcrypto provider and explicit protocol versions rather than a process-global default, soringis 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
Debugoutput.