MassiveDotNet

A .NET 10 SDK for the Massive market data platform (formerly Polygon.io).

ci nuget

Endpoints are generated from Massive's own OpenAPI description, so the surface tracks the platform rather than drifting from it. The design goals, in priority order, are complete endpoint coverage, Native AOT compatibility, and minimal allocation — then ergonomics.

Install

Four packages, all net10.0:

Package Take it for
MassiveDotNet.Rest The REST API. This is the one most consumers want.
MassiveDotNet.WebSocket Live streaming.
MassiveDotNet.Extensions.DependencyInjection AddMassive wiring for IServiceCollection. Brings both of the above.
MassiveDotNet Nothing directly — it is the shared core, and the others depend on it.
dotnet add package MassiveDotNet.Rest

Every push that passes CI on master also publishes a -ci.N prerelease, so there is a faster stream than the releases if you want it — --prerelease opts in, and pinning an exact version opts back out.

Status

Pre-release. 60 of the platform's 147 REST operations ship today, across two groups. See Coverage for what is here and what is not.

The public surface is a reviewed file rather than an accident — every public member has an entry in a checked-in PublicAPI baseline, so nothing appears, disappears, or changes type without showing up in a diff. What that buys you before 1.0 is that breaking changes are deliberate and land in CHANGELOG.md; it is not a promise that none happen.

Quickstart

using MassiveDotNet.Rest;
using MassiveDotNet.Rest.Models;

string apiKey = Environment.GetEnvironmentVariable("MASSIVE_API_KEY")!;

// One client per application. It is thread-safe, owns a pooled HttpClient, and is meant to be
// long-lived -- creating one per request exhausts sockets under load.
using MassiveRestClient client = new(apiKey);

LastTrade trade = await client.Stocks.GetLastTradeAsync("AAPL");

Console.WriteLine($"AAPL last trade  {trade.Price:N2} x {trade.Size} at {trade.SipTimestamp}");
// AAPL last trade  327.99 x 0 at 2026-09-03T21:00:20Z

The key is sent as an Authorization: Bearer header, not as the apiKey query parameter the platform's description declares — query strings leak into access logs, proxies, and browser history. Query-string auth is still available through MassiveAuthenticationScheme.QueryString.

samples/MassiveDotNet.Quickstart is a runnable version of the walkthrough below. Every snippet in The shape of the API is lifted from it, so the build is what keeps them honest — a signature change that would silently rot this page breaks that project instead. The AddMassive registrations under Dependency injection call the same API that samples/MassiveDotNet.HostSample and samples/MassiveDotNet.WebSample compile; the resilience line there is illustrative, since it needs a package neither sample references.

export MASSIVE_API_KEY=...
dotnet run --project samples/MassiveDotNet.Quickstart

The shape of the API

Groups

Endpoints hang off groups that mirror the platform's own taxonomy. Groups are readonly struct over the shared transport, so navigating to one allocates nothing.

client.Stocks.ListAggregatesAsync(...)
client.Reference.ListTickersAsync(...)

One value, one page, or every page

A singular operation returns the value itself, and throws if the service answers 200 without one:

LastTrade trade = await client.Stocks.GetLastTradeAsync("AAPL");

A paginated operation gets two methods. ListXxxAsync returns a single page and reports whether more exist:

MassivePage<Agg> page = await client.Stocks.ListAggregatesAsync(
    "AAPL",
    multiplier: 1,
    timespan: AggregateTimespan.Day,
    from: new LocalDate(2024, 1, 2),
    to: new LocalDate(2024, 1, 12),
    adjusted: true,
    sort: SortOrder.Ascending);

Console.WriteLine($"{page.Results.Length} bars (more: {page.HasMore})");

EnumerateXxxAsync follows the server's cursor across every page, holding one page in memory at a time:

await foreach (TickerSummary ticker in client.Reference.EnumerateTickersAsync(
    market: MarketType.Stocks,
    active: true,
    limit: 100))
{
    Console.WriteLine($"{ticker.Ticker,-8} {ticker.Name}");
}

The split follows the BCL's own Directory.EnumerateFiles / Directory.GetFiles distinction. Operations that do not paginate return T[], or T for a singular result. Whether an operation paginates is detected from the description, never declared by hand.

Cursors are followed verbatim, and only when next_url names the same origin as the configured base address. A cursor pointing elsewhere throws rather than being followed, because the SDK re-attaches your API key to every page and the cursor is chosen by the response body.

Filters

Massive declares comparator variants as separate query parameters — strike_price.gt, strike_price.gte, strike_price.lte, and so on. Flattened, that gave one endpoint a 114-argument method. Here each field collapses to one parameter, typed by the comparators it actually supports:

Type Comparators the field declares
RangeFilter<T> .gt .gte .lt .lte
SetFilter<T> .any_of
Filter<T> both of the above
ArrayFilter<T> .any_of .all_of

A plain value converts implicitly to equality, so the common case reads as if there were no filter type at all:

MassivePage<ReferenceDividend> dividends = await client.Reference.ListDividendsAsync(
    ticker: "AAPL",                                    // implicit equality
    exDividendDate: RangeFilter.Between(new LocalDate(2024, 1, 1), new LocalDate(2024, 12, 31)),
    order: SortOrder.Ascending,
    limit: 10);

Because the grouping is read from the description rather than declared by hand, asking for a comparator an endpoint does not support is a compile error instead of a parameter the server silently drops:

MassivePage<FinancialRatios> screened = await client.Reference.ListRatiosAsync(
    priceToEarnings: RangeFilter.Between(5d, 15d),
    dividendYield: RangeFilter.Gt(0.03),
    limit: 5);

Dates and times

The SDK uses NodaTime throughout. No BCL DateTime, DateTimeOffset, DateOnly, TimeOnly, or TimeSpan is named anywhere in its source — not in a signature, not in a private field, not in a local. The rule is machine-checked by reflection over the exported surface plus a comment- and literal-aware scan of every tree in the repository.

Market data is unforgiving about temporal ambiguity: an aggregate window is Eastern Time, a tick timestamp is epoch nanoseconds, and a dividend's ex-date is a calendar date carrying no time or zone at all. DateTime collapses all three into one type whose meaning rides on a Kind flag that is trivially lost. NodaTime keeps them distinct, so the wrong one does not compile.

Concept Type Example
A moment on the global timeline Instant Agg.Timestamp, LastTrade.SipTimestamp
A calendar date, no time or zone LocalDate ex-dividend date, IPO date
A wall-clock time in a named zone ZonedDateTime session open and close
An elapsed amount of time Duration MassiveClientOptions.Timeout

Wire timestamps are stored as raw long epoch values, with the NodaTime type exposed as a computed property, so a 50,000-row series pays for the conversion only on the values you actually read:

foreach (Agg bar in page.Results)
{
    // TimestampMilliseconds is the raw wire value; Timestamp computes the Instant on demand.
    Console.WriteLine($"  {bar.Timestamp}  o {bar.Open:N2}  h {bar.High:N2}  l {bar.Low:N2}  c {bar.Close:N2}");
}

NodaTime's default LocalDate formatting is a long culture string. For ISO output use uuuu-MM-dd — in NodaTime uuuu is the absolute year, where yyyy is the era year:

Console.WriteLine($"{dividend.ExDividendDate:uuuu-MM-dd}");   // 2024-02-09

Errors

Failures surface as exceptions rather than a result type, so the allocation happens only on the failure path:

try
{
    LastTrade trade = await client.Stocks.GetLastTradeAsync("AAPL");
}
catch (MassiveRateLimitExceededException exception)
{
    // The free tier allows five requests a minute, so this is the first error most callers meet.
    Console.Error.WriteLine($"Rate limited. Retry after: {exception.RetryAfter}");
}
catch (MassiveApiException exception)
{
    Console.Error.WriteLine($"{(int)exception.StatusCode} {exception.Message} (request {exception.RequestId})");
}

RequestId is what Massive's support asks for. RetryAfter is a NodaTime Duration, read from the response's Retry-After header when the server sends one.

Rate limiting and retry are not applied automatically — the SDK cannot know your tier, and silently retrying is not a decision to make on a caller's behalf. Both are available opt-in; see Rate limiting and retry.

Rate limiting and retry

Off by default, because the SDK never learns your entitlement: a default tuned for the free tier's five requests a minute would throttle a paid key to a fraction of its allowance, and any other default would rate-limit the callers it was meant to protect. Each feature is one option.

using MassiveRestClient client = new(new MassiveClientOptions
{
    ApiKey = apiKey,

    // Pace requests so you stay inside your tier rather than discovering it through a 429.
    RateLimit = new MassiveRateLimitOptions
    {
        PermitsPerWindow = 5,
        Window = Duration.FromMinutes(1),
    },

    // Ride out a transient 429, 5xx or broken connection. Nothing else is retried.
    Retry = new MassiveRetryOptions { MaxAttempts = 3 },
});

They are separate options because they solve different problems — on a paid tier you may want to survive a transient 502 without pacing your requests at all.

Throttling. Permits refill continuously rather than all at once on a window boundary, so a burst up to PermitsPerWindow goes out immediately and everything after it is paced. By default a request over the allowance waits; set QueueLimit = 0 to have it throw MassiveRateLimitExceededException without reaching the network instead.

Retry. HTTP 429 and 5xx, and a connection that broke before the response headers arrived: a reset, a refused connect, a failed DNS lookup or TLS handshake. Every other 4xx describes a request that fails identically however often it is sent, so retrying one spends quota to reach the same answer. Two failures are not retried. A connection that breaks while a body is being read surfaces from deserialization, after the retry has handed the response on. And a timeout: Timeout is a budget for the whole call, every retry and backoff included, so once it runs out a retry needs a fresh call. Backoff is exponential with jitter, and a server's Retry-After takes precedence over the computed delay — except when it exceeds MaxBackoff, where the 429 surfaces with its hint intact rather than holding your task for a period the SDK did not choose:

catch (MassiveRateLimitExceededException exception)
{
    // Retry gave up, or the server asked for longer than MaxBackoff.
    Console.Error.WriteLine($"Still limited. The server asked for {exception.RetryAfter}.");
}

Both work identically under AddMassive, which reads the same options.

Streaming

MassiveDotNet.WebSocket is the fourth package in the SDK, alongside core, .Rest, and .Extensions.DependencyInjection. It streams six stock topics — trades, NBBO quotes, second- and minute-aggregate bars, net order imbalances, and limit up-limit down bands — over a persistent connection to the platform's stocks feed, reconnecting with backoff and replaying every subscription when the connection drops.

using MassiveDotNet.WebSocket;
using MassiveDotNet.WebSocket.Events;

await using MassiveStreamClient client = new(new MassiveStreamOptions { ApiKey = apiKey });
await using MassiveStockStream stream = await client.ConnectStocksAsync();

MassiveTopicSubscription<StockTrade> trades = await stream.SubscribeTradesAsync(["AAPL"]);

await foreach (StockTrade trade in trades)
{
    Console.WriteLine($"{trade.Ticker}  {trade.Price:N2} x {trade.Size}  at {trade.SipTimestamp}");
}

The other five topics follow the same shape: SubscribeQuotesAsync, SubscribeSecondAggregatesAsync, SubscribeMinuteAggregatesAsync, SubscribeImbalancesAsync, and SubscribeLimitUpLimitDownAsync, each returning a MassiveTopicSubscription<T> over its own event type.

Topics are a typed enum, StockTopic, rather than a string: the server silently drops a topic code it does not recognise — no acknowledgement, no error — so a caller who mistypes a string would see a healthy connection producing nothing, indefinitely. Every subscribe call is acknowledgement-counted for the same reason, and throws MassiveStreamSubscriptionException if the server accepted fewer pairs than were requested.

Each topic owns one bounded buffer, TopicBufferCapacity events by default (1024). A slow consumer drops the oldest event rather than blocking the read loop — every topic shares one socket, so a writer that waits would stall every other topic on the same connection, not just its own. A drop is counted exactly, on MassiveTopicSubscription<T>.DroppedCount, and raised as an event:

stream.DropObserved += (topicCode, droppedCount) =>
    Console.Error.WriteLine($"{topicCode} dropped an event (total dropped: {droppedCount})");

Authentication travels in a message rather than a header, because the wire protocol has no concept of one. A refused key and a plan without WebSocket access for the market both answer auth_failed, distinguished only by the server's own prose, so MassiveStreamAuthenticationException.ServerMessage carries it verbatim rather than a guessed category. This failure is terminal — the stream does not retry it — unlike a dropped connection, which reconnects automatically by default.

AddMassiveStream registers MassiveStreamClient as a singleton the same way AddMassive does for the REST client, and LogStreamHealth bridges reconnects and drops onto ILogger without core ever learning that logging exists:

builder.Services.AddMassiveStream(options => options.ApiKey = builder.Configuration["Massive:ApiKey"]);
stream.LogStreamHealth(logger);

Native AOT

The SDK is IsAotCompatible and IsTrimmable, and uses System.Text.Json source generation exclusively — there is no reflection-based serialization anywhere in shipped code.

samples/MassiveDotNet.AotSmokeTest is the enforcement mechanism, not a tutorial: it disables the reflection fallback outright and is published in CI, where any IL trim or AOT warning fails the build.

dotnet publish samples/MassiveDotNet.AotSmokeTest -r osx-arm64 -c Release

Core takes exactly two external dependencies: NodaTime, verified AOT-clean at 3.3.3 including TZDB zone resolution, and System.Threading.RateLimiting, which backs the opt-in throttle. Neither carries a transitive dependency of its own, and the smoke test reaches both — an unexercised type is trimmed away, so a clean publish only proves what the sample actually calls.

Allocation

Minimal allocation is the third design priority, so it is measured rather than asserted. Two instruments, because allocation is byte-exact and elapsed time is not:

  • AllocationTests runs with the offline suite and fails the build on a regression. It asserts ceilings on request URI building, comparator rendering, aggregate deserialization at 1k / 10k / 50k rows, and per-page cost across a cursor traversal — and asserts exactly zero for group navigation, which is the one place where zero is the actual claim.
  • benchmarks/MassiveDotNet.Benchmarks produces the comparative figures against naive baselines. It never runs in CI, because a timing assertion on a shared runner is noise.

Headline numbers, measured 2026-09-04 on an M4 Max:

SDK Naive baseline
Build an aggregates request URI 51 ns, 200 B UriBuilder + Dictionary: 410 ns, 1,856 B
Read 50,000 aggregate bars 24.1 ms, 32.0 MB same, body buffered into a string: 25.6 ms, 49.5 MB
Navigate to a group 0 B —

The 200 bytes are the returned string and nothing else. Streaming the response body rather than buffering it saves half the allocation at every payload size.

One figure is not flattering and is documented anyway: reading 50,000 rows allocates between 7.6x and 8.8x the 4.2 MB array it returns — the range is how warm the JIT is — because System.Text.Json builds a JSON array through a doubling List<T>, where every growth slot costs a full 88-byte struct. That is #47; the ceilings pin it so it cannot get worse, and they come down with the fix.

Full tables, baselines, and the reasoning behind every ceiling are in docs/performance/2026-09-04-allocation-figures.md.

dotnet run --project benchmarks/MassiveDotNet.Benchmarks -c Release

Client lifetime

MassiveRestClient is thread-safe and meant to be long-lived. Create one per application and share it; creating one per request exhausts sockets under load.

using MassiveRestClient client = new(new MassiveClientOptions
{
    ApiKey = apiKey,
    BaseAddress = MassiveEndpoints.Production,
    Timeout = Duration.FromSeconds(30),
    UserAgent = "my-app/1.0",
});

Dependency injection

MassiveDotNet.Extensions.DependencyInjection wires the client through IHttpClientFactory. It is a separate package so a REST consumer never pulls the DI stack — it is the only project allowed to reference Microsoft.Extensions.*, and CI asserts that.

builder.Services.AddMassive(options =>
{
    options.ApiKey = builder.Configuration["Massive:ApiKey"];
    options.UserAgent = "my-app/1.0";
});

MassiveRestClient then arrives by injection like any other service. It and MassiveHttpTransport are registered as singletons, matching the lifetime the client documents for itself.

AddMassive returns the IHttpClientBuilder for the underlying named client, so resilience, logging, or any other handler goes on the same pipeline:

builder.Services.AddMassive(apiKey)
       .AddStandardResilienceHandler();   // Microsoft.Extensions.Http.Resilience

There is deliberately no overload that binds an IConfiguration section. Timeout is a NodaTime Duration, which the configuration binder cannot convert — a bound section compiles, publishes AOT clean, raises nothing at runtime, and silently keeps the default. Reading the values yourself costs a line and fails visibly instead.

Registering twice layers another options delegate without attaching the key twice, and the Authorization header's value is redacted in the factory's logs no matter how you configure logging — the SDK put the key there, so it is the SDK's job to keep it out of your log sink.

Both samples in samples/ register the SDK this way: MassiveDotNet.HostSample on the Generic Host, MassiveDotNet.WebSample on a minimal API. Both publish Native AOT with zero warnings in CI.

Without dependency injection

Hand the transport an HttpClient you own and it will not manage that client's lifetime. Add MassiveAuthenticationHandler so every request — including pages fetched while following a cursor — carries the key. Both types live in MassiveDotNet.Http:

HttpClient http = factory.CreateClient("massive");
MassiveHttpTransport transport = new(http);
MassiveRestClient client = new(transport);

To get authentication and the resilience handlers in the order the SDK composes them, build the pipeline rather than assembling it by hand:

HttpMessageHandler pipeline = MassiveHttpTransport.CreateHandlerPipeline(options, new SocketsHttpHandler());
HttpClient http = new(pipeline) { BaseAddress = options.BaseAddress };

Order matters and its failure mode is silent. MassiveAuthenticationHandler rewrites the request URI under the query-string scheme, so a retry handler placed outside it re-authenticates every attempt and appends the key again — ?apiKey=k&apiKey=k&apiKey=k. The request still succeeds; the only symptom is your key reaching access logs once per attempt.

BaseAddress is required on that HttpClient: pagination checks a cursor's origin against it before sending your key, and throws if it is unset.

The transport does not own a caller-supplied HttpClient, so disposing it leaves the factory's client — and its pooled connections — alone.

Coverage

Group Operations Methods
Stocks 21 32
Reference 39 69
Total 60 of 147 101

The method count exceeds the operation count because each of the 40 paginated operations gets both a List and an Enumerate. The odd one out is Reference.DownloadFilingFileAsync, the SDK's only hand-written endpoint method: the SEC filing-file route declares JSON and serves text/html, so the generated method ships as declared and a byte-copying download sits beside it.

Not yet mapped, each tracked by its own issue: Options · Crypto · Forex · Futures · Indices · vendor datasets · cross-market snapshots.

WebSocket streams ship today for the stocks market; the other five markets are tracked by #21. S3 flat files are planned for v0.3.

Deprecated and experimental operations

Nothing in the description is silently omitted. An operation Massive marks deprecated ships with [Obsolete] under diagnostic MASSIVE0002, a warning whose message names the method that replaces it. An operation on a vX-prefixed or dev route ships with [Experimental("MASSIVE0001")], which is an error until you opt in:

<NoWarn>$(NoWarn);MASSIVE0001</NoWarn>

Both are read from the description itself, never declared by hand, so neither can drift from what Massive publishes.

Building and testing

dotnet build MassiveDotNet.slnx                    # warning-free; warnings are errors
dotnet test MassiveDotNet.slnx --filter "Category!=Integration"

The suite has two tiers. The offline tier runs against committed fixtures and needs no key — it is all CI ever runs. The live tier calls the real service, is committed and compiled everywhere, and runs only locally with a key in a gitignored .env:

dotnet test MassiveDotNet.slnx --filter "Category=Integration"

No Massive key is ever placed in CI. A key there would leak through build logs, consume quota on every push, make the build depend on a third party's uptime, and could not work for pull requests from forks, where secrets are deliberately withheld.

Endpoints are generated from specs/openapi.json plus a curated specs/endpoints.map.json, and the output is committed so diffs stay reviewable and consumers need no build step:

dotnet run --project tools/MassiveDotNet.CodeGen
git diff --exit-code src/                          # generation is deterministic

CLAUDE.md carries the full architecture decision record, including the reasoning behind the choices summarised here.

License

MIT. See LICENSE.