Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **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 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). Rows come back in the same canonical order as before (grouped by expiration, strike, right; call before put; time-ascending within a contract), so buffered output is unchanged — only the fan-out is smarter. A chain already pinned to one right (a strike wildcard with `right = "call"` / `"put"`) has nothing to divide on this axis and keeps the equal time split.

- **Streaming history builders (`*_stream()`) now shard concurrently like the buffered path, and shard sizing cuts the requested time or date range into equal concurrent bands from the request shape.** Under `bulk_fetch = "auto"` (the default), a large chunk-streaming history pull fans out across the same equal bands as its buffered sibling: every band streams as its own concurrent request, and each band's chunks reach the handler as they arrive — every chunk exactly once, with chunks from different bands interleaved in arrival order (use the buffered builder, or `bulk_fetch = "off"`, when the single stream's exact order matters). The split is decided from the request shape alone — a multi-day range cuts into equal date bands, a single day with an intraday window cuts into equal time bands — with no density-probe request and no per-endpoint tuning; provably small pulls still run as a single stream.

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

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

### Added

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

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

### Fixed

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

## [0.2.0] - 2026-07-16

### Added
Expand Down
13 changes: 7 additions & 6 deletions docs-site/docs/articles/bulk-downloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Raise them for even fatter streams if you have memory to spare, or lower them wh

## Automatic sharding

A single large request does not have to be one stream. The SDK sizes the request, splits it into balanced pieces along its time range, runs them in parallel across your tier's concurrent-request budget, and reassembles them into exactly the rows one request would have returned.
A single large request does not have to be one stream. The SDK splits the requested time (or date) range into equal concurrent pieces — straight from the shape of the request, with no sizing round-trip — runs them in parallel across your tier's concurrent-request budget, and reassembles them into exactly the rows one request would have returned.

The number of pieces is your tier's concurrency:

Expand All @@ -38,7 +38,7 @@ The number of pieces is your tier's concurrency:
| Standard | 4 |
| Pro | 8 |

On Pro, a full-day chain that runs in 546 s as one 8 MB stream comes back in about 155 s, a further 3.5x, because eight pieces fetch at once. Combined with the window that is roughly 5.6x faster than the protocol default. It scales down cleanly with tier: four pieces on Standard, two on Value, one on Free.
On Pro, one big query becomes eight pieces fetching at once. How much that shortens the wall clock depends on how much of the fan-out the server actually runs in parallel under current conditions; the table below shows one measured day. It scales down cleanly with tier: four pieces on Standard, two on Value, one on Free.

Sharding is on by default. Small pulls stay a single request, so there is nothing to tune for them. If you want control:

Expand Down Expand Up @@ -77,7 +77,7 @@ let ticks = client

### Streaming: chunks as they arrive

The streaming terminal (`.stream(handler)`) hands you the result in chunks as each piece produces them, so you never hold the whole dataset in memory. It is the fastest path for the largest pulls because it skips building and ordering the full frame.
The streaming terminal (`.stream(handler)`) hands you the result in chunks as each piece produces them, so you never hold the whole dataset in memory. It skips building and ordering the full frame, which makes it the path for results too large to hold whole.

```python
rows = 0
Expand All @@ -103,22 +103,23 @@ Because pieces stream in parallel, chunks from different pieces interleave. Sort

### Which to use

Both are fast. When the server is the bottleneck they finish in the same time. On a fast link the buffered call trails streaming by about 20 percent, the cost of assembling the complete ordered frame in memory that streaming skips. Pick by the shape of the job, not speed:
Both shard and window the same way. Buffered spends extra client-side work assembling the complete ordered frame; streaming skips that. Pick by the shape of the job:

- Buffered when you want the finished dataset, to save it, convert it, or work with it whole, and it fits in memory.
- Streaming when the result may not fit in memory, or you want to process rows as they arrive. This is the path for the largest pulls.

## What it adds up to

Measured on a full-day SPXW options chain (125,849,342 rows, `strike="*"`, `right="both"`, tick interval) on a Pro account. Absolute times depend on your tier, your distance to the server, and current load, so read them as one box on one day, not a guarantee.
Measured on a full-day SPXW options chain (125,849,342 rows, `strike="*"`, `right="both"`, tick interval) on a Pro account, using the buffered path. Absolute times depend on your tier, your distance to the server, current load, and how much of the shard fan-out the server ran in parallel that day, so read them as one box on one day, not a guarantee.

| Setup | Full-day chain | vs default |
|---|---:|---:|
| 64 KB window, single stream (protocol default) | 872.7 s | 1x |
| 8 MB window, single stream | 546.3 s | 1.60x |
| 8 MB window + 8-way sharding, streaming | 155.0 s | 5.63x |
| 8 MB window + 8-way sharding, buffered | 183.8 s | 4.75x |

Streaming shards the same way, and its realized gain rides on the same server-side parallelism, so we quote no wall clock for it.

Every setup returns the same rows. A concrete single contract comes back byte-for-byte identical to a single request. A full chain comes back in a deterministic canonical order (ascending expiration, then strike, then calls before puts, in trading-time order within each contract). Set `bulk_fetch = "off"` if you want the server's own ordering instead.

## Configuration reference
Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/articles/concurrent-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ With a Pro subscription, eight of those requests run concurrently and the rest w

## One giant request, split for you

The pattern above parallelizes work you have already split into many requests. The SDK also does the reverse for you: a **single** large history request is split into balanced pieces, run in parallel across your tier's concurrent-request budget, and reassembled into exactly the rows one request would have returned. You write one ordinary query and it runs at your tier's concurrency. This is on by default.
The pattern above parallelizes work you have already split into many requests. The SDK also does the reverse for you: a **single** large history request has its time or date range split into equal pieces, run in parallel across your tier's concurrent-request budget, and reassembled into exactly the rows one request would have returned. You write one ordinary query and it runs at your tier's concurrency. This is on by default.

See [Bulk Downloads](/articles/bulk-downloads) for the full picture: how the split works, buffered versus streaming delivery, the `bulk_fetch` and `shard_concurrency` knobs, API examples, and measured performance.

Expand Down
20 changes: 20 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **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 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). Rows come back in the same canonical order as before (grouped by expiration, strike, right; call before put; time-ascending within a contract), so buffered output is unchanged — only the fan-out is smarter. A chain already pinned to one right (a strike wildcard with `right = "call"` / `"put"`) has nothing to divide on this axis and keeps the equal time split.

- **Streaming history builders (`*_stream()`) now shard concurrently like the buffered path, and shard sizing cuts the requested time or date range into equal concurrent bands from the request shape.** Under `bulk_fetch = "auto"` (the default), a large chunk-streaming history pull fans out across the same equal bands as its buffered sibling: every band streams as its own concurrent request, and each band's chunks reach the handler as they arrive — every chunk exactly once, with chunks from different bands interleaved in arrival order (use the buffered builder, or `bulk_fetch = "off"`, when the single stream's exact order matters). The split is decided from the request shape alone — a multi-day range cuts into equal date bands, a single day with an intraday window cuts into equal time bands — with no density-probe request and no per-endpoint tuning; provably small pulls still run as a single stream.

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

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

### Added

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

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

### Fixed

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

## [0.2.0] - 2026-07-16

### Added
Expand Down
5 changes: 3 additions & 2 deletions thetadatadx-cpp/include/config_accessors.hpp.inc

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading