Skip to content

Add the options resource#10

Merged
MarketDataDev03 merged 14 commits into
mainfrom
10_options_resource
Jun 9, 2026
Merged

Add the options resource#10
MarketDataDev03 merged 14 commits into
mainfrom
10_options_resource

Conversation

@MarketDataDev03

@MarketDataDev03 MarketDataDev03 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Add the options resource (+ the response & parameter conventions it establishes)

Branch: 10_options_resourcemain

This PR adds the second consumer-facing resource, client.options() (every options endpoint), and with it the response-model and parameter conventions every future resource will follow. utilities is migrated onto the same model in this PR; stocks/funds/markets will copy the template.

For a flow-by-flow walkthrough aimed at reviewers, see docs/OPTIONS_REVIEW_GUIDE.md.


What's added

Endpoints (sync + async parity, ADR-006)

client.options() exposes all endpoints, each with a foo(...) + fooAsync(...) pair returning a named response type:

Method Endpoint Returns (.values())
lookup /v1/options/lookup/{userInput} OptionsLookupResponseString (OCC symbol)
expirations /v1/options/expirations/{symbol} OptionsExpirationsResponseList<ZonedDateTime> (+ updated())
strikes /v1/options/strikes/{symbol} OptionsStrikesResponseList<ExpirationStrikes> (+ updated())
quote /v1/options/quotes/{symbol} OptionsQuotesResponseList<OptionQuote>
quotes /v1/options/quotes/{symbol} Multi-contract fan-outMap<String, OptionsQuotesResponse> (one concurrent request per symbol, fail-fast)
chain /v1/options/chain/{symbol} OptionsChainResponseList<OptionQuote>

Response model (new, SDK-wide) — replaces the old Response<T>

  • MarketDataResponse<T> interface: one uniform values() data accessor (typed per endpoint) plus the metadata surface (statusCode, isNoData, requestId, requestUrl, json, isJson/isCsv/isHtml, saveToFile).
  • Named response types per endpoint (OptionsChainResponse, …) implement it; values() is the flat payload (a List, or the scalar String for lookup) — one hop, no wrapper to unwrap.
  • The old generic Response<T> is deleted; utilities (status/headers/user) is migrated to named response types too (Response<T> had no remaining users).
List<OptionQuote> rows = client.options().chain(req).values();   // .values() is a List
for (OptionQuote q : rows) { /* … */ }

Universal parameters (now implemented on the resource)

Set fluently on the resource (immutable configured copies via RequestConfig):

  • Type-preserving: dateFormat(), mode(), limit(), offset().
  • columns(String...) — projection (see below).
  • Output-shaping human()/headers() live only on the CSV facet — they reshape the payload, so they don't cohere with the typed path.

columns + nullable fields + Option A (correctness-critical)

  • Every OptionQuote field is @Nullable (boxed), so a columns projection decodes cleanly — a field the consumer didn't ask for comes back null.
  • Option A restores strictness: the deserializer reads every column leniently, then validateRequestedColumns fails loudly (ParseError) when a required column that was requested (or wasn't projected away) is missing. So a null only ever means "I projected it away" or "legitimately-optional (greek/IV)" — never "the backend silently dropped a required field." Strict-by-default survives the nullable fields.

Format facets

  • asCsv()OptionsCsvResourceCsvResponse (raw CSV text). Carries the universal-param config and additionally exposes columns/human/headers. Omits lookup (a scalar — no CSV). Fan-out mirrors the container: Map<String, CsvResponse>.
  • asHtml()OptionsHtmlResource/HtmlResponse is built but package-private (not exposed) — the backend serves no HTML for data endpoints. Flip the entry point to public when it does; the isHtml() detector ships regardless.

Greeks helpers

OptionQuote.presentGreeks()Set<Greek> and greek(Greek)@Nullable Double. Greek enum = DELTA/GAMMA/THETA/VEGA/RHO (IV is not a greek; read via iv()).

Request-class convention (SDK-wide pattern)

Every endpoint takes a Builder-based request object in com.marketdata.sdk.optionsno String convenience overloads, uniform call shape:

client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"));
client.options().chain(OptionsChainRequest.builder("AAPL").side(OptionSide.CALL).strikeLimit(5).build());
  • of(required...) shortcut for no-optional endpoints; builder(required...) otherwise; cross-field validation in build().
  • Mutually-exclusive chain groups are sealed types (compiler-enforced "pick one variant"): ExpirationFilterOnDate/Dte/Between/MonthYear/All; StrikeFilterExact/Range/Comparison.

Infra reused by future resources

  • ParallelArrays lenient accessors — dblOrNull plus new textOrNull/lngOrNull/boolOrNull/nodeOrNull (absent column or null cell → null; still strict on a present-but-wrong-type cell). The strict text/dbl/lng/bool path is unchanged for existing callers.
  • RequestConfig — the immutable universal-param bundle a resource carries across its endpoints.
  • JsonResponseParser.parse(env, type, requestedColumns) — threads the requested columns into deserializers (via a Jackson context attribute) so Option A can enforce them.
  • MarketDataDates, PathSegments — date parsing, per-segment URL encoding.

Testing

  • Unit: OptionsResourceTest (URL/filter translation, sealed-type dispatch, envelope handling, columns projection, Option A failures, strict-by-default, greeks helpers, CSV/HTML facets, universal params on the wire, response metadata) + UtilitiesResourceTest (migrated) + ParallelArraysTest. ./gradlew build green (unit + Spotless + JaCoCo).
  • Integration: OptionsIntegrationTest — 10 tests, shape assertions, run against the live API (MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest).
  • Consumer example: OptionsApp (./gradlew runOptions / make demo-options) — every endpoint with the full parameter surface, the CSV facet (plain + columns/human/headers + fan-out), columns projection, and both Option-A failure paths. Verified end-to-end against the mock server.

Findings that shaped the design

  1. HTTP 203 is success. The API returns 203 Non-Authoritative Information for cached/delayed data; tests accept 200 || 203.
  2. Null model values. A historical countback query returned null iv/greeks — the original driver for nullable model values; generalized here to "all fields nullable for columns", with Option A keeping it safe.

Deliberately left for later (please don't flag as missing)

  • HTML facet exposure — DEFERRED. HtmlResponse + the facet are built and tested, but asHtml() is package-private until the backend serves format=html. Enabling it is then a one-line change.
  • source parameter — INTENTIONALLY NOT ADDED. Internal data-provider param, not in the public schema / cross-language requirements / Python SDK. Left out for v1.
  • §13 JaCoCo 100% threshold — DEFERRED until the full resource layer lands (so the ratchet bites functional code, not scaffolding).
  • §8 per-response rate-limit snapshot on the response object — still client-level via client.getRateLimits(); a small follow-up.
  • stocks/funds/markets — out of scope; they adopt this convention next.

Review focus

The load-bearing, non-obvious pieces: the response model (MarketDataResponse<T> + named types + values()), the nullable + Option A mechanism (buildOptionRow lenient decode + validateRequestedColumns strictness — correctness of the columns/no-silent-failure guarantee), the chain filter→query translation (applyChainParams / applyExpirationFilter / strikeFilterWireValue), the facets (CSV exposed, HTML built-not-exposed), and the quotes fan-out semantics. See the review guide for the suggested reading order.

@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

The author of this PR, MarketDataDev03, is not an activated member of this organization on Codecov.
Please activate this user on Codecov to display this PR comment.
Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.
Please don't hesitate to email us at support@codecov.io with any questions.

Captures the request-object vs. params-bag vs. fluent-terminal decision
that the options resource introduces SDK-wide. Leads with the cross-SDK
comparison (sdk-py / sdk-js bags vs. the four Java candidates) and
recommends the inert request object plus an additive Consumer<Builder>
overload, preserving the compile-time sealed-filter guarantee.
@MarketDataDev01

Copy link
Copy Markdown
Collaborator

Added ADR-008: Endpoint Parameter Convention (Request Objects) in 07412a6docs/adr/ADR-008-endpoint-parameter-convention.md.

This PR introduces the per-endpoint request-class convention SDK-wide (the template stocks/funds/markets will copy), but the repo's ADR-first workflow had no ADR backing it. ADR-008 fills that gap. Status: Proposed — it documents the decision and a recommendation, but doesn't presume the call.

It leads with the cross-SDK comparison and weighs four candidates against a constant filter set (calls / strikeLimit 10 / minOpenInterest 100):

  • Both siblings use a flat params bag, runtime-validated, with no mutual-exclusivity enforcement:
    • sdk-py: client.options.chain("AAPL", side="call", strike_limit=10, min_open_interest=100)
    • sdk-js: client.options.chain("AAPL", { side: "call", strikeLimit: 10, minOpenInterest: 100 }) (Zod .passthrough())
  • A — request object + builder (this PR as written)
  • B — A plus an additive Consumer<Builder> overload: chain("AAPL", b -> b.side(CALL).strikeLimit(10))
  • C — transport-bound fluent + fetch()/fetchAsync() terminal
  • D — flat params object mirroring the siblings

Recommendation: A + B. Keep the inert, decoupled request object as canonical (reuse, testability, trivial sync/async parity), add the lambda overload as the ergonomic front door. C and D are not recommended — C sacrifices the decoupled spec, adds an un-enforceable dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK call-shape outlier; D discards the compile-time sealed-filter guarantee (ExpirationFilter/StrikeFilter) that is the Java SDK's one advantage over its siblings.

Two notes for reviewers:

  1. This PR currently ships Option A only. If we ratify A+B, the overloads are a purely additive, non-breaking follow-up (~6 lines/endpoint delegating to chain(req)); no change to the request classes.
  2. Per the workflow, the docs/java-sdk-requirements.md section + citation should land after the ADR is Accepted — I deliberately didn't write a requirement against a Proposed ADR.

This is a convention call that propagates to every future resource, so it'd be good to settle it here before the pattern multiplies.

@MarketDataDev03

Copy link
Copy Markdown
Collaborator Author

ADR-008 — Companion: resource & response design (consumer-facing foundations)

Companion to ADR-008. Two jobs:

  1. Decide the open question. It agrees with ADR-008's input conclusion (request
    object + builder) and fills in the output/format piece ADR-008 left open.
  2. Lay the foundations. It establishes how every resource is used and built — the
    call grammar a consumer types (§1–§3), the response types they get back (§3), and the
    recipe an SDK author follows to add a resource or endpoint (§4). options is the
    reference implementation and will be refactored to match it (§4).

Read §1–§3 for the consumer's view, §4 for the author's view, §5 to confirm it holds
across all five resources, §6 for the one-paragraph summary.

TL;DR

ADR-008 framed the decision as "how does an endpoint accept its parameters" (the input axis) and lands on the request‑object + builder. That input conclusion is right. But the ADR only brushed past a second, coupled question: how does the consumer choose the response format — and in a statically‑typed language that is a harder problem than it looks, because the format changes the response type, and Java types are not dynamic.

This note: (1) names that constraint explicitly, (2) argues why the builder is the best input convention from the consumer's seat, (3) answers the output/format question by selecting the format through a typed resource facet (a compile‑time choice the compiler enforces), and (4) turns all of that into a construction recipe every resource follows — so the SDK stays uniform as stocks, funds, and markets land and options is brought into line.


1. The constraint Java has that Python and JS don't

The sibling SDKs answer "what format do I want back?" with a runtime parameter, and the return type changes with it:

# Python — one method, the RETURN TYPE changes at runtime
client.stocks.candles("AAPL", output_format="dataframe")  # -> DataFrame
client.stocks.candles("AAPL", output_format="json")       # -> dict
client.stocks.candles("AAPL", output_format="csv")        # -> str
// JavaScript — same idea, return type resolved at runtime
client.stocks.candles("AAPL", { format: "csv" });         // -> string | object

They can do this because they're dynamically typed: the caller sorts out what came back. Java cannot. A Java method has exactly one static return type, fixed at compile time:

// Impossible: a method can't return StockCandles OR String based on a runtime flag.
??? result = client.stocks().candles(req, format);   // what type is `result`?
flowchart LR
  subgraph DYN["Python / JS — dynamic"]
    A["candles(req, format)"] --> B{format at runtime}
    B --> C["DataFrame"]
    B --> D["dict / object"]
    B --> E["CSV string"]
  end
  subgraph STAT["Java — static"]
    F["candles(req)"] --> G["exactly ONE compile-time type"]
  end
Loading

So the output‑format problem — trivial in py/js — is structural in Java. We can't bolt a format argument onto a typed method; the type system has to know the format up front.

This is also why it's worth separating two things the ADR grouped together under "universal parameters". They live on different axes:

Axis Examples Changes the response type/shape?
Input symbol, filters (sealed) No
Universal params — type-preserving dateformat, mode, limit, offset No
Universal params — output-shaping columns, human, headers Yes — they break typed decode (see §5, Decision 1)
Output format json (default) / csv / html Yes

So "universal parameters" is not one bucket. dateformat/mode/limit/offset are ordinary modifiers — they don't alter the result type, so they sit on the typed path. But columns/human/headers reshape the payload (drop columns, rename fields, toggle a CSV header), which breaks the typed decoder — so they belong with the output formats, on the CSV facet (§5, Decision 1). And format changes the type outright. The type-changing axes — the output-shaping params and format — are the ones that need a structural answer; the rest are just setters.


2. Input: why the builder/request object is the best call for the consumer

For the input axis, the request‑object + builder (ADR-008 Option A) is the strongest option from the consumer's seat. The hard case proves it: options.chain has ~25 optional filters, two of which are mutually‑exclusive groups (expiration vs dte vs between …; strike exact vs range vs comparison).

OptionsChain chain = client.options().chain(
    OptionsChainRequest.builder("AAPL")
        .expirationFilter(ExpirationFilter.dte(45))      // pick exactly ONE expiration variant
        .strikeFilter(StrikeFilter.range(150, 250))      // pick exactly ONE strike variant
        .side(OptionSide.CALL)
        .strikeLimit(10)
        .build());

Advantages (consumer side)

  • Named, discoverable parameters. .minOpenInterest(100) documents itself; IDE autocomplete lists every filter. No positional or keyword guessing.
  • Mutual exclusivity becomes a compile error, not a server error. The sealed ExpirationFilter / StrikeFilter types make "pass dte and expiration" unrepresentable — you physically cannot write it. In Python/JS that combination type‑checks and fails on the wire. This is the single dimension where a typed SDK is strictly safer than its siblings, and the builder is what unlocks it.
  • Inert and reusable. The request is plain data: build it once, reuse it across calls/threads, log it, and unit‑test the query translation without a live client or network.
  • Uniform. Every endpoint reads the same way regardless of how many parameters it has.

Disadvantages (consumer side)

  • Ceremony: …Request.builder(…).build() is more to type than a kwargs bag.
  • Naming stutter: options().chain(…) then OptionsChainRequest.
  • Never as terse as a Python kwargs call or a JS object literal — and that's an acceptable trade: terseness isn't where a typed SDK competes; compile‑time safety is.

The cost is real but bounded, and it buys the one advantage that justifies writing an SDK in a typed language at all.


3. Output: select the format through a typed resource facet

Because the format changes the response type, the consumer chooses it by selecting a typed view (facet) of the resource. Each facet's endpoints return the matching response type; the default — no facet — is the typed JSON path. Cross‑cutting modifiers that don't change the type (the universal parameters) are set fluently on the resource before the endpoint call.

client.<resource>()
        [ .dateFormat(..).mode(..).limit(..).offset(..) | .params(universalParams) ]   // universal params (type-preserving)
        [ .asCsv() | .asHtml() ]                                                       // FORMAT — selects a typed facet (default: typed JSON)
        .<endpoint>[Async]( <request: symbol + resource params> )
flowchart TD
  R["client.stocks()"] -->|default| T["typed facet"]
  R -->|".asCsv()"| CSV["CSV facet"]
  R -->|".asHtml()"| HTML["HTML facet"]
  T -->|"candles(req)"| TR["StockCandles<br/>(typed rows + raw json)"]
  CSV -->|"candles(req)"| CR["CsvResponse"]
  HTML -->|"candles(req)"| HR["HtmlResponse"]
Loading

The format is no longer a runtime value the return type has to anticipate — it's a compile‑time selection: which facet you call decides which type you get, and the compiler enforces it from there.

How the consumer uses it

Typed (default) — the 99% path:

StockCandles c = client.stocks().candles(
    StocksCandlesRequest.builder("AAPL", StockResolution.daily()).countback(30).build());

for (StockCandle bar : c.candles()) { /* typed rows */ }
String rawJson = c.json();   // the original payload, available if you want it

With universal parameters (set fluently on the resource):

StockCandles c = client.stocks()
    .dateFormat(DateFormat.TIMESTAMP).limit(50)
    .candles(StocksCandlesRequest.of("AAPL", StockResolution.daily()));

// the resource is an immutable configured value — configure once, reuse across calls:
StocksResource live = client.stocks().mode(Mode.LIVE).limit(100);
StockCandles candles = live.candles(candlesReq);
StockQuotes  quotes  = live.quotes(quotesReq);

// or bundle the universal params and pass them in one shot:
StockCandles d = client.stocks().params(myUniversalParams).candles(candlesReq);

Each setter returns a configured copy of the resource; .asCsv()/.asHtml() carry that
config into the facet. A few params are endpoint-scoped — e.g. mode(CACHED) is only
honored by quote endpoints — so a setter being available everywhere doesn't mean every
endpoint acts on it; the per-endpoint Javadoc says which apply.

CSV:

CsvResponse csv = client.stocks().asCsv()
    .candles(StocksCandlesRequest.builder("AAPL", StockResolution.daily()).countback(30).build());

csv.saveToFile(Path.of("aapl.csv"));   // or csv.csv() for the text

HTML:

HtmlResponse html = client.stocks().asHtml()
    .candles(StocksCandlesRequest.of("AAPL", StockResolution.daily()));

html.saveToFile(Path.of("aapl.html"));

Async — same shape, both axes:

CompletableFuture<StockCandles> f = client.stocks().candlesAsync(req);
CompletableFuture<CsvResponse>   g = client.stocks().asCsv().candlesAsync(req);

Full example — sealed filters (input) + universal params + CSV (output):

CsvResponse chain = client.options().mode(Mode.DELAYED).asCsv().chain(
    OptionsChainRequest.builder("AAPL")
        .expirationFilter(ExpirationFilter.dte(45))
        .strikeFilter(StrikeFilter.range(150, 250))
        .side(OptionSide.CALL).strikeLimit(10)
        .build());

chain.saveToFile(Path.of("aapl-chain.csv"));

The compile‑time guarantee:

CsvResponse  ok   = client.stocks().asCsv().candles(req);   // ✓
StockCandles bad1 = client.stocks().asCsv().candles(req);   // ✗ won't compile (CsvResponse ≠ StockCandles)
HtmlResponse bad2 = client.stocks().asCsv().candles(req);   // ✗ won't compile (CsvResponse ≠ HtmlResponse)

Every resource, one grammar

The same client.<resource>()[.universalParams][.asCsv()].<endpoint>(request) shape holds across every resource and every required‑input shape the API has — a single symbol, a list of symbols, an OCC option symbol, a free‑text lookup, or no symbol at all. The request object absorbs that variety; the call grammar never changes.

// stocks — single symbol
StockCandles candles = client.stocks().candles(
    StocksCandlesRequest.builder("AAPL", StockResolution.daily()).countback(30).build());

// stocks — MANY symbols in one call (required input is a list, not a string)
StockPrices prices = client.stocks().prices(
    StocksPricesRequest.of("AAPL", "MSFT", "GOOG"));

// options.chain — the sealed‑filter showcase (input safety + universal param + CSV)
CsvResponse chainCsv = client.options().dateFormat(DateFormat.TIMESTAMP).asCsv().chain(
    OptionsChainRequest.builder("AAPL")
        .expirationFilter(ExpirationFilter.dte(45))      // pick exactly ONE expiration variant
        .strikeFilter(StrikeFilter.range(150, 250))      // pick exactly ONE strike variant
        .side(OptionSide.CALL).strikeLimit(10)
        .build());

// options.quotes — required input is an OCC OPTION symbol, not a ticker
OptionsQuotes oq = client.options().quote(
    OptionsQuoteRequest.of("AAPL250117C00150000"));

// options.lookup — required input is a free‑text human string; the response is a scalar
OptionsLookup sym = client.options().lookup(
    OptionsLookupRequest.of("AAPL 1/17/25 $150 call"));
String occSymbol = sym.optionSymbol();               // natural per‑endpoint accessor

// options.expirations — typed response is a LIST, not rows
OptionsExpirations exps = client.options().expirations(
    OptionsExpirationsRequest.of("AAPL"));
for (LocalDate d : exps.expirations()) { /* ... */ }

// options.strikes — typed response is a date‑KEYED MAP, not rows
OptionsStrikes strikes = client.options().strikes(
    OptionsStrikesRequest.of("AAPL"));
List<Double> jan = strikes.byExpiration().get(LocalDate.of(2025, 1, 17));

// markets.status — NO required input at all
MarketsStatus status = client.markets().status(
    MarketsStatusRequest.builder().country("US").build());

// funds — same candle shape as stocks, different resource
FundCandles fc = client.funds().candles(
    FundsCandlesRequest.of("VFIAX", StockResolution.daily()));

Every line above reads the same way: pick the resource, optionally set type‑preserving universal params and/or a format facet, then call the endpoint with its request object. The return type differs per endpoint (rows, a list, a date‑keyed map, a scalar) — but that's the typed model doing its job, not a break in the grammar.

The response model

classDiagram
  class MarketDataResponse {
    <<interface>>
    +statusCode() int
    +requestId() String
    +isNoData() boolean
    +saveToFile(path) void
  }
  class StockCandles {
    +candles() List~StockCandle~
    +json() String
  }
  class CsvResponse {
    +csv() String
  }
  class HtmlResponse {
    +html() String
  }
  MarketDataResponse <|.. StockCandles
  MarketDataResponse <|.. CsvResponse
  MarketDataResponse <|.. HtmlResponse
Loading
  • Typed responses are per‑endpoint (StockCandles, OptionsChain, …): they carry real, parsed structure plus the raw JSON, and expose the data directly (.candles()) — no generic wrapper to unwrap.
  • CSV and HTML are shared (CsvResponse, HtmlResponse): their body is opaque text with no per‑endpoint structure, so a single type each covers every endpoint. They remain distinct types, so the three response kinds never cross‑assign.
  • All three implement one interface carrying the response‑object metadata required by the SDK requirements (statusCode, requestId, isNoData, saveToFile).

Advantages / disadvantages (consumer side)

Advantages

  • Format is type‑safe. You can't call typed accessors on a CSV response, or mix CSV with HTML — the compiler stops it.
  • The typed (default) path stays cleanclient.stocks().candles(req) — with no terminal verb and no wrapper to unwrap just to read the data.
  • Uniform across every endpoint and resource; the format is chosen once, the same way, everywhere.
  • Input safety (sealed filters) is untouched — the two axes compose without interfering.
  • No "did I forget to run it?" — every call is eager.

Disadvantages

  • No dynamic‑format‑from‑one‑call like Python/JS — the consumer picks the format up front. This is a direct consequence of static typing, not of this design.
  • One extra hop (.asCsv()) for the non‑JSON formats; JSON, the common case, has none.
  • More response types internally (per‑endpoint typed + the two shared raw types) — invisible to the consumer, but real maintenance for us.

Prior art

Selecting a typed view that changes the return type is a well‑trodden SDK shape, most visibly in sync/async client pairs:

  • gRPC — the same RPC yields a blocking stub (T), a future stub (ListenableFuture<T>), or an async stub (StreamObserver); the stub you pick decides the return type.
  • AWS SDK for Java v2 (S3Client / S3AsyncClient) and Azure SDK (BlobClient / BlobAsyncClient) — a typed view selects T vs CompletableFuture<T>.

And where SDKs also expose raw bytes, they keep it as a separate surface rather than overloading the typed response — e.g. Elasticsearch's low‑level RestClient alongside the typed ElasticsearchClient, or Retrofit's errorBody()/raw() next to the typed body(). This proposal makes the same separation, and additionally gives that raw surface a compile‑time type (CsvResponse / HtmlResponse).

Alternative considered — a single runtime‑typed response

One Response per endpoint holding any format, with isJson()/isCsv()/isHtml() predicates and format accessors that throw on mismatch (the requests / JsonNode / JDBC ResultSet shape). Rejected: it trades the compile‑time format guarantee for runtime exceptions — inconsistent with the SDK's own input‑side safety (sealed filters), and the response type advertises accessors that, for any given instance, mostly throw. The facet gives the same ergonomics with the mistake caught by the compiler instead.


4. How a resource is built (the author's view)

The consumer grammar in §3 only stays uniform if every resource is assembled the same way. This section is the recipe: what an SDK author writes to add a resource or an endpoint, and — just as important — what they don't write because it's shared infrastructure. The goal is that adding stocks.candles and adding markets.status differ only in the per‑endpoint specifics, never in the wiring.

The anatomy: four layers

flowchart TD
  subgraph PUB["public — what the consumer touches"]
    F["resource facade<br/>StocksResource (+ .asCsv() facet)"]
    RQ["request object<br/>StocksCandlesRequest (builder, sealed filters)"]
    RS["response record<br/>StockCandles : MarketDataResponse"]
  end
  subgraph INT["package-private — shared infra, written once"]
    T["HttpTransport<br/>(retry, rate-limit, semaphore, timeouts)"]
    PA["ParallelArrays + deserializer module"]
    SP["RequestSpec (path + query builder)"]
  end
  F -->|"builds a"| SP --> T
  RQ -->|"feeds"| F
  T -->|"decodes via"| PA --> RS
Loading

The public layer is per‑endpoint and is all an author writes. The package‑private layer (per ADR‑007) is the transport/retry/rate‑limit/decoding machinery that already exists — endpoints plug into it, they don't reimplement it.

The per‑endpoint checklist

To add one endpoint, an author writes exactly four things:

  1. Response record(s). A per‑endpoint container that implements MarketDataResponse, plus a row record when the shape is tabular. Legitimately‑nullable wire fields are @Nullable boxed types; always‑present fields stay primitive.
    public record StockCandles(List<StockCandle> candles, String json /* + metadata */)
        implements MarketDataResponse { }
    public record StockCandle(ZonedDateTime time, double open, double high,
                              double low, double close, long volume) { }
  2. Deserializer registration. For the parallel‑arrays shape, declare the column list + a row builder + the wrapper — the ~30 lines of boilerplate collapse to the three things that differ per endpoint. Registered programmatically on the transport's ObjectMapper via a package‑private SimpleModule (ADR‑007), so the record carries no @JsonDeserialize.
    ParallelArrays.listDeserializer(
        List.of("t", "o", "h", "l", "c", "v"),          // required columns
        List.of(),                                       // optional columns → row.dblOrNull(...)
        row -> new StockCandle(MarketDataDates.parse(row.node("t")),
                               row.dbl("o"), row.dbl("h"), row.dbl("l"),
                               row.dbl("c"), row.lng("v")),
        StockCandles::new);                              // wrap the row list in the container
    Non‑tabular shapes (the scalar options.lookup, the list options.expirations, the date‑keyed map options.strikes) get a small hand‑written JsonDeserializer instead — same registration path, different body.
  3. Request object. XxxRequest with builder(required…) / of(required…), one named setter per optional param, and a sealed type per mutually‑exclusive group (ADR‑008 input convention). Inert data — no client reference.
  4. Resource method. A thin x / xAsync pair on the facade that translates the request into a RequestSpec and hands it to the transport. This is the only place query‑param translation lives.
    public CompletableFuture<StockCandles> candlesAsync(StocksCandlesRequest r) {
      RequestSpec spec = RequestSpec.get("stocks/candles/" + r.resolution() + "/"
                                         + PathSegments.encode(r.symbol()));
      applyUniversalParams(spec);        // dateformat/mode/limit/offset from the resource
      applyCandleParams(spec, r);        // endpoint-specific
      return transport.execute(spec, StockCandles.class);
    }
    public StockCandles candles(StocksCandlesRequest r) { return transport.joinSync(candlesAsync(r)); }

That's it. The facade is public final class with a package‑private constructor (ADR‑007); the consumer reaches it only through client.stocks().

Facet exposure: one decision per endpoint

The typed method above lives on the default resource. To also offer it as CSV, the CSV facet exposes the same endpoint returning CsvResponse (it sends format=csv and skips the typed decode). The author makes the §5 decisions here: expose the endpoint on the CSV facet unless the format doesn't fit (lookup → omit), and for a per‑symbol fan‑out, mirror the container (Map<String, CsvResponse>). The typed facet and the CSV facet share a package‑private base that holds the accumulated universal‑param config, so .asCsv() simply carries that config across.

What an author never touches

HttpTransport (retry §9, rate‑limit §8/§10, AsyncSemaphore §12, timeouts §10), the Response/MarketDataResponse metadata plumbing, RequestSpec, the ParallelArrays zipping, the configuration cascade, and logging are written once and shared. A new endpoint is only the four pieces above — which is what keeps the whole SDK uniform.

Bringing options into line

options shipped first, before this companion settled the response shape, so it's the one resource that needs a refactor to match the convention. The changes are mechanical and the request/deserializer layers are untouched:

// BEFORE (shipped today) — generic Response<T> wrapper, raw Map value type:
Response<OptionsChain> r = client.options().chain(req);
List<OptionQuote> rows  = r.data().chain();          // unwrap to reach the data
int code                = r.statusCode();
Map<String, Response<OptionsQuotes>> q = client.options().quotes(req);

// AFTER (this proposal) — flattened typed; metadata on the record; facets added:
OptionsChain chain = client.options().chain(req);     // no wrapper to unwrap
List<OptionQuote> rows = chain.chain();
int code = chain.statusCode();                         // via MarketDataResponse
Map<String, OptionsQuotes> q = client.options().quotes(req);   // value type flattened
CsvResponse csv = client.options().asCsv().chain(req);         // NEW: format facet

Concretely, the refactor: (1) drop the generic Response<T> wrapper — each typed record implements MarketDataResponse directly and carries .json(); (2) flatten the fan‑out value type (Response<OptionsQuotes>OptionsQuotes); (3) add the .asCsv() facet and the type‑preserving universal‑param setters to the resource; (4) keep lookup typed‑only (no CSV facet). The request classes, sealed filters, and deserializer column lists do not change — only the response wrapper and the facet surface do. The parked stocks work folds in the same way; funds and markets are built to the convention from the start.


5. Does the facet survive every resource?

A design that's clean for stocks.candles is worthless if it cracks the first time a resource behaves differently. So this section stress‑tests the facet against all five resources the SDK must ship (stocks, options, funds, markets, utilities) and the full variety the API actually has. The short answer: the grammar holds everywhere, and the few places it bends are principled, not accidental — but there are four decisions the team should make consciously now rather than rediscover per‑resource.

Compliance with the cross‑language requirements

Requirement Status under this design
§1.2 five resources stocks/options/funds/markets/utilities ✓ each is a client.<resource>() facet root
§2 endpoint coverage ✓ one method per endpoint, request object carries its params
§3 universal params (dateformat, columns, headers, human) all four supported — but split across facets by type‑safety (see Decision 1)
§11.1 decode wire format into typed models by default ✓ the default (no‑facet) path is the typed model
§11.3 additional formats "only when idiomatic", "no ecosystem‑misaligned formats for parity" ✓ CSV via a typed facet is idiomatic; we do not clone Python's format="csv" runtime string
§11.5 isJson()/isCsv()/isHtml(), saveToFile, no‑data, toString() ✓ on the shared MarketDataResponse interface

The spec does not require format to be a runtime parameter and does not require HTML output — it only requires the isHtml() detector. That is the latitude this design spends: format is a compile‑time facet, not a runtime string.

The decisions the resource landscape forces

Decision 1 — Where do columns / human / headers live?
This is the sharpest one, and it turns out to reinforce the facet split rather than fight it. Of the four spec‑universal params, only dateformat is type‑preserving. The other three are not:

  • columns projects the payload to a subset → the typed record can no longer deserialize.
  • human renames every field → the typed record can no longer deserialize.
  • headers toggles a CSV header row → meaningless for a typed/JSON response.

So the facet boundary coincides with the type‑safety boundary of the params themselves. The typed facet exposes the type‑preserving modifiers (dateformat, plus the Java‑doc extras mode/limit/offset); the CSV facet additionally exposes columns/headers/human, which only make sense once you've left the typed world. All four are "supported" (compliance holds), but they sort onto the facet where they don't corrupt anything. This is an argument for the facet, not a wart.

CsvResponse csv = client.stocks().asCsv()
    .columns("t", "c").human(true)              // legal here — output is already opaque text
    .candles(StocksCandlesRequest.of("AAPL", StockResolution.daily()));

// client.stocks().columns("t","c").candles(req)  // intentionally NOT offered:
                                                   // it would hand back a StockCandles that can't decode

Decision 2 — Ship .asHtml() now, or keep HtmlResponse dormant?
The backend implements no HTML output on any endpoint, and the spec doesn't require producing HTML. Shipping .asHtml() today means shipping a facet whose every call fails against the live API. Recommendation: keep the HtmlResponse type and the isHtml() detector (cheap, spec‑required as a detector, future‑ready), but do not expose .asHtml() facet methods until the server supports it — or expose them behind an explicitly "experimental" marker. We get HTML‑readiness without a facet that 400s.

Decision 3 — Facets are a subset, not a clone, of the typed surface.
Not every endpoint has every format. options.lookup takes a free‑text string, returns a scalar optionSymbol, and carries no format param at all on the backend. So the CSV facet simply does not expose lookup — facets are allowed to omit endpoints a format doesn't fit. Practically: client.options().asCsv() returns an object with chain/quotes/strikes/expirations but no lookup. That's a feature (you can't write a CSV call that can't exist), but the team must accept that the CSV facet's method set is a principled subset of the typed facet's, decided per‑endpoint, not a mechanical 1:1 mirror.

Decision 4 — Fan‑out endpoints mirror their container; the format facet swaps only the value type.
First, separate two kinds of fan‑out — only one poses a question:

  • Merge‑fan‑out (stocks.candles intraday auto‑split): N date‑chunk requests are merged into one StockCandles; the consumer never sees the fan‑out. CSV is a single CsvResponse (merge the chunks' CSV, as sdk-py's merge_csv_texts does). No decision.
  • Map‑fan‑out (options.quotes): the backend path is single‑symbol (options/quotes/{optionSymbol}/ — comma‑bulk isn't actually supported, verified by reading the handler), so the SDK fires one request per symbol and returns Map<String, Response<OptionsQuotes>> keyed by symbol, each value carrying its own statusCode/isNoData/requestId. This is the only shape that poses the facet question.

Whether an endpoint is map‑fan‑out is forced by the backend (does it accept the batch in one request?), not by taste — stocks.quotes takes a comma‑list in one request and returns a single multi‑row StockQuotes, so it is not a map‑fan‑out.

For map‑fan‑out, the facet should mirror the container and swap only the value type, because a format facet changes the response type, not its cardinality:

client.options().quotes(req)          // Map<String, OptionsQuotes>
client.options().asCsv().quotes(req)  // Map<String, CsvResponse>

This is the most consistent with the facet thesis, needs no merge logic, and loses no per‑symbol metadata or error isolation. Merging all symbols into one CsvResponse (the sdk-py shape) is a nice‑to‑have for "open it in one spreadsheet", but it costs us merge logic and collapses per‑symbol metadata — so it belongs as an optional convenience helper on top, not the primitive. (Flattening the typed response keeps this working: a flattened OptionsQuotes implements MarketDataResponse, so Map<String, OptionsQuotes> still carries per‑symbol metadata that previously lived on the Response<T> wrapper.) One rule, applied to every future per‑symbol endpoint.

What does not break (the reassuring part)

  • Input variety is a non‑issue. Single symbol, symbol list, OCC option symbol, free‑text lookup, no symbol at all — every one is just a different request object behind an unchanged call grammar. This is exactly what ADR‑008's request‑object decision already bought us; the facet inherits it for free.
  • Response‑shape variety is a non‑issue. Rows (candles), a list (expirations), a date‑keyed map (strikes), a scalar (lookup), a per‑symbol map (quotes fan‑out) — each is just a per‑endpoint typed record exposing its natural accessor plus .json(). The "flattened typed" convention generalizes from "has .rows()" to "exposes whatever shape fits, uniformly behind MarketDataResponse." No endpoint forces a grammar change.
  • utilities stays itself. status/headers/user are diagnostic endpoints with no CSV/HTML need; utilities simply doesn't grow format facets. The grammar tolerates a resource opting out of facets entirely.

Net assessment

The architecture stays homogeneous at the grammar level across all five resources while subsetting honestly where the API genuinely differs. The four decisions above are real, but each is a one‑time choice that then applies uniformly to every future resource — which is precisely what an ADR is for. None of them threatens the core: typed‑by‑default, format‑as‑compile‑time‑facet, sealed‑filter input safety. The migration cost is the honest caveat — options shipped before this convention settled, so adopting the proposal means the one‑time flattening pass spelled out in §4 ("Bringing options into line"); funds and markets are built to the convention from the start.

6. Summary for discussion

  1. Input: keep the request‑object + builder (ADR-008 Option A). It's the only option that turns the API's mutually‑exclusive filters into compile errors — the typed SDK's defining advantage.
  2. Output/format: recognize it as a separate axis that, in a typed language, cannot be a runtime parameter — and resolve it by selecting the format as a typed resource facet, so the compiler carries the format guarantee.
  3. The two axes compose cleanly: client.<resource>()[.universalParams…][.asCsv()/.asHtml()].<endpoint>(request).
  4. Cross‑resource: the grammar holds across all five resources and every required‑input/response shape the API has. Four conscious decisions fall out of the resource landscape — (1) columns/human/headers live on the CSV facet, where the facet boundary coincides with the params' own type‑safety boundary; (2) keep HtmlResponse dormant until the server supports HTML; (3) format facets are a principled subset of the typed surface (options.lookup has no CSV); (4) per‑symbol fan‑out endpoints mirror their container (typed Map<String, OptionsQuotes> → CSV Map<String, CsvResponse>), with single‑file merge as an optional helper. Each is decided once and applied uniformly.

@MarketDataDev03 MarketDataDev03 merged commit 67d94cc into main Jun 9, 2026
5 checks passed
@MarketDataDev03 MarketDataDev03 deleted the 10_options_resource branch June 9, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants