This guide walks a reviewer through the options resource added on the 10_options_resource branch, and the response-model + parameter conventions it establishes SDK-wide. Like the Refactor Review Guide, it is organized by flow, not by file.
This PR builds on the transport/retry/rate-limit foundation that guide describes (reused unchanged), but it does change the response layer: the old generic Response<T> is replaced by MarketDataResponse<T> + named per-endpoint types, and ParallelArrays gains lenient accessors. utilities is migrated onto the new model too.
Suggested reading order: §1 (what's here) → §2 (the response model) → §5 (the deserializer: nullable + columns + Option A — the correctness-critical part) → §3 (request convention) / §4 (chain filters) → §6 (universal params + facets) → §9 (subtle corners). ~40 minutes for the load-bearing shape.
file:line citations target HEAD on this branch; line numbers drift — if one looks off, search for the symbol it names.
- What this PR adds
- The response model
- The Request-class convention
- Chain request → query translation
- The option-row deserializer: nullable fields + columns + Option A
- Universal parameters + the CSV/HTML facets
- The
quotesmulti-symbol fan-out - lookup / expirations / strikes
- Subtle corners (finding-driven)
- Out of scope for this review
make build # unit tests + Spotless + JaCoCo (JDK 17)
# Integration tests hit the live API (gated). A token in .env or the env is required:
MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest
# Full options demo against the mock server (every endpoint + all params, CSV facet,
# columns projection, Option A). Needs the mock server up:
make publish && make mock-server # (in one terminal)
make demo-options # (in another) — or: ./gradlew -p examples/consumer-test runOptionsOptionsIntegrationTest (10 tests, shape assertions) was run green against api.marketdata.app. OptionsApp (examples/consumer-test) is the "what consumer code looks like" surface — it scripts the mock server's responses to demonstrate the columns/Option-A scenarios.
com.marketdata.sdk.MarketDataResponse<T> (interface every response implements; T values() + metadata)
com.marketdata.sdk.OptionsResource (returned from client.options())
com.marketdata.sdk.OptionsCsvResource (returned from .asCsv())
com.marketdata.sdk.CsvResponse / HtmlResponse (raw-text responses)
com.marketdata.sdk.Options{Chain,Quotes,Strikes,Expirations,Lookup}Response (named per-endpoint responses)
com.marketdata.sdk.options.Options{Lookup,Expirations,Strikes,Quote,Quotes,Chain}Request
com.marketdata.sdk.options.{OptionQuote, ExpirationStrikes} (row records — every field @Nullable)
com.marketdata.sdk.options.ExpirationFilter (sealed) / StrikeFilter (sealed)
com.marketdata.sdk.options.OptionSide / StrikeRange / Greek (enums)
OptionsResource (and OptionsCsvResource) are public final with package-private constructors (ADR-007) — reached through client.options() / .asCsv(), never instantiated directly. The response wrapper types live in the root package (like the old Response<T> did, so resource façades can construct them via package-private constructors); the request/row records live in the public com.marketdata.sdk.options subpackage.
| Area | Files | What to check |
|---|---|---|
| Response model | MarketDataResponse.java, AbstractMarketDataResponse.java, Options*Response.java, CsvResponse/HtmlResponse |
values() payload typing, metadata, the package-private-constructor wiring |
| Resource façade | OptionsResource.java |
universal-param config, URL/param translation, deserializer + Option A, fan-out, asCsv()/asHtml() |
| CSV/HTML facets | OptionsCsvResource.java, OptionsHtmlResource.java |
reuse of the static *Spec builders, format=csv/html, fan-out mirroring |
| Requests | options/Options*Request.java, ExpirationFilter, StrikeFilter, OptionSide, StrikeRange |
Builder validation, sealed-type modeling |
| Row records | options/OptionQuote.java, ExpirationStrikes |
all fields @Nullable, greeks helpers (presentGreeks/greek) |
| Reused infra (changed) | ParallelArrays.java, JsonResponseParser.java, RequestConfig.java |
OrNull accessors; the requestedColumns attribute threading |
| Wiring | MarketDataClient.java |
client.options(); the StatusCache supplier adapted to r.values() |
Response<T>is deleted. Bothoptionsandutilitiesuse namedMarketDataResponse<T>types now. Confirm nothing still importsResponse.ParallelArraysgained lenienttextOrNull/lngOrNull/boolOrNull/nodeOrNull(alongside the pre-existingdblOrNull). The stricttext/dbl/lng/bool/nodeaccessors are unchanged —utilities'ApiStatusdeserializer still uses them, so confirm noutilitiestest regressed.JsonResponseParsergained aparse(env, type, requestedColumns)overload that sets a Jackson context attribute (REQUESTED_COLUMNS_ATTR). The no-columnsparse(env, type)delegates with an empty list.- Transport, retry, rate-limit, status-cache, and exception layers are untouched.
Every endpoint returns a named type implementing MarketDataResponse<T>:
interface MarketDataResponse<T> {
T values(); // the flat payload, typed per endpoint
int statusCode(); boolean isNoData(); String requestId(); java.net.URI requestUrl();
String json(); boolean isJson(); boolean isCsv(); boolean isHtml();
void saveToFile(Path path);
}values()is the one uniform data accessor.OptionsChainResponse implements MarketDataResponse<List<OptionQuote>>→values()is the rows;OptionsLookupResponse implements MarketDataResponse<String>→values()is the scalar OCC symbol.OptionsStrikesResponse/OptionsExpirationsResponseadditionally exposeupdated().- One hop, no double-unwrap:
Tis the flat payload (aList, aString), not a sub-container. AbstractMarketDataResponse<T>(package-private, root) holds the payload + metadata and implements every accessor; the named types are thin subclasses with a package-private constructor.
What to check:
values()return types match the wire shape per endpoint (rows / list / scalar).- The metadata surface matches §11.5 detectors + the SDK's §13.5 additions (
requestId,requestUrl,json).rawBody()(byte[]) was dropped —json()(the body as text) replaces it. MarketDataClient'sStatusCachesupplier was adapted:statusAsync().thenApply(r -> new ApiStatus(r.values())).
Unchanged from the original design: one Builder-based request class per endpoint, no String overloads.
OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"); // no-optionals: of(...)
OptionsExpirationsRequest.builder("AAPL").strike(150.0).build(); // optionals: builder(...)…build()- Required args are factory params (can't be omitted); optionals are fluent setters; cross-field validation in
build()(e.g. the sharedvalidateWindow(...)rejectsdate+from/to, andcountbackwithdate/from). - Getters are plain reads; the resource translates them into a
RequestSpec.
ExpirationFilter (OnDate/Dte/Between/MonthYear/All) and StrikeFilter (Exact/Range/Comparison + Operator) are sealed, so "pick one variant" is compiler-enforced. Factory methods validate at creation. Pair constraints the type system can't express (minBid ≤ maxBid) stay as runtime checks in OptionsChainRequest.build().
Reviewer note: this is the pattern future resources copy. This is the PR to push back on it.
chain is the densest mapping: ~25 optional params → query string, via applyChainParams (OptionsResource.java), called from chainSpec / chainAsync.
- Flat
if (r.foo() != null) b.query("foo", …)for independent params, plus two sealed-type delegations:applyExpirationFilter→OnDate→?expiration=YYYY-MM-DD,Dte→?dte=N,Between→?from=…&to=…,MonthYear→?month=M&year=YYYY,All→?expiration=all. Check the trailingelse { throw IllegalStateException }— theinstanceofchain isn't compiler-exhaustive; the guard fails loudly if a future variant is added without a branch (unreachable today, won't show in coverage — by design).strikeFilterWireValue→?strike=value:150/140-160/>150(no trailing zeros viaformatStrike).
What to verify:
- Every
OptionsChainRequestgetter has a line inapplyChainParams(a param read nowhere silently does nothing). minBid/maxBid/minAsk/maxAskare real backend params (verified against the handler).
The chainExpirationFilter* / chainStrikeFilter* / per-param URL unit tests assert the exact query string per branch.
This is the correctness-critical section. quotes and chain emit the same per-contract parallel-arrays row, so they share optionRowsDeserializer(wrapper) (OptionsResource.java), registered for both OptionsQuotes and OptionsChain in wireFormatModule().
OptionQuote is now all @Nullable boxed types — including the structural ones (strike, bid, side, …), not just the greeks. This is what lets columns project the response to a subset: a field the consumer didn't request comes back null.
buildOptionRow reads every column through an OrNull accessor (textOrNull/dblOrNull/lngOrNull/boolOrNull, dates via nodeOrNull). The deserializer calls ParallelArrays.zip(p, root, List.of(), OPTION_ALL_FIELDS, …) — note the empty required-fields list: every column is optional at the wire level, so zip never throws on an absent column.
ParallelArrays.Row.cellOrNull(field) is the primitive: null when the column isn't in the response map or the cell is null/missing; the OrNull accessors still throw typeMismatch on a present-but-wrong-type cell (leniency covers absence, not corruption).
If every column is optional, how is "the backend dropped a field you asked for" detected? validateRequestedColumns(p, root, rows, ctxt), run after decode:
- Reads the requested columns from the Jackson context attribute (
JsonResponseParser.REQUESTED_COLUMNS_ATTR), which the resource sets toconfig.columns()viaparse(env, type, requestedColumns). - For each field in
OPTION_REQUIRED_FIELDS(the 20 structural columns — notiv/greeks): if it was requested (explicitly incolumns, or implicitly because nocolumnsfilter was applied) and!root.has(field)→ throwJsonMappingException→ParseError.
The resulting contract (the one to verify):
| Field | Requested? | In response? | Result |
|---|---|---|---|
structural (strike, …) |
yes (or no columns) |
yes | value |
| structural | yes (or no columns) |
no | 💥 ParseError (Option A) |
| structural | no (projected away via columns) |
no | ✅ null |
iv / greeks |
— | no | ✅ null (legitimately optional) |
So a null a consumer sees means only "I projected it away" or "legitimately-optional model value" — never "a required field was silently dropped." Strict-by-default is preserved (no columns ⇒ all required columns implicitly requested ⇒ a missing one still ParseErrors).
OptionQuote.presentGreeks() (Set<Greek>) and greek(Greek) (@Nullable Double) give a typed way to inspect which model values are present. Greek = DELTA/GAMMA/THETA/VEGA/RHO; IV is not a greek (read via iv()).
OptionsResourceTest: columnsProjectionDecodesRequestedAndNullsTheRest, columnsRequestedButOmittedByApiThrowsParseError, noColumnsFilterStillRequiresAllStructuralColumns, presentGreeksReportsNonNullGreeks, plus the original quoteDecodesNullModelValuesAsNull (greeks null → null, not ParseError).
OptionsResource is an immutable configured value: dateFormat()/mode()/limit()/offset()/columns() each return a configured copy carrying a RequestConfig, applied to every subsequent call (config.applyTo(builder)). So "configure once, call many" works, and the config carries into .asCsv(). The universalParamsReachTheWire test asserts they land on the query string.
The split matters: type-preserving params (dateFormat/mode/limit/offset) live on the typed resource; human/headers live only on the CSV facet (they reshape the output, so they don't cohere with the typed decode). columns is on both.
asCsv()→OptionsCsvResource→CsvResponse(values()/csv()= raw CSV text). Reuses the package-private static*Specbuilders onOptionsResource, setsformat=csv, and skips the typed decode. Omitslookup(a scalar — no CSV). Fan-out mirrors the container:quotes(...)→Map<String, CsvResponse>.asHtml()→OptionsHtmlResource/HtmlResponseis package-private (built, not exposed) — the backend serves no HTML.CsvResponse ≠ HtmlResponse(distinct types). VerifyasHtmlFacetSendsFormatHtmlexercises it from the same package.
The backend quotes endpoint takes a single optionSymbol. The SDK's quotes(...) accepts N symbols and fans out one request each.
- One
quoteSpec(symbol, date, from, to, countback)per symbol, each dispatched through the normal transport (50-permitAsyncSemaphore, retry, preflight). - Results into a
LinkedHashMapkeyed by the input symbol (insertion order preserved), each value a fullOptionsQuotesResponse(so per-symbolstatusCode/isNoData/requestId/jsonstay observable). - Fail-fast:
allOf(...)⇒ the map's future completes exceptionally if any single request fails. Deliberate (a partial map would hide failures). The CSV facet mirrors the shape:Map<String, CsvResponse>.
Verify: quotesFansOutToMultipleContracts (integration) and the unit fan-out test.
Three simpler endpoints, each with a hand-written deserializer (their wire shapes don't fit the parallel-arrays row):
- lookup — flat
{"s":"ok","optionSymbol":"…"}→OptionsLookupResponse.values()is theString. Path URL-encoded per-segment viaPathSegments.encode(/preserved, space →%20,$encoded). - expirations — parallel
expirations[]+ scalarupdated→values()isList<ZonedDateTime>,updated()the timestamp (null onno_data). - strikes — one top-level key per expiration date +
s/updated→values()isList<ExpirationStrikes>. The deserializer is strict (unrecognized non-date key / non-numeric strike throws). Note:columns/Option A do not apply to these (they're not the option-row shape).
MarketDataDates handles all three dateformat variants (unix / ISO-string / spreadsheet) uniformly.
| # | Corner | What to know |
|---|---|---|
| 9.1 | No silent failures (Option A) | All fields nullable for columns, but validateRequestedColumns still ParseErrors on a requested-but-missing required column. A null only means "projected away" or "optional model value." §5.3. |
| 9.2 | HTTP 203 is success | API returns 203 Non-Authoritative Information for cached/delayed data; IT assert `200 |
| 9.3 | expiration=all ≠ no filter |
Omitting the expiration filter returns only the front-month; all() returns every expiration. Verified against the backend. |
| 9.4 | countback validation |
Positive; mutually exclusive with date and from (pair with to). |
| 9.5 | Exhaustiveness guard | applyExpirationFilter else throw — sealed instanceof chains aren't compiler-exhaustive; the guard fails loudly on an unmatched future variant. |
| 9.6 | quotes is fan-out, not bulk |
The backend path takes one symbol; the SDK fans out N concurrent calls. |
| 9.7 | human/headers are CSV-only |
They reshape the output, which breaks the typed decode — so they live on the CSV facet, not the typed resource. The facet boundary coincides with the params' type-safety boundary. |
| 9.8 | HTML built, not exposed | asHtml() is package-private; the server serves no HTML yet. Enabling later is a one-line change. |
| 9.9 | source intentionally absent |
Internal provider param, not in the public schema/requirements/Python SDK. |
Do not flag as missing — deferred decisions, documented in PR.md:
- HTML facet exposure — built and tested;
asHtml()stays package-private until the backend servesformat=html. - §13 JaCoCo 100% threshold — deferred until the full resource layer lands.
- §8 per-response rate-limit snapshot — still client-level via
client.getRateLimits(). stocks/funds/markets— adopt this convention next.
(§3 universal parameters are no longer deferred — they're implemented here, §6.)
- Response model: every endpoint returns a named
MarketDataResponse<T>;values()is the flat payload; metadata surface complete;Response<T>fully removed;utilitiesmigrated with no regression. - Request convention: Builder request per endpoint, no
Stringoverloads; required args non-optional; cross-field validation inbuild(). - Sealed
ExpirationFilter/StrikeFiltertranslate every variant inapplyChainParams, with theelse throwguard;applyChainParamsreads every getter. - Nullable + Option A: all
OptionQuotefields@Nullable;buildOptionRowlenient;validateRequestedColumnsfails on requested-but-missing required columns; strict-by-default preserved. -
ParallelArraysOrNull accessors are additive; strict path unchanged; an absent optional column can't throw. - Universal params reach the wire;
human/headersonly on the CSV facet;columnsprojection round-trips. - Facets:
asCsv()returnsCsvResponse, omitslookup, fan-out →Map<String, CsvResponse>;asHtml()package-private;CsvResponse ≠ HtmlResponse. -
quotesfan-out: per-symbol map, insertion order, fail-fast acceptable. - Unit (
./gradlew build) and integration (MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest) both green;make demo-optionsruns against the mock server. - Deferred items (§10) understood and not blocking.