From 4e9cf311d0c6797d9d6dc320df4a6e964f4103ea Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 28 May 2026 17:01:15 -0300 Subject: [PATCH 01/14] adds options.expirations --- .../com/marketdata/sdk/MarketDataClient.java | 10 + .../com/marketdata/sdk/MarketDataDates.java | 89 +++++ .../sdk/OptionsExpirationsDeserializer.java | 73 ++++ .../sdk/OptionsLookupDeserializer.java | 43 ++ .../com/marketdata/sdk/OptionsResource.java | 98 +++++ .../java/com/marketdata/sdk/PathSegments.java | 41 ++ .../sdk/options/OptionsExpirations.java | 31 ++ .../options/OptionsExpirationsRequest.java | 82 ++++ .../marketdata/sdk/options/OptionsLookup.java | 17 + .../sdk/options/OptionsLookupRequest.java | 52 +++ .../marketdata/sdk/options/package-info.java | 7 + .../marketdata/sdk/OptionsResourceTest.java | 370 ++++++++++++++++++ .../com/marketdata/sdk/PathSegmentsTest.java | 47 +++ 13 files changed, 960 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsResource.java create mode 100644 src/main/java/com/marketdata/sdk/PathSegments.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsExpirations.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsLookup.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/package-info.java create mode 100644 src/test/java/com/marketdata/sdk/OptionsResourceTest.java create mode 100644 src/test/java/com/marketdata/sdk/PathSegmentsTest.java diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 986c7c4..f445c6f 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -20,6 +20,7 @@ public final class MarketDataClient implements AutoCloseable { private final Configuration config; private final HttpTransport transport; private final UtilitiesResource utilities; + private final OptionsResource options; public MarketDataClient() { this(null, null, null, true); @@ -105,6 +106,7 @@ public MarketDataClient( try { JsonResponseParser parser = new JsonResponseParser(); this.utilities = new UtilitiesResource(transport, parser); + this.options = new OptionsResource(transport, parser); cacheRef.set( new StatusCache( () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); @@ -142,6 +144,14 @@ public UtilitiesResource utilities() { return utilities; } + /** + * Options endpoints: {@code lookup}, {@code expirations}, {@code strikes}, {@code quotes}, {@code + * chain}. + */ + public OptionsResource options() { + return options; + } + /** * Fire a single call to {@code GET /user/} to confirm the token is accepted and a billing plan is * attached (SDK requirements §5). A 401 surfaces as {@link diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java index 8627eb5..d6556e6 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataDates.java +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -1,8 +1,15 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; /** * Conventions for date/time fields surfaced to SDK consumers (§13.4). @@ -34,4 +41,86 @@ private MarketDataDates() {} static ZonedDateTime marketTimeFromEpochSecond(long epochSecond) { return Instant.ofEpochSecond(epochSecond).atZone(MARKET_ZONE); } + + // Wire-format helpers for the three values of §3's universal `dateformat` parameter + // (`unix`, `timestamp`, `spreadsheet`). The deserializer cannot ask the request "which one did + // we send?", so it detects by JSON node shape. The numeric ranges are far enough apart that a + // single threshold disambiguates safely: + // - spreadsheet serials for years 1900–2100 fit in roughly [1, 73000]; + // - unix epochs for the same period are in [≈ -2.2e9, ≈ 4.1e9]. + // A threshold at 1_000_000 (~year 1970 + 11 days as a serial, ~Jan 12 1970 as an epoch) has no + // realistic collision and we never need to refine it. + private static final long UNIX_VS_SPREADSHEET_THRESHOLD = 1_000_000L; + private static final LocalDate SPREADSHEET_EPOCH = LocalDate.of(1899, 12, 30); + + /** + * Pattern for the timestamp-format datetime emitted by the API: {@code "2025-01-17 16:00:00 + * -05:00"} — space-separated, offset with explicit colon. Matches {@code + * common/util/date_helper.py:format_date} in the backend. + */ + private static final DateTimeFormatter ZONED_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX"); + + /** + * Parse a date-field cell ({@code timestamp}, {@code unix}, or {@code spreadsheet}) to a calendar + * {@link LocalDate}. {@code timestamp} strings are date-only ({@code "yyyy-MM-dd"}); numeric + * values are either epoch seconds (interpreted in {@link #MARKET_ZONE}) or Excel serials + * (whole-day part of days since 1899-12-30). + */ + static LocalDate parseDateField(JsonParser p, JsonNode node, String fieldName) + throws JsonMappingException { + if (node == null || node.isNull()) { + throw new JsonMappingException(p, "missing field: " + fieldName); + } + if (node.isTextual()) { + try { + return LocalDate.parse(node.asText()); + } catch (DateTimeParseException e) { + throw new JsonMappingException( + p, "non-ISO date string for field " + fieldName + ": " + node.asText()); + } + } + if (!node.isNumber()) { + throw new JsonMappingException(p, "non-string, non-numeric date field: " + fieldName); + } + long whole = node.asLong(); + if (Math.abs(whole) >= UNIX_VS_SPREADSHEET_THRESHOLD) { + return marketTimeFromEpochSecond(whole).toLocalDate(); + } + return SPREADSHEET_EPOCH.plusDays(whole); + } + + /** + * Parse a timestamp-field cell ({@code timestamp}, {@code unix}, or {@code spreadsheet}) to a + * {@link ZonedDateTime} in {@link #MARKET_ZONE}. {@code timestamp} strings include time-of-day + * and offset ({@code "yyyy-MM-dd HH:mm:ss XXX"}); numeric values are epoch seconds or fractional + * Excel serials. + */ + static ZonedDateTime parseTimestampField(JsonParser p, JsonNode node, String fieldName) + throws JsonMappingException { + if (node == null || node.isNull()) { + throw new JsonMappingException(p, "missing field: " + fieldName); + } + if (node.isTextual()) { + try { + return ZonedDateTime.parse(node.asText(), ZONED_TIMESTAMP_FORMAT) + .withZoneSameInstant(MARKET_ZONE); + } catch (DateTimeParseException e) { + throw new JsonMappingException( + p, "non-conforming timestamp string for field " + fieldName + ": " + node.asText()); + } + } + if (!node.isNumber()) { + throw new JsonMappingException(p, "non-string, non-numeric timestamp field: " + fieldName); + } + double v = node.asDouble(); + if (Math.abs(v) >= UNIX_VS_SPREADSHEET_THRESHOLD) { + return marketTimeFromEpochSecond((long) v); + } + // Spreadsheet serial: fractional days since 1899-12-30 UTC (the backend constructs it from a + // UTC datetime, so the epoch reference is UTC, not Eastern). + long millis = Math.round(v * 86_400_000d); + Instant base = SPREADSHEET_EPOCH.atStartOfDay(ZoneOffset.UTC).toInstant(); + return base.plusMillis(millis).atZone(MARKET_ZONE); + } } diff --git a/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java new file mode 100644 index 0000000..4ea1b0c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java @@ -0,0 +1,73 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.OptionsExpirations; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Wire-format deserializer for {@link OptionsExpirations}. The endpoint's body mixes a parallel + * array ({@code expirations}) with a scalar metadata field ({@code updated}) at the top level, + * which is the shape {@link ParallelArrays#listDeserializer} cannot express (the wrapper there only + * receives the row list). Envelope handling mirrors {@link ParallelArrays}: + * + * + * + *

Per §3 the deserializer accepts whichever {@code dateformat} the consumer asked the API for — + * {@code unix}, {@code timestamp}, or {@code spreadsheet} — and converts to native types through + * {@link MarketDataDates#parseDateField} / {@link MarketDataDates#parseTimestampField}. That way + * the typed {@link OptionsExpirations#data} surface is uniform regardless of wire format. + */ +final class OptionsExpirationsDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String EXPIRATIONS_KEY = "expirations"; + private static final String UPDATED_KEY = "updated"; + + @Override + public OptionsExpirations deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + JsonNode root = p.readValueAsTree(); + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if (ENVELOPE_ERROR.equals(envelopeStatus)) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return new OptionsExpirations(List.of(), null); + } + + JsonNode expsNode = root.get(EXPIRATIONS_KEY); + if (expsNode == null || !expsNode.isArray()) { + throw new JsonMappingException(p, "missing or non-array field: " + EXPIRATIONS_KEY); + } + List dates = new ArrayList<>(expsNode.size()); + for (int i = 0; i < expsNode.size(); i++) { + // Expirations are calendar dates on the wire; lift each to a midnight market-zone moment so + // the consumer always sees a ZonedDateTime — the SDK's canonical market-timestamp type. + LocalDate cellDate = + MarketDataDates.parseDateField(p, expsNode.get(i), EXPIRATIONS_KEY + "[" + i + "]"); + dates.add(cellDate.atStartOfDay(MarketDataDates.MARKET_ZONE)); + } + + ZonedDateTime updated = + MarketDataDates.parseTimestampField(p, root.get(UPDATED_KEY), UPDATED_KEY); + return new OptionsExpirations(dates, updated); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java new file mode 100644 index 0000000..9b909cb --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java @@ -0,0 +1,43 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.OptionsLookup; +import java.io.IOException; + +/** + * Wire-format deserializer for {@link OptionsLookup}. The {@code /options/lookup/} endpoint is the + * one options endpoint that does not use the parallel-arrays envelope — the body is a flat object + * ({@code {"s":"ok","optionSymbol":"AAPL250117C00150000"}}). Envelope handling mirrors {@link + * ParallelArrays} so consumers see consistent behavior across resources: + * + *

+ */ +final class OptionsLookupDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String OPTION_SYMBOL_KEY = "optionSymbol"; + + @Override + public OptionsLookup deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + if (ENVELOPE_ERROR.equals(root.path(ENVELOPE_STATUS).asText(""))) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + JsonNode node = root.get(OPTION_SYMBOL_KEY); + if (node == null || !node.isTextual()) { + throw new JsonMappingException(p, "missing or non-string field: " + OPTION_SYMBOL_KEY); + } + return new OptionsLookup(node.asText()); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java new file mode 100644 index 0000000..069c664 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -0,0 +1,98 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CompletableFuture; + +/** + * Options endpoints documented at {@code https://api.marketdata.app/docs/api/options/}. All five + * endpoints — {@code lookup}, {@code expirations}, {@code strikes}, {@code quotes}, and {@code + * chain} — are versioned ({@code /v1/options/...}). + * + *

Constructed once per {@link MarketDataClient}; consumers reach it through {@code + * client.options()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. + * + *

Every endpoint returns a {@link Response} carrying both the typed model and the raw body so + * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without + * the resource caring about format choice. + */ +public final class OptionsResource { + + private final HttpTransport transport; + private final JsonResponseParser parser; + + OptionsResource(HttpTransport transport, JsonResponseParser parser) { + this.transport = transport; + this.parser = parser; + parser.registerModule(wireFormatModule()); + } + + /** + * Build the Jackson module that maps this resource's response records to their custom + * deserializers. Each call returns a fresh {@link SimpleModule}; tests that need the same wiring + * without constructing a full resource can register this directly on a bare parser. + */ + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-options"); + m.addDeserializer(OptionsLookup.class, new OptionsLookupDeserializer()); + m.addDeserializer(OptionsExpirations.class, new OptionsExpirationsDeserializer()); + return m; + } + + /** + * Async: convert a human-readable option description ({@code "AAPL 7/26/23 $200 Call"}) into a + * well-formed OCC symbol ({@code "AAPL230726C00200000"}). The request's {@code userInput} is + * URL-encoded per-segment so spaces, {@code $}, and other reserved characters travel safely + * without losing the natural {@code /} separators in dates like {@code 7/26/23}. + */ + public CompletableFuture> lookupAsync(OptionsLookupRequest request) { + RequestSpec spec = + RequestSpec.get("options/lookup/" + PathSegments.encode(request.userInput())).build(); + return executeAndWrap(spec, OptionsLookup.class); + } + + /** Sync wrapper for {@link #lookupAsync(OptionsLookupRequest)}. */ + public Response lookup(OptionsLookupRequest request) { + return transport.joinSync(lookupAsync(request)); + } + + /** + * Async: fetch the available option-expiration dates for the request's underlying. The optional + * {@code strike} and {@code date} filters narrow the result. + * + *

The §3 universal {@code dateformat} parameter is left to the API's default ({@code + * timestamp}); the typed {@link OptionsExpirations#data} surface decodes whichever format the API + * returns via {@link MarketDataDates#parseDateField}. Consumers that want a specific wire format + * for {@link Response#rawBody()} / {@link Response#saveToFile} access pass it explicitly once the + * universal-parameters overload lands (follow-up). + */ + public CompletableFuture> expirationsAsync( + OptionsExpirationsRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/expirations/" + PathSegments.encode(request.symbol())); + if (request.strike() != null) { + b.query("strike", request.strike()); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return executeAndWrap(b.build(), OptionsExpirations.class); + } + + /** Sync wrapper for {@link #expirationsAsync(OptionsExpirationsRequest)}. */ + public Response expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + // ---------- internal helpers ---------- + + private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { + return transport + .executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/PathSegments.java b/src/main/java/com/marketdata/sdk/PathSegments.java new file mode 100644 index 0000000..f7eab67 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/PathSegments.java @@ -0,0 +1,41 @@ +package com.marketdata.sdk; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Percent-encode user-provided segments before they are appended to a {@link RequestSpec} path. + * {@link HttpTransport#buildUri} writes the path verbatim (it only encodes query components, where + * {@code application/x-www-form-urlencoded} is the right dialect); when a resource places an + * unsanitized value in the path — symbol names with spaces, OCC option descriptions with {@code $}, + * etc. — it must encode beforehand or the resulting URL goes through {@code URI.create} as an + * illegal-character failure. + * + *

Slashes are preserved verbatim because some endpoints accept multi-segment user input that + * naturally contains them (e.g. {@code /options/lookup/(?P.*)} matches across slashes, + * and dates like {@code "7/26/23"} look natural). Spaces become {@code %20} (not {@code +}, which + * is the {@code form-urlencoded} dialect and would be read literally by strict path parsers). + */ +final class PathSegments { + + private PathSegments() {} + + /** Percent-encode {@code raw} for path-context use, preserving {@code /} as a separator. */ + static String encode(String raw) { + StringBuilder out = new StringBuilder(raw.length()); + int start = 0; + for (int i = 0; i < raw.length(); i++) { + if (raw.charAt(i) == '/') { + if (i > start) { + out.append(URLEncoder.encode(raw.substring(start, i), StandardCharsets.UTF_8)); + } + out.append('/'); + start = i + 1; + } + } + if (start < raw.length()) { + out.append(URLEncoder.encode(raw.substring(start), StandardCharsets.UTF_8)); + } + return out.toString().replace("+", "%20"); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java b/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java new file mode 100644 index 0000000..907feb3 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/expirations/{underlying}/} — the available expiration + * dates for the underlying's option chain. + * + *

Both fields are {@link ZonedDateTime} in {@code America/New_York} (§13.4 — the canonical + * market zone). For expirations the time-of-day is always {@code 00:00} since the wire value is a + * calendar date; the {@code ZonedDateTime} type is kept for consistency with the rest of the SDK + * ({@code ServiceStatus.updated}, future {@code OptionQuote.expiration}, etc.) so consumers reach + * for the same APIs regardless of which field they touch. Convert to {@link java.time.LocalDate} + * via {@code .toLocalDate()} when the time-of-day is irrelevant. + * + * @param expirations the expiration dates (as midnight market-zone moments) in chronological order. + * Immutable; never {@code null}. Empty when the {@code "s":"no_data"} envelope is received. + * @param updated when the server last refreshed this expirations list, in {@code America/New_York}. + * {@code null} only when the no-data envelope omits the {@code updated} field; the deserializer + * rejects any other absence with a {@link com.marketdata.sdk.exception.ParseError}. + */ +public record OptionsExpirations( + List expirations, @org.jspecify.annotations.Nullable ZonedDateTime updated) { + + public OptionsExpirations { + Objects.requireNonNull(expirations, "expirations"); + expirations = List.copyOf(expirations); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java new file mode 100644 index 0000000..7e5356f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java @@ -0,0 +1,82 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/expirations/{symbol}/} — the list of expiration dates for + * an underlying's option chain. Two optional filters narrow the result: {@code strike} restricts to + * expirations that include the given strike, and {@code date} fetches the historical list as it + * stood on a previous trading day. + * + *

Constructed via {@link #builder(String)}; the {@code symbol} is required and seeds the + * builder, mutable optional fields are set with {@code strike(...)} / {@code date(...)}. The §3 + * universal {@code dateformat}/{@code format}/etc. parameters are not part of this class — they + * land on a separate universal-parameters overload when added. + */ +public final class OptionsExpirationsRequest { + + private final String symbol; + private final @Nullable Double strike; + private final @Nullable LocalDate date; + + private OptionsExpirationsRequest(Builder b) { + this.symbol = b.symbol; + this.strike = b.strike; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()} when no filters are needed. */ + public static OptionsExpirationsRequest of(String symbol) { + return builder(symbol).build(); + } + + /** Start a builder seeded with the required underlying symbol. */ + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + /** Strike-price filter, or {@code null} when unset. */ + public @Nullable Double strike() { + return strike; + } + + /** Historical query date, or {@code null} for the current/last-trading-day list. */ + public @Nullable LocalDate date() { + return date; + } + + public static final class Builder { + private final String symbol; + private @Nullable Double strike; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Restrict the result to expirations whose chain contains {@code strike}. */ + public Builder strike(double strike) { + this.strike = strike; + return this; + } + + /** Fetch the expirations list as it stood at end-of-day on {@code date}. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public OptionsExpirationsRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + return new OptionsExpirationsRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsLookup.java b/src/main/java/com/marketdata/sdk/options/OptionsLookup.java new file mode 100644 index 0000000..9efef59 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsLookup.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk.options; + +/** + * Response shape for {@code GET /v1/options/lookup/{userInput}/} — a single OCC-formatted option + * symbol resolved from a human-readable description (e.g. {@code "AAPL 7/26/23 $200 Call"} → {@code + * "AAPL230726C00200000"}). + * + *

The wire shape is a flat single-value object ({@code {"s":"ok","optionSymbol":"..."}}), not + * the parallel-arrays envelope used by chain/quotes/expirations — that is why this record is + * decoded by a hand-written {@link com.marketdata.sdk.OptionsLookupDeserializer} instead of the + * {@code ParallelArrays.listDeserializer} factory. + * + * @param optionSymbol the OCC option symbol the user input resolves to. Always non-empty when + * present; the deserializer rejects a missing or non-textual {@code optionSymbol} as a {@link + * com.marketdata.sdk.exception.ParseError}. + */ +public record OptionsLookup(String optionSymbol) {} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java new file mode 100644 index 0000000..43e822b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java @@ -0,0 +1,52 @@ +package com.marketdata.sdk.options; + +import java.util.Objects; + +/** + * Parameters for {@code GET /v1/options/lookup/{userInput}/}. The endpoint takes a single + * path-positional value (the human-readable description) and no query parameters beyond the §3 + * universal set; the request class exists for SDK-wide consistency so every endpoint is reached the + * same way ({@code options.lookup(request)}). + * + *

Constructed via {@link #of(String)} when only the required {@code userInput} is needed, or + * {@link #builder(String)} when forward-compatible with future optional fields. + */ +public final class OptionsLookupRequest { + + private final String userInput; + + private OptionsLookupRequest(Builder b) { + this.userInput = b.userInput; + } + + /** Shortcut for {@code builder(userInput).build()}. */ + public static OptionsLookupRequest of(String userInput) { + return builder(userInput).build(); + } + + /** Start a builder seeded with the required path field. */ + public static Builder builder(String userInput) { + return new Builder(userInput); + } + + /** The human-readable option description, e.g. {@code "AAPL 7/26/23 $200 Call"}. */ + public String userInput() { + return userInput; + } + + /** Mutable builder; each chain produces a new {@link OptionsLookupRequest} via {@link #build}. */ + public static final class Builder { + private final String userInput; + + private Builder(String userInput) { + this.userInput = Objects.requireNonNull(userInput, "userInput"); + } + + public OptionsLookupRequest build() { + if (userInput.isEmpty()) { + throw new IllegalArgumentException("userInput must be non-empty"); + } + return new OptionsLookupRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/package-info.java b/src/main/java/com/marketdata/sdk/options/package-info.java new file mode 100644 index 0000000..45975ca --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records and request types for the {@code options} resource — quotes, chain, expirations, + * strikes, and OCC-symbol lookup. The {@link com.marketdata.sdk.OptionsResource} façade lives in + * the SDK root package (ADR-007); only the public consumer-facing types live here. + */ +@org.jspecify.annotations.NullMarked +package com.marketdata.sdk.options; diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java new file mode 100644 index 0000000..8d0a16d --- /dev/null +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -0,0 +1,370 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class OptionsResourceTest { + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + private static final HttpHeaders EMPTY_HEADERS = HttpHeaders.of(Map.of(), (a, b) -> true); + + /** Mints a fresh transport + resource pair against the given canned HTTP client. */ + private static OptionsResource resourceWith(HttpClient client) { + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + return new OptionsResource(transport, new JsonResponseParser()); + } + + // ---------- lookup: URL & verb ---------- + + @Test + void lookupHitsVersionedEndpoint() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL230726C00200000\"}"); + OptionsResource options = resourceWith(client); + + options.lookupAsync(OptionsLookupRequest.of("AAPL230726C00200000")).join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.uri().toString()) + .isEqualTo("http://localhost/v1/options/lookup/AAPL230726C00200000/"); + assertThat(sent.method()).isEqualTo("GET"); + } + + @Test + void lookupUrlEncodesSpacesAndReservedChars() { + // Spaces → %20 (not +, which is the application/x-www-form-urlencoded dialect and would be + // taken literally by a strict path parser). The "$" is reserved in URLs and must be encoded. + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL230726C00200000\"}"); + OptionsResource options = resourceWith(client); + + options.lookupAsync(OptionsLookupRequest.of("AAPL 7/26/23 $200 Call")).join(); + + String url = client.captured.get(0).uri().toString(); + // Slashes survive verbatim — the backend regex (?P.*) matches across them, and dates + // like "7/26/23" are natural in the input. Mirrors Python's urllib.parse.quote() default + // (safe="/"). + assertThat(url).isEqualTo("http://localhost/v1/options/lookup/AAPL%207/26/23%20%24200%20Call/"); + } + + // ---------- lookup: response decoding ---------- + + @Test + void lookupAsyncReturnsDecodedSymbol() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + OptionsLookup lookup = options.lookupAsync(OptionsLookupRequest.of("anything")).join().data(); + + assertThat(lookup.optionSymbol()).isEqualTo("AAPL250117C00150000"); + } + + @Test + void lookupSyncMirrorsAsync() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + OptionsLookup viaSync = options.lookup(OptionsLookupRequest.of("x")).data(); + assertThat(viaSync.optionSymbol()).isEqualTo("AAPL250117C00150000"); + } + + @Test + void lookupResponseExposesIsJsonAndRequestUrl() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + Response resp = options.lookup(OptionsLookupRequest.of("x")); + assertThat(resp.isJson()).isTrue(); + assertThat(resp.isCsv()).isFalse(); + assertThat(resp.isHtml()).isFalse(); + assertThat(resp.statusCode()).isEqualTo(200); + } + + // ---------- lookup: envelope handling ---------- + + @Test + void lookupErrorEnvelopeSurfacesAsParseError() { + // Backend returns {"s":"error","errmsg":"..."} when the input can't be parsed. The + // deserializer turns that into a JsonMappingException which JsonResponseParser wraps as a + // ParseError — same shape as the parallel-arrays envelope path so consumers see consistent + // error semantics across endpoints. + CapturingClient client = + okWith("{\"s\":\"error\",\"errmsg\":\"Unable to parse option description\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("bogus"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Unable to parse option description"); + } + + @Test + void lookupMissingOptionSymbolFieldThrowsParseError() { + // Strict-by-default: a server bug that drops the optionSymbol field must surface, not silently + // produce a record with an empty string. Same reasoning as UserDeserializer / ParallelArrays. + CapturingClient client = okWith("{\"s\":\"ok\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("x"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("optionSymbol"); + } + + @Test + void lookupNonStringOptionSymbolThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":12345}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("x"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("optionSymbol"); + } + + // ---------- expirations: URL & params ---------- + + @Test + void expirationsHitsVersionedEndpointWithNoExtraParams() { + // No forced ?dateformat= — §3 says the universal parameter is consumer-controlled. The typed + // deserializer adapts to whichever format the API returns. + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options.expirationsAsync(OptionsExpirationsRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/expirations/AAPL/"); + } + + @Test + void expirationsAttachesStrikeAndDateFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options + .expirationsAsync( + OptionsExpirationsRequest.builder("AAPL") + .strike(150.0) + .date(LocalDate.of(2024, Month.JANUARY, 17)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo("http://localhost/v1/options/expirations/AAPL/?strike=150.0&date=2024-01-17"); + } + + @Test + void expirationsSkipsNullFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options.expirationsAsync(OptionsExpirationsRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/expirations/AAPL/"); + } + + // ---------- expirations: response decoding ---------- + + @Test + void expirationsDecodesEpochsToMarketMidnights() { + // The API serializes expiration dates as the epoch for midnight America/New_York of that day + // (confirmed against the Python SDK fixtures + DateHelper.format_date in the backend). Derive + // the epochs from LocalDate so the test is robust to DST shifts and never carries a "what + // does 1737090000 mean" magic-number question. + ZoneId et = ZoneId.of("America/New_York"); + long e1 = LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et).toEpochSecond(); + long e2 = LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et).toEpochSecond(); + long e3 = LocalDate.of(2025, Month.MARCH, 21).atStartOfDay(et).toEpochSecond(); // after DST + long updated = + LocalDate.of(2025, Month.JANUARY, 16).atStartOfDay(et).toEpochSecond() + 19 * 3600; + + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"expirations\":[" + + e1 + + "," + + e2 + + "," + + e3 + + "],\"updated\":" + + updated + + "}"); + OptionsResource options = resourceWith(client); + + OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); + + assertThat(exps.expirations()) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et), + LocalDate.of(2025, Month.MARCH, 21).atStartOfDay(et)); + assertThat(exps.expirations().get(0).getZone().getId()).isEqualTo("America/New_York"); + assertThat(exps.updated()).isNotNull(); + assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(exps.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(exps.updated().getHour()).isEqualTo(19); + } + + @Test + void expirationsSyncMirrorsAsync() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); + assertThat(exps.expirations()).hasSize(1); + } + + // ---------- expirations: envelope handling ---------- + + @Test + void expirationsNoDataEnvelopeYieldsEmptyList() { + CapturingClient client = okWith("{\"s\":\"no_data\"}"); + OptionsResource options = resourceWith(client); + + OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("NOPE")).data(); + assertThat(exps.expirations()).isEmpty(); + assertThat(exps.updated()).isNull(); + } + + @Test + void expirationsErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Underlying not found\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Underlying not found"); + } + + @Test + void expirationsMissingUpdatedThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"expirations\":[1737072000]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("updated"); + } + + @Test + void expirationsAcceptsTimestampStringFormat() { + // dateformat=timestamp returns ISO date strings for expirations and "yyyy-MM-dd HH:mm:ss XXX" + // for updated — the deserializer detects via JSON node type and converts to native types. + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"expirations\":[\"2025-01-17\",\"2025-02-21\"]," + + "\"updated\":\"2025-01-16 19:00:00 -05:00\"}"); + OptionsResource options = resourceWith(client); + + OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); + assertThat(exps.expirations()) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(exps.updated()).isNotNull(); + assertThat(exps.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(exps.updated().getHour()).isEqualTo(19); + assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void expirationsAcceptsSpreadsheetSerialFormat() { + // Spreadsheet serials are days since 1899-12-30 UTC. 2025-01-17 = 45674. + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[45674,45709],\"updated\":45673.79166667}"); + OptionsResource options = resourceWith(client); + + OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); + assertThat(exps.expirations()) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(exps.updated()).isNotNull(); + assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void expirationsInvalidDateCellThrowsParseError() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[\"not-a-date\"],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-ISO date string"); + } + + @Test + void expirationsBooleanCellThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"expirations\":[true],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-string, non-numeric"); + } + + // ---------- helpers ---------- + + private static CapturingClient okWith(String body) { + return new CapturingClient(200, body.getBytes(), EMPTY_HEADERS); + } + + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final int status; + final byte[] body; + final HttpHeaders headers; + + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.status = status; + this.body = body; + this.headers = headers; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response(status, body, headers, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/PathSegmentsTest.java b/src/test/java/com/marketdata/sdk/PathSegmentsTest.java new file mode 100644 index 0000000..f8b1f0f --- /dev/null +++ b/src/test/java/com/marketdata/sdk/PathSegmentsTest.java @@ -0,0 +1,47 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PathSegmentsTest { + + @Test + void leavesAsciiSymbolUntouched() { + assertThat(PathSegments.encode("AAPL")).isEqualTo("AAPL"); + assertThat(PathSegments.encode("AAPL230726C00200000")).isEqualTo("AAPL230726C00200000"); + } + + @Test + void encodesSpacesAsPercent20NotPlus() { + // %20 is the path-context encoding; "+" is the application/x-www-form-urlencoded dialect that + // a strict path parser would treat literally. + assertThat(PathSegments.encode("BRK A")).isEqualTo("BRK%20A"); + } + + @Test + void encodesReservedAndUnicode() { + assertThat(PathSegments.encode("$200")).isEqualTo("%24200"); + assertThat(PathSegments.encode("café")).isEqualTo("caf%C3%A9"); + } + + @Test + void preservesSlashesAsSegmentSeparators() { + // Slashes are kept because some endpoints accept multi-segment user input and the backend's + // catch-all regex matches across them. + assertThat(PathSegments.encode("AAPL 7/26/23 $200 Call")) + .isEqualTo("AAPL%207/26/23%20%24200%20Call"); + } + + @Test + void handlesLeadingTrailingAndDoubleSlashes() { + assertThat(PathSegments.encode("/leading")).isEqualTo("/leading"); + assertThat(PathSegments.encode("trailing/")).isEqualTo("trailing/"); + assertThat(PathSegments.encode("a//b")).isEqualTo("a//b"); + } + + @Test + void emptyStringReturnsEmpty() { + assertThat(PathSegments.encode("")).isEqualTo(""); + } +} From e3c3ff64b2729e3ff1a1625a7758558a41e1fcaf Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 28 May 2026 17:32:06 -0300 Subject: [PATCH 02/14] add options.chain --- .../com/marketdata/sdk/MarketDataDates.java | 6 +- .../com/marketdata/sdk/OptionsResource.java | 316 ++++++++ .../sdk/OptionsStrikesDeserializer.java | 94 +++ .../sdk/options/ExpirationFilter.java | 72 ++ .../sdk/options/ExpirationStrikes.java | 24 + .../marketdata/sdk/options/OptionQuote.java | 41 ++ .../marketdata/sdk/options/OptionSide.java | 22 + .../marketdata/sdk/options/OptionsChain.java | 22 + .../sdk/options/OptionsChainRequest.java | 344 +++++++++ .../sdk/options/OptionsQuoteRequest.java | 92 +++ .../marketdata/sdk/options/OptionsQuotes.java | 24 + .../sdk/options/OptionsQuotesRequest.java | 104 +++ .../sdk/options/OptionsStrikes.java | 29 + .../sdk/options/OptionsStrikesRequest.java | 75 ++ .../marketdata/sdk/options/StrikeFilter.java | 61 ++ .../marketdata/sdk/options/StrikeRange.java | 23 + .../marketdata/sdk/OptionsResourceTest.java | 672 ++++++++++++++++++ 17 files changed, 2019 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/options/ExpirationFilter.java create mode 100644 src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionQuote.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionSide.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsChain.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsQuotes.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsStrikes.java create mode 100644 src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java create mode 100644 src/main/java/com/marketdata/sdk/options/StrikeFilter.java create mode 100644 src/main/java/com/marketdata/sdk/options/StrikeRange.java diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java index d6556e6..5ecd9de 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataDates.java +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -67,7 +67,8 @@ static ZonedDateTime marketTimeFromEpochSecond(long epochSecond) { * values are either epoch seconds (interpreted in {@link #MARKET_ZONE}) or Excel serials * (whole-day part of days since 1899-12-30). */ - static LocalDate parseDateField(JsonParser p, JsonNode node, String fieldName) + static LocalDate parseDateField( + @org.jspecify.annotations.Nullable JsonParser p, JsonNode node, String fieldName) throws JsonMappingException { if (node == null || node.isNull()) { throw new JsonMappingException(p, "missing field: " + fieldName); @@ -96,7 +97,8 @@ static LocalDate parseDateField(JsonParser p, JsonNode node, String fieldName) * and offset ({@code "yyyy-MM-dd HH:mm:ss XXX"}); numeric values are epoch seconds or fractional * Excel serials. */ - static ZonedDateTime parseTimestampField(JsonParser p, JsonNode node, String fieldName) + static ZonedDateTime parseTimestampField( + @org.jspecify.annotations.Nullable JsonParser p, JsonNode node, String fieldName) throws JsonMappingException { if (node == null || node.isNull()) { throw new JsonMappingException(p, "missing field: " + fieldName); diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 069c664..8e0190f 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -1,12 +1,30 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; import com.marketdata.sdk.options.OptionsExpirations; import com.marketdata.sdk.options.OptionsExpirationsRequest; import com.marketdata.sdk.options.OptionsLookup; import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; /** * Options endpoints documented at {@code https://api.marketdata.app/docs/api/options/}. All five @@ -40,9 +58,81 @@ static SimpleModule wireFormatModule() { SimpleModule m = new SimpleModule("marketdata-options"); m.addDeserializer(OptionsLookup.class, new OptionsLookupDeserializer()); m.addDeserializer(OptionsExpirations.class, new OptionsExpirationsDeserializer()); + m.addDeserializer(OptionsStrikes.class, new OptionsStrikesDeserializer()); + m.addDeserializer(OptionsQuotes.class, optionRowsDeserializer(OptionsQuotes::new)); + m.addDeserializer(OptionsChain.class, optionRowsDeserializer(OptionsChain::new)); return m; } + /** Column list for the shared {@code OptionQuote} parallel-arrays row, used by both endpoints. */ + private static final List OPTION_ROW_FIELDS = + List.of( + "optionSymbol", + "underlying", + "expiration", + "side", + "strike", + "firstTraded", + "dte", + "updated", + "bid", + "bidSize", + "mid", + "ask", + "askSize", + "last", + "openInterest", + "volume", + "inTheMoney", + "intrinsicValue", + "extrinsicValue", + "underlyingPrice", + "iv", + "delta", + "gamma", + "theta", + "vega"); + + /** + * Shared parallel-arrays deserializer that maps the API's option row into {@link OptionQuote}. + * Reused by both {@link OptionsQuotes} and {@link OptionsChain} since they emit the same + * per-contract schema; only the container record (and the semantic of how many rows come back) + * differs. + */ + private static JsonDeserializer optionRowsDeserializer( + Function, T> wrapper) { + return ParallelArrays.listDeserializer( + OPTION_ROW_FIELDS, + row -> + new OptionQuote( + row.text("optionSymbol"), + row.text("underlying"), + MarketDataDates.parseTimestampField(null, row.node("expiration"), "expiration"), + row.text("side"), + row.dbl("strike"), + MarketDataDates.parseTimestampField(null, row.node("firstTraded"), "firstTraded"), + (int) row.lng("dte"), + MarketDataDates.parseTimestampField(null, row.node("updated"), "updated"), + row.dbl("bid"), + row.lng("bidSize"), + row.dbl("mid"), + row.dbl("ask"), + row.lng("askSize"), + row.dbl("last"), + row.lng("openInterest"), + row.lng("volume"), + row.bool("inTheMoney"), + row.dbl("intrinsicValue"), + row.dbl("extrinsicValue"), + row.dbl("underlyingPrice"), + row.dbl("iv"), + row.dbl("delta"), + row.dbl("gamma"), + row.dbl("theta"), + row.dbl("vega")), + wrapper); + } + /** * Async: convert a human-readable option description ({@code "AAPL 7/26/23 $200 Call"}) into a * well-formed OCC symbol ({@code "AAPL230726C00200000"}). The request's {@code userInput} is @@ -88,8 +178,234 @@ public Response expirations(OptionsExpirationsRequest reques return transport.joinSync(expirationsAsync(request)); } + /** + * Async: fetch the strike prices available for each expiration on the request's underlying. + * Optional filters: {@code expiration} returns strikes only for that expiration date, {@code + * date} fetches the historical table as it stood on a previous trading day. + * + *

The wire-format is unusual — one top-level key per expiration date plus {@code s} / {@code + * updated} metadata — and the deserializer accepts only the API's literal ISO date keys, not the + * §3 {@code dateformat} variants (the keys themselves cannot vary: the backend always emits + * {@code str(date)}). The {@code updated} field does honor {@code dateformat}. + */ + public CompletableFuture> strikesAsync(OptionsStrikesRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/strikes/" + PathSegments.encode(request.symbol())); + if (request.expiration() != null) { + b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(request.expiration())); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return executeAndWrap(b.build(), OptionsStrikes.class); + } + + /** Sync wrapper for {@link #strikesAsync(OptionsStrikesRequest)}. */ + public Response strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + /** + * Async: fetch the current (or historical) quote for a single OCC option symbol. The + * parallel-arrays wire-format still applies — typically a single row — and is decoded into {@link + * OptionsQuotes#quotes}. + * + *

For multiple contracts use {@link #quotesAsync(OptionsQuotesRequest)} — the multi-symbol + * form fans out one HTTP call per symbol concurrently through the SDK's 50-permit semaphore and + * returns a per-symbol map. + */ + public CompletableFuture> quoteAsync(OptionsQuoteRequest request) { + return executeAndWrap( + buildQuoteSpec(request.optionSymbol(), request.date(), request.from(), request.to()), + OptionsQuotes.class); + } + + /** Sync wrapper for {@link #quoteAsync(OptionsQuoteRequest)}. */ + public Response quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + /** + * Async: fetch quotes for multiple OCC option symbols concurrently. One HTTP request is fired per + * symbol — the API path takes a single optionSymbol so comma-separated bulk isn't actually + * supported by the backend regardless of what the docstring says (verified by reading the + * handler). All requests share the same optional {@code date}/{@code from}/{@code to} filters. + * + *

Returns a {@code Map>} keyed by the original symbol input + * (insertion order preserved) so the consumer sees per-symbol {@link Response} metadata — {@code + * statusCode()}, {@code isNoData()}, {@code rawBody()}, {@code requestId()}. The map's future + * completes exceptionally if any single request fails (network error, ParseError on a malformed + * body, a {@code 5xx} after retries) — fail-fast semantics so partial-success scenarios are + * explicit. + */ + public CompletableFuture>> quotesAsync( + OptionsQuotesRequest request) { + List symbols = request.optionSymbols(); + List>>> futures = + new ArrayList<>(symbols.size()); + for (String symbol : symbols) { + RequestSpec spec = buildQuoteSpec(symbol, request.date(), request.from(), request.to()); + futures.add( + executeAndWrap(spec, OptionsQuotes.class).thenApply(resp -> Map.entry(symbol, resp))); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply( + unused -> { + Map> result = new LinkedHashMap<>(); + for (CompletableFuture>> f : futures) { + Map.Entry> entry = f.join(); + result.put(entry.getKey(), entry.getValue()); + } + return result; + }); + } + + /** Sync wrapper for {@link #quotesAsync(OptionsQuotesRequest)}. */ + public Map> quotes(OptionsQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + /** + * Async: fetch the full option chain for the request's underlying. The chain endpoint exposes the + * richest filter surface in the API; see {@link OptionsChainRequest} for the typed parameter set, + * including sealed {@link ExpirationFilter} and {@link StrikeFilter} for the mutually-exclusive + * groups. + */ + public CompletableFuture> chainAsync(OptionsChainRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/chain/" + PathSegments.encode(request.symbol())); + applyChainParams(b, request); + return executeAndWrap(b.build(), OptionsChain.class); + } + + /** Sync wrapper for {@link #chainAsync(OptionsChainRequest)}. */ + public Response chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + // ---------- internal helpers ---------- + /** Translates a fully-built {@link OptionsChainRequest} into query parameters. */ + private static void applyChainParams(RequestSpec.Builder b, OptionsChainRequest r) { + if (r.expirationFilter() != null) { + applyExpirationFilter(b, r.expirationFilter()); + } + if (r.weekly() != null) { + b.query("weekly", r.weekly()); + } + if (r.monthly() != null) { + b.query("monthly", r.monthly()); + } + if (r.quarterly() != null) { + b.query("quarterly", r.quarterly()); + } + if (r.am() != null) { + b.query("am", r.am()); + } + if (r.pm() != null) { + b.query("pm", r.pm()); + } + if (r.nonstandard() != null) { + b.query("nonstandard", r.nonstandard()); + } + if (r.strikeFilter() != null) { + b.query("strike", strikeFilterWireValue(r.strikeFilter())); + } + if (r.delta() != null) { + b.query("delta", r.delta()); + } + if (r.strikeLimit() != null) { + b.query("strikeLimit", r.strikeLimit()); + } + if (r.strikeRange() != null) { + b.query("range", r.strikeRange().wireValue()); + } + if (r.minBid() != null) { + b.query("minBid", r.minBid()); + } + if (r.maxBid() != null) { + b.query("maxBid", r.maxBid()); + } + if (r.minAsk() != null) { + b.query("minAsk", r.minAsk()); + } + if (r.maxAsk() != null) { + b.query("maxAsk", r.maxAsk()); + } + if (r.maxBidAskSpread() != null) { + b.query("maxBidAskSpread", r.maxBidAskSpread()); + } + if (r.maxBidAskSpreadPct() != null) { + b.query("maxBidAskSpreadPct", r.maxBidAskSpreadPct()); + } + if (r.minOpenInterest() != null) { + b.query("minOpenInterest", r.minOpenInterest()); + } + if (r.minVolume() != null) { + b.query("minVolume", r.minVolume()); + } + if (r.side() != null) { + b.query("side", r.side().wireValue()); + } + if (r.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(r.date())); + } + } + + private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilter f) { + if (f instanceof ExpirationFilter.OnDate v) { + b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); + } else if (f instanceof ExpirationFilter.Dte v) { + b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.Between v) { + b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); + b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); + } else if (f instanceof ExpirationFilter.MonthYear v) { + b.query("month", v.month()); + b.query("year", v.year()); + } + } + + private static String strikeFilterWireValue(StrikeFilter f) { + if (f instanceof StrikeFilter.Exact v) { + return formatStrike(v.price()); + } else if (f instanceof StrikeFilter.Range v) { + return formatStrike(v.min()) + "-" + formatStrike(v.max()); + } else if (f instanceof StrikeFilter.Comparison v) { + return v.operator().wireValue() + formatStrike(v.price()); + } + throw new IllegalStateException("unhandled StrikeFilter variant: " + f); + } + + /** + * Render a strike price without trailing zeros — the API accepts both {@code 150} and {@code + * 150.0}. + */ + private static String formatStrike(double v) { + if (v == Math.floor(v) && !Double.isInfinite(v)) { + return Long.toString((long) v); + } + return Double.toString(v); + } + + private static RequestSpec buildQuoteSpec( + String optionSymbol, + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to) { + RequestSpec.Builder b = RequestSpec.get("options/quotes/" + PathSegments.encode(optionSymbol)); + if (date != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(date)); + } + if (from != null) { + b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(from)); + } + if (to != null) { + b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(to)); + } + return b.build(); + } + private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { return transport .executeAsync(spec) diff --git a/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java new file mode 100644 index 0000000..47bc73d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java @@ -0,0 +1,94 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionsStrikes; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Wire-format deserializer for {@link OptionsStrikes}. Unlike every other endpoint, the strikes + * body uses dynamic top-level keys — one per expiration date — alongside the fixed {@code + * s} envelope and {@code updated} timestamp. {@link ParallelArrays} cannot express this shape; the + * deserializer iterates the root's fields and treats any key that parses as an ISO date ({@code + * "yyyy-MM-dd"}) as an expiration entry. Envelope handling mirrors the rest of the SDK: + * + *

+ */ +final class OptionsStrikesDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String UPDATED_KEY = "updated"; + + @Override + public OptionsStrikes deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if (ENVELOPE_ERROR.equals(envelopeStatus)) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return new OptionsStrikes(List.of(), null); + } + + ZonedDateTime updated = + MarketDataDates.parseTimestampField(p, root.get(UPDATED_KEY), UPDATED_KEY); + + List rows = new ArrayList<>(); + Iterator> it = root.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String key = entry.getKey(); + if (ENVELOPE_STATUS.equals(key) || UPDATED_KEY.equals(key)) { + continue; + } + LocalDate expirationDate; + try { + expirationDate = LocalDate.parse(key); + } catch (DateTimeParseException e) { + // The endpoint is documented as `s` + `updated` + dynamic ISO-date keys; an unrecognized + // key signals either a server bug or a forward-compatible extension. Strict-by-default + // surfaces it. + throw new JsonMappingException(p, "unrecognized top-level key: " + key); + } + JsonNode arr = entry.getValue(); + if (!arr.isArray()) { + throw new JsonMappingException(p, "non-array value for expiration " + key); + } + List strikes = new ArrayList<>(arr.size()); + for (int i = 0; i < arr.size(); i++) { + JsonNode cell = arr.get(i); + if (!cell.isNumber()) { + throw new JsonMappingException( + p, "non-numeric strike at " + key + "[" + i + "]: " + cell.asText()); + } + strikes.add(cell.asDouble()); + } + rows.add( + new ExpirationStrikes(expirationDate.atStartOfDay(MarketDataDates.MARKET_ZONE), strikes)); + } + + return new OptionsStrikes(rows, updated); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java new file mode 100644 index 0000000..6d82fc1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -0,0 +1,72 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; + +/** + * Mutually-exclusive expiration filter for {@code /v1/options/chain/}. The chain endpoint's + * expiration-side parameters ({@code expiration}, {@code dte}, {@code from}/{@code to}, {@code + * month}/{@code year}) cover overlapping selection axes; combining them produces undefined behavior + * server-side. Modeling them as variants of a sealed interface with a single {@code + * expirationFilter(...)} setter on the request builder makes that exclusivity compiler-enforced: + * there is no way to assign two variants at once. + * + *

Additive expiration-type predicates ({@code weekly}/{@code monthly}/{@code quarterly}/{@code + * am}/{@code pm}) are not part of this hierarchy — they intersect freely with any variant and stay + * as separate booleans on the request builder. + */ +public sealed interface ExpirationFilter + permits ExpirationFilter.OnDate, + ExpirationFilter.Dte, + ExpirationFilter.Between, + ExpirationFilter.MonthYear { + + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ + static OnDate onDate(LocalDate date) { + return new OnDate(date); + } + + /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + static Dte dte(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + return new Dte(days); + } + + /** + * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not + * be strictly after {@code to}. + */ + static Between between(LocalDate from, LocalDate to) { + Objects.requireNonNull(from, "from"); + Objects.requireNonNull(to, "to"); + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must be on or before to"); + } + return new Between(from, to); + } + + /** + * Calendar month-of-year filter — wire form {@code ?month=M&year=YYYY}. {@code month} is the + * 1-based calendar month (January = 1). + */ + static MonthYear monthYear(int year, int month) { + if (month < 1 || month > 12) { + throw new IllegalArgumentException("month must be in 1..12"); + } + return new MonthYear(year, month); + } + + record OnDate(LocalDate date) implements ExpirationFilter { + public OnDate { + Objects.requireNonNull(date, "date"); + } + } + + record Dte(int days) implements ExpirationFilter {} + + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} + + record MonthYear(int year, int month) implements ExpirationFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java b/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java new file mode 100644 index 0000000..45819ad --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java @@ -0,0 +1,24 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Strikes available for a single expiration in an option chain — one row of {@link OptionsStrikes}. + * + * @param expiration the expiration date as a midnight {@code America/New_York} moment. Wire-format + * comes through as an ISO date-only string ({@code "2025-01-17"}) regardless of the §3 {@code + * dateformat} parameter — the strikes endpoint uses the key itself as the expiration label, so + * it cannot vary by format. + * @param strikes strike prices for {@code expiration}, in ascending order as the API delivers them. + * Immutable; never {@code null}. + */ +public record ExpirationStrikes(ZonedDateTime expiration, List strikes) { + + public ExpirationStrikes { + Objects.requireNonNull(expiration, "expiration"); + Objects.requireNonNull(strikes, "strikes"); + strikes = List.copyOf(strikes); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionQuote.java b/src/main/java/com/marketdata/sdk/options/OptionQuote.java new file mode 100644 index 0000000..725c7c7 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionQuote.java @@ -0,0 +1,41 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; + +/** + * A single end-of-day option quote — one row of {@link OptionsQuotes}. Carries the contract's + * identification, market data (bid/ask/last/volume/open interest), in-the-money flag, intrinsic / + * extrinsic decomposition, the underlying price the quote was struck against, and the standard set + * of Black-Scholes greeks (delta, gamma, theta, vega) plus implied volatility. + * + *

Numeric size/count fields use {@code long} so a single record can carry post-Wall-Street-2.0 + * volume figures without silent truncation. Timestamps are {@link ZonedDateTime} in {@code + * America/New_York}; their wire-format may be unix, ISO-string, or spreadsheet serial per the §3 + * {@code dateformat} parameter, all of which are decoded uniformly by the deserializer. + */ +public record OptionQuote( + String optionSymbol, + String underlying, + ZonedDateTime expiration, + String side, + double strike, + ZonedDateTime firstTraded, + int dte, + ZonedDateTime updated, + double bid, + long bidSize, + double mid, + double ask, + long askSize, + double last, + long openInterest, + long volume, + boolean inTheMoney, + double intrinsicValue, + double extrinsicValue, + double underlyingPrice, + double iv, + double delta, + double gamma, + double theta, + double vega) {} diff --git a/src/main/java/com/marketdata/sdk/options/OptionSide.java b/src/main/java/com/marketdata/sdk/options/OptionSide.java new file mode 100644 index 0000000..670d648 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionSide.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.options; + +/** + * Option side — call or put. Used both as the typed enum form of the chain endpoint's {@code + * ?side=} filter and as the typed surface for {@code OptionQuote.side()} (today still a plain + * {@code String} — pending an SDK-wide migration to the enum). + */ +public enum OptionSide { + CALL("call"), + PUT("put"); + + private final String wireValue; + + OptionSide(String wireValue) { + this.wireValue = wireValue; + } + + /** The lowercase token the API uses on the wire. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsChain.java b/src/main/java/com/marketdata/sdk/options/OptionsChain.java new file mode 100644 index 0000000..11e7cd2 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsChain.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.options; + +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/chain/{underlying}/} — every option contract on the + * underlying that matches the request's filter set. The wire-format and per-row schema match the + * {@code quotes} endpoint exactly, so each row is decoded into the shared {@link OptionQuote} + * record; what differs is the volume of rows (typically many) and the filter parameters available + * via {@link OptionsChainRequest}. + * + * @param chain matching contracts in the order the API delivered them. Immutable; never {@code + * null}. Empty when the response is the {@code "s":"no_data"} envelope. + */ +public record OptionsChain(List chain) { + + public OptionsChain { + Objects.requireNonNull(chain, "chain"); + chain = List.copyOf(chain); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java new file mode 100644 index 0000000..3268966 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java @@ -0,0 +1,344 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/chain/{underlying}/}. The chain endpoint exposes the + * richest filter surface in the API — ~25 query parameters covering expiration selection, strike + * selection, liquidity floors, side, and structural metadata (weekly / monthly / quarterly / + * AM-settled / PM-settled / nonstandard). + * + *

The mutually-exclusive groups are modeled as sealed types ({@link ExpirationFilter}, {@link + * StrikeFilter}) accessed through a single setter so the compiler enforces "pick one variant". Pair + * constraints that can't be expressed in the type system ({@code minBid <= maxBid}, {@code minAsk + * <= maxAsk}) are checked at {@link Builder#build} time — runtime, but pre-HTTP. + * + *

Filters not validated server-side at construction (the combinatoric of expiration sealed-type + * variants with weekly / monthly / quarterly booleans is unconstrained) — the SDK trusts the + * backend to intersect them. Forward-compat: future API parameters drop in as additional builder + * setters without breaking any existing call. + */ +public final class OptionsChainRequest { + + private final String symbol; + + private final @Nullable ExpirationFilter expirationFilter; + private final @Nullable Boolean weekly; + private final @Nullable Boolean monthly; + private final @Nullable Boolean quarterly; + private final @Nullable Boolean am; + private final @Nullable Boolean pm; + private final @Nullable Boolean nonstandard; + + private final @Nullable StrikeFilter strikeFilter; + private final @Nullable Double delta; + private final @Nullable Integer strikeLimit; + private final @Nullable StrikeRange strikeRange; + + private final @Nullable Double minBid; + private final @Nullable Double maxBid; + private final @Nullable Double minAsk; + private final @Nullable Double maxAsk; + private final @Nullable Double maxBidAskSpread; + private final @Nullable Double maxBidAskSpreadPct; + private final @Nullable Long minOpenInterest; + private final @Nullable Long minVolume; + + private final @Nullable OptionSide side; + + private final @Nullable LocalDate date; + + private OptionsChainRequest(Builder b) { + this.symbol = b.symbol; + this.expirationFilter = b.expirationFilter; + this.weekly = b.weekly; + this.monthly = b.monthly; + this.quarterly = b.quarterly; + this.am = b.am; + this.pm = b.pm; + this.nonstandard = b.nonstandard; + this.strikeFilter = b.strikeFilter; + this.delta = b.delta; + this.strikeLimit = b.strikeLimit; + this.strikeRange = b.strikeRange; + this.minBid = b.minBid; + this.maxBid = b.maxBid; + this.minAsk = b.minAsk; + this.maxAsk = b.maxAsk; + this.maxBidAskSpread = b.maxBidAskSpread; + this.maxBidAskSpreadPct = b.maxBidAskSpreadPct; + this.minOpenInterest = b.minOpenInterest; + this.minVolume = b.minVolume; + this.side = b.side; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static OptionsChainRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + // ---------- accessors ---------- + + public String symbol() { + return symbol; + } + + public @Nullable ExpirationFilter expirationFilter() { + return expirationFilter; + } + + public @Nullable Boolean weekly() { + return weekly; + } + + public @Nullable Boolean monthly() { + return monthly; + } + + public @Nullable Boolean quarterly() { + return quarterly; + } + + public @Nullable Boolean am() { + return am; + } + + public @Nullable Boolean pm() { + return pm; + } + + public @Nullable Boolean nonstandard() { + return nonstandard; + } + + public @Nullable StrikeFilter strikeFilter() { + return strikeFilter; + } + + public @Nullable Double delta() { + return delta; + } + + public @Nullable Integer strikeLimit() { + return strikeLimit; + } + + public @Nullable StrikeRange strikeRange() { + return strikeRange; + } + + public @Nullable Double minBid() { + return minBid; + } + + public @Nullable Double maxBid() { + return maxBid; + } + + public @Nullable Double minAsk() { + return minAsk; + } + + public @Nullable Double maxAsk() { + return maxAsk; + } + + public @Nullable Double maxBidAskSpread() { + return maxBidAskSpread; + } + + public @Nullable Double maxBidAskSpreadPct() { + return maxBidAskSpreadPct; + } + + public @Nullable Long minOpenInterest() { + return minOpenInterest; + } + + public @Nullable Long minVolume() { + return minVolume; + } + + public @Nullable OptionSide side() { + return side; + } + + public @Nullable LocalDate date() { + return date; + } + + // ---------- builder ---------- + + public static final class Builder { + private final String symbol; + private @Nullable ExpirationFilter expirationFilter; + private @Nullable Boolean weekly; + private @Nullable Boolean monthly; + private @Nullable Boolean quarterly; + private @Nullable Boolean am; + private @Nullable Boolean pm; + private @Nullable Boolean nonstandard; + private @Nullable StrikeFilter strikeFilter; + private @Nullable Double delta; + private @Nullable Integer strikeLimit; + private @Nullable StrikeRange strikeRange; + private @Nullable Double minBid; + private @Nullable Double maxBid; + private @Nullable Double minAsk; + private @Nullable Double maxAsk; + private @Nullable Double maxBidAskSpread; + private @Nullable Double maxBidAskSpreadPct; + private @Nullable Long minOpenInterest; + private @Nullable Long minVolume; + private @Nullable OptionSide side; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Set the mutually-exclusive expiration-side filter. */ + public Builder expirationFilter(ExpirationFilter filter) { + this.expirationFilter = Objects.requireNonNull(filter, "filter"); + return this; + } + + public Builder weekly(boolean value) { + this.weekly = value; + return this; + } + + public Builder monthly(boolean value) { + this.monthly = value; + return this; + } + + public Builder quarterly(boolean value) { + this.quarterly = value; + return this; + } + + public Builder am(boolean value) { + this.am = value; + return this; + } + + public Builder pm(boolean value) { + this.pm = value; + return this; + } + + /** Whether to include non-standard contracts (mini-options, adjusted options, …). */ + public Builder nonstandard(boolean value) { + this.nonstandard = value; + return this; + } + + /** Set the strike-syntax filter ({@code exact}, {@code range}, {@code comparison}). */ + public Builder strikeFilter(StrikeFilter filter) { + this.strikeFilter = Objects.requireNonNull(filter, "filter"); + return this; + } + + /** Filter by Black-Scholes delta. Independent of {@link #strikeFilter}. */ + public Builder delta(double value) { + this.delta = value; + return this; + } + + /** + * Limit the response to {@code n} strikes around the at-the-money line, partitioned by {@link + * #strikeRange}. Pair semantics: setting one without the other is accepted by the server but + * may behave unexpectedly — the SDK does not enforce the pair to keep forward-compat. + */ + public Builder strikeLimit(int n) { + if (n <= 0) { + throw new IllegalArgumentException("strikeLimit must be positive"); + } + this.strikeLimit = n; + return this; + } + + public Builder strikeRange(StrikeRange range) { + this.strikeRange = Objects.requireNonNull(range, "range"); + return this; + } + + public Builder minBid(double value) { + this.minBid = value; + return this; + } + + public Builder maxBid(double value) { + this.maxBid = value; + return this; + } + + public Builder minAsk(double value) { + this.minAsk = value; + return this; + } + + public Builder maxAsk(double value) { + this.maxAsk = value; + return this; + } + + public Builder maxBidAskSpread(double value) { + this.maxBidAskSpread = value; + return this; + } + + public Builder maxBidAskSpreadPct(double value) { + this.maxBidAskSpreadPct = value; + return this; + } + + public Builder minOpenInterest(long value) { + if (value < 0) { + throw new IllegalArgumentException("minOpenInterest must be non-negative"); + } + this.minOpenInterest = value; + return this; + } + + public Builder minVolume(long value) { + if (value < 0) { + throw new IllegalArgumentException("minVolume must be non-negative"); + } + this.minVolume = value; + return this; + } + + public Builder side(OptionSide value) { + this.side = Objects.requireNonNull(value, "side"); + return this; + } + + /** Historical chain on a specific trading day. */ + public Builder date(LocalDate value) { + this.date = Objects.requireNonNull(value, "date"); + return this; + } + + public OptionsChainRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + if (minBid != null && maxBid != null && minBid > maxBid) { + throw new IllegalArgumentException("minBid must be <= maxBid"); + } + if (minAsk != null && maxAsk != null && minAsk > maxAsk) { + throw new IllegalArgumentException("minAsk must be <= maxAsk"); + } + return new OptionsChainRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java new file mode 100644 index 0000000..cacb455 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java @@ -0,0 +1,92 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the single-contract form of {@code GET /v1/options/quotes/{optionSymbol}/}. One + * OCC-formatted option symbol plus the optional historical-window filters ({@code date} or {@code + * from}/{@code to}). + * + *

For multiple contracts use {@link OptionsQuotesRequest} — the multi-symbol API fans out one + * request per symbol concurrently and returns a {@code Map>}. + */ +public final class OptionsQuoteRequest { + + private final String optionSymbol; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + + private OptionsQuoteRequest(Builder b) { + this.optionSymbol = b.optionSymbol; + this.date = b.date; + this.from = b.from; + this.to = b.to; + } + + /** Shortcut for {@code builder(optionSymbol).build()}. */ + public static OptionsQuoteRequest of(String optionSymbol) { + return builder(optionSymbol).build(); + } + + public static Builder builder(String optionSymbol) { + return new Builder(optionSymbol); + } + + public String optionSymbol() { + return optionSymbol; + } + + /** End-of-day historical quote on a specific date, or {@code null} for the current quote. */ + public @Nullable LocalDate date() { + return date; + } + + /** Start of a date range (inclusive), or {@code null} when unset. */ + public @Nullable LocalDate from() { + return from; + } + + /** End of a date range (exclusive), or {@code null} when unset. */ + public @Nullable LocalDate to() { + return to; + } + + public static final class Builder { + private final String optionSymbol; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + + private Builder(String optionSymbol) { + this.optionSymbol = Objects.requireNonNull(optionSymbol, "optionSymbol"); + } + + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + public OptionsQuoteRequest build() { + if (optionSymbol.isEmpty()) { + throw new IllegalArgumentException("optionSymbol must be non-empty"); + } + if (date != null && (from != null || to != null)) { + throw new IllegalArgumentException("date and from/to are mutually exclusive"); + } + return new OptionsQuoteRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java new file mode 100644 index 0000000..ec996fa --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java @@ -0,0 +1,24 @@ +package com.marketdata.sdk.options; + +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/quotes/{optionSymbol}/} — the end-of-day option quote + * (or historical series) for a single contract. The wire-format is the standard parallel-arrays + * envelope; the SDK lifts each row into an {@link OptionQuote}. + * + *

This wrapper is also the per-symbol response type produced by the multi-symbol {@code + * options.quotes(...)} convenience: each symbol's parallel-arrays body becomes one {@code + * OptionsQuotes} (with typically one row) under its symbol key in the returned {@code Map}. + * + * @param quotes the decoded rows in the order the API delivered them. Immutable; never {@code + * null}. Empty when the response is the {@code "s":"no_data"} envelope. + */ +public record OptionsQuotes(List quotes) { + + public OptionsQuotes { + Objects.requireNonNull(quotes, "quotes"); + quotes = List.copyOf(quotes); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java new file mode 100644 index 0000000..c740400 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java @@ -0,0 +1,104 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the multi-contract form of {@code /v1/options/quotes/}. Carries a list of one or + * more OCC option symbols plus the optional historical-window filters shared across them. The + * resource fans out one HTTP request per symbol concurrently (via the SDK's 50-permit {@code + * AsyncSemaphore}) and returns a {@code Map>} so per-symbol status, + * raw body, and error envelopes stay observable. + * + *

For a single contract, prefer {@link OptionsQuoteRequest} — clearer intent and one fewer map + * lookup at the call site. + */ +public final class OptionsQuotesRequest { + + private final List optionSymbols; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + + private OptionsQuotesRequest(Builder b) { + this.optionSymbols = List.copyOf(b.optionSymbols); + this.date = b.date; + this.from = b.from; + this.to = b.to; + } + + /** + * Start a builder with one or more option symbols. At least one symbol is required; duplicates + * are kept (each one results in its own HTTP call). + */ + public static Builder builder(String first, String... rest) { + Builder b = new Builder(); + b.addSymbol(first); + for (String s : rest) { + b.addSymbol(s); + } + return b; + } + + public List optionSymbols() { + return optionSymbols; + } + + public @Nullable LocalDate date() { + return date; + } + + public @Nullable LocalDate from() { + return from; + } + + public @Nullable LocalDate to() { + return to; + } + + public static final class Builder { + private final List optionSymbols = new ArrayList<>(); + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + + private Builder() {} + + public Builder addSymbol(String optionSymbol) { + Objects.requireNonNull(optionSymbol, "optionSymbol"); + if (optionSymbol.isEmpty()) { + throw new IllegalArgumentException("optionSymbol must be non-empty"); + } + this.optionSymbols.add(optionSymbol); + return this; + } + + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + public OptionsQuotesRequest build() { + if (optionSymbols.isEmpty()) { + throw new IllegalArgumentException("at least one optionSymbol is required"); + } + if (date != null && (from != null || to != null)) { + throw new IllegalArgumentException("date and from/to are mutually exclusive"); + } + return new OptionsQuotesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java b/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java new file mode 100644 index 0000000..56ae18f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java @@ -0,0 +1,29 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/strikes/{underlying}/} — the strike prices available + * for each expiration in the underlying's option chain. + * + *

The wire-format is unique in the API: there is no parallel-arrays envelope, and the response + * carries one top-level key per expiration date (e.g. {@code "2025-01-17"} → {@code [140, 145, + * 150]}) plus the universal {@code "s"} and {@code "updated"} metadata. The SDK lifts those into a + * positional list of {@link ExpirationStrikes} rows so consumers iterate naturally; ordering + * follows what the API returns (the backend sorts expirations ascending). + * + * @param expirations one entry per expiration in chronological order; never {@code null}. Empty + * when the {@code "s":"no_data"} envelope is received. + * @param updated when the server last refreshed this strikes table, in {@code America/New_York}. + * {@code null} only when the no-data envelope omits the {@code updated} field. + */ +public record OptionsStrikes( + List expirations, @org.jspecify.annotations.Nullable ZonedDateTime updated) { + + public OptionsStrikes { + Objects.requireNonNull(expirations, "expirations"); + expirations = List.copyOf(expirations); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java new file mode 100644 index 0000000..46ae072 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java @@ -0,0 +1,75 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/strikes/{symbol}/}. Two optional filters narrow the result: + * {@code expiration} returns strikes only for that expiration date, {@code date} fetches the + * historical table as it stood on a previous trading day. + */ +public final class OptionsStrikesRequest { + + private final String symbol; + private final @Nullable LocalDate expiration; + private final @Nullable LocalDate date; + + private OptionsStrikesRequest(Builder b) { + this.symbol = b.symbol; + this.expiration = b.expiration; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static OptionsStrikesRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + /** Single-expiration filter, or {@code null} when unset. */ + public @Nullable LocalDate expiration() { + return expiration; + } + + /** Historical query date, or {@code null} for the current/last-trading-day table. */ + public @Nullable LocalDate date() { + return date; + } + + public static final class Builder { + private final String symbol; + private @Nullable LocalDate expiration; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Restrict the result to the single given expiration date. */ + public Builder expiration(LocalDate expiration) { + this.expiration = Objects.requireNonNull(expiration, "expiration"); + return this; + } + + /** Fetch the strikes table as it stood at end-of-day on {@code date}. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public OptionsStrikesRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + return new OptionsStrikesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/StrikeFilter.java b/src/main/java/com/marketdata/sdk/options/StrikeFilter.java new file mode 100644 index 0000000..3c1f541 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/StrikeFilter.java @@ -0,0 +1,61 @@ +package com.marketdata.sdk.options; + +/** + * The chain endpoint's {@code ?strike=} parameter accepts three syntactic forms — exact value, + * range, and comparison ({@code >150}, {@code <=160}, …). Modeling them as a sealed type with + * factory entry-points gives the consumer compile-time autocomplete for valid shapes and prevents + * typos like {@code "140--160"} that a raw-string passthrough would silently ship to the server. + * + *

Other strike-related chain parameters ({@code delta}, {@code strikeLimit} + {@code range}, + * {@code minBid}/{@code maxBid}, …) are independent filters server-side — they intersect with this + * one rather than overriding it, so they stay as separate setters on the request builder. + */ +public sealed interface StrikeFilter + permits StrikeFilter.Exact, StrikeFilter.Range, StrikeFilter.Comparison { + + /** Match options whose strike equals {@code price}. */ + static Exact exact(double price) { + return new Exact(price); + } + + /** Match strikes in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}. */ + static Range range(double min, double max) { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + return new Range(min, max); + } + + /** Match strikes satisfying {@code operator price} (e.g. {@code > 150}). */ + static Comparison comparison(Operator operator, double price) { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + return new Comparison(operator, price); + } + + /** Comparison operators accepted by the API. */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">"}. */ + public String wireValue() { + return wireValue; + } + } + + record Exact(double price) implements StrikeFilter {} + + record Range(double min, double max) implements StrikeFilter {} + + record Comparison(Operator operator, double price) implements StrikeFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/StrikeRange.java b/src/main/java/com/marketdata/sdk/options/StrikeRange.java new file mode 100644 index 0000000..3de72b6 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/StrikeRange.java @@ -0,0 +1,23 @@ +package com.marketdata.sdk.options; + +/** + * Coarse-grained strike-range filter for the chain endpoint's {@code ?range=} parameter. Used + * together with {@code strikeLimit} on {@link OptionsChainRequest} to ask for "the N strikes around + * the in-the-money / out-of-the-money boundary". + */ +public enum StrikeRange { + ITM("itm"), + OTM("otm"), + ALL("all"); + + private final String wireValue; + + StrikeRange(String wireValue) { + this.wireValue = wireValue; + } + + /** The lowercase token the API uses on the wire. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 8d0a16d..0b66b2e 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,10 +4,23 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; import com.marketdata.sdk.options.OptionsExpirations; import com.marketdata.sdk.options.OptionsExpirationsRequest; import com.marketdata.sdk.options.OptionsLookup; import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; @@ -339,12 +352,671 @@ void expirationsBooleanCellThrowsParseError() { .hasMessageContaining("non-string, non-numeric"); } + // ---------- strikes: URL & params ---------- + + @Test + void strikesHitsVersionedEndpoint() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[140,145,150]}"); + OptionsResource options = resourceWith(client); + + options.strikesAsync(OptionsStrikesRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/strikes/AAPL/"); + } + + @Test + void strikesAttachesExpirationAndDateFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[140,145]}"); + OptionsResource options = resourceWith(client); + + options + .strikesAsync( + OptionsStrikesRequest.builder("AAPL") + .expiration(LocalDate.of(2025, Month.JANUARY, 17)) + .date(LocalDate.of(2024, Month.DECEMBER, 16)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/strikes/AAPL/?expiration=2025-01-17&date=2024-12-16"); + } + + // ---------- strikes: response decoding ---------- + + @Test + void strikesDecodesMultipleExpirations() { + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"updated\":1705449600," + + "\"2025-01-17\":[140.0,145.0,150.0]," + + "\"2025-02-21\":[135.0,140.0,145.0,150.0]}"); + OptionsResource options = resourceWith(client); + + OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); + + assertThat(strikes.expirations()).hasSize(2); + ExpirationStrikes first = strikes.expirations().get(0); + assertThat(first.expiration()) + .isEqualTo(LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et)); + assertThat(first.strikes()).containsExactly(140.0, 145.0, 150.0); + ExpirationStrikes second = strikes.expirations().get(1); + assertThat(second.expiration()) + .isEqualTo(LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(second.strikes()).containsExactly(135.0, 140.0, 145.0, 150.0); + assertThat(strikes.updated()).isNotNull(); + assertThat(strikes.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void strikesAcceptsTimestampStringFormatForUpdated() { + // The expiration keys are ALWAYS literal ISO dates regardless of dateformat (the backend + // emits str(date) for the key, ignoring the format param). Only `updated` honors dateformat. + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":\"2025-01-16 19:00:00 -05:00\",\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); + assertThat(strikes.expirations()).hasSize(1); + assertThat(strikes.updated()).isNotNull(); + assertThat(strikes.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + } + + // ---------- strikes: envelope handling ---------- + + @Test + void strikesNoDataEnvelopeYieldsEmptyList() { + // The strikes endpoint also attaches nextTime/prevTime hints in no_data envelopes; the + // deserializer ignores them (they aren't part of the typed surface). + CapturingClient client = okWith("{\"s\":\"no_data\",\"nextTime\":null,\"prevTime\":null}"); + OptionsResource options = resourceWith(client); + + OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("BOGUS")).data(); + assertThat(strikes.expirations()).isEmpty(); + assertThat(strikes.updated()).isNull(); + } + + @Test + void strikesErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Symbol not found\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Symbol not found"); + } + + @Test + void strikesMissingUpdatedThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("updated"); + } + + @Test + void strikesUnrecognizedTopLevelKeyThrowsParseError() { + // Strict-by-default — a non-date, non-{s,updated} key signals server change. Surfacing it + // gives us a diagnostic breadcrumb instead of silently dropping data. + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"surprise\":[1.0]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("unrecognized top-level key: surprise"); + } + + @Test + void strikesNonNumericStrikeThrowsParseError() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[\"oops\"]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-numeric strike"); + } + + @Test + void strikesNonArrayExpirationValueThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":150.0}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-array value for expiration"); + } + + @Test + void strikesSyncMirrorsAsync() { + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); + assertThat(strikes.expirations()).hasSize(1); + } + + // ---------- quote (singular): URL & params ---------- + + private static final String CANNED_QUOTE_BODY = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\"]," + + "\"underlying\":[\"AAPL\"]," + + "\"expiration\":[1737136800]," + + "\"side\":[\"call\"]," + + "\"strike\":[150]," + + "\"firstTraded\":[1663118400]," + + "\"dte\":[45]," + + "\"updated\":[1705449600]," + + "\"bid\":[52.1],\"bidSize\":[10],\"mid\":[52.35],\"ask\":[52.6],\"askSize\":[15]," + + "\"last\":[52.3],\"openInterest\":[5000],\"volume\":[1500]," + + "\"inTheMoney\":[true],\"intrinsicValue\":[50.22],\"extrinsicValue\":[2.13]," + + "\"underlyingPrice\":[200.22]," + + "\"iv\":[0.3012],\"delta\":[0.89],\"gamma\":[0.012],\"theta\":[-0.05],\"vega\":[0.15]}"; + + @Test + void quoteHitsVersionedEndpointWithEncodedSymbol() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options.quoteAsync(OptionsQuoteRequest.of("AAPL250117C00150000")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/quotes/AAPL250117C00150000/"); + } + + @Test + void quoteAttachesDateFilter() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .date(LocalDate.of(2025, Month.JANUARY, 15)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo("http://localhost/v1/options/quotes/AAPL250117C00150000/?date=2025-01-15"); + } + + @Test + void quoteAttachesFromToFilters() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/quotes/AAPL250117C00150000/" + + "?from=2024-12-01&to=2025-01-01"); + } + + @Test + void quoteRequestRejectsDateAndFromToTogether() { + assertThatThrownBy( + () -> + OptionsQuoteRequest.builder("X") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + + // ---------- quote: response decoding ---------- + + @Test + void quoteDecodesAllFields() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotes response = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data(); + + assertThat(response.quotes()).hasSize(1); + OptionQuote q = response.quotes().get(0); + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.underlying()).isEqualTo("AAPL"); + assertThat(q.side()).isEqualTo("call"); + assertThat(q.strike()).isEqualTo(150.0); + assertThat(q.dte()).isEqualTo(45); + assertThat(q.bid()).isEqualTo(52.1); + assertThat(q.bidSize()).isEqualTo(10L); + assertThat(q.mid()).isEqualTo(52.35); + assertThat(q.ask()).isEqualTo(52.6); + assertThat(q.askSize()).isEqualTo(15L); + assertThat(q.last()).isEqualTo(52.3); + assertThat(q.openInterest()).isEqualTo(5000L); + assertThat(q.volume()).isEqualTo(1500L); + assertThat(q.inTheMoney()).isTrue(); + assertThat(q.intrinsicValue()).isEqualTo(50.22); + assertThat(q.extrinsicValue()).isEqualTo(2.13); + assertThat(q.underlyingPrice()).isEqualTo(200.22); + assertThat(q.iv()).isEqualTo(0.3012); + assertThat(q.delta()).isEqualTo(0.89); + assertThat(q.gamma()).isEqualTo(0.012); + assertThat(q.theta()).isEqualTo(-0.05); + assertThat(q.vega()).isEqualTo(0.15); + assertThat(q.expiration().getZone().getId()).isEqualTo("America/New_York"); + assertThat(q.firstTraded().getZone().getId()).isEqualTo("America/New_York"); + assertThat(q.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void quoteSyncMirrorsAsync() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotes data = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data(); + assertThat(data.quotes()).hasSize(1); + } + + @Test + void quoteErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Unknown contract\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.quote(OptionsQuoteRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Unknown contract"); + } + + // ---------- quotes (plural, multi-symbol) ---------- + + @Test + void quotesFansOutOnePerSymbolAndPreservesOrder() { + // Cancellable-but-canned client that responds based on the requested URL — lets us route each + // fan-out per symbol to a distinct body. Without this, all fan-outs would get the same canned + // bytes and the test wouldn't observe per-symbol responses. + SymbolRoutingClient client = + new SymbolRoutingClient( + Map.of( + "AAPL250117C00150000", + CANNED_QUOTE_BODY, + "AAPL250117P00150000", + CANNED_QUOTE_BODY + .replace("AAPL250117C00150000", "AAPL250117P00150000") + .replace("\"side\":[\"call\"]", "\"side\":[\"put\"]"))); + + OptionsResource options = resourceWith(client); + + Map> result = + options.quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + + // Insertion order preserved — first symbol in the builder is first in the iteration. + assertThat(result.keySet()).containsExactly("AAPL250117C00150000", "AAPL250117P00150000"); + assertThat(result.get("AAPL250117C00150000").data().quotes().get(0).side()).isEqualTo("call"); + assertThat(result.get("AAPL250117P00150000").data().quotes().get(0).side()).isEqualTo("put"); + + // Two HTTP requests were sent. + assertThat(client.captured).hasSize(2); + } + + @Test + void quotesAttachesDateFilterToEachFanOut() { + SymbolRoutingClient client = + new SymbolRoutingClient(Map.of("X", CANNED_QUOTE_BODY, "Y", CANNED_QUOTE_BODY)); + OptionsResource options = resourceWith(client); + + options.quotes( + OptionsQuotesRequest.builder("X", "Y").date(LocalDate.of(2025, Month.JANUARY, 1)).build()); + + assertThat(client.captured).hasSize(2); + for (HttpRequest req : client.captured) { + assertThat(req.uri().toString()).endsWith("?date=2025-01-01"); + } + } + + @Test + void quotesRequestRequiresAtLeastOneSymbol() { + // The static factory takes the first symbol non-optionally, so there is no way to construct + // an empty Builder from public API. The internal Builder constructor is private; this test + // documents the public-API contract via the static factory. + assertThatThrownBy(() -> OptionsQuotesRequest.builder("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + // ---------- chain: URL & filters ---------- + + /** Reuses the canned single-row body from quotes — same wire schema. */ + private static final String CANNED_CHAIN_BODY = CANNED_QUOTE_BODY; + + @Test + void chainHitsVersionedEndpointWithNoFilters() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chainAsync(OptionsChainRequest.of("AAPL")).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/"); + } + + @Test + void chainExpirationFilterOnDateTranslatesToExpirationParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.onDate(LocalDate.of(2025, Month.JANUARY, 17))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?expiration=2025-01-17"); + } + + @Test + void chainExpirationFilterDteTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); + } + + @Test + void chainExpirationFilterBetweenTranslatesToFromTo() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.between( + LocalDate.of(2025, Month.JANUARY, 1), LocalDate.of(2025, Month.MARCH, 31))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?from=2025-01-01&to=2025-03-31"); + } + + @Test + void chainExpirationFilterMonthYearTranslatesToMonthYear() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.monthYear(2025, 3)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); + } + + @Test + void chainStrikeFilterExactRendersAsBareNumber() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeFilter(StrikeFilter.exact(150)).build()) + .join(); + + // Integer-valued strikes render without decimal noise so the wire matches the API docs + // verbatim. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?strike=150"); + } + + @Test + void chainStrikeFilterRangeRendersAsDashed() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeFilter(StrikeFilter.range(140, 160)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?strike=140-160"); + } + + @Test + void chainStrikeFilterComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .strikeFilter(StrikeFilter.comparison(StrikeFilter.Operator.GTE, 150)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through. + assertThat(url).contains("strike=%3E%3D150"); + } + + @Test + void chainBooleanAdditiveFiltersStack() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .weekly(true) + .monthly(false) + .quarterly(true) + .nonstandard(false) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("weekly=true"); + assertThat(url).contains("monthly=false"); + assertThat(url).contains("quarterly=true"); + assertThat(url).contains("nonstandard=false"); + } + + @Test + void chainSideEnumTranslatesToWireValue() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chainAsync(OptionsChainRequest.builder("AAPL").side(OptionSide.PUT).build()).join(); + + assertThat(client.captured.get(0).uri().toString()).endsWith("?side=put"); + } + + @Test + void chainStrikeLimitAndRangeTranslate() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeLimit(4).strikeRange(StrikeRange.OTM).build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("strikeLimit=4"); + assertThat(url).contains("range=otm"); + } + + @Test + void chainLiquidityFiltersTranslate() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .minOpenInterest(500) + .minVolume(100) + .maxBidAskSpread(0.5) + .maxBidAskSpreadPct(0.1) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("minOpenInterest=500"); + assertThat(url).contains("minVolume=100"); + assertThat(url).contains("maxBidAskSpread=0.5"); + assertThat(url).contains("maxBidAskSpreadPct=0.1"); + } + + // ---------- chain: response decoding ---------- + + @Test + void chainDecodesSingleRowResponse() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + OptionsChain chain = options.chain(OptionsChainRequest.of("AAPL")).data(); + assertThat(chain.chain()).hasSize(1); + OptionQuote q = chain.chain().get(0); + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.delta()).isEqualTo(0.89); + } + + @Test + void chainSyncMirrorsAsync() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + OptionsChain chain = options.chain(OptionsChainRequest.of("AAPL")).data(); + assertThat(chain.chain()).hasSize(1); + } + + // ---------- chain: builder validation ---------- + + @Test + void chainRequestRejectsMinBidGreaterThanMaxBid() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minBid(10.0).maxBid(5.0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minBid must be <= maxBid"); + } + + @Test + void chainRequestRejectsMinAskGreaterThanMaxAsk() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minAsk(10.0).maxAsk(5.0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minAsk must be <= maxAsk"); + } + + @Test + void chainRequestRejectsNegativeStrikeLimit() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").strikeLimit(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strikeLimit must be positive"); + } + + @Test + void chainRequestRejectsNegativeMinOpenInterest() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minOpenInterest(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minOpenInterest must be non-negative"); + } + + // ---------- sealed type factory validation ---------- + + @Test + void expirationFilterDteRejectsNegative() { + assertThatThrownBy(() -> ExpirationFilter.dte(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterBetweenRejectsReversedDates() { + assertThatThrownBy( + () -> + ExpirationFilter.between( + LocalDate.of(2025, Month.MARCH, 1), LocalDate.of(2025, Month.JANUARY, 1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("from must be on or before to"); + } + + @Test + void expirationFilterMonthYearRejectsInvalidMonth() { + assertThatThrownBy(() -> ExpirationFilter.monthYear(2025, 13)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("month must be in 1..12"); + } + + @Test + void strikeFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> StrikeFilter.range(160, 140)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + // ---------- helpers ---------- private static CapturingClient okWith(String body) { return new CapturingClient(200, body.getBytes(), EMPTY_HEADERS); } + /** + * Test double that routes the request body based on the last non-empty path segment of the URL — + * lets a single test exercise multi-symbol fan-out where each fan-out should observe a distinct + * canned response. Falls back to a 200 OK with empty {@code "{}"} for an unmapped path. + */ + private static final class SymbolRoutingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final Map bodies; + + SymbolRoutingClient(Map bodies) { + this.bodies = bodies; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + String[] parts = request.uri().getPath().split("/"); + String tail = ""; + for (int i = parts.length - 1; i >= 0; i--) { + if (!parts[i].isEmpty()) { + tail = parts[i]; + break; + } + } + String body = bodies.getOrDefault(tail, "{\"s\":\"no_data\"}"); + HttpResponse resp = + TestHttpClients.response( + 200, body.getBytes(), EMPTY_HEADERS, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } + private static final class CapturingClient extends TestHttpClients.StubHttpClient { final List captured = new ArrayList<>(); final int status; From 8941559dc44e9663ef50f7b26b08c280b3c15302 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 28 May 2026 17:41:55 -0300 Subject: [PATCH 03/14] add integration test --- .../sdk/OptionsIntegrationTest.java | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java new file mode 100644 index 0000000..9b10f34 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -0,0 +1,171 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeRange; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Integration tests for the {@code options} resource against the live Market Data API. Gated by the + * {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} environment variable in {@code build.gradle.kts}; a + * valid {@code MARKETDATA_TOKEN} is also required (the {@link MarketDataClient} constructor's + * startup validation will surface a clear error if missing). + * + *

Tests assert shape ("AAPL has at least one expiration", "every quote row + * carries finite greeks") rather than specific values, since the live data drifts daily. AAPL is + * used as the underlying everywhere — it is the largest options market by volume so the response is + * always non-empty during market hours and historical queries are well-populated outside them. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class OptionsIntegrationTest { + + private static final String UNDERLYING = "AAPL"; + + private MarketDataClient client; + + @BeforeAll + void setUp() { + client = new MarketDataClient(); + } + + @AfterAll + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void lookupConvertsHumanDescriptionToOccSymbol() { + // A far-future date keeps the test stable against expiration drift — the endpoint converts + // the description regardless of whether such a contract actually exists today. + Response resp = + client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.data().optionSymbol()) + .as("OCC symbol shape: 4-6 letter root + YYMMDD + C/P + 8-digit strike") + .matches("[A-Z]{1,6}\\d{6}[CP]\\d{8}"); + } + + @Test + void expirationsReturnsAtLeastOneFutureDate() { + Response resp = + client.options().expirations(OptionsExpirationsRequest.of(UNDERLYING)); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.data().expirations()) + .as("AAPL has options expirations year-round") + .isNotEmpty(); + assertThat(resp.data().updated()).isNotNull(); + assertThat(resp.data().updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void strikesReturnsStrikesPerExpiration() { + Response resp = client.options().strikes(OptionsStrikesRequest.of(UNDERLYING)); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.data().expirations()).isNotEmpty(); + ExpirationStrikes first = resp.data().expirations().get(0); + assertThat(first.strikes()).as("first expiration's strike ladder is non-empty").isNotEmpty(); + assertThat(first.expiration().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void chainReturnsFilteredContracts() { + // Light filter: a narrow strike-limit window keeps the response small without depending on + // a specific dte that might fall on a non-trading day. + Response resp = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(5) + .strikeRange(StrikeRange.ITM) + .build()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.data().chain()).isNotEmpty(); + OptionQuote first = resp.data().chain().get(0); + assertThat(first.optionSymbol()).startsWith(UNDERLYING); + assertThat(first.side()).isEqualTo("call"); + assertThat(first.strike()).isGreaterThan(0.0); + } + + @Test + void quoteFetchesSingleContract() { + // Derive a real option symbol from the chain so the quote endpoint has a live contract to + // resolve — lookup gives a well-formed symbol but doesn't guarantee one exists in the API's + // data set. + String optionSymbol = sampleOptionSymbol(); + + Response resp = client.options().quote(OptionsQuoteRequest.of(optionSymbol)); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.data().quotes()).hasSize(1); + OptionQuote q = resp.data().quotes().get(0); + assertThat(q.optionSymbol()).isEqualTo(optionSymbol); + assertThat(q.underlying()).isEqualTo(UNDERLYING); + } + + @Test + void quotesFansOutToMultipleContracts() { + // Use the first two contracts from the chain so both are guaranteed to exist. + OptionsChain chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(2) + .strikeRange(StrikeRange.ITM) + .build()) + .data(); + assertThat(chain.chain()).hasSizeGreaterThanOrEqualTo(2); + String first = chain.chain().get(0).optionSymbol(); + String second = chain.chain().get(1).optionSymbol(); + + Map> resp = + client.options().quotes(OptionsQuotesRequest.builder(first, second).build()); + + assertThat(resp.keySet()).containsExactly(first, second); + assertThat(resp.get(first).data().quotes().get(0).optionSymbol()).isEqualTo(first); + assertThat(resp.get(second).data().quotes().get(0).optionSymbol()).isEqualTo(second); + } + + /** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */ + private String sampleOptionSymbol() { + OptionsChain chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .data(); + assertThat(chain.chain()).isNotEmpty(); + return chain.chain().get(0).optionSymbol(); + } +} From a963db2d2deca2dfe97c43a5a077cd23bfb7d89b Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 10:20:58 -0300 Subject: [PATCH 04/14] add expiration=all --- .../com/marketdata/sdk/OptionsResource.java | 14 +++- .../com/marketdata/sdk/ParallelArrays.java | 82 ++++++++++++++++++- .../sdk/options/ExpirationFilter.java | 18 +++- .../marketdata/sdk/options/OptionQuote.java | 10 ++- .../marketdata/sdk/OptionsResourceTest.java | 32 ++++++++ 5 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 8e0190f..74a981b 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -93,6 +93,14 @@ static SimpleModule wireFormatModule() { "theta", "vega"); + /** + * Optional columns on the option row. {@code rho} is part of the documented schema but several + * feeds omit it (the API's own fixtures don't carry it); declaring it optional lets a response + * without rho decode cleanly to {@link OptionQuote#rho()} == {@code null} instead of raising a + * {@link com.marketdata.sdk.exception.ParseError} on a missing required column. + */ + private static final List OPTION_OPTIONAL_ROW_FIELDS = List.of("rho"); + /** * Shared parallel-arrays deserializer that maps the API's option row into {@link OptionQuote}. * Reused by both {@link OptionsQuotes} and {@link OptionsChain} since they emit the same @@ -103,6 +111,7 @@ private static JsonDeserializer optionRowsDeserializer( Function, T> wrapper) { return ParallelArrays.listDeserializer( OPTION_ROW_FIELDS, + OPTION_OPTIONAL_ROW_FIELDS, row -> new OptionQuote( row.text("optionSymbol"), @@ -129,7 +138,8 @@ private static JsonDeserializer optionRowsDeserializer( row.dbl("delta"), row.dbl("gamma"), row.dbl("theta"), - row.dbl("vega")), + row.dbl("vega"), + row.dblOrNull("rho")), wrapper); } @@ -363,6 +373,8 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte } else if (f instanceof ExpirationFilter.MonthYear v) { b.query("month", v.month()); b.query("year", v.year()); + } else if (f instanceof ExpirationFilter.All) { + b.query("expiration", "all"); } } diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index 7ffabcc..54ed4ed 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import org.jspecify.annotations.Nullable; /** * Helper for deserializing the API's parallel-arrays wire format. Almost every endpoint that @@ -68,6 +69,28 @@ private ParallelArrays() {} */ static List zip(JsonParser p, JsonNode root, List fields, RowBuilder rowBuilder) throws IOException { + return zip(p, root, fields, List.of(), rowBuilder); + } + + /** + * Same as {@link #zip(JsonParser, JsonNode, List, RowBuilder)} but with a set of + * optional columns layered on top of the required {@code fields}. An optional column + * that is absent, null, or non-array is simply skipped (no error); when present it is + * length-checked against the required columns like any other. The row builder reads optional + * cells through {@link Row#dblOrNull} (and future {@code …OrNull} accessors), which return {@code + * null} for an absent column or null cell instead of throwing — the strict-by-default accessors + * stay strict for required columns. + * + *

This is the escape hatch the class doc anticipates for "legitimately-nullable columns": e.g. + * the options {@code rho} greek, which several feeds omit entirely. + */ + static List zip( + JsonParser p, + JsonNode root, + List fields, + List optionalFields, + RowBuilder rowBuilder) + throws IOException { String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); if (ENVELOPE_ERROR.equals(envelopeStatus)) { @@ -103,6 +126,26 @@ static List zip(JsonParser p, JsonNode root, List fields, RowBuil arrays.put(field, node); } + for (String field : optionalFields) { + JsonNode node = root.get(field); + if (node == null || node.isNull() || !node.isArray()) { + continue; // absent optional column — Row.dblOrNull will yield null for every row + } + if (expected == -1) { + expected = node.size(); + } else if (node.size() != expected) { + throw new JsonMappingException( + p, + "mismatched lengths: optional " + + field + + "=" + + node.size() + + " vs expected=" + + expected); + } + arrays.put(field, node); + } + int rowCount = Math.max(expected, 0); List rows = new ArrayList<>(rowCount); for (int i = 0; i < rowCount; i++) { @@ -142,11 +185,25 @@ interface RowBuilder { */ static JsonDeserializer listDeserializer( List fields, RowBuilder rowBuilder, Function, T> wrapper) { + return listDeserializer(fields, List.of(), rowBuilder, wrapper); + } + + /** + * Same as {@link #listDeserializer(List, RowBuilder, Function)} but with a set of optional + * columns (see {@link #zip(JsonParser, JsonNode, List, List, RowBuilder)}). Use when the wire + * schema has a column that some feeds omit — the row builder reads it through {@link + * Row#dblOrNull}. + */ + static JsonDeserializer listDeserializer( + List fields, + List optionalFields, + RowBuilder rowBuilder, + Function, T> wrapper) { return new JsonDeserializer() { @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode root = p.readValueAsTree(); - List rows = zip(p, root, fields, rowBuilder); + List rows = zip(p, root, fields, optionalFields, rowBuilder); return wrapper.apply(rows); } }; @@ -178,6 +235,14 @@ interface Row { */ long lng(String field) throws JsonMappingException; + /** + * Lenient numeric accessor for optional columns: returns {@code null} when the column + * was absent from the response (i.e. declared via the optional-fields list and not present) or + * the cell itself is null/missing. A present-but-wrong-type cell still raises {@link + * JsonMappingException} — leniency covers absence, not corruption. + */ + @Nullable Double dblOrNull(String field) throws JsonMappingException; + /** * Raw access for custom conversions (e.g. nested objects or non-trivial date parsing). Returns * the node verbatim — the caller decides how to validate. @@ -230,6 +295,21 @@ public long lng(String field) throws JsonMappingException { return cell.asLong(); } + @Override + public @Nullable Double dblOrNull(String field) throws JsonMappingException { + if (!arrays.containsKey(field)) { + return null; // optional column absent from the response entirely + } + JsonNode cell = arrays.get(field).get(index); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + return null; + } + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asDouble(); + } + @Override public JsonNode node(String field) { return cell(field); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 6d82fc1..3b05379 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -19,13 +19,26 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, ExpirationFilter.Between, - ExpirationFilter.MonthYear { + ExpirationFilter.MonthYear, + ExpirationFilter.All { /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); } + /** + * Every available expiration — wire form {@code ?expiration=all}. + * + *

This is not equivalent to leaving the expiration filter unset: with no filter the + * chain endpoint returns only the front-month (nearest) expiration, whereas {@code all()} returns + * the full chain across every expiration. The additive {@code weekly}/{@code monthly}/{@code + * quarterly} predicates still narrow the result on top of it. + */ + static All all() { + return new All(); + } + /** Days-to-expiration filter — wire form {@code ?dte=N}. */ static Dte dte(int days) { if (days < 0) { @@ -69,4 +82,7 @@ record Dte(int days) implements ExpirationFilter {} record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} + + /** The whole chain across every expiration — see {@link #all()}. Carries no data of its own. */ + record All() implements ExpirationFilter {} } diff --git a/src/main/java/com/marketdata/sdk/options/OptionQuote.java b/src/main/java/com/marketdata/sdk/options/OptionQuote.java index 725c7c7..d76b579 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionQuote.java +++ b/src/main/java/com/marketdata/sdk/options/OptionQuote.java @@ -1,17 +1,22 @@ package com.marketdata.sdk.options; import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; /** * A single end-of-day option quote — one row of {@link OptionsQuotes}. Carries the contract's * identification, market data (bid/ask/last/volume/open interest), in-the-money flag, intrinsic / * extrinsic decomposition, the underlying price the quote was struck against, and the standard set - * of Black-Scholes greeks (delta, gamma, theta, vega) plus implied volatility. + * of Black-Scholes greeks (delta, gamma, theta, vega, rho) plus implied volatility. * *

Numeric size/count fields use {@code long} so a single record can carry post-Wall-Street-2.0 * volume figures without silent truncation. Timestamps are {@link ZonedDateTime} in {@code * America/New_York}; their wire-format may be unix, ISO-string, or spreadsheet serial per the §3 * {@code dateformat} parameter, all of which are decoded uniformly by the deserializer. + * + *

{@code rho} is part of the API schema but is an optional column — several feeds omit + * it entirely or emit null cells. It is therefore the one greek typed as a nullable {@link Double}; + * {@code null} means "the response carried no rho for this contract", not zero. */ public record OptionQuote( String optionSymbol, @@ -38,4 +43,5 @@ public record OptionQuote( double delta, double gamma, double theta, - double vega) {} + double vega, + @Nullable Double rho) {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 0b66b2e..44d2a86 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -614,11 +614,27 @@ void quoteDecodesAllFields() { assertThat(q.gamma()).isEqualTo(0.012); assertThat(q.theta()).isEqualTo(-0.05); assertThat(q.vega()).isEqualTo(0.15); + // rho is an optional column absent from CANNED_QUOTE_BODY — it must decode to null, not 0.0, + // and the missing column must not trip the strict parallel-arrays parser. + assertThat(q.rho()).isNull(); assertThat(q.expiration().getZone().getId()).isEqualTo("America/New_York"); assertThat(q.firstTraded().getZone().getId()).isEqualTo("America/New_York"); assertThat(q.updated().getZone().getId()).isEqualTo("America/New_York"); } + @Test + void quoteDecodesRhoWhenPresent() { + String bodyWithRho = + CANNED_QUOTE_BODY.replace("\"vega\":[0.15]}", "\"vega\":[0.15],\"rho\":[0.0456]}"); + CapturingClient client = okWith(bodyWithRho); + OptionsResource options = resourceWith(client); + + OptionQuote q = + options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data().quotes().get(0); + + assertThat(q.rho()).isEqualTo(0.0456); + } + @Test void quoteSyncMirrorsAsync() { CapturingClient client = okWith(CANNED_QUOTE_BODY); @@ -759,6 +775,22 @@ void chainExpirationFilterBetweenTranslatesToFromTo() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?from=2025-01-01&to=2025-03-31"); } + @Test + void chainExpirationFilterAllTranslatesToExpirationAll() { + // expiration=all returns the full chain — distinct from omitting the filter, which the API + // narrows to the front-month expiration. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.all()).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?expiration=all"); + } + @Test void chainExpirationFilterMonthYearTranslatesToMonthYear() { CapturingClient client = okWith(CANNED_CHAIN_BODY); From 94a69d042f0d180322bb0cca36a1442dfc0ed3df Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 10:29:33 -0300 Subject: [PATCH 05/14] add countback --- .../com/marketdata/sdk/OptionsResource.java | 19 ++++-- .../sdk/options/OptionsQuoteRequest.java | 54 +++++++++++++-- .../sdk/options/OptionsQuotesRequest.java | 26 ++++++- .../marketdata/sdk/OptionsResourceTest.java | 68 +++++++++++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 74a981b..158258b 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -226,7 +226,12 @@ public Response strikes(OptionsStrikesRequest request) { */ public CompletableFuture> quoteAsync(OptionsQuoteRequest request) { return executeAndWrap( - buildQuoteSpec(request.optionSymbol(), request.date(), request.from(), request.to()), + buildQuoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback()), OptionsQuotes.class); } @@ -239,7 +244,8 @@ public Response quote(OptionsQuoteRequest request) { * Async: fetch quotes for multiple OCC option symbols concurrently. One HTTP request is fired per * symbol — the API path takes a single optionSymbol so comma-separated bulk isn't actually * supported by the backend regardless of what the docstring says (verified by reading the - * handler). All requests share the same optional {@code date}/{@code from}/{@code to} filters. + * handler). All requests share the same optional {@code date}/{@code from}/{@code to}/{@code + * countback} filters. * *

Returns a {@code Map>} keyed by the original symbol input * (insertion order preserved) so the consumer sees per-symbol {@link Response} metadata — {@code @@ -254,7 +260,8 @@ public CompletableFuture>> quotesAsync( List>>> futures = new ArrayList<>(symbols.size()); for (String symbol : symbols) { - RequestSpec spec = buildQuoteSpec(symbol, request.date(), request.from(), request.to()); + RequestSpec spec = + buildQuoteSpec(symbol, request.date(), request.from(), request.to(), request.countback()); futures.add( executeAndWrap(spec, OptionsQuotes.class).thenApply(resp -> Map.entry(symbol, resp))); } @@ -404,7 +411,8 @@ private static RequestSpec buildQuoteSpec( String optionSymbol, @Nullable LocalDate date, @Nullable LocalDate from, - @Nullable LocalDate to) { + @Nullable LocalDate to, + @Nullable Integer countback) { RequestSpec.Builder b = RequestSpec.get("options/quotes/" + PathSegments.encode(optionSymbol)); if (date != null) { b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(date)); @@ -415,6 +423,9 @@ private static RequestSpec buildQuoteSpec( if (to != null) { b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(to)); } + if (countback != null) { + b.query("countback", countback); + } return b.build(); } diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java index cacb455..96ccb79 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java @@ -6,8 +6,8 @@ /** * Parameters for the single-contract form of {@code GET /v1/options/quotes/{optionSymbol}/}. One - * OCC-formatted option symbol plus the optional historical-window filters ({@code date} or {@code - * from}/{@code to}). + * OCC-formatted option symbol plus the optional historical-window filters ({@code date}, {@code + * from}/{@code to}, or {@code to}/{@code countback}). * *

For multiple contracts use {@link OptionsQuotesRequest} — the multi-symbol API fans out one * request per symbol concurrently and returns a {@code Map>}. @@ -18,12 +18,14 @@ public final class OptionsQuoteRequest { private final @Nullable LocalDate date; private final @Nullable LocalDate from; private final @Nullable LocalDate to; + private final @Nullable Integer countback; private OptionsQuoteRequest(Builder b) { this.optionSymbol = b.optionSymbol; this.date = b.date; this.from = b.from; this.to = b.to; + this.countback = b.countback; } /** Shortcut for {@code builder(optionSymbol).build()}. */ @@ -54,11 +56,20 @@ public String optionSymbol() { return to; } + /** + * Number of quotes to fetch before {@code to} (to its left), or {@code null} when unset. An + * alternative to {@code from} for bounding the left edge of the window. + */ + public @Nullable Integer countback() { + return countback; + } + public static final class Builder { private final String optionSymbol; private @Nullable LocalDate date; private @Nullable LocalDate from; private @Nullable LocalDate to; + private @Nullable Integer countback; private Builder(String optionSymbol) { this.optionSymbol = Objects.requireNonNull(optionSymbol, "optionSymbol"); @@ -79,14 +90,47 @@ public Builder to(LocalDate to) { return this; } + /** + * Fetch {@code countback} quotes before {@code to}. Must be positive; mutually exclusive with + * {@code date} and with {@code from} (per the API: "if you use from, countback is not + * required"). Pair it with {@code to} to anchor the window. + */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + public OptionsQuoteRequest build() { if (optionSymbol.isEmpty()) { throw new IllegalArgumentException("optionSymbol must be non-empty"); } - if (date != null && (from != null || to != null)) { - throw new IllegalArgumentException("date and from/to are mutually exclusive"); - } + validateWindow(date, from, to, countback); return new OptionsQuoteRequest(this); } } + + /** + * Shared validation for the historical-window parameters across both quote request forms: {@code + * date} is a single snapshot incompatible with any ranging parameter; {@code countback} is an + * alternative to {@code from} for the left edge, so the two cannot be combined; and {@code + * countback} must be positive. + */ + static void validateWindow( + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to, + @Nullable Integer countback) { + if (date != null && (from != null || to != null || countback != null)) { + throw new IllegalArgumentException("date and from/to/countback are mutually exclusive"); + } + if (countback != null) { + if (countback <= 0) { + throw new IllegalArgumentException("countback must be positive"); + } + if (from != null) { + throw new IllegalArgumentException( + "countback and from are mutually exclusive; pair countback with to"); + } + } + } } diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java index c740400..6226c6c 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java @@ -22,12 +22,14 @@ public final class OptionsQuotesRequest { private final @Nullable LocalDate date; private final @Nullable LocalDate from; private final @Nullable LocalDate to; + private final @Nullable Integer countback; private OptionsQuotesRequest(Builder b) { this.optionSymbols = List.copyOf(b.optionSymbols); this.date = b.date; this.from = b.from; this.to = b.to; + this.countback = b.countback; } /** @@ -59,11 +61,21 @@ public List optionSymbols() { return to; } + /** + * Number of quotes to fetch before {@code to} (to its left), or {@code null} when unset. An + * alternative to {@code from} for bounding the left edge of the window; applied identically to + * every symbol in the fan-out. + */ + public @Nullable Integer countback() { + return countback; + } + public static final class Builder { private final List optionSymbols = new ArrayList<>(); private @Nullable LocalDate date; private @Nullable LocalDate from; private @Nullable LocalDate to; + private @Nullable Integer countback; private Builder() {} @@ -91,13 +103,21 @@ public Builder to(LocalDate to) { return this; } + /** + * Fetch {@code countback} quotes before {@code to} for each symbol. Must be positive; mutually + * exclusive with {@code date} and with {@code from} (per the API: "if you use from, countback + * is not required"). Pair it with {@code to} to anchor the window. + */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + public OptionsQuotesRequest build() { if (optionSymbols.isEmpty()) { throw new IllegalArgumentException("at least one optionSymbol is required"); } - if (date != null && (from != null || to != null)) { - throw new IllegalArgumentException("date and from/to are mutually exclusive"); - } + OptionsQuoteRequest.validateWindow(date, from, to, countback); return new OptionsQuotesRequest(this); } } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 44d2a86..61d3d6e 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -581,6 +581,56 @@ void quoteRequestRejectsDateAndFromToTogether() { .hasMessageContaining("mutually exclusive"); } + @Test + void quoteAttachesCountbackWithTo() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(5) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/quotes/AAPL250117C00150000/?to=2025-01-01&countback=5"); + } + + @Test + void quoteRequestRejectsCountbackWithDate() { + assertThatThrownBy( + () -> + OptionsQuoteRequest.builder("X") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(5) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + + @Test + void quoteRequestRejectsCountbackWithFrom() { + assertThatThrownBy( + () -> + OptionsQuoteRequest.builder("X") + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .countback(5) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback and from"); + } + + @Test + void quoteRequestRejectsNonPositiveCountback() { + assertThatThrownBy(() -> OptionsQuoteRequest.builder("X").countback(0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback must be positive"); + } + // ---------- quote: response decoding ---------- @Test @@ -701,6 +751,24 @@ void quotesAttachesDateFilterToEachFanOut() { } } + @Test + void quotesAttachesCountbackToEachFanOut() { + SymbolRoutingClient client = + new SymbolRoutingClient(Map.of("X", CANNED_QUOTE_BODY, "Y", CANNED_QUOTE_BODY)); + OptionsResource options = resourceWith(client); + + options.quotes( + OptionsQuotesRequest.builder("X", "Y") + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(3) + .build()); + + assertThat(client.captured).hasSize(2); + for (HttpRequest req : client.captured) { + assertThat(req.uri().toString()).endsWith("?to=2025-01-01&countback=3"); + } + } + @Test void quotesRequestRequiresAtLeastOneSymbol() { // The static factory takes the first symbol non-optionally, so there is no way to construct From 8e56206a0191f74ca44918c83452b781d8cab03d Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 10:41:24 -0300 Subject: [PATCH 06/14] add IT --- .../sdk/OptionsIntegrationTest.java | 68 +++++++++++++++++++ .../com/marketdata/sdk/OptionsResource.java | 5 ++ 2 files changed, 73 insertions(+) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 9b10f34..1728fbf 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.ExpirationStrikes; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -17,6 +18,7 @@ import com.marketdata.sdk.options.OptionsStrikes; import com.marketdata.sdk.options.OptionsStrikesRequest; import com.marketdata.sdk.options.StrikeRange; +import java.time.LocalDate; import java.util.Map; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -112,6 +114,72 @@ void chainReturnsFilteredContracts() { assertThat(first.strike()).isGreaterThan(0.0); } + @Test + void chainExpirationAllSpansMultipleExpirations() { + // expiration=all is the distinguishing case: omitting the filter returns only the front-month + // expiration, whereas all() returns the full chain. strikeLimit(1) keeps it to ~one contract + // per expiration so the payload stays small while still proving the span. + OptionsChain chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .data(); + + long distinctExpirations = + chain.chain().stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("expiration=all returns every expiration, not just the front-month") + .isGreaterThan(1); + } + + @Test + void chainDecodesOptionalRhoColumn() { + // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes + // whatever comes back without error — every row's rho is either null (omitted) or a finite + // double, never a ParseError from a missing required column. + OptionsChain chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(3) + .strikeRange(StrikeRange.ITM) + .build()) + .data(); + + assertThat(chain.chain()).isNotEmpty(); + for (OptionQuote q : chain.chain()) { + Double rho = q.rho(); + if (rho != null) { + assertThat(rho.doubleValue()).isFinite(); + } + } + } + + @Test + void quoteCountbackBoundsHistoricalSeries() { + // countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has + // ample recent history, so we expect between 1 and 5 rows — the upper bound is the real + // assertion that countback reached the wire. + String optionSymbol = sampleOptionSymbol(); + + Response resp = + client + .options() + .quote( + OptionsQuoteRequest.builder(optionSymbol).to(LocalDate.now()).countback(5).build()); + + assertThat(resp.data().quotes()) + .as("countback caps the series to at most 5 rows") + .hasSizeBetween(1, 5); + } + @Test void quoteFetchesSingleContract() { // Derive a real option symbol from the chain so the quote endpoint has a live contract to diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 158258b..c4b0b6a 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -382,6 +382,11 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("year", v.year()); } else if (f instanceof ExpirationFilter.All) { b.query("expiration", "all"); + } else { + // ExpirationFilter is sealed — this is unreachable today. It exists so that adding a new + // variant without a matching branch here fails loudly instead of silently dropping the + // filter. Mirrors strikeFilterWireValue's exhaustiveness guard. + throw new IllegalStateException("unhandled ExpirationFilter variant: " + f); } } From e70d4f726799518efd4626bc151d89f38314e5da Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 10:48:46 -0300 Subject: [PATCH 07/14] documentation --- CHANGELOG.md | 11 ++++++ README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f49db..ef3ea8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 down the call stack. ### Added +- **Options resource** (`client.options()`) — all six endpoints, each in sync + + async form: `lookup`, `expirations`, `strikes`, `quote` (single contract), + `quotes` (multi-contract fan-out returning + `Map>`), and `chain`. Every endpoint takes a + Builder-based per-endpoint request object (no `String` convenience overloads). + The `chain` request models its mutually-exclusive expiration and strike groups + as sealed types (`ExpirationFilter`, `StrikeFilter`) so the exclusivity is + compiler-enforced. Covers the `rho` greek (decoded as an optional, nullable + column — absent on some feeds), the `expiration=all` filter (the full chain + vs. the default front-month), and the `countback` historical-window parameter + (validated: positive, and mutually exclusive with `date`/`from`). - Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain, `integrationTest` source set, Spotless + JaCoCo, Vanniktech Maven Publish. - `MarketDataClient` skeleton with two public constructors — a no-arg one diff --git a/README.md b/README.md index ee2f4c0..97ed7cc 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Market Data Java SDK -Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release -scaffold** — endpoints are not yet implemented; this iteration sets up the -build, package layout, configuration cascade, exception taxonomy, and -Kotlin-interop foundations. +Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release** +— the `utilities` and `options` resources are implemented; `stocks`, `funds`, +and `markets` are forthcoming. The build, package layout, configuration cascade, +exception taxonomy, and Kotlin-interop foundations are in place. ## Requirements @@ -32,7 +32,9 @@ common path is two lines: ```java try (var client = new MarketDataClient()) { - // endpoint methods land in subsequent iterations + Response resp = + client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + System.out.println(resp.data().expirations()); } ``` @@ -40,10 +42,85 @@ try (var client = new MarketDataClient()) { ```kotlin MarketDataClient().use { client -> - // endpoint methods land in subsequent iterations + val resp = client.options().expirations(OptionsExpirationsRequest.of("AAPL")) + println(resp.data().expirations) } ``` +## Options + +Reached via `client.options()`. Every endpoint has a synchronous method and an +`…Async` variant returning `CompletableFuture`, and takes a Builder-based +request object — there are no `String` convenience overloads, so the call shape +is uniform across the SDK regardless of how many parameters an endpoint has. + +| Method | Purpose | +|--------|---------| +| `lookup` | Resolve a human description (`"AAPL 1/16/2026 $200 Call"`) to an OCC symbol | +| `expirations` | Expiration dates for an underlying | +| `strikes` | Strike ladder per expiration | +| `quote` | Quote for a single OCC option symbol | +| `quotes` | Quotes for many symbols — fans out concurrently, returns a per-symbol map | +| `chain` | Full option chain with the rich filter surface | + +### Chain with filters + +The `chain` request exposes the API's full filter set. Mutually-exclusive groups +(expiration, strike) are modeled as sealed types, so the compiler lets you pick +only one variant per group: + +#### Java + +```java +try (var client = new MarketDataClient()) { + Response resp = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) // every expiration, not just front-month + .strikeFilter(StrikeFilter.range(150, 200)) // 150 <= strike <= 200 + .side(OptionSide.CALL) + .strikeLimit(5) + .build()); + + for (OptionQuote q : resp.data().chain()) { + System.out.printf("%s delta=%.3f rho=%s%n", + q.optionSymbol(), q.delta(), q.rho()); // rho may be null (optional column) + } +} +``` + +#### Kotlin + +```kotlin +MarketDataClient().use { client -> + val resp = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) + .strikeFilter(StrikeFilter.range(150.0, 200.0)) + .side(OptionSide.CALL) + .strikeLimit(5) + .build() + ) + resp.data().chain.forEach { q -> + println("${q.optionSymbol} delta=${q.delta} rho=${q.rho}") // rho may be null + } +} +``` + +### Multiple quotes + +`quotes` fans out one request per symbol concurrently and returns a +`Map>` keyed by the input symbol, so per-symbol +status and errors stay observable. `countback` caps the historical series to the +N most recent rows before `to`: + +```java +Map> bySymbol = client.options().quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000") + .to(LocalDate.now()) + .countback(5) // at most 5 EOD rows per symbol, before `to` + .build()); +``` + ## Configuration Values are resolved through this cascade (highest priority first): @@ -136,9 +213,13 @@ isn't an exact match is rejected before any request is made. ## Package layout ``` -com.marketdata.sdk # MarketDataClient + RateLimits (public); - # Configuration, EnvVars, Tokens, Version +com.marketdata.sdk # MarketDataClient, RateLimits, and the resource + # façades (UtilitiesResource, OptionsResource) — + # public; Configuration, EnvVars, Tokens, Version # are package-private and not part of the API +com.marketdata.sdk.options # Options request builders + response records + # (OptionsChainRequest, OptionQuote, sealed + # ExpirationFilter / StrikeFilter, …) com.marketdata.sdk.exception # Sealed MarketDataException hierarchy + ErrorContext ``` From a5537001c2bb857cdcb577888eb0389c92f38439 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 11:25:18 -0300 Subject: [PATCH 08/14] add options to example --- examples/consumer-test/README.md | 2 +- .../marketdata/consumer/QuickstartApp.java | 174 +++++++++++++++++- 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/examples/consumer-test/README.md b/examples/consumer-test/README.md index ccf9915..d2e3627 100644 --- a/examples/consumer-test/README.md +++ b/examples/consumer-test/README.md @@ -59,7 +59,7 @@ Without it the demo fails fast with a clear "server not reachable" message. | App | Scenario | What you should see | |---|---|---| -| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers). | +| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers) and `options` (lookup / expirations / strikes / chain incl. sealed filters + `expiration=all` + `rho` / quote / quotes with `countback`). | | **LiveSmokeApp** | The happy path against the real API | client.toString redacted; sync + async parity on status/user/headers; parallel calls completing in ≈ slowest-single-call wall-time; final rate-limit snapshot populated | | **DemoAndConfigApp** | Construction-time behavior | demo-mode skip; §16 token redaction (short ≤8 → full; >8 → ***…***ABCD); cascade (explicit wins); IAE on invalid baseUrl / CRLF API key; validateOnStartup 200 vs 401 paths | | **ExceptionsApp** | Every sealed exception subtype | 401 → AuthenticationError; 400 → BadRequestError; 429 → RateLimitError (+ Retry-After); 500 → ServerError (no retry); 503×4 → ServerError after ≈7s; malformed JSON → ParseError; empty body → ParseError (§29 fix message); connection refused → NetworkError after retries; ADR-002 sealed routing via instanceof | diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java index 695005b..d1bc285 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java @@ -5,10 +5,30 @@ import com.marketdata.sdk.Response; import com.marketdata.sdk.exception.AuthenticationError; import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; /** * Idiomatic consumer-style examples — one short snippet per SDK resource showing @@ -50,8 +70,8 @@ public static void main(String[] args) { return; } utilitiesExamples(client); + optionsExamples(client); // stocksExamples(client); // ← add when client.stocks() lands - // optionsExamples(client); // ← add when client.options() lands // fundsExamples(client); // ← add when client.funds() lands // marketsExamples(client); // ← add when client.markets() lands } @@ -105,6 +125,158 @@ private static void utilitiesExamples(MarketDataClient client) { } } + // ---------- options ---------- + + /** + * One short snippet per options endpoint, in the order a consumer typically discovers them: + * lookup → expirations → strikes → chain → quote/quotes. The {@code chain} examples show the two + * sealed filter groups ({@link ExpirationFilter}, {@link StrikeFilter}), the {@code + * expiration=all} span, the optional nullable {@code rho} greek, and the {@code countback} + * window. Options data needs entitlements, so each step catches {@link AuthenticationError} + * separately and prints a hint — the tour stays runnable in demo mode. + */ + private static void optionsExamples(MarketDataClient client) { + Console.header("options — lookup, expirations, strikes, chain, quote, quotes"); + Console.info( + "Entry point is client.options(); every endpoint takes a Builder-based request object" + + " (no String overloads) and returns a Response."); + + // 1) lookup — turn a human description into a well-formed OCC symbol. + Console.step("client.options().lookup(...) — human description → OCC symbol"); + try { + Response r = + client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); + Console.ok("resolved to " + r.data().optionSymbol()); + } catch (AuthenticationError e) { + Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the options endpoints."); + } catch (MarketDataException e) { + Console.fail("lookup() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 2) expirations — the expiration calendar for an underlying. + Console.step("client.options().expirations(\"AAPL\") — expiration dates"); + try { + Response r = + client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + Console.ok( + r.data().expirations().size() + " expirations; updated " + r.data().updated()); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("expirations() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 3) strikes — the strike ladder, grouped per expiration. + Console.step("client.options().strikes(\"AAPL\") — strike ladder per expiration"); + try { + Response r = client.options().strikes(OptionsStrikesRequest.of("AAPL")); + if (r.data().expirations().isEmpty()) { + Console.ok("no strikes returned"); + } else { + ExpirationStrikes first = r.data().expirations().get(0); + Console.ok( + first.expiration().toLocalDate() + " has " + first.strikes().size() + " strikes"); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("strikes() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 4) chain — the rich filter surface. The mutually-exclusive groups are sealed types: you pick + // one ExpirationFilter and one StrikeFilter variant, enforced by the compiler. Here: ITM-ish + // calls within 45 DTE, strikes 150–250, the 5 nearest the money. + Console.step( + "client.options().chain(...) — filtered chain via sealed ExpirationFilter / StrikeFilter"); + try { + Response r = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(45)) + .strikeFilter(StrikeFilter.range(150, 250)) + .side(OptionSide.CALL) + .strikeLimit(5) + .build()); + Console.ok(r.data().chain().size() + " contracts"); + if (!r.data().chain().isEmpty()) { + OptionQuote q = r.data().chain().get(0); + // rho is an optional column — may be null when the feed omits it. + Console.ok(q.optionSymbol() + " delta=" + q.delta() + " rho=" + q.rho()); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("chain() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 5) chain with ExpirationFilter.all() — the whole chain across every expiration, distinct from + // omitting the filter (which the API narrows to the front-month). strikeLimit(1) keeps it small. + Console.step("client.options().chain(... ExpirationFilter.all()) — every expiration at once"); + try { + OptionsChain chain = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .data(); + long distinct = chain.chain().stream().map(OptionQuote::expiration).distinct().count(); + Console.ok("spans " + distinct + " distinct expirations (front-month-only would be 1)"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("chain(all) failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 6) quote / quotes — single contract, then concurrent multi-contract fan-out. Real symbols are + // pulled from a tiny chain query so the contracts are guaranteed to exist. quotes returns a + // Map keyed by symbol; countback caps each per-symbol series to the N most recent rows. + Console.step( + "client.options().quote(...) / quotes(...) — single + concurrent multi-contract (countback)"); + try { + List sample = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(2) + .build()) + .data() + .chain(); + if (sample.size() < 2) { + Console.info("not enough contracts returned to demo quote/quotes — skipping"); + return; + } + String s1 = sample.get(0).optionSymbol(); + String s2 = sample.get(1).optionSymbol(); + + Response one = client.options().quote(OptionsQuoteRequest.of(s1)); + Console.ok("quote(" + s1 + ") → " + one.data().quotes().size() + " row"); + + Map> many = + client + .options() + .quotes( + OptionsQuotesRequest.builder(s1, s2) + .to(LocalDate.now()) + .countback(5) + .build()); + Console.ok( + "quotes(" + s1 + ", " + s2 + ") → " + many.size() + " symbols, <=5 rows each (countback)"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("quote/quotes failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + } + // ---------- stocks (TODO: enable when client.stocks() lands) ---------- // // private static void stocksExamples(MarketDataClient client) { From 77a67e3933e11b18d43d4351edd56ddc89fe0fa6 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 1 Jun 2026 16:36:41 -0300 Subject: [PATCH 09/14] fix IT --- .../sdk/OptionsIntegrationTest.java | 15 +++++++---- .../com/marketdata/sdk/OptionsResource.java | 10 +++---- .../marketdata/sdk/options/OptionQuote.java | 18 +++++++------ .../marketdata/sdk/OptionsResourceTest.java | 26 +++++++++++++++++++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 1728fbf..91a7f7f 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -35,6 +35,11 @@ * carries finite greeks") rather than specific values, since the live data drifts daily. AAPL is * used as the underlying everywhere — it is the largest options market by volume so the response is * always non-empty during market hours and historical queries are well-populated outside them. + * + *

Status is asserted as {@code 200 || 203}: the API returns 203 Non-Authoritative + * Information when it serves cached/delayed data (outside market hours, or on a + * cached/delayed plan), which the SDK surfaces as a normal success. Pinning to 200 would make these + * tests flap with market hours. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class OptionsIntegrationTest { @@ -62,7 +67,7 @@ void lookupConvertsHumanDescriptionToOccSymbol() { Response resp = client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isIn(200, 203); assertThat(resp.data().optionSymbol()) .as("OCC symbol shape: 4-6 letter root + YYMMDD + C/P + 8-digit strike") .matches("[A-Z]{1,6}\\d{6}[CP]\\d{8}"); @@ -73,7 +78,7 @@ void expirationsReturnsAtLeastOneFutureDate() { Response resp = client.options().expirations(OptionsExpirationsRequest.of(UNDERLYING)); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isIn(200, 203); assertThat(resp.data().expirations()) .as("AAPL has options expirations year-round") .isNotEmpty(); @@ -85,7 +90,7 @@ void expirationsReturnsAtLeastOneFutureDate() { void strikesReturnsStrikesPerExpiration() { Response resp = client.options().strikes(OptionsStrikesRequest.of(UNDERLYING)); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isIn(200, 203); assertThat(resp.data().expirations()).isNotEmpty(); ExpirationStrikes first = resp.data().expirations().get(0); assertThat(first.strikes()).as("first expiration's strike ladder is non-empty").isNotEmpty(); @@ -106,7 +111,7 @@ void chainReturnsFilteredContracts() { .strikeRange(StrikeRange.ITM) .build()); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isIn(200, 203); assertThat(resp.data().chain()).isNotEmpty(); OptionQuote first = resp.data().chain().get(0); assertThat(first.optionSymbol()).startsWith(UNDERLYING); @@ -189,7 +194,7 @@ void quoteFetchesSingleContract() { Response resp = client.options().quote(OptionsQuoteRequest.of(optionSymbol)); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isIn(200, 203); assertThat(resp.data().quotes()).hasSize(1); OptionQuote q = resp.data().quotes().get(0); assertThat(q.optionSymbol()).isEqualTo(optionSymbol); diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index c4b0b6a..ec6a85f 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -134,11 +134,11 @@ private static JsonDeserializer optionRowsDeserializer( row.dbl("intrinsicValue"), row.dbl("extrinsicValue"), row.dbl("underlyingPrice"), - row.dbl("iv"), - row.dbl("delta"), - row.dbl("gamma"), - row.dbl("theta"), - row.dbl("vega"), + row.dblOrNull("iv"), + row.dblOrNull("delta"), + row.dblOrNull("gamma"), + row.dblOrNull("theta"), + row.dblOrNull("vega"), row.dblOrNull("rho")), wrapper); } diff --git a/src/main/java/com/marketdata/sdk/options/OptionQuote.java b/src/main/java/com/marketdata/sdk/options/OptionQuote.java index d76b579..98d3307 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionQuote.java +++ b/src/main/java/com/marketdata/sdk/options/OptionQuote.java @@ -14,9 +14,11 @@ * America/New_York}; their wire-format may be unix, ISO-string, or spreadsheet serial per the §3 * {@code dateformat} parameter, all of which are decoded uniformly by the deserializer. * - *

{@code rho} is part of the API schema but is an optional column — several feeds omit - * it entirely or emit null cells. It is therefore the one greek typed as a nullable {@link Double}; - * {@code null} means "the response carried no rho for this contract", not zero. + *

The model-derived values — implied volatility and the Black-Scholes greeks ({@code iv}, {@code + * delta}, {@code gamma}, {@code theta}, {@code vega}, {@code rho}) — are typed as nullable {@link + * Double}. On historical or illiquid rows the API legitimately returns {@code null} for them (no + * model output that day); {@code null} therefore means "not provided for this contract/row", not + * zero. The market-data fields (bid/ask/last/volume/…) stay primitive — they are always present. */ public record OptionQuote( String optionSymbol, @@ -39,9 +41,9 @@ public record OptionQuote( double intrinsicValue, double extrinsicValue, double underlyingPrice, - double iv, - double delta, - double gamma, - double theta, - double vega, + @Nullable Double iv, + @Nullable Double delta, + @Nullable Double gamma, + @Nullable Double theta, + @Nullable Double vega, @Nullable Double rho) {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 61d3d6e..0853586 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -685,6 +685,32 @@ void quoteDecodesRhoWhenPresent() { assertThat(q.rho()).isEqualTo(0.0456); } + @Test + void quoteDecodesNullModelValuesAsNull() { + // Historical / illiquid rows legitimately carry null iv + greeks (no model output that day). + // The columns are present, only the cell values are null — they must decode to null rather than + // tripping the strict parallel-arrays parser. Reproduces the live-API ParseError seen on a + // countback query for a thinly-traded contract. + String body = + CANNED_QUOTE_BODY.replace( + "\"iv\":[0.3012],\"delta\":[0.89],\"gamma\":[0.012],\"theta\":[-0.05],\"vega\":[0.15]}", + "\"iv\":[null],\"delta\":[null],\"gamma\":[null],\"theta\":[null],\"vega\":[null]}"); + CapturingClient client = okWith(body); + OptionsResource options = resourceWith(client); + + OptionQuote q = + options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data().quotes().get(0); + + assertThat(q.iv()).isNull(); + assertThat(q.delta()).isNull(); + assertThat(q.gamma()).isNull(); + assertThat(q.theta()).isNull(); + assertThat(q.vega()).isNull(); + assertThat(q.rho()).isNull(); + // Market-data fields on the same row stay populated and primitive. + assertThat(q.bid()).isEqualTo(52.1); + } + @Test void quoteSyncMirrorsAsync() { CapturingClient client = okWith(CANNED_QUOTE_BODY); From 63ac16d942090c1d0274cf4c6f7f842a6feda2b0 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 2 Jun 2026 10:01:33 -0300 Subject: [PATCH 10/14] adds review guide --- docs/OPTIONS_REVIEW_GUIDE.md | 249 +++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/OPTIONS_REVIEW_GUIDE.md diff --git a/docs/OPTIONS_REVIEW_GUIDE.md b/docs/OPTIONS_REVIEW_GUIDE.md new file mode 100644 index 0000000..ed12563 --- /dev/null +++ b/docs/OPTIONS_REVIEW_GUIDE.md @@ -0,0 +1,249 @@ +# Options Review Guide — `10_options_resource` + +This guide walks a reviewer through the `options` resource added on the `10_options_resource` branch. Like the [Refactor Review Guide](REFACTOR_REVIEW_GUIDE.md), it is organized by **flow**, not by file: each section names the parts that participate in one slice of behavior, explains how they fit, and calls out the non-obvious decisions. + +This PR builds directly on the foundation that guide describes — transport, retry, rate-limit, `Response`, and the `ParallelArrays` wire-format helper are all reused unchanged (except for one additive change, §4). If a mechanism here looks like it's assumed rather than explained, it's in the Refactor guide. + +Suggested reading order for someone new to the change: §1 (what's here) → §2 (the Request convention) → §4 (the deserializer + nullable columns — the correctness-critical part) → §3 (chain filters) → §7 (subtle corners). That's the load-bearing shape in ~30 minutes. + +All `file:line` citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. + +## Table of contents + +- [Running it locally](#running-it-locally) +1. [What this PR adds](#1-what-this-pr-adds) +2. [The Request-class convention](#2-the-request-class-convention) +3. [Chain request → query translation](#3-chain-request--query-translation) +4. [The shared option-row deserializer + optional columns](#4-the-shared-option-row-deserializer--optional-columns) +5. [The `quotes` multi-symbol fan-out](#5-the-quotes-multi-symbol-fan-out) +6. [lookup / expirations / strikes](#6-lookup--expirations--strikes) +7. [Subtle corners (finding-driven)](#7-subtle-corners-finding-driven) +8. [Out of scope for this review](#8-out-of-scope-for-this-review) +- [Reviewer checklist](#reviewer-checklist) + +--- + +## Running it locally + +```bash +make build # unit tests + Spotless + JaCoCo + +# Integration tests hit the live API (gated). A token in .env or the env is required: +MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest + +# Consumer-side demo of every options call (runs in demo mode without a token): +make publish && make demo-quickstart +``` + +The integration suite (`OptionsIntegrationTest`) was run green against `api.marketdata.app` as part of this PR — 9 tests, shape assertions. `QuickstartApp`'s `optionsExamples(...)` is the "what consumer code looks like" surface. + +--- + +## 1. What this PR adds + +### 1.1 Public API surface (new) + +What a consumer can now `import` and call, on top of the existing surface: + +``` +com.marketdata.sdk.OptionsResource (returned from client.options()) + +com.marketdata.sdk.options.OptionsLookupRequest / OptionsLookup +com.marketdata.sdk.options.OptionsExpirationsRequest / OptionsExpirations +com.marketdata.sdk.options.OptionsStrikesRequest / OptionsStrikes (+ ExpirationStrikes) +com.marketdata.sdk.options.OptionsQuoteRequest / OptionsQuotes (+ OptionQuote) +com.marketdata.sdk.options.OptionsQuotesRequest +com.marketdata.sdk.options.OptionsChainRequest / OptionsChain +com.marketdata.sdk.options.ExpirationFilter (sealed) / StrikeFilter (sealed) +com.marketdata.sdk.options.OptionSide / StrikeRange (enums) +``` + +`OptionsResource` is `public final` with a **package-private constructor** (ADR-007) — consumers reach it through `client.options()` (`MarketDataClient` wires it at construction) but cannot instantiate it. Response models and requests live in the public `com.marketdata.sdk.options` subpackage, mirroring `utilities`. + +### 1.2 Files to review, by role + +| Area | Files | What to check | +|---|---|---| +| Resource façade | `OptionsResource.java` | URL/param translation, fan-out, deserializer wiring | +| Requests | `options/Options*Request.java`, `ExpirationFilter`, `StrikeFilter`, `OptionSide`, `StrikeRange` | Builder validation, sealed-type modeling | +| Response models | `options/Options*.java`, `OptionQuote`, `ExpirationStrikes` | Field types (esp. nullable greeks) | +| Deserializers | `OptionsLookupDeserializer`, `OptionsExpirationsDeserializer`, `OptionsStrikesDeserializer` + `optionRowsDeserializer` in `OptionsResource` | Wire-shape correctness, strictness | +| Reused infra (changed) | `ParallelArrays.java` | The additive optional-column overload | +| New infra | `MarketDataDates.java`, `PathSegments.java` | Date parsing, URL encoding | +| Wiring | `MarketDataClient.java` (`+10 lines`) | `client.options()` accessor | + +### 1.3 What did **not** change + +The transport, retry, rate-limit, status-cache, `Response`, and exception layers are untouched. `ParallelArrays` is the only pre-existing class with a behavior-relevant change, and it is purely additive (the old 4-arg `zip` / 3-arg `listDeserializer` still exist and delegate — `ParallelArrays.java:70`, `:186`). Confirm no existing `utilities` test regressed. + +--- + +## 2. The Request-class convention + +This PR establishes the SDK-wide convention: **one Builder-based request class per endpoint, no `String` overloads.** + +```java +// no-optionals endpoints: static of(...) +OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"); +// optionals: builder(required...)... .build() +OptionsExpirationsRequest.builder("AAPL").strike(150.0).date(LocalDate.of(2024, 1, 17)).build(); +``` + +What to check per request class: + +- **Required args are constructor/factory params**; optionals are fluent setters. The required arg can't be omitted at the call site. +- **Cross-field validation is in `build()`**, throwing `IllegalArgumentException` with a message naming the conflict — e.g. `OptionsQuoteRequest.build()` rejects `date` + `from`/`to` together, and the shared `validateWindow(...)` (`OptionsQuoteRequest.java`) rejects `countback` with `date` or `from`. `OptionsQuotesRequest` reuses that same validator. +- **Getters are plain reads** (no work) — the resource reads them to build a `RequestSpec`. (The Request can't reach `RequestSpec` directly: it lives in a public subpackage, `RequestSpec` is package-private in the root.) + +### 2.1 Sealed types for mutually-exclusive groups + +`chain` has two groups where combining variants is undefined server-side. They're modeled as sealed interfaces so exclusivity is **compiler-enforced** — there's a single setter per group, and you can only pass one variant: + +- `ExpirationFilter` (`options/ExpirationFilter.java`): `OnDate` / `Dte` / `Between` / `MonthYear` / `All`. Factory methods validate at creation (`dte` ≥ 0, `between` ordered, `month` 1–12). +- `StrikeFilter` (`options/StrikeFilter.java`): `Exact` / `Range` / `Comparison` (with `Operator` GT/GTE/LT/LTE). + +Pair constraints that the type system can't express (`minBid ≤ maxBid`) stay as runtime checks in `OptionsChainRequest.build()`. + +> Reviewer note: this is the pattern future resources are expected to copy. If you disagree with it, this is the PR to say so — it's load-bearing for `stocks`/`funds`/`markets`. + +--- + +## 3. Chain request → query translation + +`chain` is the densest logic in the PR: ~25 optional parameters mapped to query string. The translation is `applyChainParams` (`OptionsResource.java:306`), called from `chainAsync` (`:291`). + +### 3.1 The shape + +`applyChainParams` is a flat sequence of `if (r.foo() != null) b.query("foo", ...)` for the independent params, plus two delegations: + +- `applyExpirationFilter(b, f)` (`:372`) — pattern-matches the sealed `ExpirationFilter`: + + | Variant | Wire | + |---|---| + | `OnDate(date)` | `?expiration=YYYY-MM-DD` | + | `Dte(days)` | `?dte=N` | + | `Between(from, to)` | `?from=…&to=…` | + | `MonthYear(year, month)` | `?month=M&year=YYYY` | + | `All` | `?expiration=all` | + + **Check the trailing `else { throw IllegalStateException }`** (`:388`). The `instanceof` chain isn't compiler-checked for exhaustiveness, so the guard exists to fail loudly if a future variant is added without a branch (it mirrors `strikeFilterWireValue`'s guard). This is unreachable today — it won't show in coverage, by design. + +- `strikeFilterWireValue(f)` (`:393`) → the `?strike=` value: `150` (exact), `140-160` (range), `>150` / `>=150` (comparison). Strikes render without trailing zeros via `formatStrike` (`:408`). + +### 3.2 What to verify + +- Every getter on `OptionsChainRequest` has a corresponding line in `applyChainParams`. A param that exists on the request but is never read would silently do nothing. (Cross-check the builder setters against `applyChainParams`.) +- `minBid`/`maxBid`/`minAsk`/`maxAsk` **are** real backend params (verified against the handler) — they were almost cut as "phantom"; they're not. +- Date params use `ISO_LOCAL_DATE` (`YYYY-MM-DD`) consistently. + +The unit tests `chainExpirationFilter*`, `chainStrikeFilter*`, and the per-param URL tests in `OptionsResourceTest` assert the exact query string for each branch. + +--- + +## 4. The shared option-row deserializer + optional columns + +**This is the correctness-critical section.** `quotes` and `chain` emit the same per-contract parallel-arrays row, so they share one deserializer: `optionRowsDeserializer(wrapper)` (`OptionsResource.java:110`), registered for both `OptionsQuotes` and `OptionsChain` in `wireFormatModule()` (`:57`). + +### 4.1 The row schema + +`OPTION_ROW_FIELDS` (`:68`) lists the 25 **required** columns; `OPTION_OPTIONAL_ROW_FIELDS` (`:102`) lists `rho` as optional. Each row builds an `OptionQuote` (`options/OptionQuote.java`). + +### 4.2 The nullable-model-values change (the heart of the review) + +The model-derived values — `iv`, `delta`, `gamma`, `theta`, `vega`, `rho` — are typed `@Nullable Double` and read via `row.dblOrNull(...)`. Everything else (bid/ask/last/sizes/strike/…) stays primitive `double`/`long`, read via the strict `row.dbl(...)`/`row.lng(...)`. + +**Why:** on historical/illiquid rows the API returns `null` for the model values (no model output that day). The strict-by-default `ParallelArrays.Row` accessors throw on a null cell — which surfaced as a live `ParseError` on a `countback` query (see §7). Making the six model values nullable is the fix. + +Two distinct kinds of "nullable" are in play here — verify you follow both: + +| Field | In which list | Decode | Tolerates | +|---|---|---|---| +| `rho` | `OPTION_OPTIONAL_ROW_FIELDS` | `dblOrNull` | column **absent entirely** *and* null cell | +| `iv`, `delta`, `gamma`, `theta`, `vega` | `OPTION_ROW_FIELDS` (required) | `dblOrNull` | null **cell** (column must still be present) | + +So `rho` may be missing as a whole array; `iv`/greeks must be present as arrays (a missing `iv` column is still a server bug → `ParseError`) but individual cells may be `null`. + +### 4.3 The `ParallelArrays` additive change + +`ParallelArrays` gained (all additive — old signatures delegate): + +- `zip(p, root, fields, optionalFields, rowBuilder)` (`:87`). After the required-field loop, optional fields are processed (`:129`): absent/null/non-array → skipped; present → length-checked like any column. +- `listDeserializer(fields, optionalFields, rowBuilder, wrapper)` (`:197`). +- `Row.dblOrNull(field)` (`:244`, impl `:299`): returns `null` if the column isn't in the row's map **or** the cell is null/missing; throws `typeMismatch` only on a present-but-non-number cell. Leniency covers **absence, not corruption.** + +What to verify: +- The old behavior is unchanged for every existing caller (utilities, lookup/expirations/strikes use the strict path). The `online`-column-regression reasoning in the `ParallelArrays` class javadoc still holds — strict is still the default. +- `dblOrNull` does **not** route through `cell(field)` (which throws on unknown field); it checks `arrays.containsKey` first. Confirm an absent optional column can't throw. + +### 4.4 Tests that document this + +`OptionsResourceTest`: +- `quoteDecodesAllFields` — all values present, including a populated row. +- `quoteDecodesRhoWhenPresent` / the rho-absent assertion — `rho` round-trips and is `null` when omitted. +- `quoteDecodesNullModelValuesAsNull` — `iv`+greeks all `null` in the body decode to `null` without a `ParseError`, while a market field on the same row stays populated. This reproduces the live-API failure. + +--- + +## 5. The `quotes` multi-symbol fan-out + +The backend `quotes` endpoint takes a single `optionSymbol` per call. The SDK's `quotes(...)` (`OptionsResource.java:257`) accepts N symbols and fans out one request each. + +Walk `quotesAsync`: +- One `buildQuoteSpec(symbol, date, from, to, countback)` per symbol (`:264`), each dispatched through the normal transport (so each rides the 50-permit `AsyncSemaphore`, retry, preflight — nothing special). +- Results collected into a `LinkedHashMap` keyed by the **input symbol**, insertion order preserved, so per-symbol `Response` metadata (`statusCode`, `isNoData`, `rawBody`, `requestId`) stays observable. +- **Fail-fast:** `CompletableFuture.allOf(...)` means the map's future completes exceptionally if any single request fails. Confirm you're comfortable with that semantic vs. partial-success — it's deliberate (a partial map would hide failures). + +`quote(...)` (single) and `quotes(...)` (multi) share `buildQuoteSpec`; the historical-window params (`date`/`from`/`to`/`countback`) apply identically to every symbol. + +Verify: `quotesFansOutToMultipleContracts` (integration) and `quotesAttachesCountbackToEachFanOut` / `quotesAttaches*` (unit). + +--- + +## 6. lookup / expirations / strikes + +The three simpler endpoints, each with its own hand-written deserializer (the wire shapes don't fit the parallel-arrays helper): + +- **lookup** (`OptionsResource.java:152`) — flat `{"s":"ok","optionSymbol":"…"}`. `OptionsLookupDeserializer`. The path is URL-encoded per-segment via `PathSegments.encode` so `AAPL 7/26/23 $200 Call` survives (`/` preserved, space → `%20`, `$` encoded). See `lookupUrlEncodesSpacesAndReservedChars`. +- **expirations** (`:173`) — parallel `expirations[]` + scalar `updated`. `OptionsExpirationsDeserializer`. Decodes epochs to `ZonedDateTime` at market-midnight (America/New_York) via `MarketDataDates`. `no_data` envelope → empty list, `updated` null. +- **strikes** (`:201`) — unusual shape: one top-level key **per expiration date** plus `s`/`updated`. `OptionsStrikesDeserializer` is strict — an unrecognized non-date key throws (`strikesUnrecognizedTopLevelKeyThrowsParseError`), a non-numeric strike throws, missing `updated` throws. + +Check `MarketDataDates` (new) handles all three `dateformat` variants (unix / ISO-string / spreadsheet) uniformly, since the typed surface is `dateformat`-agnostic. + +--- + +## 7. Subtle corners (finding-driven) + +| # | Corner | Where | What to know | +|---|---|---|---| +| 7.1 | **Null model values** | §4.2 | Historical/illiquid rows return null `iv`/greeks. Strict parse would `ParseError`. Fixed via nullable `@Nullable Double` + `dblOrNull`. Found by running live IT. | +| 7.2 | **HTTP 203 is success** | `OptionsIntegrationTest` | API returns `203 Non-Authoritative Information` for cached/delayed data. The SDK treats it as success; IT assert `200 || 203` so they don't flap with market hours. | +| 7.3 | **`expiration=all` ≠ no filter** | `ExpirationFilter.All`, `applyExpirationFilter` | Omitting the expiration filter makes the API return only the **front-month**; `all()` returns every expiration. Verified against the backend's `get_default_expiration_date` (`.head(1)`). Both are exercised in IT (`chainExpirationAllSpansMultipleExpirations`). | +| 7.4 | **`countback` validation** | `OptionsQuoteRequest.validateWindow` | Must be positive; mutually exclusive with `date` and `from` ("if you use from, countback is not required" per the API doc). Canonical use is `to` + `countback`. | +| 7.5 | **Exhaustiveness guard** | `applyExpirationFilter` `else throw` | Sealed `instanceof` chains aren't compiler-exhaustive; the guard fails loudly on a future unmatched variant instead of dropping the filter silently. | +| 7.6 | **`quotes` is fan-out, not bulk** | `quotesAsync` | The backend path takes one symbol; the docstring's comma-separated claim is wrong (verified by reading the handler). The SDK fans out N concurrent calls. | +| 7.7 | **`source` intentionally absent** | — | Internal provider param, not in the public schema / requirements / Python SDK. Deliberately not exposed. | + +--- + +## 8. Out of scope for this review + +Do **not** flag these as missing — they're deferred decisions, documented in [`PR.md`](../PR.md): + +- **§3 universal parameters** (`format`/CSV, `dateformat`, `columns`, `limit`, `offset`, `headers`, `human`, `mode`) — no consumer-facing API on options yet; lands with `stocks`. The `RequestSpec.Builder` plumbing exists but is package-private. +- **ADR-008 / ADR-009 + requirements §2.x + acceptance checklist** — pending the ADR-first workflow, to be drafted post-merge. +- **§13 JaCoCo 100% threshold** — deferred until the full resource layer lands. +- **§8 per-response rate-limit snapshot** on `Response` — still client-level. + +--- + +## Reviewer checklist + +- [ ] Request convention: every endpoint has a Builder request, no `String` overloads; required args non-optional; cross-field validation in `build()`. +- [ ] Sealed `ExpirationFilter` / `StrikeFilter` correctly translate every variant in `applyChainParams`, with the `else throw` guard. +- [ ] `applyChainParams` reads **every** `OptionsChainRequest` getter (no silently-dropped param). +- [ ] Nullable model values: `iv`/`delta`/`gamma`/`theta`/`vega`/`rho` are `@Nullable Double` + `dblOrNull`; market fields stay primitive + strict. +- [ ] `ParallelArrays` change is additive; existing strict callers unchanged; `dblOrNull` can't throw on an absent column. +- [ ] `quotes` fan-out: per-symbol map, insertion order, fail-fast semantics acceptable. +- [ ] Hand-written deserializers (lookup/expirations/strikes) stay strict on unexpected shapes. +- [ ] Unit (`./gradlew build`) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) both green. +- [ ] Deferred items (§8) understood and not blocking. From 07412a6c9eadbe9a0bebe92b29c8e9bf5d768858 Mon Sep 17 00:00:00 2001 From: MarketDataDev01 Date: Wed, 3 Jun 2026 19:08:10 -0300 Subject: [PATCH 11/14] ADR-008: endpoint parameter convention (Proposed) 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 overload, preserving the compile-time sealed-filter guarantee. --- .../ADR-008-endpoint-parameter-convention.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/adr/ADR-008-endpoint-parameter-convention.md diff --git a/docs/adr/ADR-008-endpoint-parameter-convention.md b/docs/adr/ADR-008-endpoint-parameter-convention.md new file mode 100644 index 0000000..84ec7f0 --- /dev/null +++ b/docs/adr/ADR-008-endpoint-parameter-convention.md @@ -0,0 +1,279 @@ +# ADR-008: Endpoint Parameter Convention (Request Objects) + +## Status + +Proposed. + +## Context + +ADR-006 fixed *how many* surfaces each endpoint exposes (sync + async +parity). It did **not** fix *how an endpoint accepts its parameters* — +the call shape a consumer actually types. The `options` resource (the +second resource after `utilities`, and the template the remaining +`stocks` / `funds` / `markets` resources will copy) is the first place +this decision becomes load-bearing, because `chain` carries the richest +filter surface in the API (~25 query parameters, plus two groups of +mutually-exclusive filters). + +The cross-language sibling SDKs have already answered this question, and +they answered it the same way: + +```python +# sdk-py — kwargs bag +client.options.chain("AAPL", side="call", strike_limit=10, min_open_interest=100) +``` + +```typescript +// sdk-js — options-object bag +client.options.chain("AAPL", { side: "call", strikeLimit: 10, minOpenInterest: 100 }); +``` + +Both use *positional required argument + one flat optional bag*. Both +validate at runtime (Pydantic / Zod). Neither models the +mutually-exclusive filter groups — in both, you can pass `dte` **and** +`expiration` together and the backend arbitrates. `sdk-js` even allows +unknown keys through (`.passthrough()`). + +Java cannot copy that shape directly: it has no keyword arguments and no +object literals. The idiomatic Java substitutes are a request object +with a builder, a `Consumer` lambda, a transport-bound fluent +builder, or a flat "parameters" object mirroring the siblings. The +choice matters because: + +- It is a **public-API decision**, hard to reverse without breaking + changes, and it will be **replicated across every future resource** — + so it should be decided once, here, not endpoint-by-endpoint. +- Java is the **only** SDK in the family whose type system can make the + mutually-exclusive filter groups *unrepresentable* rather than merely + *validated*. `chain` models them as sealed types (`ExpirationFilter` → + `OnDate` / `Dte` / `Between` / `MonthYear` / `All`; `StrikeFilter` → + `Exact` / `Range` / `Comparison`) reached through a single setter, so + "pick one variant" is enforced by `javac`. Whatever convention we pick + must preserve that, because it is the one dimension where the Java SDK + is strictly safer than its siblings. +- ADR-006 parity and the **deferred §3 universal parameters** + (`format`, `dateformat`, `columns`, `mode`, …, landing with `stocks`) + both interact with the convention: a request object adds universal + params as a second overload; a fluent terminal folds them in as more + setters. + +Filter set held constant across the options below: **calls only, strike +limit 10, minimum open interest 100.** + +## Options Considered + +### Option A — Request object + builder (the PR as written) + +Each endpoint takes one immutable request object, constructed via a +static `builder(required…)` (or `of(required…)` when there are no +optionals). One signature per endpoint, both surfaces. + +```java +client.options().chain( + OptionsChainRequest.builder("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).build()); + +client.options().chain(OptionsChainRequest.of("AAPL")); // no filters +``` + +**Pros** + +- The request object is **inert and decoupled** — no client reference. + Reusable across calls and clients, inspectable, loggable, and + unit-testable (assert the query translation without a transport). +- One object feeds both `chain(req)` and `chainAsync(req)` with **zero + duplicated parameter surface** — ADR-006 parity is trivial. +- **Uniform call shape** across all six endpoints regardless of + parameter richness; the future universal-params overload retrofits + cleanly as `chain(req, universalParams)`. +- Immutable, matching the records/immutability ethos (ADR-005/007). +- Sealed filters live naturally on the builder. + +**Cons** + +- `.build()` ceremony on every inline call. +- Naming stutter: `options().chain(…)` then `OptionsChainRequest`. +- Loses to a kwargs bag / object literal on raw terseness — Java will + never win that contest. + +### Option B — Option A plus a `Consumer` overload + +Keep everything in Option A; add an additive overload that hides the +builder naming and `.build()` for the inline case. + +```java +client.options().chain("AAPL", b -> b.side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100)); +client.options().chain("AAPL"); // bare overload for the no-filter case +``` + +**Pros** + +- Closest spiritual match to how `sdk-py` / `sdk-js` actually solved it + (required positional + optional configuration), so cross-SDK muscle + memory transfers. +- Keeps **all** of Option A's properties — the request object stays + inert and decoupled; `chain(req)` survives for reuse and for + conditional/dynamic filter construction (which reads badly inside a + lambda). +- Purely additive over Option A: ~6 lines per endpoint delegating to the + existing request-object method; no change to the request classes. + +**Cons** + +- Reopens the explicit "no `String` convenience overloads, uniform call + shape" decision the current PR made on purpose. +- Overload count: `chain(req)` + `chain(String, Consumer)` + + `chain(String)` = 3 signatures × sync/async = 6 per endpoint, and the + deferred universal-params overload can multiply that again unless + universal params fold into the builder. +- Per call site you can avoid the lambda **or** the builder naming, but + not both — the lambda exists precisely to hide the builder, so the + lambda-free form (`chain(req)`) necessarily names `builder()`/`build()`. + +### Option C — Transport-bound fluent builder + terminal verb + +`chain("AAPL")` returns a builder wired to the transport; filters are +fluent setters; a mandatory terminal (`fetch()` / `fetchAsync()`) +executes. This is the only shape that drops the lambda **and** the +builder naming **and** `.build()` simultaneously. + +```java +client.options().chain("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).fetch(); +``` + +**Pros** + +- Shortest possible call site; closest to the siblings' brevity. +- Universal params fold in as more setters before the terminal — **no + overload growth ever**. +- Uniform if applied to all six endpoints. + +**Cons** + +- The builder is **fused to the transport** — it *is* a live request, + not data. Loses the free-standing, reusable, unit-testable spec unless + a parallel inert `OptionsChainRequest` + `chain(req)` surface is kept + too (two ways to do everything). +- **Dangling-terminal footgun:** `chain("AAPL").side(CALL);` (no + terminal) compiles and silently does nothing. Java's type system + cannot force termination; only an ErrorProne `@CheckReturnValue` + backstop catches it. +- Moves the ADR-006 sync/async pair off the resource method onto the + terminal verb — an **ADR-006 amendment**, not a free refactor. +- Makes Java the **call-shape outlier** among the three SDKs (neither + sibling has a terminal verb). + +### Option D — Flat parameters object (mirror the siblings) + +A single `OptionsChainParams` object (or a long overloaded signature) +with every filter as an independent optional field, mirroring the +Python/JS bag exactly — no sealed types. + +```java +var p = new OptionsChainParams(); +p.setSide(OptionSide.CALL); p.setStrikeLimit(10); p.setMinOpenInterest(100); +client.options().chain("AAPL", p); +``` + +**Pros** + +- Maximum cross-SDK structural consistency. +- Familiar to consumers coming from `sdk-py` / `sdk-js`. + +**Cons** + +- **Throws away the one Java advantage:** mutually-exclusive filters + become independent fields again, so `dte` + `expiration` is back to a + runtime/server error instead of a compile error. Re-implements the + siblings' weakest property in the one language that doesn't have to. +- Mutable params object cuts against the immutability ethos. +- Validation regresses from "unrepresentable" to "checked in `build()`/ + on the wire." + +## Claude's Recommendation + +**Option A as the canonical, ADR-blessed form, plus Option B's +`Consumer` overload as the ergonomic front door.** + +The deciding factors: + +1. Terseness is unwinnable for Java against an object literal or kwargs — + so it should not be the optimization target. The Java SDK's + differentiator is the **compile-time sealed filters** (Options A/B/C + keep them; Option D discards them). Protect that; it is the only + column where Java leads its siblings. +2. The **inert, decoupled request object** (A/B) buys reuse, + testability, trivial ADR-006 parity, and freedom from the + dangling-terminal footgun. Option C trades all of that for a cosmetic + win and forces an ADR-006 amendment plus cross-SDK divergence. +3. Option B's overload is **purely additive** and recovers the siblings' + call ergonomics (`chain("AAPL", b -> …)` / `chain("AAPL")`) without + sacrificing anything in A. Its only real cost — overload count — is a + conscious, bounded trade, not the open-ended growth Option C's + detractors and the original PR feared. +4. Adopting B is an **all-or-nothing convention**: if `chain` gets the + overload, every endpoint should, so the SDK has one front door, not a + per-endpoint coin flip. That uniformity is exactly what an ADR is for. + +The strongest counter-recommendation is **Option A alone** (the PR as +written): one signature per endpoint is the simplest possible surface +and the cleanest universal-params retrofit. The case against it is +purely ergonomic — `OptionsChainRequest.builder(…).build()` at every +call site — and Option B answers that without giving anything up. If the +team values minimal surface over call-site ergonomics, shipping A in the +options PR and adding B in a follow-up is a reasonable middle path (the +overload is additive and non-breaking, so deferring it costs nothing +structurally). + +## Decision + +*Pending team ratification (status: Proposed).* + +Recommended: **Option A + Option B.** Each endpoint keeps a single +immutable request object (`builder(required…)` / `of(required…)`) as the +canonical form feeding both sync and async surfaces, and additionally +exposes a `foo(String required, Consumer)` overload +(plus a bare `foo(String required)` for the no-optional case) as the +ergonomic front door. The sealed mutually-exclusive filter groups +(`ExpirationFilter`, `StrikeFilter`) are retained unchanged. Universal +parameters (§3, deferred to `stocks`) retrofit as a second request-object +overload, not as additional builder state. + +Options C (transport-bound fluent terminal) and D (flat params object) +were considered and are not recommended — C because it sacrifices the +decoupled request object, introduces an un-enforceable +dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK +call-shape outlier; D because it discards the compile-time +mutual-exclusivity guarantee that is the Java SDK's one advantage over +its siblings. + +## Consequences + +Follow-on work implied by each option. The recommended option is marked. + +- **A (request object only):** One signature per endpoint. Call sites + always name `FooRequest.builder(…).build()` (or `of(…)`). No lambda + overloads. The convention already shipped in the `options` PR. +- **A + B (recommended):** Every endpoint additionally gets + `foo(String, Consumer)` and `foo(String)` overloads + delegating to `foo(FooRequest)`. Applied uniformly across `options` + and every future resource. Docs name `foo(FooRequest)` as canonical + (reuse / conditional construction) and the lambda overload as the + quick path. Each new endpoint adds ~6 lines of delegating overloads. +- **C (fluent terminal):** All six endpoints return transport-bound + builders with `fetch()` / `fetchAsync()` terminals; ADR-006 amended so + the terminal pair is the documented endpoint surface; ErrorProne + `@CheckReturnValue` added on builder setters to mitigate the + dangling-terminal footgun; the inert request object is dropped or + maintained as a second surface. +- **D (flat params object):** Sealed `ExpirationFilter` / `StrikeFilter` + collapse into independent optional fields on a mutable params object; + mutual-exclusivity moves to `build()`/wire-time validation. + +## References + +- [Market Data SDK Requirements §3 Universal Parameters, §Language-Idiomatic Design](../sdk-requirements.md) +- [Java SDK Requirements §2 — Kotlin interop](../java-sdk-requirements.md) +- [ADR-005 — JSON Library](./ADR-005-json-library.md) — records / immutability ethos +- [ADR-006 — Async API Surface](./ADR-006-async-api-surface.md) — sync/async parity that the terminal-verb option (C) would amend +- [ADR-007 — Internal API Encapsulation](./ADR-007-internal-api-encapsulation.md) — package-private constructors on resource façades and request types +- Sibling SDKs (not committed in this repo): `sdk-py` `client.options.chain(...)` (kwargs bag); `sdk-js` `client.options.chain(...)` (options-object bag, Zod `.passthrough()`) From d895c8f36311ea943b8ebf5b9de72907e4978203 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 8 Jun 2026 17:56:20 -0300 Subject: [PATCH 12/14] builder + request - refactor --- examples/consumer-test/build.gradle.kts | 6 +- .../marketdata/consumer/ConcurrencyApp.java | 4 +- .../marketdata/consumer/ExceptionsApp.java | 2 +- .../com/marketdata/consumer/LiveSmokeApp.java | 34 +- .../com/marketdata/consumer/OptionsApp.java | 353 +++++++++++ .../marketdata/consumer/QuickstartApp.java | 58 +- .../consumer/ResponseFeaturesApp.java | 35 +- .../marketdata/consumer/RetryBehaviorApp.java | 2 +- .../sdk/OptionsIntegrationTest.java | 85 ++- .../sdk/AbstractMarketDataResponse.java | 109 ++++ .../java/com/marketdata/sdk/CsvResponse.java | 18 + .../java/com/marketdata/sdk/HtmlResponse.java | 20 + .../marketdata/sdk/JsonResponseParser.java | 19 +- .../com/marketdata/sdk/MarketDataClient.java | 4 +- .../marketdata/sdk/MarketDataResponse.java | 55 ++ .../marketdata/sdk/OptionsChainResponse.java | 15 + .../marketdata/sdk/OptionsCsvResource.java | 144 +++++ .../sdk/OptionsExpirationsResponse.java | 30 + .../marketdata/sdk/OptionsHtmlResource.java | 76 +++ .../marketdata/sdk/OptionsLookupResponse.java | 12 + .../marketdata/sdk/OptionsQuotesResponse.java | 15 + .../com/marketdata/sdk/OptionsResource.java | 584 +++++++++++------- .../sdk/OptionsStrikesResponse.java | 31 + .../com/marketdata/sdk/ParallelArrays.java | 74 ++- .../com/marketdata/sdk/RequestConfig.java | 85 +++ .../java/com/marketdata/sdk/Response.java | 150 ----- .../sdk/UtilitiesHeadersResponse.java | 17 + .../com/marketdata/sdk/UtilitiesResource.java | 55 +- .../sdk/UtilitiesStatusResponse.java | 16 + .../marketdata/sdk/UtilitiesUserResponse.java | 14 + .../com/marketdata/sdk/options/Greek.java | 18 + .../marketdata/sdk/options/OptionQuote.java | 93 ++- .../sdk/options/OptionsQuoteRequest.java | 2 +- .../sdk/options/OptionsQuotesRequest.java | 2 +- .../marketdata/sdk/OptionsResourceTest.java | 296 +++++++-- .../java/com/marketdata/sdk/ResponseTest.java | 188 ------ .../marketdata/sdk/UtilitiesResourceTest.java | 50 +- 37 files changed, 1948 insertions(+), 823 deletions(-) create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java create mode 100644 src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java create mode 100644 src/main/java/com/marketdata/sdk/CsvResponse.java create mode 100644 src/main/java/com/marketdata/sdk/HtmlResponse.java create mode 100644 src/main/java/com/marketdata/sdk/MarketDataResponse.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsChainResponse.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsCsvResource.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsHtmlResource.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsLookupResponse.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java create mode 100644 src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java create mode 100644 src/main/java/com/marketdata/sdk/RequestConfig.java delete mode 100644 src/main/java/com/marketdata/sdk/Response.java create mode 100644 src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java create mode 100644 src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java create mode 100644 src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java create mode 100644 src/main/java/com/marketdata/sdk/options/Greek.java delete mode 100644 src/test/java/com/marketdata/sdk/ResponseTest.java diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index 8de5ec1..17ba788 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -33,9 +33,11 @@ val demoApps = mapOf( "runRetry" to ("com.marketdata.consumer.RetryBehaviorApp" to "Retry policy, Retry-After header, preflight gate. Needs mock server."), "runResponse" to ("com.marketdata.consumer.ResponseFeaturesApp" to - "Response surface: predicates, isNoData, rawBody, saveToFile, toString. Needs mock server."), + "MarketDataResponse surface: predicates, isNoData, json, saveToFile, toString. Needs mock server."), "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to - "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server.") + "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server."), + "runOptions" to ("com.marketdata.consumer.OptionsApp" to + "Full options surface: every endpoint + all params, CSV facet, columns projection, Option A. Needs mock server.") ) demoApps.forEach { (taskName, app) -> diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java index 77a4abf..e9494be 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java @@ -4,7 +4,7 @@ import com.marketdata.consumer.shared.MockServerControl; import com.marketdata.consumer.shared.MockServerControl.Step; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; +import com.marketdata.sdk.UtilitiesStatusResponse; import com.marketdata.sdk.utilities.ApiStatus; import java.util.ArrayList; import java.util.List; @@ -51,7 +51,7 @@ public static void main(String[] args) { try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { long t0 = System.nanoTime(); - List>> futures = new ArrayList<>(fanout); + List> futures = new ArrayList<>(fanout); for (int i = 0; i < fanout; i++) { futures.add(client.utilities().statusAsync()); } diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java index 03a4b11..460e992 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java @@ -80,7 +80,7 @@ private static void notFound404WithRealError(MockServerControl mock, MarketDataC Console.info( "Spec §11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response. The SDK returns a"); Console.info( - "Response with isNoData() = true. To see NotFoundError, we'd need a 404 that"); + "a MarketDataResponse with isNoData() = true. To see NotFoundError, we'd need a 404 that"); Console.info( "ISN'T the no-data envelope — but the current routing maps all 404s to a successful"); Console.info( diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java index 5dad3b4..6b5fc85 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java @@ -2,7 +2,11 @@ import com.marketdata.consumer.shared.Console; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; +import com.marketdata.sdk.MarketDataResponse; +import com.marketdata.sdk.UtilitiesHeadersResponse; +import com.marketdata.sdk.UtilitiesStatusResponse; +import com.marketdata.sdk.UtilitiesUserResponse; +import java.util.Map; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.ServiceStatus; @@ -38,18 +42,18 @@ public static void main(String[] args) { Console.header("/status/ (sync) — unversioned, no token required"); Console.run( () -> client.utilities().status(), - r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + r -> "data() has " + r.values().size() + " services; " + describe(r)); Console.header("/status/ (async) — same call via the async surface"); Console.run( () -> joinResponse(client.utilities().statusAsync()), - r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + r -> "data() has " + r.values().size() + " services; " + describe(r)); Console.header("/user/ (sync) — needs a token"); Console.run( () -> client.utilities().user(), r -> { - User u = r.data(); + User u = r.values(); return "requestsRemaining=" + u.requestsRemaining() + ", requestsLimit=" @@ -64,10 +68,10 @@ public static void main(String[] args) { Console.run( () -> client.utilities().headers(), r -> { - RequestHeaders rh = r.data(); - String auth = rh.headers().getOrDefault("authorization", "(absent)"); + Map rh = r.values(); + String auth = rh.getOrDefault("authorization", "(absent)"); return "headers=" - + rh.headers().size() + + rh.size() + " entries (authorization echoed back: " + auth + "); " @@ -76,9 +80,9 @@ public static void main(String[] args) { Console.header("Parallel async — fan out 3 calls, await all"); long t0 = System.nanoTime(); - CompletableFuture> a = client.utilities().statusAsync(); - CompletableFuture> b = client.utilities().userAsync(); - CompletableFuture> c = client.utilities().headersAsync(); + CompletableFuture a = client.utilities().statusAsync(); + CompletableFuture b = client.utilities().userAsync(); + CompletableFuture c = client.utilities().headersAsync(); // exceptionally() turns a failure into a null sentinel so allOf doesn't short-circuit on // the first failing call — we still want to see whether the others succeeded. CompletableFuture aSafe = a.thenApply(r -> (Object) r).exceptionally(t -> t); @@ -91,11 +95,11 @@ public static void main(String[] args) { + elapsedMs + " ms (≈ slowest single call, not sum — proves true parallelism)"); describeResult("status", aSafe.join(), r -> { - List services = ((Response) r).data().services(); + List services = ((UtilitiesStatusResponse) r).values(); return services.size() + " services; first: " + services.get(0).service(); }); - describeResult("user", bSafe.join(), r -> "remaining=" + ((Response) r).data().requestsRemaining()); - describeResult("headers", cSafe.join(), r -> ((Response) r).data().headers().size() + " entries"); + describeResult("user", bSafe.join(), r -> "remaining=" + ((UtilitiesUserResponse) r).values().requestsRemaining()); + describeResult("headers", cSafe.join(), r -> ((UtilitiesHeadersResponse) r).values().size() + " entries"); Console.header("Final rate-limit snapshot"); Console.info("rateLimits after the calls: " + client.getRateLimits()); @@ -112,11 +116,11 @@ private static void describeResult(String label, Object resultOrThrowable, java. } } - private static String describe(Response r) { + private static String describe(MarketDataResponse r) { return "status=" + r.statusCode() + ", requestId=" + r.requestId() + ", url=" + r.requestUrl(); } - private static Response joinResponse(CompletableFuture> f) { + private static R joinResponse(CompletableFuture f) { // CompletableFuture.join wraps the cause in CompletionException, but the SDK's joinSync // contract is to surface MarketDataException directly. We mimic that here so the demo's // exception output matches what a sync caller would see. diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java new file mode 100644 index 0000000..fa18bcc --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java @@ -0,0 +1,353 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.DateFormat; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Mode; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; +import java.time.LocalDate; +import java.util.List; + +/** + * Exhaustive {@code options} resource demo against the mock server. Covers: + * + *
    + *
  • every endpoint (lookup, expirations, strikes, quote, quotes, chain) with the full + * parameter surface — universal params (dateFormat/mode/limit/offset) + the rich chain + * filters (sealed expiration/strike groups, side, liquidity/price filters, …); + *
  • the CSV facet ({@code asCsv()}); + *
  • {@code columns} projection: requested fields populate, fields you did not ask for + * come back {@code null} with no error; + *
  • Option A failures: a required column you did request (or didn't project away) that + * the API omits raises a {@link ParseError}. + *
+ * + *

Each scenario scripts the mock server's response with {@link MockServerControl#script}, so the + * "API" returns exactly the body the scenario needs. + * + *

Run: {@code ./gradlew runOptions} (needs the mock server up). + */ +public final class OptionsApp { + + private OptionsApp() {} + + // A full option-quote row (every column present) — used by chain/quote. + private static final String FULL_ROW = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\"],\"underlying\":[\"AAPL\"]," + + "\"expiration\":[1737136800],\"side\":[\"call\"],\"strike\":[150]," + + "\"firstTraded\":[1663118400],\"dte\":[45],\"updated\":[1705449600]," + + "\"bid\":[12.55],\"bidSize\":[10],\"mid\":[12.7],\"ask\":[12.85],\"askSize\":[8]," + + "\"last\":[12.8],\"openInterest\":[15234],\"volume\":[289],\"inTheMoney\":[true]," + + "\"intrinsicValue\":[3.38],\"extrinsicValue\":[9.32],\"underlyingPrice\":[153.38]," + + "\"iv\":[0.2432],\"delta\":[0.5862],\"gamma\":[0.015],\"theta\":[-0.1347]," + + "\"vega\":[0.4152],\"rho\":[0.0891]}"; + + // A chain with three contracts — so iterating values() shows more than one row. + private static final String THREE_ROWS = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\",\"AAPL250117C00155000\",\"AAPL250117C00160000\"]," + + "\"underlying\":[\"AAPL\",\"AAPL\",\"AAPL\"]," + + "\"expiration\":[1737136800,1737136800,1737136800]," + + "\"side\":[\"call\",\"call\",\"call\"]," + + "\"strike\":[150,155,160]," + + "\"firstTraded\":[1663118400,1663118400,1663118400]," + + "\"dte\":[45,45,45],\"updated\":[1705449600,1705449600,1705449600]," + + "\"bid\":[12.55,8.90,6.10],\"bidSize\":[10,12,8],\"mid\":[12.7,9.0,6.2]," + + "\"ask\":[12.85,9.10,6.30],\"askSize\":[8,9,7],\"last\":[12.8,9.0,6.2]," + + "\"openInterest\":[15234,9921,7044],\"volume\":[289,144,98]," + + "\"inTheMoney\":[true,false,false],\"intrinsicValue\":[3.38,0,0]," + + "\"extrinsicValue\":[9.32,9.0,6.2],\"underlyingPrice\":[153.38,153.38,153.38]," + + "\"iv\":[0.2432,0.2401,0.2380],\"delta\":[0.5862,0.5096,0.4401]," + + "\"gamma\":[0.015,0.0155,0.0150],\"theta\":[-0.1347,-0.1343,-0.1320]," + + "\"vega\":[0.4152,0.4251,0.4180],\"rho\":[0.0891,0.0810,0.0732]}"; + + private static final String EXPIRATIONS = + "{\"s\":\"ok\",\"expirations\":[1737072000,1739491200],\"updated\":1705449600}"; + private static final String STRIKES = + "{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[145.0,150.0,155.0]}"; + private static final String LOOKUP = "{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"; + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + everyEndpointWithAllParams(mock, client); + csvFacet(mock, client); + columnsProjectionDoesNotFail(mock, client); + optionARequestedColumnMissingFails(mock, client); + strictByDefaultMissingColumnFails(mock, client); + } + } + + // ---------- every endpoint, all params ---------- + + private static void everyEndpointWithAllParams(MockServerControl mock, MarketDataClient client) { + Console.header("Every options endpoint with the full parameter surface"); + + // chain — the richest filter surface, plus universal params set fluently on the resource. + Console.step("chain(...) — sealed filters + side/liquidity/price filters + universal params"); + mock.reset(); + mock.script(Step.of(200, THREE_ROWS)); + var chain = + client + .options() + .dateFormat(DateFormat.TIMESTAMP) // universal param (type-preserving) + .mode(Mode.DELAYED) // universal param + .limit(50) // universal param + .offset(0) // universal param + .chain( + OptionsChainRequest.builder("AAPL") // required: underlying + .expirationFilter(ExpirationFilter.dte(45)) // sealed mutex: pick ONE + .strikeFilter(StrikeFilter.range(150, 250)) // sealed mutex: pick ONE + .strikeRange(StrikeRange.ITM) + .side(OptionSide.CALL) + .strikeLimit(10) // (strikeLimit/delta are alternative strike selectors) + .delta(0.5) + .weekly(true) + .monthly(true) + .quarterly(true) + .am(true) + .pm(true) + .nonstandard(true) + .minBid(0.1) + .maxBid(100.0) + .minAsk(0.1) + .maxAsk(100.0) + .maxBidAskSpread(5.0) + .maxBidAskSpreadPct(50.0) + .minOpenInterest(100) + .minVolume(10) + .date(LocalDate.of(2025, 1, 2)) // historical snapshot + .build()); + // .values() returns a List — iterate it row by row. + List rows = chain.values(); + Console.ok("chain.values() → List with " + rows.size() + " contracts; iterating:"); + for (OptionQuote row : rows) { + Console.info( + " " + + row.optionSymbol() + + " strike=" + + row.strike() + + " bid/ask=" + + row.bid() + + "/" + + row.ask() + + " inTheMoney=" + + row.inTheMoney() + + " delta=" + + row.delta() + + " greeks=" + + row.presentGreeks()); + } + Console.info("statusCode=" + chain.statusCode() + " isNoData=" + chain.isNoData()); + + // quote — single OCC symbol, date-window params. + Console.step("quote(...) — single OCC symbol + date window"); + mock.reset(); + mock.script(Step.of(200, FULL_ROW)); + var quote = + client + .options() + .dateFormat(DateFormat.UNIX) + .quote( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .from(LocalDate.of(2025, 1, 1)) // from+to = a date range (date window is mutex) + .to(LocalDate.of(2025, 1, 10)) + .build()); + List quoteRows = quote.values(); // also a List + Console.ok("quote.values() → List with " + quoteRows.size() + " row(s); iterating:"); + for (OptionQuote row : quoteRows) { + Console.info(" " + row.optionSymbol() + " last=" + row.last() + " iv=" + row.iv()); + } + + // quotes — fan-out over several OCC symbols → one request per symbol → per-symbol map. + Console.step("quotes(...) — multi OCC symbol fan-out (Map)"); + mock.reset(); + mock.script(List.of(Step.of(200, FULL_ROW), Step.of(200, FULL_ROW))); + var quotes = + client + .options() + .quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + quotes.forEach((sym, resp) -> Console.info(" " + sym + " → " + resp.values().size() + " row(s)")); + + // strikes — underlying + optional expiration/date; response is per-expiration strike lists. + Console.step("strikes(...) — strike ladder per expiration"); + mock.reset(); + mock.script(Step.of(200, STRIKES)); + var strikes = + client + .options() + .strikes( + OptionsStrikesRequest.builder("AAPL") + .expiration(LocalDate.of(2025, 1, 17)) + .date(LocalDate.of(2025, 1, 2)) + .build()); + Console.ok( + "strikes → " + + strikes.values().size() + + " expiration(s), updated=" + + strikes.updated()); + + // expirations — underlying + optional strike/date; response is a list of dates. + Console.step("expirations(...) — available expiration dates"); + mock.reset(); + mock.script(Step.of(200, EXPIRATIONS)); + var exps = + client + .options() + .expirations( + OptionsExpirationsRequest.builder("AAPL") + .strike(150.0) + .date(LocalDate.of(2025, 1, 2)) + .build()); + Console.ok("expirations → " + exps.values().size() + " date(s), updated=" + exps.updated()); + + // lookup — free-text → OCC symbol (scalar; no universal params, no facet). + Console.step("lookup(...) — human description → OCC symbol"); + mock.reset(); + mock.script(Step.of(200, LOOKUP)); + var sym = client.options().lookup(OptionsLookupRequest.of("AAPL 1/17/25 $150 call")); + Console.ok("lookup → " + sym.values()); + } + + // ---------- CSV facet ---------- + + private static void csvFacet(MockServerControl mock, MarketDataClient client) { + Console.header("CSV facet — client.options().asCsv()"); + + Console.step("asCsv().chain(...) — plain CSV"); + mock.reset(); + mock.script(Step.of(200, "optionSymbol,strike,bid\nAAPL250117C00150000,150,12.55")); + var csv = client + .options() + .asCsv() + .chain( + OptionsChainRequest.of("AAPL") + ); + Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); + Console.info(csv.csv()); + + // columns / human / headers reshape the output, so they live ONLY on the CSV facet. + Console.step("asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); + mock.reset(); + mock.script(Step.of(200, "Option Symbol,Strike Price,Bid\nAAPL250117C00150000,150,12.55")); + var shaped = + client + .options() + .asCsv() + .columns("optionSymbol", "strike", "bid") // project to a subset + .human(true) // human-readable column names + .headers(true) // include the header row + .chain(OptionsChainRequest.of("AAPL")); + Console.ok("→ CSV with human headers + projected columns:"); + Console.info(shaped.csv()); + + // Fan-out in CSV mirrors the typed map: one CsvResponse per symbol. + Console.step("asCsv().quotes(...) — fan-out → Map"); + mock.reset(); + mock.script( + List.of( + Step.of(200, "optionSymbol,bid\nAAPL250117C00150000,12.55"), + Step.of(200, "optionSymbol,bid\nAAPL250117P00150000,3.10"))); + var csvMap = + client + .options() + .asCsv() + .quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + csvMap.forEach( + (sym, resp) -> Console.info(" " + sym + " → " + resp.csv().replace("\n", " ⏎ "))); + } + + // ---------- columns projection: no failure when a non-requested field is absent ---------- + + private static void columnsProjectionDoesNotFail(MockServerControl mock, MarketDataClient client) { + Console.header("columns projection — non-requested fields come back null, NO error"); + mock.reset(); + // The mock returns ONLY the projected columns (as the real API would for ?columns=...). + mock.script( + Step.of( + 200, + "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]," + + "\"delta\":[0.5862]}")); + + var proj = + client + .options() + .columns("optionSymbol", "strike", "delta") + .chain(OptionsChainRequest.of("AAPL")); + + List rows = proj.values(); // still a List, just with most fields null + for (OptionQuote row : rows) { + Console.ok( + "requested → optionSymbol=" + + row.optionSymbol() + + " strike=" + + row.strike() + + " delta=" + + row.delta()); + Console.ok( + "NOT requested (null, decoded cleanly) → bid=" + + row.bid() + + " volume=" + + row.volume() + + " iv=" + + row.iv()); + } + } + + // ---------- Option A: requested column missing → ParseError ---------- + + private static void optionARequestedColumnMissingFails( + MockServerControl mock, MarketDataClient client) { + Console.header("Option A — requested a column the API omitted → ParseError"); + mock.reset(); + // Consumer asks for bid, but the body omits it → anomaly, not a projection. + mock.script( + Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); + + try { + client + .options() + .columns("optionSymbol", "strike", "bid") + .chain(OptionsChainRequest.of("AAPL")); + Console.fail("expected a ParseError — 'bid' was requested but the API did not return it"); + } catch (ParseError e) { + Console.ok("ParseError as expected: " + e.getMessage()); + } + } + + // ---------- strict by default: no columns filter still requires all structural columns ---------- + + private static void strictByDefaultMissingColumnFails( + MockServerControl mock, MarketDataClient client) { + Console.header("Strict by default — no columns filter, but a required column is missing"); + mock.reset(); + mock.script( + Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); + + try { + // No .columns(...) → every required column is implicitly requested, so a missing one fails. + client.options().chain(OptionsChainRequest.of("AAPL")); + Console.fail("expected a ParseError — required columns are missing and none were projected away"); + } catch (ParseError e) { + Console.ok("ParseError as expected (nullable fields did NOT weaken the strict default): " + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java index d1bc285..ecb0fc8 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java @@ -2,7 +2,6 @@ import com.marketdata.consumer.shared.Console; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; import com.marketdata.sdk.exception.AuthenticationError; import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.options.ExpirationFilter; @@ -85,9 +84,9 @@ private static void utilitiesExamples(MarketDataClient client) { // 1) Public endpoint: no token required. Useful as a liveness check. Console.step("client.utilities().status() — per-service health snapshot"); try { - Response health = client.utilities().status(); - long online = health.data().services().stream().filter(ServiceStatus::online).count(); - Console.ok(online + " of " + health.data().services().size() + " services online"); + var health = client.utilities().status(); + long online = health.values().stream().filter(ServiceStatus::online).count(); + Console.ok(online + " of " + health.values().size() + " services online"); } catch (MarketDataException e) { Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); } @@ -97,8 +96,8 @@ private static void utilitiesExamples(MarketDataClient client) { // invalid" — surface a hint to the user rather than crashing. Console.step("client.utilities().user() — your quota & permissions"); try { - Response me = client.utilities().user(); - User u = me.data(); + var me = client.utilities().user(); + User u = me.values(); Console.ok( u.requestsRemaining() + " requests remaining of " + u.requestsLimit() + " (today)"); } catch (AuthenticationError e) { @@ -113,10 +112,10 @@ private static void utilitiesExamples(MarketDataClient client) { // when debugging "is my Authorization header actually getting through?". Console.step("client.utilities().headers() — what the server saw on this call"); try { - Response echo = client.utilities().headers(); + var echo = client.utilities().headers(); Console.ok( "server received " - + echo.data().headers().size() + + echo.values().size() + " request headers (Authorization echoed back redacted)"); } catch (AuthenticationError e) { Console.info("401 — needs a token (same reason as utilities().user())."); @@ -139,14 +138,14 @@ private static void optionsExamples(MarketDataClient client) { Console.header("options — lookup, expirations, strikes, chain, quote, quotes"); Console.info( "Entry point is client.options(); every endpoint takes a Builder-based request object" - + " (no String overloads) and returns a Response."); + + " (no String overloads) and returns a typed MarketDataResponse (access the payload via .values())."); // 1) lookup — turn a human description into a well-formed OCC symbol. Console.step("client.options().lookup(...) — human description → OCC symbol"); try { - Response r = + var r = client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); - Console.ok("resolved to " + r.data().optionSymbol()); + Console.ok("resolved to " + r.values()); } catch (AuthenticationError e) { Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the options endpoints."); } catch (MarketDataException e) { @@ -156,10 +155,10 @@ private static void optionsExamples(MarketDataClient client) { // 2) expirations — the expiration calendar for an underlying. Console.step("client.options().expirations(\"AAPL\") — expiration dates"); try { - Response r = + var r = client.options().expirations(OptionsExpirationsRequest.of("AAPL")); Console.ok( - r.data().expirations().size() + " expirations; updated " + r.data().updated()); + r.values().size() + " expirations; updated " + r.updated()); } catch (AuthenticationError e) { Console.info("401 — needs a token."); } catch (MarketDataException e) { @@ -169,11 +168,11 @@ private static void optionsExamples(MarketDataClient client) { // 3) strikes — the strike ladder, grouped per expiration. Console.step("client.options().strikes(\"AAPL\") — strike ladder per expiration"); try { - Response r = client.options().strikes(OptionsStrikesRequest.of("AAPL")); - if (r.data().expirations().isEmpty()) { + var r = client.options().strikes(OptionsStrikesRequest.of("AAPL")); + if (r.values().isEmpty()) { Console.ok("no strikes returned"); } else { - ExpirationStrikes first = r.data().expirations().get(0); + ExpirationStrikes first = r.values().get(0); Console.ok( first.expiration().toLocalDate() + " has " + first.strikes().size() + " strikes"); } @@ -189,7 +188,7 @@ private static void optionsExamples(MarketDataClient client) { Console.step( "client.options().chain(...) — filtered chain via sealed ExpirationFilter / StrikeFilter"); try { - Response r = + var r = client .options() .chain( @@ -199,9 +198,9 @@ private static void optionsExamples(MarketDataClient client) { .side(OptionSide.CALL) .strikeLimit(5) .build()); - Console.ok(r.data().chain().size() + " contracts"); - if (!r.data().chain().isEmpty()) { - OptionQuote q = r.data().chain().get(0); + Console.ok(r.values().size() + " contracts"); + if (!r.values().isEmpty()) { + OptionQuote q = r.values().get(0); // rho is an optional column — may be null when the feed omits it. Console.ok(q.optionSymbol() + " delta=" + q.delta() + " rho=" + q.rho()); } @@ -215,7 +214,7 @@ private static void optionsExamples(MarketDataClient client) { // omitting the filter (which the API narrows to the front-month). strikeLimit(1) keeps it small. Console.step("client.options().chain(... ExpirationFilter.all()) — every expiration at once"); try { - OptionsChain chain = + var chain = client .options() .chain( @@ -224,8 +223,8 @@ private static void optionsExamples(MarketDataClient client) { .side(OptionSide.CALL) .strikeLimit(1) .build()) - .data(); - long distinct = chain.chain().stream().map(OptionQuote::expiration).distinct().count(); + .values(); + long distinct = chain.stream().map(OptionQuote::expiration).distinct().count(); Console.ok("spans " + distinct + " distinct expirations (front-month-only would be 1)"); } catch (AuthenticationError e) { Console.info("401 — needs a token."); @@ -248,8 +247,7 @@ private static void optionsExamples(MarketDataClient client) { .strikeRange(StrikeRange.ITM) .strikeLimit(2) .build()) - .data() - .chain(); + .values(); if (sample.size() < 2) { Console.info("not enough contracts returned to demo quote/quotes — skipping"); return; @@ -257,10 +255,10 @@ private static void optionsExamples(MarketDataClient client) { String s1 = sample.get(0).optionSymbol(); String s2 = sample.get(1).optionSymbol(); - Response one = client.options().quote(OptionsQuoteRequest.of(s1)); - Console.ok("quote(" + s1 + ") → " + one.data().quotes().size() + " row"); + var one = client.options().quote(OptionsQuoteRequest.of(s1)); + Console.ok("quote(" + s1 + ") → " + one.values().size() + " row"); - Map> many = + var many = client .options() .quotes( @@ -284,11 +282,11 @@ private static void optionsExamples(MarketDataClient client) { // // Console.step("client.stocks().quote(\"AAPL\") — latest quote"); // var q = client.stocks().quote("AAPL"); - // Console.ok("AAPL last=" + q.data().last() + " (asOf " + q.data().asOf() + ")"); + // Console.ok("AAPL last=" + q.values().last() + " (asOf " + q.values().asOf() + ")"); // // Console.step("client.stocks().candles(\"AAPL\", Resolution.D, from, to) — historical OHLCV"); // var c = client.stocks().candles("AAPL", Resolution.D, ...); - // Console.ok(c.data().rows().size() + " daily candles fetched"); + // Console.ok(c.values().rows().size() + " daily candles fetched"); // } // ---------- helpers ---------- diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java index 3a8c5a8..98d1e09 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java @@ -4,16 +4,15 @@ import com.marketdata.consumer.shared.MockServerControl; import com.marketdata.consumer.shared.MockServerControl.Step; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; import com.marketdata.sdk.utilities.ApiStatus; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; /** - * §13.5 {@code Response} surface: format predicates ({@code isJson}, + * §13.5 {@code MarketDataResponse} surface: format predicates ({@code isJson}, * {@code isCsv}, {@code isHtml}), the no-data envelope ({@code isNoData} - * from a 404 + {@code s:no_data}), defensive copies on {@code rawBody()}, the + * from a 404 + {@code s:no_data}), the raw body via {@code json()}, the * {@code saveToFile} helper, and the redacted {@code toString} shape. * *

Run: {@code ./gradlew runResponse} @@ -29,7 +28,7 @@ public static void main(String[] args) throws Exception { new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { formatPredicates(mock, client); noDataEnvelope(mock, client); - rawBodyIsDefensiveCopy(mock, client); + jsonReturnsRawBody(mock, client); saveToFileWritesVerbatim(mock, client); toStringIsLogSafe(mock, client); } @@ -42,7 +41,7 @@ private static void formatPredicates(MockServerControl mock, MarketDataClient cl mock.reset(); mock.script(Step.of(200, "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}")); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); Console.info("isJson(): " + resp.isJson()); Console.info("isCsv(): " + resp.isCsv()); Console.info("isHtml(): " + resp.isHtml()); @@ -65,13 +64,13 @@ private static void noDataEnvelope(MockServerControl mock, MarketDataClient clie mock.script(Step.of(404, "{\"s\":\"no_data\"}")); try { - Response resp = client.utilities().status(); + var resp = client.utilities().status(); Console.ok( "no exception thrown; statusCode=" + resp.statusCode() + ", isNoData=" + resp.isNoData()); - Console.info("data().services() = " + resp.data().services() + " (empty list as designed)"); + Console.info("data().services() = " + resp.values() + " (empty list as designed)"); } catch (Exception e) { Console.fail("404+no_data became an exception: " + e.getClass().getSimpleName()); } @@ -79,22 +78,18 @@ private static void noDataEnvelope(MockServerControl mock, MarketDataClient clie // ---------- defensive rawBody copy ---------- - private static void rawBodyIsDefensiveCopy(MockServerControl mock, MarketDataClient client) { - Console.header("rawBody() returns a defensive copy (mutations don't leak)"); + private static void jsonReturnsRawBody(MockServerControl mock, MarketDataClient client) { + Console.header("json() returns the raw response body verbatim"); mock.reset(); String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); - byte[] first = resp.rawBody(); - Console.info("first rawBody() length: " + first.length); - first[0] = 'X'; // mutate the returned array — must not affect internal state - - byte[] second = resp.rawBody(); - if (Arrays.equals(second, payload.getBytes())) { - Console.ok("second rawBody() matches the original payload — defensive copy honored"); + var resp = client.utilities().user(); + String body = resp.json(); + if (body.equals(payload)) { + Console.ok("json() matches the original payload (" + body.length() + " chars)"); } else { - Console.fail("internal body state was mutated by the consumer"); + Console.fail("json() differs from the original payload"); } } @@ -107,7 +102,7 @@ private static void saveToFileWritesVerbatim(MockServerControl mock, MarketDataC String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); Path tmp = Files.createTempFile("sdk-consumer-", ".json"); try { resp.saveToFile(tmp); @@ -130,7 +125,7 @@ private static void toStringIsLogSafe(MockServerControl mock, MarketDataClient c String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); String repr = resp.toString(); Console.info(repr); if (repr.contains("requestsRemaining")) { diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java index 5dd465e..d7c0a6c 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java @@ -54,7 +54,7 @@ private static void retryRecovers503Then200(MockServerControl mock, MarketDataCl long elapsed = (System.nanoTime() - t0) / 1_000_000; Console.ok( "succeeded after retries; data.services()=" - + resp.data().services().size() + + resp.values().size() + ", wall-time=" + elapsed + " ms"); diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 91a7f7f..e08ed82 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -6,19 +6,15 @@ import com.marketdata.sdk.options.ExpirationStrikes; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; -import com.marketdata.sdk.options.OptionsChain; import com.marketdata.sdk.options.OptionsChainRequest; -import com.marketdata.sdk.options.OptionsExpirations; import com.marketdata.sdk.options.OptionsExpirationsRequest; -import com.marketdata.sdk.options.OptionsLookup; import com.marketdata.sdk.options.OptionsLookupRequest; import com.marketdata.sdk.options.OptionsQuoteRequest; -import com.marketdata.sdk.options.OptionsQuotes; import com.marketdata.sdk.options.OptionsQuotesRequest; -import com.marketdata.sdk.options.OptionsStrikes; import com.marketdata.sdk.options.OptionsStrikesRequest; import com.marketdata.sdk.options.StrikeRange; import java.time.LocalDate; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -64,35 +60,33 @@ void tearDown() { void lookupConvertsHumanDescriptionToOccSymbol() { // A far-future date keeps the test stable against expiration drift — the endpoint converts // the description regardless of whether such a contract actually exists today. - Response resp = + OptionsLookupResponse resp = client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); assertThat(resp.statusCode()).isIn(200, 203); - assertThat(resp.data().optionSymbol()) + assertThat(resp.values()) .as("OCC symbol shape: 4-6 letter root + YYMMDD + C/P + 8-digit strike") .matches("[A-Z]{1,6}\\d{6}[CP]\\d{8}"); } @Test void expirationsReturnsAtLeastOneFutureDate() { - Response resp = + OptionsExpirationsResponse resp = client.options().expirations(OptionsExpirationsRequest.of(UNDERLYING)); assertThat(resp.statusCode()).isIn(200, 203); - assertThat(resp.data().expirations()) - .as("AAPL has options expirations year-round") - .isNotEmpty(); - assertThat(resp.data().updated()).isNotNull(); - assertThat(resp.data().updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(resp.values()).as("AAPL has options expirations year-round").isNotEmpty(); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().getZone().getId()).isEqualTo("America/New_York"); } @Test void strikesReturnsStrikesPerExpiration() { - Response resp = client.options().strikes(OptionsStrikesRequest.of(UNDERLYING)); + OptionsStrikesResponse resp = client.options().strikes(OptionsStrikesRequest.of(UNDERLYING)); assertThat(resp.statusCode()).isIn(200, 203); - assertThat(resp.data().expirations()).isNotEmpty(); - ExpirationStrikes first = resp.data().expirations().get(0); + assertThat(resp.values()).isNotEmpty(); + ExpirationStrikes first = resp.values().get(0); assertThat(first.strikes()).as("first expiration's strike ladder is non-empty").isNotEmpty(); assertThat(first.expiration().getZone().getId()).isEqualTo("America/New_York"); } @@ -101,7 +95,7 @@ void strikesReturnsStrikesPerExpiration() { void chainReturnsFilteredContracts() { // Light filter: a narrow strike-limit window keeps the response small without depending on // a specific dte that might fall on a non-trading day. - Response resp = + OptionsChainResponse resp = client .options() .chain( @@ -112,8 +106,8 @@ void chainReturnsFilteredContracts() { .build()); assertThat(resp.statusCode()).isIn(200, 203); - assertThat(resp.data().chain()).isNotEmpty(); - OptionQuote first = resp.data().chain().get(0); + assertThat(resp.values()).isNotEmpty(); + OptionQuote first = resp.values().get(0); assertThat(first.optionSymbol()).startsWith(UNDERLYING); assertThat(first.side()).isEqualTo("call"); assertThat(first.strike()).isGreaterThan(0.0); @@ -124,7 +118,7 @@ void chainExpirationAllSpansMultipleExpirations() { // expiration=all is the distinguishing case: omitting the filter returns only the front-month // expiration, whereas all() returns the full chain. strikeLimit(1) keeps it to ~one contract // per expiration so the payload stays small while still proving the span. - OptionsChain chain = + List chain = client .options() .chain( @@ -133,10 +127,9 @@ void chainExpirationAllSpansMultipleExpirations() { .side(OptionSide.CALL) .strikeLimit(1) .build()) - .data(); + .values(); - long distinctExpirations = - chain.chain().stream().map(OptionQuote::expiration).distinct().count(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); assertThat(distinctExpirations) .as("expiration=all returns every expiration, not just the front-month") .isGreaterThan(1); @@ -147,7 +140,7 @@ void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes // whatever comes back without error — every row's rho is either null (omitted) or a finite // double, never a ParseError from a missing required column. - OptionsChain chain = + List chain = client .options() .chain( @@ -156,10 +149,10 @@ void chainDecodesOptionalRhoColumn() { .strikeLimit(3) .strikeRange(StrikeRange.ITM) .build()) - .data(); + .values(); - assertThat(chain.chain()).isNotEmpty(); - for (OptionQuote q : chain.chain()) { + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { Double rho = q.rho(); if (rho != null) { assertThat(rho.doubleValue()).isFinite(); @@ -174,13 +167,13 @@ void quoteCountbackBoundsHistoricalSeries() { // assertion that countback reached the wire. String optionSymbol = sampleOptionSymbol(); - Response resp = + OptionsQuotesResponse resp = client .options() .quote( OptionsQuoteRequest.builder(optionSymbol).to(LocalDate.now()).countback(5).build()); - assertThat(resp.data().quotes()) + assertThat(resp.values()) .as("countback caps the series to at most 5 rows") .hasSizeBetween(1, 5); } @@ -192,11 +185,11 @@ void quoteFetchesSingleContract() { // data set. String optionSymbol = sampleOptionSymbol(); - Response resp = client.options().quote(OptionsQuoteRequest.of(optionSymbol)); + OptionsQuotesResponse resp = client.options().quote(OptionsQuoteRequest.of(optionSymbol)); assertThat(resp.statusCode()).isIn(200, 203); - assertThat(resp.data().quotes()).hasSize(1); - OptionQuote q = resp.data().quotes().get(0); + assertThat(resp.values()).hasSize(1); + OptionQuote q = resp.values().get(0); assertThat(q.optionSymbol()).isEqualTo(optionSymbol); assertThat(q.underlying()).isEqualTo(UNDERLYING); } @@ -204,7 +197,7 @@ void quoteFetchesSingleContract() { @Test void quotesFansOutToMultipleContracts() { // Use the first two contracts from the chain so both are guaranteed to exist. - OptionsChain chain = + List chain = client .options() .chain( @@ -213,22 +206,24 @@ void quotesFansOutToMultipleContracts() { .strikeLimit(2) .strikeRange(StrikeRange.ITM) .build()) - .data(); - assertThat(chain.chain()).hasSizeGreaterThanOrEqualTo(2); - String first = chain.chain().get(0).optionSymbol(); - String second = chain.chain().get(1).optionSymbol(); - - Map> resp = + .values(); + assertThat(chain).hasSizeGreaterThanOrEqualTo(2); + // optionSymbol is @Nullable on the row (every field is, to support columns projection); on a + // live chain it's always present, so assert that and keep the symbols non-null for the fan-out. + String first = java.util.Objects.requireNonNull(chain.get(0).optionSymbol()); + String second = java.util.Objects.requireNonNull(chain.get(1).optionSymbol()); + + Map resp = client.options().quotes(OptionsQuotesRequest.builder(first, second).build()); assertThat(resp.keySet()).containsExactly(first, second); - assertThat(resp.get(first).data().quotes().get(0).optionSymbol()).isEqualTo(first); - assertThat(resp.get(second).data().quotes().get(0).optionSymbol()).isEqualTo(second); + assertThat(resp.get(first).values().get(0).optionSymbol()).isEqualTo(first); + assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second); } /** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */ private String sampleOptionSymbol() { - OptionsChain chain = + List chain = client .options() .chain( @@ -237,8 +232,8 @@ private String sampleOptionSymbol() { .strikeLimit(1) .strikeRange(StrikeRange.ITM) .build()) - .data(); - assertThat(chain.chain()).isNotEmpty(); - return chain.chain().get(0).optionSymbol(); + .values(); + assertThat(chain).isNotEmpty(); + return chain.get(0).optionSymbol(); } } diff --git a/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java new file mode 100644 index 0000000..0e65a25 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java @@ -0,0 +1,109 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Shared implementation of {@link MarketDataResponse}: holds the flat payload plus the response + * metadata (raw body, format, status, request id, url) and implements every accessor. Named + * per-endpoint response types extend this with nothing more than their payload type binding and a + * package-private constructor, so consumers cannot instantiate them (ADR-007). + * + *

Lives in the root package so the resource façades (also root) can construct the subtypes via + * their package-private constructors, mirroring how the resource façades construct their responses. + * + * @param the flat payload type. + */ +abstract class AbstractMarketDataResponse implements MarketDataResponse { + + private final T values; + private final byte[] rawBody; + private final Format format; + private final int statusCode; + private final @Nullable String requestId; + private final URI requestUrl; + + AbstractMarketDataResponse(T values, HttpResponseEnvelope envelope, Format format) { + this.values = Objects.requireNonNull(values, "values"); + this.rawBody = Objects.requireNonNull(envelope, "envelope").body().clone(); + this.format = Objects.requireNonNull(format, "format"); + this.statusCode = envelope.statusCode(); + this.requestId = envelope.requestId(); + this.requestUrl = envelope.url(); + } + + @Override + public T values() { + return values; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public boolean isNoData() { + return statusCode == 404; + } + + @Override + public @Nullable String requestId() { + return requestId; + } + + @Override + public URI requestUrl() { + return requestUrl; + } + + @Override + public String json() { + return new String(rawBody, StandardCharsets.UTF_8); + } + + @Override + public boolean isJson() { + return format == Format.JSON; + } + + @Override + public boolean isCsv() { + return format == Format.CSV; + } + + @Override + public boolean isHtml() { + return format == Format.HTML; + } + + @Override + public void saveToFile(Path path) { + try { + Files.write(path, rawBody); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write response body to " + path, e); + } + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "[status=" + + statusCode + + ", format=" + + format.name().toLowerCase(Locale.ROOT) + + ", bytes=" + + rawBody.length + + ", url=" + + HttpDispatcher.safeUri(requestUrl) + + "]"; + } +} diff --git a/src/main/java/com/marketdata/sdk/CsvResponse.java b/src/main/java/com/marketdata/sdk/CsvResponse.java new file mode 100644 index 0000000..51dba5b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/CsvResponse.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk; + +/** + * Response from a CSV facet (e.g. {@code client.options().asCsv().chain(req)}): {@link #values()} + * is the raw CSV text. Shared across every endpoint that supports CSV — the body is opaque text + * with no per-endpoint structure. Distinct from {@link HtmlResponse} so the two never cross-assign. + */ +public final class CsvResponse extends AbstractMarketDataResponse { + + CsvResponse(String csv, HttpResponseEnvelope envelope, Format format) { + super(csv, envelope, format); + } + + /** The raw CSV text (same as {@link #values()}; named for readability at call sites). */ + public String csv() { + return values(); + } +} diff --git a/src/main/java/com/marketdata/sdk/HtmlResponse.java b/src/main/java/com/marketdata/sdk/HtmlResponse.java new file mode 100644 index 0000000..268b734 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HtmlResponse.java @@ -0,0 +1,20 @@ +package com.marketdata.sdk; + +/** + * Response from an HTML facet: {@link #values()} is the raw HTML text. The type and the underlying + * facet plumbing exist so enabling HTML is a trivial release once the backend supports {@code + * format=html}; the {@code asHtml()} entry point is intentionally not exposed on the + * resource today (the server returns no HTML for any data endpoint). Distinct from {@link + * CsvResponse} so the two never cross-assign. + */ +public final class HtmlResponse extends AbstractMarketDataResponse { + + HtmlResponse(String html, HttpResponseEnvelope envelope, Format format) { + super(html, envelope, format); + } + + /** The raw HTML text (same as {@link #values()}). */ + public String html() { + return values(); + } +} diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 76bc8ed..06ec954 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -6,6 +6,7 @@ import com.marketdata.sdk.exception.ParseError; import java.io.IOException; import java.time.Clock; +import java.util.List; /** * Decodes {@link HttpResponseEnvelope} bodies into typed records. @@ -52,7 +53,20 @@ void registerModule(Module module) { * cannot read the body — the error context carries the envelope's url, status, and request id for * the consumer's diagnostics. */ + /** Attribute key under which the requested {@code columns} (§3) travel into deserializers. */ + static final String REQUESTED_COLUMNS_ATTR = "marketdata.requestedColumns"; + T parse(HttpResponseEnvelope env, Class type) { + return parse(env, type, List.of()); + } + + /** + * Decode like {@link #parse(HttpResponseEnvelope, Class)} but additionally make the consumer's + * requested {@code columns} available to deserializers (via a Jackson context attribute) so they + * can enforce Option A: a required column that was requested but the API omitted surfaces as a + * {@link ParseError}, never a silent null. {@code requestedColumns} empty means "all columns". + */ + T parse(HttpResponseEnvelope env, Class type, List requestedColumns) { // Issue #29: a zero-length body surfaces from Jackson as a generic "No content to map" // MismatchedInputException — diagnostically thin, often confusing in the presence of a // body-stripping proxy. Pre-check so the failure carries a precise, actionable message that @@ -69,7 +83,10 @@ T parse(HttpResponseEnvelope env, Class type) { context); } try { - return mapper.readValue(env.body(), type); + return mapper + .readerFor(type) + .withAttribute(REQUESTED_COLUMNS_ATTR, requestedColumns) + .readValue(env.body()); } catch (IOException e) { ErrorContext context = ErrorContext.forResponse( diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index f445c6f..83b0be8 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -1,5 +1,6 @@ package com.marketdata.sdk; +import com.marketdata.sdk.utilities.ApiStatus; import java.nio.file.Path; import java.time.Clock; import java.util.ArrayList; @@ -109,7 +110,8 @@ public MarketDataClient( this.options = new OptionsResource(transport, parser); cacheRef.set( new StatusCache( - () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); + () -> utilities.statusAsync().thenApply(r -> new ApiStatus(r.values())), + Clock.systemUTC())); } catch (Throwable t) { try { transport.close(); diff --git a/src/main/java/com/marketdata/sdk/MarketDataResponse.java b/src/main/java/com/marketdata/sdk/MarketDataResponse.java new file mode 100644 index 0000000..53323db --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataResponse.java @@ -0,0 +1,55 @@ +package com.marketdata.sdk; + +import java.net.URI; +import java.nio.file.Path; +import org.jspecify.annotations.Nullable; + +/** + * Uniform surface every endpoint response implements. {@code T} is the flat payload the + * endpoint produces — a {@code List} for tabular endpoints, a {@code String} for the scalar + * {@code options.lookup}, and so on — reached through the single {@link #values()} accessor. + * + *

The point is that a consumer learns one shape and reuses it across every resource: {@code + * values()} for the data (typed per endpoint), and the same metadata accessors everywhere. Concrete + * responses are named per endpoint (e.g. {@code OptionsChainResponse implements + * MarketDataResponse>}) so signatures read well and the compiler enforces the + * accessor. + * + * @param the flat payload type for this endpoint. + */ +public interface MarketDataResponse { + + /** The typed payload — {@code List<...>}, a scalar, etc. Never {@code null}. */ + T values(); + + /** HTTP status code (one of 200, 203, 404). */ + int statusCode(); + + /** + * Whether the API signalled {@code {"s":"no_data"}} (HTTP 404 with that envelope) — a successful + * "nothing for that query", distinct from an error. + */ + boolean isNoData(); + + /** Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when absent. */ + @Nullable String requestId(); + + /** Absolute URL the response came from. */ + URI requestUrl(); + + /** The raw response body as text (the original payload the API returned). */ + String json(); + + boolean isJson(); + + boolean isCsv(); + + /** + * Whether the body is HTML — typically a misrouted request that hit the web tier (marketing/error + * page) rather than the API tier. + */ + boolean isHtml(); + + /** Write the raw body verbatim to {@code path}, creating or overwriting it. */ + void saveToFile(Path path); +} diff --git a/src/main/java/com/marketdata/sdk/OptionsChainResponse.java b/src/main/java/com/marketdata/sdk/OptionsChainResponse.java new file mode 100644 index 0000000..da42941 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsChainResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionQuote; +import java.util.List; + +/** + * Response for {@code options.chain}: {@link #values()} is the chain's option rows. Construct only + * through the resource façade (package-private constructor, ADR-007). + */ +public final class OptionsChainResponse extends AbstractMarketDataResponse> { + + OptionsChainResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsCsvResource.java b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java new file mode 100644 index 0000000..e0d0f33 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java @@ -0,0 +1,144 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * CSV facet of {@code options} — reached through {@code client.options().asCsv()}. Every endpoint + * here returns a {@link CsvResponse} (opaque CSV text). {@code lookup} is intentionally absent: it + * is a scalar with no CSV representation (resource-architecture §2.4). + * + *

Carries the universal-param config from the typed resource and additionally exposes the + * output-shaping {@code columns}/{@code human}/{@code headers} params, which only cohere with CSV. + */ +public final class OptionsCsvResource { + + private final HttpTransport transport; + private final RequestConfig config; + + OptionsCsvResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + // ---------- universal + output-shaping params ---------- + + public OptionsCsvResource dateFormat(DateFormat v) { + return new OptionsCsvResource(transport, config.withDateFormat(v)); + } + + public OptionsCsvResource mode(Mode v) { + return new OptionsCsvResource(transport, config.withMode(v)); + } + + public OptionsCsvResource limit(int v) { + return new OptionsCsvResource(transport, config.withLimit(v)); + } + + public OptionsCsvResource offset(int v) { + return new OptionsCsvResource(transport, config.withOffset(v)); + } + + public OptionsCsvResource columns(String... v) { + return new OptionsCsvResource(transport, config.withColumns(List.of(v))); + } + + public OptionsCsvResource human(boolean v) { + return new OptionsCsvResource(transport, config.withHuman(v)); + } + + public OptionsCsvResource headers(boolean v) { + return new OptionsCsvResource(transport, config.withHeaders(v)); + } + + // ---------- endpoints ---------- + + public CompletableFuture chainAsync(OptionsChainRequest request) { + return executeCsv(OptionsResource.chainSpec(request)); + } + + public CsvResponse chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + return executeCsv( + OptionsResource.quoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback())); + } + + public CsvResponse quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + /** Fan-out: one CSV per symbol, mirroring the typed map (resource-architecture §2.5). */ + public CompletableFuture> quotesAsync(OptionsQuotesRequest request) { + List symbols = request.optionSymbols(); + List>> futures = + new ArrayList<>(symbols.size()); + for (String symbol : symbols) { + futures.add( + executeCsv( + OptionsResource.quoteSpec( + symbol, request.date(), request.from(), request.to(), request.countback())) + .thenApply(resp -> Map.entry(symbol, resp))); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply( + unused -> { + Map result = new LinkedHashMap<>(); + for (CompletableFuture> f : futures) { + Map.Entry entry = f.join(); + result.put(entry.getKey(), entry.getValue()); + } + return result; + }); + } + + public Map quotes(OptionsQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + return executeCsv(OptionsResource.strikesSpec(request)); + } + + public CsvResponse strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + public CompletableFuture expirationsAsync(OptionsExpirationsRequest request) { + return executeCsv(OptionsResource.expirationsSpec(request)); + } + + public CsvResponse expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + // ---------- execute ---------- + + private CompletableFuture executeCsv(RequestSpec.Builder b) { + config.applyTo(b); + b.format(Format.CSV); + RequestSpec spec = b.build(); + return transport + .executeAsync(spec) + .thenApply( + env -> + new CsvResponse( + new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java b/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java new file mode 100644 index 0000000..8f12274 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java @@ -0,0 +1,30 @@ +package com.marketdata.sdk; + +import java.time.ZonedDateTime; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Response for {@code options.expirations}: {@link #values()} is the available expiration dates. + * The endpoint also carries an {@code updated} timestamp, exposed via {@link #updated()} ({@code + * null} on the {@code no_data} envelope). Construct only through the resource façade. + */ +public final class OptionsExpirationsResponse + extends AbstractMarketDataResponse> { + + private final @Nullable ZonedDateTime updated; + + OptionsExpirationsResponse( + List values, + @Nullable ZonedDateTime updated, + HttpResponseEnvelope envelope, + Format format) { + super(values, envelope, format); + this.updated = updated; + } + + /** When the expiration list was last refreshed, or {@code null} when the API omitted it. */ + public @Nullable ZonedDateTime updated() { + return updated; + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java b/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java new file mode 100644 index 0000000..f68aff5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java @@ -0,0 +1,76 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +/** + * HTML facet of {@code options}. Mirrors {@link OptionsCsvResource} but returns {@link + * HtmlResponse} and requests {@code format=html}. Not exposed to consumers — the + * backend serves no HTML for data endpoints today, so the {@code asHtml()} entry point on {@link + * OptionsResource} is package-private. Kept built and ready so enabling HTML later is a one-line + * change (flip the entry point to {@code public}). + */ +public final class OptionsHtmlResource { + + private final HttpTransport transport; + private final RequestConfig config; + + OptionsHtmlResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + public CompletableFuture chainAsync(OptionsChainRequest request) { + return executeHtml(OptionsResource.chainSpec(request)); + } + + public HtmlResponse chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + return executeHtml( + OptionsResource.quoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback())); + } + + public HtmlResponse quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + return executeHtml(OptionsResource.strikesSpec(request)); + } + + public HtmlResponse strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + public CompletableFuture expirationsAsync(OptionsExpirationsRequest request) { + return executeHtml(OptionsResource.expirationsSpec(request)); + } + + public HtmlResponse expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + private CompletableFuture executeHtml(RequestSpec.Builder b) { + config.applyTo(b); + b.format(Format.HTML); + RequestSpec spec = b.build(); + return transport + .executeAsync(spec) + .thenApply( + env -> + new HtmlResponse( + new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java b/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java new file mode 100644 index 0000000..15a522d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java @@ -0,0 +1,12 @@ +package com.marketdata.sdk; + +/** + * Response for {@code options.lookup}: {@link #values()} is the resolved OCC option symbol (a + * scalar). Construct only through the resource façade. + */ +public final class OptionsLookupResponse extends AbstractMarketDataResponse { + + OptionsLookupResponse(String values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java b/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java new file mode 100644 index 0000000..8c37a0f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionQuote; +import java.util.List; + +/** + * Response for {@code options.quote} (and each per-symbol entry of {@code options.quotes}): {@link + * #values()} is the option-quote rows. Construct only through the resource façade. + */ +public final class OptionsQuotesResponse extends AbstractMarketDataResponse> { + + OptionsQuotesResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index ec6a85f..fcf7282 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -1,6 +1,10 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.module.SimpleModule; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; @@ -16,7 +20,9 @@ import com.marketdata.sdk.options.OptionsStrikes; import com.marketdata.sdk.options.OptionsStrikesRequest; import com.marketdata.sdk.options.StrikeFilter; +import java.io.IOException; import java.time.LocalDate; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -27,250 +33,180 @@ import org.jspecify.annotations.Nullable; /** - * Options endpoints documented at {@code https://api.marketdata.app/docs/api/options/}. All five - * endpoints — {@code lookup}, {@code expirations}, {@code strikes}, {@code quotes}, and {@code - * chain} — are versioned ({@code /v1/options/...}). + * Options endpoints ({@code /v1/options/...}). Reached through {@code client.options()}. * - *

Constructed once per {@link MarketDataClient}; consumers reach it through {@code - * client.options()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. + *

The resource is an immutable configured value (ADR / resource-architecture §1.3): the + * universal-parameter setters ({@link #dateFormat}, {@link #mode}, {@link #limit}, {@link #offset}, + * {@link #columns}) each return a configured copy, so "configure once, call many" works and the + * config carries into the {@link #asCsv()} facet. Every endpoint returns a named {@link + * MarketDataResponse} whose {@link MarketDataResponse#values()} is the flat payload. * - *

Every endpoint returns a {@link Response} carrying both the typed model and the raw body so - * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without - * the resource caring about format choice. + *

Constructor is package-private (ADR-007) — consumers cannot instantiate. */ public final class OptionsResource { private final HttpTransport transport; private final JsonResponseParser parser; + private final RequestConfig config; + /** Client-facing constructor: registers the wire-format module once, starts with empty config. */ OptionsResource(HttpTransport transport, JsonResponseParser parser) { + this(transport, parser, RequestConfig.empty()); + parser.registerModule(wireFormatModule()); + } + + private OptionsResource( + HttpTransport transport, JsonResponseParser parser, RequestConfig config) { this.transport = transport; this.parser = parser; - parser.registerModule(wireFormatModule()); + this.config = config; + } + + // ---------- universal parameters (type-preserving + columns) ---------- + + /** Returns a copy that requests {@code dateformat} on every subsequent call. */ + public OptionsResource dateFormat(DateFormat dateFormat) { + return new OptionsResource(transport, parser, config.withDateFormat(dateFormat)); } /** - * Build the Jackson module that maps this resource's response records to their custom - * deserializers. Each call returns a fresh {@link SimpleModule}; tests that need the same wiring - * without constructing a full resource can register this directly on a bare parser. + * Returns a copy with the data-freshness {@code mode} (cached honored only by quote endpoints). */ - static SimpleModule wireFormatModule() { - SimpleModule m = new SimpleModule("marketdata-options"); - m.addDeserializer(OptionsLookup.class, new OptionsLookupDeserializer()); - m.addDeserializer(OptionsExpirations.class, new OptionsExpirationsDeserializer()); - m.addDeserializer(OptionsStrikes.class, new OptionsStrikesDeserializer()); - m.addDeserializer(OptionsQuotes.class, optionRowsDeserializer(OptionsQuotes::new)); - m.addDeserializer(OptionsChain.class, optionRowsDeserializer(OptionsChain::new)); - return m; + public OptionsResource mode(Mode mode) { + return new OptionsResource(transport, parser, config.withMode(mode)); } - /** Column list for the shared {@code OptionQuote} parallel-arrays row, used by both endpoints. */ - private static final List OPTION_ROW_FIELDS = - List.of( - "optionSymbol", - "underlying", - "expiration", - "side", - "strike", - "firstTraded", - "dte", - "updated", - "bid", - "bidSize", - "mid", - "ask", - "askSize", - "last", - "openInterest", - "volume", - "inTheMoney", - "intrinsicValue", - "extrinsicValue", - "underlyingPrice", - "iv", - "delta", - "gamma", - "theta", - "vega"); + /** Returns a copy with the pagination {@code limit}. */ + public OptionsResource limit(int limit) { + return new OptionsResource(transport, parser, config.withLimit(limit)); + } - /** - * Optional columns on the option row. {@code rho} is part of the documented schema but several - * feeds omit it (the API's own fixtures don't carry it); declaring it optional lets a response - * without rho decode cleanly to {@link OptionQuote#rho()} == {@code null} instead of raising a - * {@link com.marketdata.sdk.exception.ParseError} on a missing required column. - */ - private static final List OPTION_OPTIONAL_ROW_FIELDS = List.of("rho"); + /** Returns a copy with the pagination {@code offset}. */ + public OptionsResource offset(int offset) { + return new OptionsResource(transport, parser, config.withOffset(offset)); + } /** - * Shared parallel-arrays deserializer that maps the API's option row into {@link OptionQuote}. - * Reused by both {@link OptionsQuotes} and {@link OptionsChain} since they emit the same - * per-contract schema; only the container record (and the semantic of how many rows come back) - * differs. + * Returns a copy that projects the response to the given columns (wire field names). Fields not + * requested decode to {@code null}; a requested column the API fails to return surfaces as a + * {@link com.marketdata.sdk.exception.ParseError} rather than a silent null. */ - private static JsonDeserializer optionRowsDeserializer( - Function, T> wrapper) { - return ParallelArrays.listDeserializer( - OPTION_ROW_FIELDS, - OPTION_OPTIONAL_ROW_FIELDS, - row -> - new OptionQuote( - row.text("optionSymbol"), - row.text("underlying"), - MarketDataDates.parseTimestampField(null, row.node("expiration"), "expiration"), - row.text("side"), - row.dbl("strike"), - MarketDataDates.parseTimestampField(null, row.node("firstTraded"), "firstTraded"), - (int) row.lng("dte"), - MarketDataDates.parseTimestampField(null, row.node("updated"), "updated"), - row.dbl("bid"), - row.lng("bidSize"), - row.dbl("mid"), - row.dbl("ask"), - row.lng("askSize"), - row.dbl("last"), - row.lng("openInterest"), - row.lng("volume"), - row.bool("inTheMoney"), - row.dbl("intrinsicValue"), - row.dbl("extrinsicValue"), - row.dbl("underlyingPrice"), - row.dblOrNull("iv"), - row.dblOrNull("delta"), - row.dblOrNull("gamma"), - row.dblOrNull("theta"), - row.dblOrNull("vega"), - row.dblOrNull("rho")), - wrapper); + public OptionsResource columns(String... columns) { + return new OptionsResource(transport, parser, config.withColumns(List.of(columns))); + } + + // ---------- format facet ---------- + + /** A CSV-flavored view of this resource (carrying the same universal-param config). */ + public OptionsCsvResource asCsv() { + return new OptionsCsvResource(transport, config); } /** - * Async: convert a human-readable option description ({@code "AAPL 7/26/23 $200 Call"}) into a - * well-formed OCC symbol ({@code "AAPL230726C00200000"}). The request's {@code userInput} is - * URL-encoded per-segment so spaces, {@code $}, and other reserved characters travel safely - * without losing the natural {@code /} separators in dates like {@code 7/26/23}. + * HTML facet — built but not exposed to consumers (the backend returns no HTML for any data + * endpoint today). Package-private so it can be exercised by tests; flip to {@code public} when + * the server supports {@code format=html}. */ - public CompletableFuture> lookupAsync(OptionsLookupRequest request) { - RequestSpec spec = - RequestSpec.get("options/lookup/" + PathSegments.encode(request.userInput())).build(); - return executeAndWrap(spec, OptionsLookup.class); + OptionsHtmlResource asHtml() { + return new OptionsHtmlResource(transport, config); + } + + // ---------- endpoints (typed) ---------- + + /** Async: resolve a human-readable option description into an OCC symbol. */ + public CompletableFuture lookupAsync(OptionsLookupRequest request) { + // lookup carries no universal params (no GLOBAL_PARAMS on the backend). + RequestSpec spec = lookupSpec(request).build(); + return execute( + spec, + OptionsLookup.class, + (d, env, fmt) -> new OptionsLookupResponse(d.optionSymbol(), env, fmt)); } /** Sync wrapper for {@link #lookupAsync(OptionsLookupRequest)}. */ - public Response lookup(OptionsLookupRequest request) { + public OptionsLookupResponse lookup(OptionsLookupRequest request) { return transport.joinSync(lookupAsync(request)); } - /** - * Async: fetch the available option-expiration dates for the request's underlying. The optional - * {@code strike} and {@code date} filters narrow the result. - * - *

The §3 universal {@code dateformat} parameter is left to the API's default ({@code - * timestamp}); the typed {@link OptionsExpirations#data} surface decodes whichever format the API - * returns via {@link MarketDataDates#parseDateField}. Consumers that want a specific wire format - * for {@link Response#rawBody()} / {@link Response#saveToFile} access pass it explicitly once the - * universal-parameters overload lands (follow-up). - */ - public CompletableFuture> expirationsAsync( + /** Async: fetch the available option-expiration dates for the request's underlying. */ + public CompletableFuture expirationsAsync( OptionsExpirationsRequest request) { - RequestSpec.Builder b = - RequestSpec.get("options/expirations/" + PathSegments.encode(request.symbol())); - if (request.strike() != null) { - b.query("strike", request.strike()); - } - if (request.date() != null) { - b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); - } - return executeAndWrap(b.build(), OptionsExpirations.class); + RequestSpec.Builder b = expirationsSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsExpirations.class, + (d, env, fmt) -> new OptionsExpirationsResponse(d.expirations(), d.updated(), env, fmt)); } /** Sync wrapper for {@link #expirationsAsync(OptionsExpirationsRequest)}. */ - public Response expirations(OptionsExpirationsRequest request) { + public OptionsExpirationsResponse expirations(OptionsExpirationsRequest request) { return transport.joinSync(expirationsAsync(request)); } - /** - * Async: fetch the strike prices available for each expiration on the request's underlying. - * Optional filters: {@code expiration} returns strikes only for that expiration date, {@code - * date} fetches the historical table as it stood on a previous trading day. - * - *

The wire-format is unusual — one top-level key per expiration date plus {@code s} / {@code - * updated} metadata — and the deserializer accepts only the API's literal ISO date keys, not the - * §3 {@code dateformat} variants (the keys themselves cannot vary: the backend always emits - * {@code str(date)}). The {@code updated} field does honor {@code dateformat}. - */ - public CompletableFuture> strikesAsync(OptionsStrikesRequest request) { - RequestSpec.Builder b = - RequestSpec.get("options/strikes/" + PathSegments.encode(request.symbol())); - if (request.expiration() != null) { - b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(request.expiration())); - } - if (request.date() != null) { - b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); - } - return executeAndWrap(b.build(), OptionsStrikes.class); + /** Async: fetch the strike prices available for each expiration on the request's underlying. */ + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + RequestSpec.Builder b = strikesSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsStrikes.class, + (d, env, fmt) -> new OptionsStrikesResponse(d.expirations(), d.updated(), env, fmt)); } /** Sync wrapper for {@link #strikesAsync(OptionsStrikesRequest)}. */ - public Response strikes(OptionsStrikesRequest request) { + public OptionsStrikesResponse strikes(OptionsStrikesRequest request) { return transport.joinSync(strikesAsync(request)); } - /** - * Async: fetch the current (or historical) quote for a single OCC option symbol. The - * parallel-arrays wire-format still applies — typically a single row — and is decoded into {@link - * OptionsQuotes#quotes}. - * - *

For multiple contracts use {@link #quotesAsync(OptionsQuotesRequest)} — the multi-symbol - * form fans out one HTTP call per symbol concurrently through the SDK's 50-permit semaphore and - * returns a per-symbol map. - */ - public CompletableFuture> quoteAsync(OptionsQuoteRequest request) { - return executeAndWrap( - buildQuoteSpec( + /** Async: fetch the current (or historical) quote for a single OCC option symbol. */ + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + RequestSpec.Builder b = + quoteSpec( request.optionSymbol(), request.date(), request.from(), request.to(), - request.countback()), - OptionsQuotes.class); + request.countback()); + config.applyTo(b); + return execute( + b.build(), + OptionsQuotes.class, + (d, env, fmt) -> new OptionsQuotesResponse(d.quotes(), env, fmt)); } /** Sync wrapper for {@link #quoteAsync(OptionsQuoteRequest)}. */ - public Response quote(OptionsQuoteRequest request) { + public OptionsQuotesResponse quote(OptionsQuoteRequest request) { return transport.joinSync(quoteAsync(request)); } /** - * Async: fetch quotes for multiple OCC option symbols concurrently. One HTTP request is fired per - * symbol — the API path takes a single optionSymbol so comma-separated bulk isn't actually - * supported by the backend regardless of what the docstring says (verified by reading the - * handler). All requests share the same optional {@code date}/{@code from}/{@code to}/{@code - * countback} filters. - * - *

Returns a {@code Map>} keyed by the original symbol input - * (insertion order preserved) so the consumer sees per-symbol {@link Response} metadata — {@code - * statusCode()}, {@code isNoData()}, {@code rawBody()}, {@code requestId()}. The map's future - * completes exceptionally if any single request fails (network error, ParseError on a malformed - * body, a {@code 5xx} after retries) — fail-fast semantics so partial-success scenarios are - * explicit. + * Async: fetch quotes for multiple OCC option symbols concurrently (one request per symbol — the + * backend path is single-symbol). Returns a per-symbol map (insertion order preserved); the + * future completes exceptionally if any single request fails (fail-fast). */ - public CompletableFuture>> quotesAsync( + public CompletableFuture> quotesAsync( OptionsQuotesRequest request) { List symbols = request.optionSymbols(); - List>>> futures = + List>> futures = new ArrayList<>(symbols.size()); for (String symbol : symbols) { - RequestSpec spec = - buildQuoteSpec(symbol, request.date(), request.from(), request.to(), request.countback()); + RequestSpec.Builder b = + quoteSpec(symbol, request.date(), request.from(), request.to(), request.countback()); + config.applyTo(b); futures.add( - executeAndWrap(spec, OptionsQuotes.class).thenApply(resp -> Map.entry(symbol, resp))); + execute( + b.build(), + OptionsQuotes.class, + (d, env, fmt) -> new OptionsQuotesResponse(d.quotes(), env, fmt)) + .thenApply(resp -> Map.entry(symbol, resp))); } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .thenApply( unused -> { - Map> result = new LinkedHashMap<>(); - for (CompletableFuture>> f : futures) { - Map.Entry> entry = f.join(); + Map result = new LinkedHashMap<>(); + for (CompletableFuture> f : futures) { + Map.Entry entry = f.join(); result.put(entry.getKey(), entry.getValue()); } return result; @@ -278,31 +214,101 @@ public CompletableFuture>> quotesAsync( } /** Sync wrapper for {@link #quotesAsync(OptionsQuotesRequest)}. */ - public Map> quotes(OptionsQuotesRequest request) { + public Map quotes(OptionsQuotesRequest request) { return transport.joinSync(quotesAsync(request)); } - /** - * Async: fetch the full option chain for the request's underlying. The chain endpoint exposes the - * richest filter surface in the API; see {@link OptionsChainRequest} for the typed parameter set, - * including sealed {@link ExpirationFilter} and {@link StrikeFilter} for the mutually-exclusive - * groups. - */ - public CompletableFuture> chainAsync(OptionsChainRequest request) { - RequestSpec.Builder b = - RequestSpec.get("options/chain/" + PathSegments.encode(request.symbol())); - applyChainParams(b, request); - return executeAndWrap(b.build(), OptionsChain.class); + /** Async: fetch the full option chain for the request's underlying. */ + public CompletableFuture chainAsync(OptionsChainRequest request) { + RequestSpec.Builder b = chainSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsChain.class, + (d, env, fmt) -> new OptionsChainResponse(d.chain(), env, fmt)); } /** Sync wrapper for {@link #chainAsync(OptionsChainRequest)}. */ - public Response chain(OptionsChainRequest request) { + public OptionsChainResponse chain(OptionsChainRequest request) { return transport.joinSync(chainAsync(request)); } - // ---------- internal helpers ---------- + // ---------- execute ---------- + + private CompletableFuture execute( + RequestSpec spec, Class decodeType, ResponseFactory factory) { + return transport + .executeAsync(spec) + .thenApply( + env -> + factory.create( + parser.parse(env, decodeType, config.columns()), env, spec.format())); + } + + @FunctionalInterface + interface ResponseFactory { + R create(D decoded, HttpResponseEnvelope envelope, Format format); + } + + // ---------- request spec builders (package-private static — reused by the facets) ---------- + + static RequestSpec.Builder lookupSpec(OptionsLookupRequest request) { + return RequestSpec.get("options/lookup/" + PathSegments.encode(request.userInput())); + } + + static RequestSpec.Builder expirationsSpec(OptionsExpirationsRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/expirations/" + PathSegments.encode(request.symbol())); + if (request.strike() != null) { + b.query("strike", request.strike()); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return b; + } + + static RequestSpec.Builder strikesSpec(OptionsStrikesRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/strikes/" + PathSegments.encode(request.symbol())); + if (request.expiration() != null) { + b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(request.expiration())); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return b; + } + + static RequestSpec.Builder quoteSpec( + String optionSymbol, + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to, + @Nullable Integer countback) { + RequestSpec.Builder b = RequestSpec.get("options/quotes/" + PathSegments.encode(optionSymbol)); + if (date != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(date)); + } + if (from != null) { + b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(from)); + } + if (to != null) { + b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(to)); + } + if (countback != null) { + b.query("countback", countback); + } + return b; + } + + static RequestSpec.Builder chainSpec(OptionsChainRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/chain/" + PathSegments.encode(request.symbol())); + applyChainParams(b, request); + return b; + } - /** Translates a fully-built {@link OptionsChainRequest} into query parameters. */ private static void applyChainParams(RequestSpec.Builder b, OptionsChainRequest r) { if (r.expirationFilter() != null) { applyExpirationFilter(b, r.expirationFilter()); @@ -383,9 +389,6 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte } else if (f instanceof ExpirationFilter.All) { b.query("expiration", "all"); } else { - // ExpirationFilter is sealed — this is unreachable today. It exists so that adding a new - // variant without a matching branch here fails loudly instead of silently dropping the - // filter. Mirrors strikeFilterWireValue's exhaustiveness guard. throw new IllegalStateException("unhandled ExpirationFilter variant: " + f); } } @@ -401,10 +404,6 @@ private static String strikeFilterWireValue(StrikeFilter f) { throw new IllegalStateException("unhandled StrikeFilter variant: " + f); } - /** - * Render a strike price without trailing zeros — the API accepts both {@code 150} and {@code - * 150.0}. - */ private static String formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); @@ -412,31 +411,166 @@ private static String formatStrike(double v) { return Double.toString(v); } - private static RequestSpec buildQuoteSpec( - String optionSymbol, - @Nullable LocalDate date, - @Nullable LocalDate from, - @Nullable LocalDate to, - @Nullable Integer countback) { - RequestSpec.Builder b = RequestSpec.get("options/quotes/" + PathSegments.encode(optionSymbol)); - if (date != null) { - b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(date)); - } - if (from != null) { - b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(from)); - } - if (to != null) { - b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(to)); - } - if (countback != null) { - b.query("countback", countback); - } - return b.build(); + // ---------- wire-format module ---------- + + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-options"); + m.addDeserializer(OptionsLookup.class, new OptionsLookupDeserializer()); + m.addDeserializer(OptionsExpirations.class, new OptionsExpirationsDeserializer()); + m.addDeserializer(OptionsStrikes.class, new OptionsStrikesDeserializer()); + m.addDeserializer(OptionsQuotes.class, optionRowsDeserializer(OptionsQuotes::new)); + m.addDeserializer(OptionsChain.class, optionRowsDeserializer(OptionsChain::new)); + return m; } - private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { - return transport - .executeAsync(spec) - .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + /** + * Structural columns that must be present whenever they're requested. A {@code columns} + * projection may legitimately drop any of these (→ {@code null}); but if the consumer asked for + * one (or used no {@code columns} filter at all) and the API omitted it, that's an anomaly, not a + * projection — Option A turns it into a {@link com.marketdata.sdk.exception.ParseError}. The + * model-derived values ({@code iv} + the greeks) are excluded: they may legitimately be null even + * when present. + */ + private static final List OPTION_REQUIRED_FIELDS = + List.of( + "optionSymbol", + "underlying", + "expiration", + "side", + "strike", + "firstTraded", + "dte", + "updated", + "bid", + "bidSize", + "mid", + "ask", + "askSize", + "last", + "openInterest", + "volume", + "inTheMoney", + "intrinsicValue", + "extrinsicValue", + "underlyingPrice"); + + /** Every column on the option row — all optional at the wire level (projection-friendly). */ + private static final List OPTION_ALL_FIELDS = + List.of( + "optionSymbol", + "underlying", + "expiration", + "side", + "strike", + "firstTraded", + "dte", + "updated", + "bid", + "bidSize", + "mid", + "ask", + "askSize", + "last", + "openInterest", + "volume", + "inTheMoney", + "intrinsicValue", + "extrinsicValue", + "underlyingPrice", + "iv", + "delta", + "gamma", + "theta", + "vega", + "rho"); + + /** + * Deserializer for the option-quote rows. Every column is optional at the wire level so a {@code + * columns} projection decodes cleanly to nulls; the strict guarantee is restored by {@link + * #validateRequestedColumns} (Option A), which fails loudly if a requested required + * column was omitted. + */ + private static JsonDeserializer optionRowsDeserializer( + Function, T> wrapper) { + return new JsonDeserializer<>() { + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + List rows = + ParallelArrays.zip( + p, root, List.of(), OPTION_ALL_FIELDS, OptionsResource::buildOptionRow); + validateRequestedColumns(p, root, rows, ctxt); + return wrapper.apply(rows); + } + }; + } + + /** Builds one {@link OptionQuote} from a row, reading every column leniently (absent → null). */ + private static OptionQuote buildOptionRow(ParallelArrays.Row row) throws IOException { + return new OptionQuote( + row.textOrNull("optionSymbol"), + row.textOrNull("underlying"), + dateOrNull(row, "expiration"), + row.textOrNull("side"), + row.dblOrNull("strike"), + dateOrNull(row, "firstTraded"), + intOrNull(row.lngOrNull("dte")), + dateOrNull(row, "updated"), + row.dblOrNull("bid"), + row.lngOrNull("bidSize"), + row.dblOrNull("mid"), + row.dblOrNull("ask"), + row.lngOrNull("askSize"), + row.dblOrNull("last"), + row.lngOrNull("openInterest"), + row.lngOrNull("volume"), + row.boolOrNull("inTheMoney"), + row.dblOrNull("intrinsicValue"), + row.dblOrNull("extrinsicValue"), + row.dblOrNull("underlyingPrice"), + row.dblOrNull("iv"), + row.dblOrNull("delta"), + row.dblOrNull("gamma"), + row.dblOrNull("theta"), + row.dblOrNull("vega"), + row.dblOrNull("rho")); + } + + private static @Nullable ZonedDateTime dateOrNull(ParallelArrays.Row row, String field) + throws IOException { + JsonNode n = row.nodeOrNull(field); + return n == null ? null : MarketDataDates.parseTimestampField(null, n, field); + } + + private static @Nullable Integer intOrNull(@Nullable Long v) { + return v == null ? null : v.intValue(); + } + + /** + * Option A: for every required column that the consumer asked for (explicitly via {@code + * columns}, or implicitly by not projecting at all), verify the API actually returned it. A + * requested-but- absent required column throws, so the {@code null} a consumer sees only ever + * means "I projected it away" — never "the backend silently dropped it". + */ + private static void validateRequestedColumns( + JsonParser p, JsonNode root, List rows, DeserializationContext ctxt) + throws JsonMappingException { + if (rows.isEmpty()) { + return; // no_data / empty response — no projection to validate + } + Object attr = ctxt.getAttribute(JsonResponseParser.REQUESTED_COLUMNS_ATTR); + List requested = + attr instanceof List list ? list.stream().map(String::valueOf).toList() : List.of(); + for (String field : OPTION_REQUIRED_FIELDS) { + boolean asked = requested.isEmpty() || requested.contains(field); + if (asked && !root.has(field)) { + throw new JsonMappingException( + p, + "Response is missing requested required column '" + + field + + "' — it was requested (or no columns filter was applied) but the API did not" + + " return it"); + } + } } } diff --git a/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java b/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java new file mode 100644 index 0000000..6f91a6b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.ExpirationStrikes; +import java.time.ZonedDateTime; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Response for {@code options.strikes}: {@link #values()} is the per-expiration strike lists. The + * endpoint also carries an {@code updated} timestamp, exposed via {@link #updated()} ({@code null} + * on the {@code no_data} envelope). Construct only through the resource façade. + */ +public final class OptionsStrikesResponse + extends AbstractMarketDataResponse> { + + private final @Nullable ZonedDateTime updated; + + OptionsStrikesResponse( + List values, + @Nullable ZonedDateTime updated, + HttpResponseEnvelope envelope, + Format format) { + super(values, envelope, format); + this.updated = updated; + } + + /** When the strike table was last refreshed, or {@code null} when the API omitted it. */ + public @Nullable ZonedDateTime updated() { + return updated; + } +} diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index 54ed4ed..5159908 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -59,8 +59,9 @@ private ParallelArrays() {} * see {@code HttpTransport.routeAndEnvelope}) for "the query has no results"; the data * arrays are deliberately omitted in that case. Returning an empty list lets the resource * wrap it in its container type ({@code new ApiStatus(emptyList)}, etc.) so the consumer - * reaches {@link Response#isNoData()} and {@link Response#data()} normally instead of - * hitting a spurious {@code "missing field"} error from the field-validation loop. + * reaches {@link MarketDataResponse#isNoData()} and {@link MarketDataResponse#values()} + * normally instead of hitting a spurious {@code "missing field"} error from the + * field-validation loop. *

  • Any other status (typically {@code "ok"}) → normal field validation. * * @@ -199,7 +200,7 @@ static JsonDeserializer listDeserializer( List optionalFields, RowBuilder rowBuilder, Function, T> wrapper) { - return new JsonDeserializer() { + return new JsonDeserializer<>() { @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode root = p.readValueAsTree(); @@ -243,11 +244,23 @@ interface Row { */ @Nullable Double dblOrNull(String field) throws JsonMappingException; + /** Lenient string accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable String textOrNull(String field) throws JsonMappingException; + + /** Lenient long accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable Long lngOrNull(String field) throws JsonMappingException; + + /** Lenient boolean accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable Boolean boolOrNull(String field) throws JsonMappingException; + /** * Raw access for custom conversions (e.g. nested objects or non-trivial date parsing). Returns * the node verbatim — the caller decides how to validate. */ JsonNode node(String field); + + /** Like {@link #node} but {@code null} when the column is absent or the cell is null. */ + @Nullable JsonNode nodeOrNull(String field); } private static final class IndexedRow implements Row { @@ -310,11 +323,66 @@ public long lng(String field) throws JsonMappingException { return cell.asDouble(); } + @Override + public @Nullable String textOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isTextual()) { + throw typeMismatch(field, "string", cell); + } + return cell.asText(); + } + + @Override + public @Nullable Long lngOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asLong(); + } + + @Override + public @Nullable Boolean boolOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isBoolean()) { + throw typeMismatch(field, "boolean", cell); + } + return cell.asBoolean(); + } + @Override public JsonNode node(String field) { return cell(field); } + @Override + public @Nullable JsonNode nodeOrNull(String field) { + return cellOrNull(field); + } + + /** + * The cell for {@code field}, or {@code null} when the column is absent or the cell is null. + */ + private @Nullable JsonNode cellOrNull(String field) { + if (!arrays.containsKey(field)) { + return null; + } + JsonNode cell = arrays.get(field).get(index); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + return null; + } + return cell; + } + private JsonNode cell(String field) { JsonNode array = arrays.get(field); if (array == null) { diff --git a/src/main/java/com/marketdata/sdk/RequestConfig.java b/src/main/java/com/marketdata/sdk/RequestConfig.java new file mode 100644 index 0000000..e07f741 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestConfig.java @@ -0,0 +1,85 @@ +package com.marketdata.sdk; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Immutable bundle of the universal query parameters (§3) a resource carries across its endpoints. + * A resource is a configured value: each {@code with*} method returns a copy, and {@link + * #applyTo(RequestSpec.Builder)} writes the accumulated params onto a request being built. + * + *

    Type-preserving params ({@code dateFormat}, {@code mode}, {@code limit}, {@code offset}) and + * {@code columns} are valid on the typed path; {@code human}/{@code headers} only cohere with the + * CSV facet (they reshape the output). The holder carries them all; each facet exposes the setters + * that make sense there. + */ +record RequestConfig( + @Nullable DateFormat dateFormat, + @Nullable Mode mode, + @Nullable Integer limit, + @Nullable Integer offset, + List columns, + @Nullable Boolean human, + @Nullable Boolean headers) { + + RequestConfig { + columns = List.copyOf(columns); + } + + static RequestConfig empty() { + return new RequestConfig(null, null, null, null, List.of(), null, null); + } + + RequestConfig withDateFormat(DateFormat v) { + return new RequestConfig(v, mode, limit, offset, columns, human, headers); + } + + RequestConfig withMode(Mode v) { + return new RequestConfig(dateFormat, v, limit, offset, columns, human, headers); + } + + RequestConfig withLimit(int v) { + return new RequestConfig(dateFormat, mode, v, offset, columns, human, headers); + } + + RequestConfig withOffset(int v) { + return new RequestConfig(dateFormat, mode, limit, v, columns, human, headers); + } + + RequestConfig withColumns(List v) { + return new RequestConfig(dateFormat, mode, limit, offset, v, human, headers); + } + + RequestConfig withHuman(boolean v) { + return new RequestConfig(dateFormat, mode, limit, offset, columns, v, headers); + } + + RequestConfig withHeaders(boolean v) { + return new RequestConfig(dateFormat, mode, limit, offset, columns, human, v); + } + + /** Writes every set universal param onto a request builder. */ + void applyTo(RequestSpec.Builder b) { + if (dateFormat != null) { + b.dateformat(dateFormat); + } + if (mode != null) { + b.mode(mode); + } + if (limit != null) { + b.limit(limit); + } + if (offset != null) { + b.offset(offset); + } + if (!columns.isEmpty()) { + b.columns(columns); + } + if (human != null) { + b.human(human); + } + if (headers != null) { + b.headers(headers); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java deleted file mode 100644 index 3d254dc..0000000 --- a/src/main/java/com/marketdata/sdk/Response.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.marketdata.sdk; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -/** - * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, - * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), - * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} - * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. - * - *

    The {@link Format} enum is package-private — consumers query format via the boolean accessors - * rather than importing the enum. That keeps {@code Format} free to grow new values without - * breaking compiled consumer code (a {@code switch (response.format())} would otherwise be a - * source-compatibility hazard). - * - *

    Immutable. {@link #rawBody()} returns a defensive copy on every call. - * - * @param the typed deserialization of {@link #rawBody()}. - */ -public final class Response { - - private final T data; - private final byte[] rawBody; - private final Format format; - private final int statusCode; - private final @Nullable String requestId; - private final URI requestUrl; - - private Response( - T data, - byte[] rawBody, - Format format, - int statusCode, - @Nullable String requestId, - URI requestUrl) { - this.data = Objects.requireNonNull(data, "data"); - this.rawBody = Objects.requireNonNull(rawBody, "rawBody").clone(); - this.format = Objects.requireNonNull(format, "format"); - this.statusCode = statusCode; - this.requestId = requestId; - this.requestUrl = Objects.requireNonNull(requestUrl, "requestUrl"); - } - - /** - * Package-private factory used by resource façades. Resources parse the envelope's body to a - * typed {@code T}, then wrap. - */ - static Response wrap(T data, HttpResponseEnvelope envelope, Format format) { - return new Response<>( - data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); - } - - /** The typed deserialization. Never {@code null}. */ - public T data() { - return data; - } - - /** - * Defensive copy of the raw response bytes. Mutating the result does not affect this response. - */ - public byte[] rawBody() { - return rawBody.clone(); - } - - /** HTTP status code (currently one of 200, 203, 404). */ - public int statusCode() { - return statusCode; - } - - /** Absolute URL the response came from. */ - public URI requestUrl() { - return requestUrl; - } - - /** - * Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when the response did - * not carry one — useful when correlating with the support team. Matches the nullability shape of - * {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can branch - * the same way regardless of which surface carries the id. - */ - public @Nullable String requestId() { - return requestId; - } - - public boolean isJson() { - return format == Format.JSON; - } - - public boolean isCsv() { - return format == Format.CSV; - } - - /** - * Whether the response body is HTML — typically a misrouted request that landed on the API's - * web-server tier (a marketing or error page) rather than the API tier. Consumers can use this to - * short-circuit JSON-shaped parsing and log the {@link #rawBody()} for diagnosis. - */ - public boolean isHtml() { - return format == Format.HTML; - } - - /** - * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 - * for that envelope (it is a successful "we have nothing for that query", not an error), so we - * gate on the status code rather than parsing the body. - */ - public boolean isNoData() { - return statusCode == 404; - } - - /** - * Write the raw body verbatim to {@code path}, creating or overwriting it. The on-disk content - * matches what the server sent — if you requested {@code ?format=csv}, you get CSV. Errors are - * rewrapped as {@link UncheckedIOException} so {@code saveToFile} fits naturally inside a fluent - * call chain. - */ - public void saveToFile(Path path) { - try { - Files.write(path, rawBody); - } catch (IOException e) { - throw new UncheckedIOException("Failed to write response body to " + path, e); - } - } - - /** - * Log-safe representation: status, format, byte count, and the request URL with the query string - * redacted (§16 — token, account_id, symbol queries must not persist through {@code toString}). - * {@code data} is intentionally omitted: consumers that need the payload have {@link #data()}; - * embedding it here would let a routine {@code log.info(response)} leak a {@code RequestHeaders} - * map (Authorization, client IP) or whatever else the future resource models carry. - */ - @Override - public String toString() { - return "Response[status=" - + statusCode - + ", format=" - + format.name().toLowerCase(java.util.Locale.ROOT) - + ", bytes=" - + rawBody.length - + ", url=" - + HttpDispatcher.safeUri(requestUrl) - + "]"; - } -} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java new file mode 100644 index 0000000..6682b3c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk; + +import java.util.Map; + +/** + * Response for {@code utilities.headers}: {@link #values()} is the request headers the server + * received (sensitive values redacted server-side), as a map. Construct only through the resource + * façade. + */ +public final class UtilitiesHeadersResponse + extends AbstractMarketDataResponse> { + + UtilitiesHeadersResponse( + Map values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 2716871..3f5997d 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -15,9 +15,10 @@ *

    Constructed once per {@link MarketDataClient}; the consumer reaches it through {@code * client.utilities()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. * - *

    Every endpoint returns a {@link Response} carrying both the typed model and the raw body so - * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without - * the resource caring about format choice. + *

    Every endpoint returns a named {@link MarketDataResponse} whose {@link + * MarketDataResponse#values()} is the flat payload (the service list for {@code status}, the header + * map for {@code headers}, the {@link User} for {@code user}). These diagnostic endpoints take no + * universal parameters and have no CSV/HTML facet. */ public final class UtilitiesResource { @@ -69,13 +70,16 @@ static SimpleModule wireFormatModule() { * {@code Authorization}) redacted server-side. Useful for diagnosing auth issues from a deployed * consumer. */ - public CompletableFuture> headersAsync() { + public CompletableFuture headersAsync() { RequestSpec spec = RequestSpec.get("headers").unversioned().build(); - return executeAndWrap(spec, RequestHeaders.class); + return execute( + spec, + RequestHeaders.class, + (d, env, fmt) -> new UtilitiesHeadersResponse(d.headers(), env, fmt)); } /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ - public Response headers() { + public UtilitiesHeadersResponse headers() { return transport.joinSync(headersAsync()); } @@ -88,12 +92,13 @@ public Response headers() { * prefix), same as {@code /status/} and {@code /headers/}. Hitting {@code /v1/user/} falls * through to the global 404 handler. */ - public CompletableFuture> userAsync() { - return executeAndWrap(RequestSpec.get("user").unversioned().build(), User.class); + public CompletableFuture userAsync() { + return execute( + RequestSpec.get("user").unversioned().build(), User.class, UtilitiesUserResponse::new); } /** Sync wrapper for {@link #userAsync()}. */ - public Response user() { + public UtilitiesUserResponse user() { return transport.joinSync(userAsync()); } @@ -113,8 +118,11 @@ public Response user() { */ void validateAuth() { transport.joinSync( - executeAndWrap( - RequestSpec.get("user").unversioned().build(), RetryPolicy.noRetry(), User.class)); + execute( + RequestSpec.get("user").unversioned().build(), + RetryPolicy.noRetry(), + User.class, + UtilitiesUserResponse::new)); } /** @@ -122,28 +130,37 @@ void validateAuth() { * the API root) and public — works without a token. The server refreshes the snapshot every five * minutes; polling more often than that is wasted work. */ - public CompletableFuture> statusAsync() { + public CompletableFuture statusAsync() { RequestSpec spec = RequestSpec.get("status").unversioned().build(); - return executeAndWrap(spec, ApiStatus.class); + return execute( + spec, + ApiStatus.class, + (d, env, fmt) -> new UtilitiesStatusResponse(d.services(), env, fmt)); } /** Sync wrapper for {@link #statusAsync()}. */ - public Response status() { + public UtilitiesStatusResponse status() { return transport.joinSync(statusAsync()); } // ---------- internal helpers ---------- - private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { + private CompletableFuture execute( + RequestSpec spec, Class decodeType, ResponseFactory factory) { return transport .executeAsync(spec) - .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + .thenApply(env -> factory.create(parser.parse(env, decodeType), env, spec.format())); } - private CompletableFuture> executeAndWrap( - RequestSpec spec, RetryPolicy policy, Class type) { + private CompletableFuture execute( + RequestSpec spec, RetryPolicy policy, Class decodeType, ResponseFactory factory) { return transport .executeAsync(spec, policy) - .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + .thenApply(env -> factory.create(parser.parse(env, decodeType), env, spec.format())); + } + + @FunctionalInterface + interface ResponseFactory { + R create(D decoded, HttpResponseEnvelope envelope, Format format); } } diff --git a/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java new file mode 100644 index 0000000..b14cf24 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.ServiceStatus; +import java.util.List; + +/** + * Response for {@code utilities.status}: {@link #values()} is the per-service health snapshot. + * Construct only through the resource façade (package-private constructor, ADR-007). + */ +public final class UtilitiesStatusResponse extends AbstractMarketDataResponse> { + + UtilitiesStatusResponse( + List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java new file mode 100644 index 0000000..b16bafd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java @@ -0,0 +1,14 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.User; + +/** + * Response for {@code utilities.user}: {@link #values()} is the caller's quota / permissions + * snapshot. Construct only through the resource façade. + */ +public final class UtilitiesUserResponse extends AbstractMarketDataResponse { + + UtilitiesUserResponse(User values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/Greek.java b/src/main/java/com/marketdata/sdk/options/Greek.java new file mode 100644 index 0000000..41d93b0 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/Greek.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.options; + +/** + * The Black-Scholes greeks an option quote can carry. Used by {@link OptionQuote#presentGreeks()} + * and {@link OptionQuote#greek(Greek)} to inspect which model-derived sensitivities are present on + * a given row (they may be absent on illiquid contracts, or — for {@code rho} — omitted by the + * feed). + * + *

    Implied volatility ({@code iv}) is deliberately not here: it is a volatility, not a + * sensitivity/greek. Read it via {@link OptionQuote#iv()}. + */ +public enum Greek { + DELTA, + GAMMA, + THETA, + VEGA, + RHO +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionQuote.java b/src/main/java/com/marketdata/sdk/options/OptionQuote.java index 98d3307..a64a6e7 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionQuote.java +++ b/src/main/java/com/marketdata/sdk/options/OptionQuote.java @@ -1,6 +1,9 @@ package com.marketdata.sdk.options; import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -14,36 +17,74 @@ * America/New_York}; their wire-format may be unix, ISO-string, or spreadsheet serial per the §3 * {@code dateformat} parameter, all of which are decoded uniformly by the deserializer. * - *

    The model-derived values — implied volatility and the Black-Scholes greeks ({@code iv}, {@code - * delta}, {@code gamma}, {@code theta}, {@code vega}, {@code rho}) — are typed as nullable {@link - * Double}. On historical or illiquid rows the API legitimately returns {@code null} for them (no - * model output that day); {@code null} therefore means "not provided for this contract/row", not - * zero. The market-data fields (bid/ask/last/volume/…) stay primitive — they are always present. + *

    Every field is a nullable boxed type. This is what lets the {@code columns} universal + * parameter project the response to a subset of fields: a column the consumer did not request comes + * back {@code null}. The deserializer is still strict about requested fields — a required + * field that was asked for but is missing surfaces as a {@code ParseError} (Option A), never a + * silent null — so a {@code null} here means either "not requested (projected away)" or, for the + * model-derived values ({@code iv} and the greeks), "legitimately not provided for this row". */ public record OptionQuote( - String optionSymbol, - String underlying, - ZonedDateTime expiration, - String side, - double strike, - ZonedDateTime firstTraded, - int dte, - ZonedDateTime updated, - double bid, - long bidSize, - double mid, - double ask, - long askSize, - double last, - long openInterest, - long volume, - boolean inTheMoney, - double intrinsicValue, - double extrinsicValue, - double underlyingPrice, + @Nullable String optionSymbol, + @Nullable String underlying, + @Nullable ZonedDateTime expiration, + @Nullable String side, + @Nullable Double strike, + @Nullable ZonedDateTime firstTraded, + @Nullable Integer dte, + @Nullable ZonedDateTime updated, + @Nullable Double bid, + @Nullable Long bidSize, + @Nullable Double mid, + @Nullable Double ask, + @Nullable Long askSize, + @Nullable Double last, + @Nullable Long openInterest, + @Nullable Long volume, + @Nullable Boolean inTheMoney, + @Nullable Double intrinsicValue, + @Nullable Double extrinsicValue, + @Nullable Double underlyingPrice, @Nullable Double iv, @Nullable Double delta, @Nullable Double gamma, @Nullable Double theta, @Nullable Double vega, - @Nullable Double rho) {} + @Nullable Double rho) { + + /** + * The greeks present (non-null) on this row, as an immutable set. Empty when none were computed + * (e.g. an illiquid contract whose implied volatility couldn't be solved). Note {@code rho} is + * often absent even when the rest are present. + */ + public Set presentGreeks() { + EnumSet s = EnumSet.noneOf(Greek.class); + if (delta != null) { + s.add(Greek.DELTA); + } + if (gamma != null) { + s.add(Greek.GAMMA); + } + if (theta != null) { + s.add(Greek.THETA); + } + if (vega != null) { + s.add(Greek.VEGA); + } + if (rho != null) { + s.add(Greek.RHO); + } + return Collections.unmodifiableSet(s); + } + + /** The value of a given greek on this row, or {@code null} when that greek is absent. */ + public @Nullable Double greek(Greek g) { + return switch (g) { + case DELTA -> delta; + case GAMMA -> gamma; + case THETA -> theta; + case VEGA -> vega; + case RHO -> rho; + }; + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java index 96ccb79..0dafd3a 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java @@ -10,7 +10,7 @@ * from}/{@code to}, or {@code to}/{@code countback}). * *

    For multiple contracts use {@link OptionsQuotesRequest} — the multi-symbol API fans out one - * request per symbol concurrently and returns a {@code Map>}. + * request per symbol concurrently and returns a {@code Map}. */ public final class OptionsQuoteRequest { diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java index 6226c6c..36e46c2 100644 --- a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java @@ -10,7 +10,7 @@ * Parameters for the multi-contract form of {@code /v1/options/quotes/}. Carries a list of one or * more OCC option symbols plus the optional historical-window filters shared across them. The * resource fans out one HTTP request per symbol concurrently (via the SDK's 50-permit {@code - * AsyncSemaphore}) and returns a {@code Map>} so per-symbol status, + * AsyncSemaphore}) and returns a {@code Map} so per-symbol status, * raw body, and error envelopes stay observable. * *

    For a single contract, prefer {@link OptionsQuoteRequest} — clearer intent and one fewer map diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 0853586..950c3d0 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -6,18 +6,14 @@ import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; -import com.marketdata.sdk.options.OptionsChain; import com.marketdata.sdk.options.OptionsChainRequest; -import com.marketdata.sdk.options.OptionsExpirations; import com.marketdata.sdk.options.OptionsExpirationsRequest; -import com.marketdata.sdk.options.OptionsLookup; import com.marketdata.sdk.options.OptionsLookupRequest; import com.marketdata.sdk.options.OptionsQuoteRequest; -import com.marketdata.sdk.options.OptionsQuotes; import com.marketdata.sdk.options.OptionsQuotesRequest; -import com.marketdata.sdk.options.OptionsStrikes; import com.marketdata.sdk.options.OptionsStrikesRequest; import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; @@ -31,6 +27,7 @@ import java.time.LocalDate; import java.time.Month; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -96,9 +93,9 @@ void lookupAsyncReturnsDecodedSymbol() { CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); OptionsResource options = resourceWith(client); - OptionsLookup lookup = options.lookupAsync(OptionsLookupRequest.of("anything")).join().data(); + String lookup = options.lookupAsync(OptionsLookupRequest.of("anything")).join().values(); - assertThat(lookup.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(lookup).isEqualTo("AAPL250117C00150000"); } @Test @@ -106,8 +103,8 @@ void lookupSyncMirrorsAsync() { CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); OptionsResource options = resourceWith(client); - OptionsLookup viaSync = options.lookup(OptionsLookupRequest.of("x")).data(); - assertThat(viaSync.optionSymbol()).isEqualTo("AAPL250117C00150000"); + String viaSync = options.lookup(OptionsLookupRequest.of("x")).values(); + assertThat(viaSync).isEqualTo("AAPL250117C00150000"); } @Test @@ -115,7 +112,7 @@ void lookupResponseExposesIsJsonAndRequestUrl() { CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); OptionsResource options = resourceWith(client); - Response resp = options.lookup(OptionsLookupRequest.of("x")); + OptionsLookupResponse resp = options.lookup(OptionsLookupRequest.of("x")); assertThat(resp.isJson()).isTrue(); assertThat(resp.isCsv()).isFalse(); assertThat(resp.isHtml()).isFalse(); @@ -236,18 +233,19 @@ void expirationsDecodesEpochsToMarketMidnights() { + "}"); OptionsResource options = resourceWith(client); - OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); - assertThat(exps.expirations()) + assertThat(exps) .containsExactly( LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et), LocalDate.of(2025, Month.MARCH, 21).atStartOfDay(et)); - assertThat(exps.expirations().get(0).getZone().getId()).isEqualTo("America/New_York"); - assertThat(exps.updated()).isNotNull(); - assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); - assertThat(exps.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); - assertThat(exps.updated().getHour()).isEqualTo(19); + assertThat(exps.get(0).getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(expResp.updated().getHour()).isEqualTo(19); } @Test @@ -256,8 +254,9 @@ void expirationsSyncMirrorsAsync() { okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); OptionsResource options = resourceWith(client); - OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); - assertThat(exps.expirations()).hasSize(1); + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps).hasSize(1); } // ---------- expirations: envelope handling ---------- @@ -267,9 +266,10 @@ void expirationsNoDataEnvelopeYieldsEmptyList() { CapturingClient client = okWith("{\"s\":\"no_data\"}"); OptionsResource options = resourceWith(client); - OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("NOPE")).data(); - assertThat(exps.expirations()).isEmpty(); - assertThat(exps.updated()).isNull(); + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("NOPE")); + List exps = expResp.values(); + assertThat(exps).isEmpty(); + assertThat(expResp.updated()).isNull(); } @Test @@ -303,15 +303,16 @@ void expirationsAcceptsTimestampStringFormat() { + "\"updated\":\"2025-01-16 19:00:00 -05:00\"}"); OptionsResource options = resourceWith(client); - OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); - assertThat(exps.expirations()) + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps) .containsExactly( LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); - assertThat(exps.updated()).isNotNull(); - assertThat(exps.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); - assertThat(exps.updated().getHour()).isEqualTo(19); - assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(expResp.updated().getHour()).isEqualTo(19); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); } @Test @@ -322,13 +323,14 @@ void expirationsAcceptsSpreadsheetSerialFormat() { okWith("{\"s\":\"ok\",\"expirations\":[45674,45709],\"updated\":45673.79166667}"); OptionsResource options = resourceWith(client); - OptionsExpirations exps = options.expirations(OptionsExpirationsRequest.of("AAPL")).data(); - assertThat(exps.expirations()) + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps) .containsExactly( LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); - assertThat(exps.updated()).isNotNull(); - assertThat(exps.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); } @Test @@ -398,19 +400,20 @@ void strikesDecodesMultipleExpirations() { + "\"2025-02-21\":[135.0,140.0,145.0,150.0]}"); OptionsResource options = resourceWith(client); - OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("AAPL")); + List strikes = resp.values(); - assertThat(strikes.expirations()).hasSize(2); - ExpirationStrikes first = strikes.expirations().get(0); + assertThat(strikes).hasSize(2); + ExpirationStrikes first = strikes.get(0); assertThat(first.expiration()) .isEqualTo(LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et)); assertThat(first.strikes()).containsExactly(140.0, 145.0, 150.0); - ExpirationStrikes second = strikes.expirations().get(1); + ExpirationStrikes second = strikes.get(1); assertThat(second.expiration()) .isEqualTo(LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); assertThat(second.strikes()).containsExactly(135.0, 140.0, 145.0, 150.0); - assertThat(strikes.updated()).isNotNull(); - assertThat(strikes.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().getZone().getId()).isEqualTo("America/New_York"); } @Test @@ -421,10 +424,11 @@ void strikesAcceptsTimestampStringFormatForUpdated() { okWith("{\"s\":\"ok\",\"updated\":\"2025-01-16 19:00:00 -05:00\",\"2025-01-17\":[150.0]}"); OptionsResource options = resourceWith(client); - OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); - assertThat(strikes.expirations()).hasSize(1); - assertThat(strikes.updated()).isNotNull(); - assertThat(strikes.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("AAPL")); + List strikes = resp.values(); + assertThat(strikes).hasSize(1); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); } // ---------- strikes: envelope handling ---------- @@ -436,9 +440,10 @@ void strikesNoDataEnvelopeYieldsEmptyList() { CapturingClient client = okWith("{\"s\":\"no_data\",\"nextTime\":null,\"prevTime\":null}"); OptionsResource options = resourceWith(client); - OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("BOGUS")).data(); - assertThat(strikes.expirations()).isEmpty(); - assertThat(strikes.updated()).isNull(); + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("BOGUS")); + List strikes = resp.values(); + assertThat(strikes).isEmpty(); + assertThat(resp.updated()).isNull(); } @Test @@ -499,8 +504,8 @@ void strikesSyncMirrorsAsync() { CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[150.0]}"); OptionsResource options = resourceWith(client); - OptionsStrikes strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).data(); - assertThat(strikes.expirations()).hasSize(1); + List strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).values(); + assertThat(strikes).hasSize(1); } // ---------- quote (singular): URL & params ---------- @@ -638,10 +643,11 @@ void quoteDecodesAllFields() { CapturingClient client = okWith(CANNED_QUOTE_BODY); OptionsResource options = resourceWith(client); - OptionsQuotes response = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data(); + List response = + options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values(); - assertThat(response.quotes()).hasSize(1); - OptionQuote q = response.quotes().get(0); + assertThat(response).hasSize(1); + OptionQuote q = response.get(0); assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); assertThat(q.underlying()).isEqualTo("AAPL"); assertThat(q.side()).isEqualTo("call"); @@ -667,9 +673,12 @@ void quoteDecodesAllFields() { // rho is an optional column absent from CANNED_QUOTE_BODY — it must decode to null, not 0.0, // and the missing column must not trip the strict parallel-arrays parser. assertThat(q.rho()).isNull(); - assertThat(q.expiration().getZone().getId()).isEqualTo("America/New_York"); - assertThat(q.firstTraded().getZone().getId()).isEqualTo("America/New_York"); - assertThat(q.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(java.util.Objects.requireNonNull(q.expiration()).getZone().getId()) + .isEqualTo("America/New_York"); + assertThat(java.util.Objects.requireNonNull(q.firstTraded()).getZone().getId()) + .isEqualTo("America/New_York"); + assertThat(java.util.Objects.requireNonNull(q.updated()).getZone().getId()) + .isEqualTo("America/New_York"); } @Test @@ -679,8 +688,7 @@ void quoteDecodesRhoWhenPresent() { CapturingClient client = okWith(bodyWithRho); OptionsResource options = resourceWith(client); - OptionQuote q = - options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data().quotes().get(0); + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); assertThat(q.rho()).isEqualTo(0.0456); } @@ -698,8 +706,7 @@ void quoteDecodesNullModelValuesAsNull() { CapturingClient client = okWith(body); OptionsResource options = resourceWith(client); - OptionQuote q = - options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data().quotes().get(0); + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); assertThat(q.iv()).isNull(); assertThat(q.delta()).isNull(); @@ -716,8 +723,8 @@ void quoteSyncMirrorsAsync() { CapturingClient client = okWith(CANNED_QUOTE_BODY); OptionsResource options = resourceWith(client); - OptionsQuotes data = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).data(); - assertThat(data.quotes()).hasSize(1); + List data = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values(); + assertThat(data).hasSize(1); } @Test @@ -730,6 +737,161 @@ void quoteErrorEnvelopeSurfacesAsParseError() { .hasMessageContaining("Unknown contract"); } + // ---------- columns projection + Option A + greeks ---------- + + @Test + void columnsProjectionDecodesRequestedAndNullsTheRest() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + OptionQuote q = + options + .columns("optionSymbol", "strike") + .quote(OptionsQuoteRequest.of("AAPL250117C00150000")) + .values() + .get(0); + + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.strike()).isEqualTo(150.0); + // Projected away → null, no error. + assertThat(q.bid()).isNull(); + assertThat(q.volume()).isNull(); + // The columns param reached the wire (comma is URL-encoded, so decode before asserting). + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).contains("columns=optionSymbol,strike"); + } + + @Test + void columnsRequestedButOmittedByApiThrowsParseError() { + // Option A: asked for bid via columns, but the API didn't return it → loud failure, not a null. + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy( + () -> + options + .columns("optionSymbol", "strike", "bid") + .quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("bid"); + } + + @Test + void noColumnsFilterStillRequiresAllStructuralColumns() { + // With no columns projection every required column is implicitly requested, so a missing one is + // still a ParseError (the strict-by-default guarantee survives the nullable fields). + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) + .isInstanceOf(ParseError.class); + } + + @Test + void presentGreeksReportsNonNullGreeks() { + // CANNED_QUOTE_BODY carries delta/gamma/theta/vega but not rho. + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); + + assertThat(q.presentGreeks()) + .containsExactlyInAnyOrder(Greek.DELTA, Greek.GAMMA, Greek.THETA, Greek.VEGA); + assertThat(q.greek(Greek.DELTA)).isEqualTo(0.89); + assertThat(q.greek(Greek.RHO)).isNull(); + } + + // ---------- CSV / HTML facets ---------- + + @Test + void asCsvSendsFormatCsvAndReturnsRawText() { + CapturingClient client = okWith("optionSymbol,strike\nAAPL250117C00150000,150"); + OptionsResource options = resourceWith(client); + + CsvResponse csv = options.asCsv().chain(OptionsChainRequest.of("AAPL")); + + assertThat(csv.csv()).contains("optionSymbol,strike"); + assertThat(csv.values()).isEqualTo(csv.csv()); + assertThat(csv.isCsv()).isTrue(); + assertThat(client.captured.get(0).uri().toString()).contains("format=csv"); + } + + @Test + void asHtmlFacetSendsFormatHtml() { + // asHtml() is package-private (built, not exposed) — exercised here from the same package. + CapturingClient client = okWith("chain"); + OptionsResource options = resourceWith(client); + + HtmlResponse html = options.asHtml().chain(OptionsChainRequest.of("AAPL")); + + assertThat(html.html()).contains(""); + assertThat(client.captured.get(0).uri().toString()).contains("format=html"); + } + + // ---------- response metadata (§13.5 / §16) ---------- + + @Test + void responseToStringRedactsQueryString() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = + options.quote(OptionsQuoteRequest.builder("AAPL250117C00150000").countback(5).build()); + + // §16: the query string is redacted in toString (logged as /path?…) so params never persist. + assertThat(resp.toString()).contains("?…").doesNotContain("countback"); + } + + @Test + void responseSaveToFileWritesRawBodyVerbatim( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws java.io.IOException { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + java.nio.file.Path out = dir.resolve("quote.json"); + resp.saveToFile(out); + + assertThat(java.nio.file.Files.readString(out)) + .isEqualTo(CANNED_QUOTE_BODY) + .isEqualTo(resp.json()); + } + + @Test + void universalParamsReachTheWire() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("limit=50") + .contains("offset=10"); + } + + @Test + void responseExposesRequestIdAndUrlMetadata() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + assertThat(resp.requestId()).isNull(); // the mock sends no cf-ray header + assertThat(resp.requestUrl().toString()).contains("/v1/options/quotes/"); + } + // ---------- quotes (plural, multi-symbol) ---------- @Test @@ -749,14 +911,14 @@ void quotesFansOutOnePerSymbolAndPreservesOrder() { OptionsResource options = resourceWith(client); - Map> result = + Map result = options.quotes( OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); // Insertion order preserved — first symbol in the builder is first in the iteration. assertThat(result.keySet()).containsExactly("AAPL250117C00150000", "AAPL250117P00150000"); - assertThat(result.get("AAPL250117C00150000").data().quotes().get(0).side()).isEqualTo("call"); - assertThat(result.get("AAPL250117P00150000").data().quotes().get(0).side()).isEqualTo("put"); + assertThat(result.get("AAPL250117C00150000").values().get(0).side()).isEqualTo("call"); + assertThat(result.get("AAPL250117P00150000").values().get(0).side()).isEqualTo("put"); // Two HTTP requests were sent. assertThat(client.captured).hasSize(2); @@ -1024,9 +1186,9 @@ void chainDecodesSingleRowResponse() { CapturingClient client = okWith(CANNED_CHAIN_BODY); OptionsResource options = resourceWith(client); - OptionsChain chain = options.chain(OptionsChainRequest.of("AAPL")).data(); - assertThat(chain.chain()).hasSize(1); - OptionQuote q = chain.chain().get(0); + List chain = options.chain(OptionsChainRequest.of("AAPL")).values(); + assertThat(chain).hasSize(1); + OptionQuote q = chain.get(0); assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); assertThat(q.delta()).isEqualTo(0.89); } @@ -1036,8 +1198,8 @@ void chainSyncMirrorsAsync() { CapturingClient client = okWith(CANNED_CHAIN_BODY); OptionsResource options = resourceWith(client); - OptionsChain chain = options.chain(OptionsChainRequest.of("AAPL")).data(); - assertThat(chain.chain()).hasSize(1); + List chain = options.chain(OptionsChainRequest.of("AAPL")).values(); + assertThat(chain).hasSize(1); } // ---------- chain: builder validation ---------- diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java deleted file mode 100644 index 8f46b87..0000000 --- a/src/test/java/com/marketdata/sdk/ResponseTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.net.http.HttpHeaders; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class ResponseTest { - - private static HttpResponseEnvelope env(byte[] body, int status, String url) { - return new HttpResponseEnvelope( - body, status, "req-id-123", HttpHeaders.of(Map.of(), (a, b) -> true), URI.create(url)); - } - - // ---------- typed accessors ---------- - - @Test - void exposesDataStatusAndUrl() { - Response r = - Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); - - assertThat(r.data()).isEqualTo("payload"); - assertThat(r.statusCode()).isEqualTo(200); - assertThat(r.requestUrl()).isEqualTo(URI.create("http://x/y")); - assertThat(r.requestId()).isEqualTo("req-id-123"); - } - - @Test - void requestIdNullWhenServerOmitsIt() { - HttpResponseEnvelope e = - new HttpResponseEnvelope( - "x".getBytes(), - 200, - null, - HttpHeaders.of(Map.of(), (a, b) -> true), - URI.create("http://x")); - Response r = Response.wrap("data", e, Format.JSON); - - assertThat(r.requestId()).isNull(); - } - - // ---------- format detection ---------- - - @Test - void formatDetectionExposesBooleansOnly() { - // The Format enum itself is package-private; consumers only see the booleans. - Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); - Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); - Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); - - assertThat(json.isJson()).isTrue(); - assertThat(json.isCsv()).isFalse(); - assertThat(json.isHtml()).isFalse(); - - assertThat(csv.isCsv()).isTrue(); - assertThat(csv.isJson()).isFalse(); - assertThat(csv.isHtml()).isFalse(); - - // §13.5: HTML detection — typically a misrouted request that landed on the web-server tier. - assertThat(html.isHtml()).isTrue(); - assertThat(html.isJson()).isFalse(); - assertThat(html.isCsv()).isFalse(); - } - - // ---------- no-data ---------- - - @Test - void isNoDataReflects404Convention() { - Response ok = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); - Response noData = Response.wrap("d", env("d".getBytes(), 404, "http://x"), Format.JSON); - - assertThat(ok.isNoData()).isFalse(); - assertThat(noData.isNoData()).isTrue(); - } - - // ---------- raw body immutability ---------- - - @Test - void rawBodyReturnsDefensiveCopy() { - byte[] source = "hello".getBytes(); - Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); - - byte[] firstCopy = r.rawBody(); - firstCopy[0] = 'X'; // mutate the returned array - byte[] secondCopy = r.rawBody(); - - assertThat(secondCopy[0]) - .as("internal state must not be affected by mutation") - .isEqualTo((byte) 'h'); - } - - @Test - void constructorCopiesIncomingRawBody() { - // Symmetric: the constructor must clone the input so mutations to the source after - // construction don't bleed into the Response. - byte[] source = "hello".getBytes(); - Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); - source[0] = 'X'; - - assertThat(r.rawBody()[0]).isEqualTo((byte) 'h'); - } - - // ---------- saveToFile ---------- - - @Test - void saveToFileWritesRawBodyVerbatim(@TempDir Path tmp) throws IOException { - byte[] body = "alpha,beta\n1,2\n".getBytes(); - Response r = Response.wrap("ignored", env(body, 200, "http://x"), Format.CSV); - - Path target = tmp.resolve("out.csv"); - r.saveToFile(target); - - assertThat(Files.readAllBytes(target)).isEqualTo(body); - } - - @Test - void saveToFileWrapsIoFailuresInUncheckedIoException(@TempDir Path tmp) { - Response r = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); - - // A non-existent parent directory triggers NoSuchFileException — the wrapper turns it into - // UncheckedIOException so the call fits in a fluent chain without checked-exception noise. - Path inaccessible = tmp.resolve("does-not-exist").resolve("out.txt"); - - assertThatThrownBy(() -> r.saveToFile(inaccessible)) - .isInstanceOf(UncheckedIOException.class) - .hasMessageContaining(inaccessible.toString()); - } - - // ---------- toString ---------- - - @Test - void toStringIncludesStatusFormatBytesAndUrl() { - Response r = - Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); - - String repr = r.toString(); - - // safeUri emits the path only (its contract): host/scheme are uninteresting in a log line and - // omitting them mirrors what HttpDispatcher already does for ambient request logs. - assertThat(repr) - .contains("status=200") - .contains("format=json") - .contains("bytes=4") - .contains("url=/y"); - } - - /** - * Issue #38: {@code toString} is a routine logging surface. The typed payload may carry sensitive - * content (e.g. a {@code RequestHeaders} map with {@code authorization} or client IPs), so it - * must not be embedded. Consumers that want the payload have {@link Response#data()}. - */ - @Test - void toStringDoesNotIncludeDataPayload() { - Response r = - Response.wrap( - "sensitive-payload-do-not-leak", - env("body".getBytes(), 200, "http://x/y"), - Format.JSON); - - assertThat(r.toString()).doesNotContain("sensitive-payload-do-not-leak"); - } - - /** - * Issue #38 + §16: query strings (tokens, account_ids, symbols) must not survive through {@code - * toString}. The full URI is still available via {@link Response#requestUrl()}. - */ - @Test - void toStringRedactsQueryStringInUrl() { - Response r = - Response.wrap( - "data", - env("body".getBytes(), 200, "http://x/quotes/?token=secret&symbol=AAPL"), - Format.JSON); - - String repr = r.toString(); - - assertThat(repr).doesNotContain("secret").doesNotContain("AAPL"); - assertThat(repr).contains("?…"); - } -} diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 6a9601c..28dbd84 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -4,8 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpClient; @@ -67,9 +67,9 @@ void headersAsyncReturnsDecodedRecord() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headersAsync().join().data(); + Map rh = utilities.headersAsync().join().values(); - assertThat(rh.headers()) + assertThat(rh) .containsEntry("accept", "*/*") .containsEntry("authorization", "Bearer ***REDACTED***") .containsEntry("cf-ray", "abc-123-xyz"); @@ -82,9 +82,9 @@ void headersSyncMirrorsHeadersAsync() { 200, "{\"x\":\"1\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headers().data(); + Map rh = utilities.headers().values(); - assertThat(rh.headers()).containsEntry("x", "1"); + assertThat(rh).containsEntry("x", "1"); } // ---------- /user/ endpoint ---------- @@ -119,7 +119,7 @@ void userAsyncReturnsDecodedRecord() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.userAsync().join().data(); + User u = utilities.userAsync().join().values(); assertThat(u.requestsRemaining()).isEqualTo(42); assertThat(u.requestsLimit()).isEqualTo(100); @@ -137,7 +137,7 @@ void userSyncMirrorsAsync() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.user().data(); + User u = utilities.user().values(); assertThat(u.requestsRemaining()).isEqualTo(7); } @@ -187,13 +187,13 @@ void statusAsyncReturnsZippedServiceList() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.statusAsync().join().data(); + List status = utilities.statusAsync().join().values(); - assertThat(status.services()).hasSize(2); - assertThat(status.services().get(0).service()).isEqualTo("/v1/a/"); - assertThat(status.services().get(0).online()).isTrue(); - assertThat(status.services().get(1).service()).isEqualTo("/v1/b/"); - assertThat(status.services().get(1).online()).isFalse(); + assertThat(status).hasSize(2); + assertThat(status.get(0).service()).isEqualTo("/v1/a/"); + assertThat(status.get(0).online()).isTrue(); + assertThat(status.get(1).service()).isEqualTo("/v1/b/"); + assertThat(status.get(1).online()).isFalse(); } @Test @@ -205,18 +205,18 @@ void statusSyncMirrorsAsync() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.status().data(); + List status = utilities.status().values(); - assertThat(status.services()).hasSize(1); + assertThat(status).hasSize(1); } // ---------- Response wrapper composition ---------- /** * The resource layer is responsible for composing typed model + raw body + format into a {@link - * Response}. This verifies the wiring end-to-end: the bytes from the wire reach {@code rawBody}, - * the request URL is preserved for support, and the format from the spec is reflected in the - * format accessors. + * MarketDataResponse}. This verifies the wiring end-to-end: the raw body is reachable via {@code + * json()}, the request URL is preserved for support, and the format from the spec is reflected in + * the format accessors. */ @Test void resourceWrapsTypedDataWithRawBodyAndMetadata() { @@ -225,10 +225,10 @@ void resourceWrapsTypedDataWithRawBodyAndMetadata() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - Response r = utilities.headers(); + UtilitiesHeadersResponse r = utilities.headers(); - assertThat(r.data().headers()).containsEntry("x", "1"); - assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.values()).containsEntry("x", "1"); + assertThat(r.json()).isEqualTo(body); assertThat(r.statusCode()).isEqualTo(200); assertThat(r.isJson()).isTrue(); assertThat(r.isNoData()).isFalse(); @@ -239,7 +239,7 @@ void resourceWrapsTypedDataWithRawBodyAndMetadata() { /** * When the backend returns the no_data envelope with HTTP 404, the consumer must reach {@link - * Response#isNoData()} and {@link Response#data()} normally — no {@link + * MarketDataResponse#isNoData()} and {@link MarketDataResponse#values()} normally — no {@link * com.marketdata.sdk.exception.ParseError} from the parallel-array field validation. The data * payload is the typed model with an empty collection. */ @@ -250,12 +250,12 @@ void statusEndpointSurfaces404NoDataAsEmptyResponseInsteadOfParseError() { new CapturingClient(404, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - Response r = utilities.status(); + UtilitiesStatusResponse r = utilities.status(); assertThat(r.statusCode()).isEqualTo(404); assertThat(r.isNoData()).isTrue(); - assertThat(r.data().services()).isEmpty(); - assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.values()).isEmpty(); + assertThat(r.json()).isEqualTo(body); } // ---------- error surfacing through sync ---------- From d0a8ade71e78c8344a646769bfe2d1b2b09f9169 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 9 Jun 2026 09:43:03 -0300 Subject: [PATCH 13/14] documentation update --- Makefile | 8 +- README.md | 74 +++-- docs/OPTIONS_REVIEW_GUIDE.md | 290 ++++++++++-------- docs/REFACTOR_REVIEW_GUIDE.md | 8 + .../ADR-008-endpoint-parameter-convention.md | 110 ++++--- 5 files changed, 291 insertions(+), 199 deletions(-) diff --git a/Makefile b/Makefile index 0a2151b..a09f14d 100644 --- a/Makefile +++ b/Makefile @@ -80,13 +80,17 @@ demo-retry: ## Retry, Retry-After, preflight (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runRetry .PHONY: demo-response -demo-response: ## Response surface features (needs mock-server) +demo-response: ## MarketDataResponse surface features (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runResponse .PHONY: demo-concurrency demo-concurrency: ## 50-permit semaphore (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runConcurrency +.PHONY: demo-options +demo-options: ## Full options surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runOptions + .PHONY: demos-all demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency runOptions diff --git a/README.md b/README.md index 97ed7cc..5da8ee6 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,8 @@ common path is two lines: ```java try (var client = new MarketDataClient()) { - Response resp = - client.options().expirations(OptionsExpirationsRequest.of("AAPL")); - System.out.println(resp.data().expirations()); + var resp = client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + System.out.println(resp.values()); // values() is the typed payload (a List) } ``` @@ -43,10 +42,15 @@ try (var client = new MarketDataClient()) { ```kotlin MarketDataClient().use { client -> val resp = client.options().expirations(OptionsExpirationsRequest.of("AAPL")) - println(resp.data().expirations) + println(resp.values()) // List } ``` +Every response implements `MarketDataResponse`: `values()` returns the typed payload +(typed per endpoint — a `List`, a scalar `String`, …), and the same metadata accessors +(`statusCode()`, `isNoData()`, `requestId()`, `json()`, `saveToFile(path)`) are available on +every response, on every resource. + ## Options Reached via `client.options()`. Every endpoint has a synchronous method and an @@ -73,7 +77,7 @@ only one variant per group: ```java try (var client = new MarketDataClient()) { - Response resp = client.options().chain( + var resp = client.options().chain( OptionsChainRequest.builder("AAPL") .expirationFilter(ExpirationFilter.all()) // every expiration, not just front-month .strikeFilter(StrikeFilter.range(150, 200)) // 150 <= strike <= 200 @@ -81,9 +85,9 @@ try (var client = new MarketDataClient()) { .strikeLimit(5) .build()); - for (OptionQuote q : resp.data().chain()) { - System.out.printf("%s delta=%.3f rho=%s%n", - q.optionSymbol(), q.delta(), q.rho()); // rho may be null (optional column) + for (OptionQuote q : resp.values()) { // values() is a List + System.out.printf("%s delta=%s rho=%s%n", + q.optionSymbol(), q.delta(), q.rho()); // delta/rho are @Nullable Double } } ``` @@ -100,8 +104,8 @@ MarketDataClient().use { client -> .strikeLimit(5) .build() ) - resp.data().chain.forEach { q -> - println("${q.optionSymbol} delta=${q.delta} rho=${q.rho}") // rho may be null + resp.values().forEach { q -> + println("${q.optionSymbol} delta=${q.delta} rho=${q.rho}") // delta/rho are nullable } } ``` @@ -109,18 +113,42 @@ MarketDataClient().use { client -> ### Multiple quotes `quotes` fans out one request per symbol concurrently and returns a -`Map>` keyed by the input symbol, so per-symbol +`Map` keyed by the input symbol, so per-symbol status and errors stay observable. `countback` caps the historical series to the N most recent rows before `to`: ```java -Map> bySymbol = client.options().quotes( +Map bySymbol = client.options().quotes( OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000") .to(LocalDate.now()) .countback(5) // at most 5 EOD rows per symbol, before `to` .build()); + +bySymbol.forEach((sym, resp) -> System.out.println(sym + " → " + resp.values().size() + " rows")); +``` + +### Universal parameters & CSV + +Universal parameters are set fluently on the resource (an immutable configured value, so you +can "configure once, call many"); `columns` projects the response to a subset of fields, and +`asCsv()` selects a CSV view of any endpoint: + +```java +// type-preserving universal params + column projection (typed): +var chain = client.options() + .dateFormat(DateFormat.TIMESTAMP).mode(Mode.DELAYED).limit(50) + .columns("optionSymbol", "strike", "delta") // fields you don't request come back null + .chain(OptionsChainRequest.of("AAPL")); + +// CSV facet (adds human/headers, which only make sense for CSV): +CsvResponse csv = client.options().asCsv().columns("optionSymbol", "strike").chain(req); +csv.saveToFile(Path.of("aapl-chain.csv")); ``` +With `columns`, a field you didn't request decodes to `null` (no error); a **required** field +you *did* request (or didn't project away) that the API omits raises a `ParseError` — so a +`null` never silently hides a dropped field. + ## Configuration Values are resolved through this cascade (highest priority first): @@ -147,9 +175,11 @@ Values are resolved through this cascade (highest priority first): | `MARKETDATA_USE_HUMAN_READABLE` | Human-readable field names | `false` | | `MARKETDATA_MODE` | Data mode (live/cached/delayed) | `live` | -Endpoint-shape variables (`OUTPUT_FORMAT`, `DATE_FORMAT`, `COLUMNS`, -`ADD_HEADERS`, `USE_HUMAN_READABLE`, `MODE`) are reserved here and will be -honored when the request layer lands. +The corresponding per-call setters — `dateFormat`/`limit`/`offset`/`mode`/`columns` on the +resource, plus `human`/`headers` and `asCsv()` on the CSV facet — are exposed on `options` +today (and on every resource as it lands). Auto-applying these env-var values as request +*defaults* (`DATE_FORMAT`, `COLUMNS`, `ADD_HEADERS`, `USE_HUMAN_READABLE`, `MODE`, +`OUTPUT_FORMAT`) is still reserved. ### Demo mode @@ -213,13 +243,15 @@ isn't an exact match is rejected before any request is made. ## Package layout ``` -com.marketdata.sdk # MarketDataClient, RateLimits, and the resource - # façades (UtilitiesResource, OptionsResource) — - # public; Configuration, EnvVars, Tokens, Version - # are package-private and not part of the API -com.marketdata.sdk.options # Options request builders + response records +com.marketdata.sdk # MarketDataClient, RateLimits, the resource façades + # (UtilitiesResource, OptionsResource, OptionsCsvResource), + # and MarketDataResponse + the named response types + # (OptionsChainResponse, CsvResponse, …) — public; + # Configuration, EnvVars, Tokens, Version are + # package-private and not part of the API +com.marketdata.sdk.options # Options request builders + row records # (OptionsChainRequest, OptionQuote, sealed - # ExpirationFilter / StrikeFilter, …) + # ExpirationFilter / StrikeFilter, Greek, …) com.marketdata.sdk.exception # Sealed MarketDataException hierarchy + ErrorContext ``` diff --git a/docs/OPTIONS_REVIEW_GUIDE.md b/docs/OPTIONS_REVIEW_GUIDE.md index ed12563..6fe7aaa 100644 --- a/docs/OPTIONS_REVIEW_GUIDE.md +++ b/docs/OPTIONS_REVIEW_GUIDE.md @@ -1,24 +1,26 @@ # Options Review Guide — `10_options_resource` -This guide walks a reviewer through the `options` resource added on the `10_options_resource` branch. Like the [Refactor Review Guide](REFACTOR_REVIEW_GUIDE.md), it is organized by **flow**, not by file: each section names the parts that participate in one slice of behavior, explains how they fit, and calls out the non-obvious decisions. +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](REFACTOR_REVIEW_GUIDE.md), it is organized by **flow**, not by file. -This PR builds directly on the foundation that guide describes — transport, retry, rate-limit, `Response`, and the `ParallelArrays` wire-format helper are all reused unchanged (except for one additive change, §4). If a mechanism here looks like it's assumed rather than explained, it's in the Refactor guide. +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` is replaced by `MarketDataResponse` + named per-endpoint types, and `ParallelArrays` gains lenient accessors. `utilities` is migrated onto the new model too. -Suggested reading order for someone new to the change: §1 (what's here) → §2 (the Request convention) → §4 (the deserializer + nullable columns — the correctness-critical part) → §3 (chain filters) → §7 (subtle corners). That's the load-bearing shape in ~30 minutes. +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. -All `file:line` citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. +`file:line` citations target `HEAD` on this branch; line numbers drift — if one looks off, search for the symbol it names. ## Table of contents - [Running it locally](#running-it-locally) 1. [What this PR adds](#1-what-this-pr-adds) -2. [The Request-class convention](#2-the-request-class-convention) -3. [Chain request → query translation](#3-chain-request--query-translation) -4. [The shared option-row deserializer + optional columns](#4-the-shared-option-row-deserializer--optional-columns) -5. [The `quotes` multi-symbol fan-out](#5-the-quotes-multi-symbol-fan-out) -6. [lookup / expirations / strikes](#6-lookup--expirations--strikes) -7. [Subtle corners (finding-driven)](#7-subtle-corners-finding-driven) -8. [Out of scope for this review](#8-out-of-scope-for-this-review) +2. [The response model](#2-the-response-model) +3. [The Request-class convention](#3-the-request-class-convention) +4. [Chain request → query translation](#4-chain-request--query-translation) +5. [The option-row deserializer: nullable fields + columns + Option A](#5-the-option-row-deserializer-nullable-fields--columns--option-a) +6. [Universal parameters + the CSV/HTML facets](#6-universal-parameters--the-csvhtml-facets) +7. [The `quotes` multi-symbol fan-out](#7-the-quotes-multi-symbol-fan-out) +8. [lookup / expirations / strikes](#8-lookup--expirations--strikes) +9. [Subtle corners (finding-driven)](#9-subtle-corners-finding-driven) +10. [Out of scope for this review](#10-out-of-scope-for-this-review) - [Reviewer checklist](#reviewer-checklist) --- @@ -26,16 +28,18 @@ All `file:line` citations target `HEAD` on this branch. Line numbers drift; if a ## Running it locally ```bash -make build # unit tests + Spotless + JaCoCo +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 -# Consumer-side demo of every options call (runs in demo mode without a token): -make publish && make demo-quickstart +# 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 runOptions ``` -The integration suite (`OptionsIntegrationTest`) was run green against `api.marketdata.app` as part of this PR — 9 tests, shape assertions. `QuickstartApp`'s `optionsExamples(...)` is the "what consumer code looks like" surface. +`OptionsIntegrationTest` (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. --- @@ -43,207 +47,221 @@ The integration suite (`OptionsIntegrationTest`) was run green against `api.mark ### 1.1 Public API surface (new) -What a consumer can now `import` and call, on top of the existing surface: - ``` +com.marketdata.sdk.MarketDataResponse (interface every response implements; T values() + metadata) com.marketdata.sdk.OptionsResource (returned from client.options()) - -com.marketdata.sdk.options.OptionsLookupRequest / OptionsLookup -com.marketdata.sdk.options.OptionsExpirationsRequest / OptionsExpirations -com.marketdata.sdk.options.OptionsStrikesRequest / OptionsStrikes (+ ExpirationStrikes) -com.marketdata.sdk.options.OptionsQuoteRequest / OptionsQuotes (+ OptionQuote) -com.marketdata.sdk.options.OptionsQuotesRequest -com.marketdata.sdk.options.OptionsChainRequest / OptionsChain -com.marketdata.sdk.options.ExpirationFilter (sealed) / StrikeFilter (sealed) -com.marketdata.sdk.options.OptionSide / StrikeRange (enums) +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` is `public final` with a **package-private constructor** (ADR-007) — consumers reach it through `client.options()` (`MarketDataClient` wires it at construction) but cannot instantiate it. Response models and requests live in the public `com.marketdata.sdk.options` subpackage, mirroring `utilities`. +`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` 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. ### 1.2 Files to review, by role | Area | Files | What to check | |---|---|---| -| Resource façade | `OptionsResource.java` | URL/param translation, fan-out, deserializer wiring | +| 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 | -| Response models | `options/Options*.java`, `OptionQuote`, `ExpirationStrikes` | Field types (esp. nullable greeks) | -| Deserializers | `OptionsLookupDeserializer`, `OptionsExpirationsDeserializer`, `OptionsStrikesDeserializer` + `optionRowsDeserializer` in `OptionsResource` | Wire-shape correctness, strictness | -| Reused infra (changed) | `ParallelArrays.java` | The additive optional-column overload | -| New infra | `MarketDataDates.java`, `PathSegments.java` | Date parsing, URL encoding | -| Wiring | `MarketDataClient.java` (`+10 lines`) | `client.options()` accessor | +| 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()` | -### 1.3 What did **not** change +### 1.3 What changed in shared layers (was "unchanged") -The transport, retry, rate-limit, status-cache, `Response`, and exception layers are untouched. `ParallelArrays` is the only pre-existing class with a behavior-relevant change, and it is purely additive (the old 4-arg `zip` / 3-arg `listDeserializer` still exist and delegate — `ParallelArrays.java:70`, `:186`). Confirm no existing `utilities` test regressed. +- **`Response` is deleted.** Both `options` and `utilities` use named `MarketDataResponse` types now. Confirm nothing still imports `Response`. +- **`ParallelArrays`** gained lenient `textOrNull`/`lngOrNull`/`boolOrNull`/`nodeOrNull` (alongside the pre-existing `dblOrNull`). The strict `text`/`dbl`/`lng`/`bool`/`node` accessors are unchanged — `utilities`' `ApiStatus` deserializer still uses them, so confirm no `utilities` test regressed. +- **`JsonResponseParser`** gained a `parse(env, type, requestedColumns)` overload that sets a Jackson context attribute (`REQUESTED_COLUMNS_ATTR`). The no-columns `parse(env, type)` delegates with an empty list. +- Transport, retry, rate-limit, status-cache, and exception layers are untouched. --- -## 2. The Request-class convention +## 2. The response model -This PR establishes the SDK-wide convention: **one Builder-based request class per endpoint, no `String` overloads.** +Every endpoint returns a **named** type implementing `MarketDataResponse`: ```java -// no-optionals endpoints: static of(...) -OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"); -// optionals: builder(required...)... .build() -OptionsExpirationsRequest.builder("AAPL").strike(150.0).date(LocalDate.of(2024, 1, 17)).build(); +interface MarketDataResponse { + 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); +} ``` -What to check per request class: +- `values()` is the **one** uniform data accessor. `OptionsChainResponse implements MarketDataResponse>` → `values()` is the rows; `OptionsLookupResponse implements MarketDataResponse` → `values()` is the scalar OCC symbol. `OptionsStrikesResponse`/`OptionsExpirationsResponse` additionally expose `updated()`. +- **One hop, no double-unwrap:** `T` is the flat payload (a `List`, a `String`), not a sub-container. +- `AbstractMarketDataResponse` (package-private, root) holds the payload + metadata and implements every accessor; the named types are thin subclasses with a package-private constructor. -- **Required args are constructor/factory params**; optionals are fluent setters. The required arg can't be omitted at the call site. -- **Cross-field validation is in `build()`**, throwing `IllegalArgumentException` with a message naming the conflict — e.g. `OptionsQuoteRequest.build()` rejects `date` + `from`/`to` together, and the shared `validateWindow(...)` (`OptionsQuoteRequest.java`) rejects `countback` with `date` or `from`. `OptionsQuotesRequest` reuses that same validator. -- **Getters are plain reads** (no work) — the resource reads them to build a `RequestSpec`. (The Request can't reach `RequestSpec` directly: it lives in a public subpackage, `RequestSpec` is package-private in the root.) +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`'s `StatusCache` supplier was adapted: `statusAsync().thenApply(r -> new ApiStatus(r.values()))`. -### 2.1 Sealed types for mutually-exclusive groups +--- -`chain` has two groups where combining variants is undefined server-side. They're modeled as sealed interfaces so exclusivity is **compiler-enforced** — there's a single setter per group, and you can only pass one variant: +## 3. The Request-class convention -- `ExpirationFilter` (`options/ExpirationFilter.java`): `OnDate` / `Dte` / `Between` / `MonthYear` / `All`. Factory methods validate at creation (`dte` ≥ 0, `between` ordered, `month` 1–12). -- `StrikeFilter` (`options/StrikeFilter.java`): `Exact` / `Range` / `Comparison` (with `Operator` GT/GTE/LT/LTE). +Unchanged from the original design: **one Builder-based request class per endpoint, no `String` overloads.** -Pair constraints that the type system can't express (`minBid ≤ maxBid`) stay as runtime checks in `OptionsChainRequest.build()`. +```java +OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"); // no-optionals: of(...) +OptionsExpirationsRequest.builder("AAPL").strike(150.0).build(); // optionals: builder(...)…build() +``` -> Reviewer note: this is the pattern future resources are expected to copy. If you disagree with it, this is the PR to say so — it's load-bearing for `stocks`/`funds`/`markets`. +- Required args are factory params (can't be omitted); optionals are fluent setters; cross-field validation in `build()` (e.g. the shared `validateWindow(...)` rejects `date` + `from`/`to`, and `countback` with `date`/`from`). +- Getters are plain reads; the resource translates them into a `RequestSpec`. ---- +### 3.1 Sealed types for mutually-exclusive `chain` groups -## 3. Chain request → query translation +`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()`. -`chain` is the densest logic in the PR: ~25 optional parameters mapped to query string. The translation is `applyChainParams` (`OptionsResource.java:306`), called from `chainAsync` (`:291`). +> Reviewer note: this is the pattern future resources copy. This is the PR to push back on it. -### 3.1 The shape +--- -`applyChainParams` is a flat sequence of `if (r.foo() != null) b.query("foo", ...)` for the independent params, plus two delegations: +## 4. Chain request → query translation -- `applyExpirationFilter(b, f)` (`:372`) — pattern-matches the sealed `ExpirationFilter`: +`chain` is the densest mapping: ~25 optional params → query string, via `applyChainParams` (`OptionsResource.java`), called from `chainSpec` / `chainAsync`. - | Variant | Wire | - |---|---| - | `OnDate(date)` | `?expiration=YYYY-MM-DD` | - | `Dte(days)` | `?dte=N` | - | `Between(from, to)` | `?from=…&to=…` | - | `MonthYear(year, month)` | `?month=M&year=YYYY` | - | `All` | `?expiration=all` | +- 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 trailing `else { throw IllegalStateException }`** — the `instanceof` chain 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 via `formatStrike`). - **Check the trailing `else { throw IllegalStateException }`** (`:388`). The `instanceof` chain isn't compiler-checked for exhaustiveness, so the guard exists to fail loudly if a future variant is added without a branch (it mirrors `strikeFilterWireValue`'s guard). This is unreachable today — it won't show in coverage, by design. +What to verify: +- Every `OptionsChainRequest` getter has a line in `applyChainParams` (a param read nowhere silently does nothing). +- `minBid`/`maxBid`/`minAsk`/`maxAsk` are real backend params (verified against the handler). -- `strikeFilterWireValue(f)` (`:393`) → the `?strike=` value: `150` (exact), `140-160` (range), `>150` / `>=150` (comparison). Strikes render without trailing zeros via `formatStrike` (`:408`). +The `chainExpirationFilter*` / `chainStrikeFilter*` / per-param URL unit tests assert the exact query string per branch. -### 3.2 What to verify +--- -- Every getter on `OptionsChainRequest` has a corresponding line in `applyChainParams`. A param that exists on the request but is never read would silently do nothing. (Cross-check the builder setters against `applyChainParams`.) -- `minBid`/`maxBid`/`minAsk`/`maxAsk` **are** real backend params (verified against the handler) — they were almost cut as "phantom"; they're not. -- Date params use `ISO_LOCAL_DATE` (`YYYY-MM-DD`) consistently. +## 5. The option-row deserializer: nullable fields + columns + Option A -The unit tests `chainExpirationFilter*`, `chainStrikeFilter*`, and the per-param URL tests in `OptionsResourceTest` assert the exact query string for each 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()`. ---- +### 5.1 Every field is nullable -## 4. The shared option-row deserializer + optional columns +`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`. -**This is the correctness-critical section.** `quotes` and `chain` emit the same per-contract parallel-arrays row, so they share one deserializer: `optionRowsDeserializer(wrapper)` (`OptionsResource.java:110`), registered for both `OptionsQuotes` and `OptionsChain` in `wireFormatModule()` (`:57`). +### 5.2 Lenient decode -### 4.1 The row schema +`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. -`OPTION_ROW_FIELDS` (`:68`) lists the 25 **required** columns; `OPTION_OPTIONAL_ROW_FIELDS` (`:102`) lists `rho` as optional. Each row builds an `OptionQuote` (`options/OptionQuote.java`). +`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**). -### 4.2 The nullable-model-values change (the heart of the review) +### 5.3 Option A restores strictness — no silent failures -The model-derived values — `iv`, `delta`, `gamma`, `theta`, `vega`, `rho` — are typed `@Nullable Double` and read via `row.dblOrNull(...)`. Everything else (bid/ask/last/sizes/strike/…) stays primitive `double`/`long`, read via the strict `row.dbl(...)`/`row.lng(...)`. +If every column is optional, how is "the backend dropped a field you asked for" detected? `validateRequestedColumns(p, root, rows, ctxt)`, run after decode: -**Why:** on historical/illiquid rows the API returns `null` for the model values (no model output that day). The strict-by-default `ParallelArrays.Row` accessors throw on a null cell — which surfaced as a live `ParseError` on a `countback` query (see §7). Making the six model values nullable is the fix. +- Reads the requested columns from the Jackson context attribute (`JsonResponseParser.REQUESTED_COLUMNS_ATTR`), which the resource sets to `config.columns()` via `parse(env, type, requestedColumns)`. +- For each field in `OPTION_REQUIRED_FIELDS` (the 20 structural columns — **not** `iv`/greeks): if it was requested (explicitly in `columns`, or implicitly because no `columns` filter was applied) and `!root.has(field)` → throw `JsonMappingException` → `ParseError`. -Two distinct kinds of "nullable" are in play here — verify you follow both: +The resulting contract (the one to verify): -| Field | In which list | Decode | Tolerates | +| Field | Requested? | In response? | Result | |---|---|---|---| -| `rho` | `OPTION_OPTIONAL_ROW_FIELDS` | `dblOrNull` | column **absent entirely** *and* null cell | -| `iv`, `delta`, `gamma`, `theta`, `vega` | `OPTION_ROW_FIELDS` (required) | `dblOrNull` | null **cell** (column must still be present) | +| 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 `rho` may be missing as a whole array; `iv`/greeks must be present as arrays (a missing `iv` column is still a server bug → `ParseError`) but individual cells may be `null`. +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 `ParseError`s). -### 4.3 The `ParallelArrays` additive change +### 5.4 Greeks helpers -`ParallelArrays` gained (all additive — old signatures delegate): +`OptionQuote.presentGreeks()` (`Set`) 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()`). -- `zip(p, root, fields, optionalFields, rowBuilder)` (`:87`). After the required-field loop, optional fields are processed (`:129`): absent/null/non-array → skipped; present → length-checked like any column. -- `listDeserializer(fields, optionalFields, rowBuilder, wrapper)` (`:197`). -- `Row.dblOrNull(field)` (`:244`, impl `:299`): returns `null` if the column isn't in the row's map **or** the cell is null/missing; throws `typeMismatch` only on a present-but-non-number cell. Leniency covers **absence, not corruption.** +### 5.5 Tests that document this -What to verify: -- The old behavior is unchanged for every existing caller (utilities, lookup/expirations/strikes use the strict path). The `online`-column-regression reasoning in the `ParallelArrays` class javadoc still holds — strict is still the default. -- `dblOrNull` does **not** route through `cell(field)` (which throws on unknown field); it checks `arrays.containsKey` first. Confirm an absent optional column can't throw. +`OptionsResourceTest`: `columnsProjectionDecodesRequestedAndNullsTheRest`, `columnsRequestedButOmittedByApiThrowsParseError`, `noColumnsFilterStillRequiresAllStructuralColumns`, `presentGreeksReportsNonNullGreeks`, plus the original `quoteDecodesNullModelValuesAsNull` (greeks null → null, not `ParseError`). -### 4.4 Tests that document this +--- -`OptionsResourceTest`: -- `quoteDecodesAllFields` — all values present, including a populated row. -- `quoteDecodesRhoWhenPresent` / the rho-absent assertion — `rho` round-trips and is `null` when omitted. -- `quoteDecodesNullModelValuesAsNull` — `iv`+greeks all `null` in the body decode to `null` without a `ParseError`, while a market field on the same row stays populated. This reproduces the live-API failure. +## 6. Universal parameters + the CSV/HTML facets ---- +### 6.1 Universal params on the resource + +`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. + +### 6.2 The facets -## 5. The `quotes` multi-symbol fan-out +- `asCsv()` → `OptionsCsvResource` → `CsvResponse` (`values()`/`csv()` = raw CSV text). Reuses the package-private static `*Spec` builders on `OptionsResource`, sets `format=csv`, and skips the typed decode. **Omits `lookup`** (a scalar — no CSV). Fan-out mirrors the container: `quotes(...)` → `Map`. +- `asHtml()` → `OptionsHtmlResource`/`HtmlResponse` is **package-private (built, not exposed)** — the backend serves no HTML. `CsvResponse ≠ HtmlResponse` (distinct types). Verify `asHtmlFacetSendsFormatHtml` exercises it from the same package. -The backend `quotes` endpoint takes a single `optionSymbol` per call. The SDK's `quotes(...)` (`OptionsResource.java:257`) accepts N symbols and fans out one request each. +--- + +## 7. The `quotes` multi-symbol fan-out -Walk `quotesAsync`: -- One `buildQuoteSpec(symbol, date, from, to, countback)` per symbol (`:264`), each dispatched through the normal transport (so each rides the 50-permit `AsyncSemaphore`, retry, preflight — nothing special). -- Results collected into a `LinkedHashMap` keyed by the **input symbol**, insertion order preserved, so per-symbol `Response` metadata (`statusCode`, `isNoData`, `rawBody`, `requestId`) stays observable. -- **Fail-fast:** `CompletableFuture.allOf(...)` means the map's future completes exceptionally if any single request fails. Confirm you're comfortable with that semantic vs. partial-success — it's deliberate (a partial map would hide failures). +The backend `quotes` endpoint takes a single `optionSymbol`. The SDK's `quotes(...)` accepts N symbols and fans out one request each. -`quote(...)` (single) and `quotes(...)` (multi) share `buildQuoteSpec`; the historical-window params (`date`/`from`/`to`/`countback`) apply identically to every symbol. +- One `quoteSpec(symbol, date, from, to, countback)` per symbol, each dispatched through the normal transport (50-permit `AsyncSemaphore`, retry, preflight). +- Results into a `LinkedHashMap` keyed by the **input symbol** (insertion order preserved), each value a full `OptionsQuotesResponse` (so per-symbol `statusCode`/`isNoData`/`requestId`/`json` stay 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`. -Verify: `quotesFansOutToMultipleContracts` (integration) and `quotesAttachesCountbackToEachFanOut` / `quotesAttaches*` (unit). +Verify: `quotesFansOutToMultipleContracts` (integration) and the unit fan-out test. --- -## 6. lookup / expirations / strikes +## 8. lookup / expirations / strikes -The three simpler endpoints, each with its own hand-written deserializer (the wire shapes don't fit the parallel-arrays helper): +Three simpler endpoints, each with a hand-written deserializer (their wire shapes don't fit the parallel-arrays row): -- **lookup** (`OptionsResource.java:152`) — flat `{"s":"ok","optionSymbol":"…"}`. `OptionsLookupDeserializer`. The path is URL-encoded per-segment via `PathSegments.encode` so `AAPL 7/26/23 $200 Call` survives (`/` preserved, space → `%20`, `$` encoded). See `lookupUrlEncodesSpacesAndReservedChars`. -- **expirations** (`:173`) — parallel `expirations[]` + scalar `updated`. `OptionsExpirationsDeserializer`. Decodes epochs to `ZonedDateTime` at market-midnight (America/New_York) via `MarketDataDates`. `no_data` envelope → empty list, `updated` null. -- **strikes** (`:201`) — unusual shape: one top-level key **per expiration date** plus `s`/`updated`. `OptionsStrikesDeserializer` is strict — an unrecognized non-date key throws (`strikesUnrecognizedTopLevelKeyThrowsParseError`), a non-numeric strike throws, missing `updated` throws. +- **lookup** — flat `{"s":"ok","optionSymbol":"…"}` → `OptionsLookupResponse.values()` is the `String`. Path URL-encoded per-segment via `PathSegments.encode` (`/` preserved, space → `%20`, `$` encoded). +- **expirations** — parallel `expirations[]` + scalar `updated` → `values()` is `List`, `updated()` the timestamp (null on `no_data`). +- **strikes** — one top-level key **per expiration date** + `s`/`updated` → `values()` is `List`. 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). -Check `MarketDataDates` (new) handles all three `dateformat` variants (unix / ISO-string / spreadsheet) uniformly, since the typed surface is `dateformat`-agnostic. +`MarketDataDates` handles all three `dateformat` variants (unix / ISO-string / spreadsheet) uniformly. --- -## 7. Subtle corners (finding-driven) +## 9. Subtle corners (finding-driven) -| # | Corner | Where | What to know | -|---|---|---|---| -| 7.1 | **Null model values** | §4.2 | Historical/illiquid rows return null `iv`/greeks. Strict parse would `ParseError`. Fixed via nullable `@Nullable Double` + `dblOrNull`. Found by running live IT. | -| 7.2 | **HTTP 203 is success** | `OptionsIntegrationTest` | API returns `203 Non-Authoritative Information` for cached/delayed data. The SDK treats it as success; IT assert `200 || 203` so they don't flap with market hours. | -| 7.3 | **`expiration=all` ≠ no filter** | `ExpirationFilter.All`, `applyExpirationFilter` | Omitting the expiration filter makes the API return only the **front-month**; `all()` returns every expiration. Verified against the backend's `get_default_expiration_date` (`.head(1)`). Both are exercised in IT (`chainExpirationAllSpansMultipleExpirations`). | -| 7.4 | **`countback` validation** | `OptionsQuoteRequest.validateWindow` | Must be positive; mutually exclusive with `date` and `from` ("if you use from, countback is not required" per the API doc). Canonical use is `to` + `countback`. | -| 7.5 | **Exhaustiveness guard** | `applyExpirationFilter` `else throw` | Sealed `instanceof` chains aren't compiler-exhaustive; the guard fails loudly on a future unmatched variant instead of dropping the filter silently. | -| 7.6 | **`quotes` is fan-out, not bulk** | `quotesAsync` | The backend path takes one symbol; the docstring's comma-separated claim is wrong (verified by reading the handler). The SDK fans out N concurrent calls. | -| 7.7 | **`source` intentionally absent** | — | Internal provider param, not in the public schema / requirements / Python SDK. Deliberately not exposed. | +| # | Corner | What to know | +|---|---|---| +| 9.1 | **No silent failures (Option A)** | All fields nullable for `columns`, but `validateRequestedColumns` still `ParseError`s 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 || 203` so they don't flap with market hours. | +| 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. | --- -## 8. Out of scope for this review +## 10. Out of scope for this review -Do **not** flag these as missing — they're deferred decisions, documented in [`PR.md`](../PR.md): +Do **not** flag as missing — deferred decisions, documented in [`PR.md`](../PR.md): -- **§3 universal parameters** (`format`/CSV, `dateformat`, `columns`, `limit`, `offset`, `headers`, `human`, `mode`) — no consumer-facing API on options yet; lands with `stocks`. The `RequestSpec.Builder` plumbing exists but is package-private. -- **ADR-008 / ADR-009 + requirements §2.x + acceptance checklist** — pending the ADR-first workflow, to be drafted post-merge. +- **HTML facet exposure** — built and tested; `asHtml()` stays package-private until the backend serves `format=html`. - **§13 JaCoCo 100% threshold** — deferred until the full resource layer lands. -- **§8 per-response rate-limit snapshot** on `Response` — still client-level. +- **§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.) --- ## Reviewer checklist -- [ ] Request convention: every endpoint has a Builder request, no `String` overloads; required args non-optional; cross-field validation in `build()`. -- [ ] Sealed `ExpirationFilter` / `StrikeFilter` correctly translate every variant in `applyChainParams`, with the `else throw` guard. -- [ ] `applyChainParams` reads **every** `OptionsChainRequest` getter (no silently-dropped param). -- [ ] Nullable model values: `iv`/`delta`/`gamma`/`theta`/`vega`/`rho` are `@Nullable Double` + `dblOrNull`; market fields stay primitive + strict. -- [ ] `ParallelArrays` change is additive; existing strict callers unchanged; `dblOrNull` can't throw on an absent column. -- [ ] `quotes` fan-out: per-symbol map, insertion order, fail-fast semantics acceptable. -- [ ] Hand-written deserializers (lookup/expirations/strikes) stay strict on unexpected shapes. -- [ ] Unit (`./gradlew build`) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) both green. -- [ ] Deferred items (§8) understood and not blocking. +- [ ] Response model: every endpoint returns a named `MarketDataResponse`; `values()` is the flat payload; metadata surface complete; `Response` fully removed; `utilities` migrated with no regression. +- [ ] Request convention: Builder request per endpoint, no `String` overloads; required args non-optional; cross-field validation in `build()`. +- [ ] Sealed `ExpirationFilter`/`StrikeFilter` translate every variant in `applyChainParams`, with the `else throw` guard; `applyChainParams` reads **every** getter. +- [ ] Nullable + Option A: all `OptionQuote` fields `@Nullable`; `buildOptionRow` lenient; `validateRequestedColumns` fails on requested-but-missing required columns; strict-by-default preserved. +- [ ] `ParallelArrays` OrNull accessors are additive; strict path unchanged; an absent optional column can't throw. +- [ ] Universal params reach the wire; `human`/`headers` only on the CSV facet; `columns` projection round-trips. +- [ ] Facets: `asCsv()` returns `CsvResponse`, omits `lookup`, fan-out → `Map`; `asHtml()` package-private; `CsvResponse ≠ HtmlResponse`. +- [ ] `quotes` fan-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-options` runs against the mock server. +- [ ] Deferred items (§10) understood and not blocking. diff --git a/docs/REFACTOR_REVIEW_GUIDE.md b/docs/REFACTOR_REVIEW_GUIDE.md index 996e4b7..6b15825 100644 --- a/docs/REFACTOR_REVIEW_GUIDE.md +++ b/docs/REFACTOR_REVIEW_GUIDE.md @@ -6,6 +6,8 @@ If you've never read this codebase, start with §1 (topology) → §2 (sync requ All file:line citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. +> **Superseded since this branch:** the generic `Response` wrapper this guide describes (`data()`, `rawBody()`, §7) was **replaced** in the `options` PR (`10_options_resource`) by `MarketDataResponse` + named per-endpoint response types, with a uniform `values()` accessor (and `json()` in place of `rawBody()`). The mechanics here — transport, retry, rate-limit, the parser / `ParallelArrays` decode path — are unchanged; only the response *carrier* changed. For the current model see [`OPTIONS_REVIEW_GUIDE.md`](OPTIONS_REVIEW_GUIDE.md). This guide is left as the historical record of the foundation PR. + ## Table of contents - [Running it locally](#running-it-locally) @@ -1114,6 +1116,12 @@ make demo-concurrency # terminal 3 ## 7. `Response` + JSON parsing +> **Note:** `Response` was replaced after this branch by `MarketDataResponse` + named +> per-endpoint types (`values()` instead of `data()`, `json()` instead of `rawBody()`). The +> **JSON parsing** half of this section (`JsonResponseParser`, `ParallelArrays`, the +> `s:"no_data"`/envelope handling) is still current — only the *carrier* changed. See +> [`OPTIONS_REVIEW_GUIDE.md`](OPTIONS_REVIEW_GUIDE.md) §2 / §5. + ### 7.1 `Response` surface `Response` at `Response.java:26` is the carrier consumers see for every successful call. diff --git a/docs/adr/ADR-008-endpoint-parameter-convention.md b/docs/adr/ADR-008-endpoint-parameter-convention.md index 84ec7f0..40e5cdd 100644 --- a/docs/adr/ADR-008-endpoint-parameter-convention.md +++ b/docs/adr/ADR-008-endpoint-parameter-convention.md @@ -2,7 +2,14 @@ ## Status -Proposed. +**Accepted** (2026-06-08). Implemented on branch `10_options_resource`. + +Decided as **Option A** (request object + builder) — the `Consumer` overload (Option +B) was *not* adopted. Two pieces the original Context flagged but didn't resolve were settled +here in the Decision below: the **§3 universal parameters** are a fluent resource-level config +(not a request-object overload), and the **output/format axis** is a typed **resource facet** +(not a parameter). The Context and Options-Considered sections are preserved as the decision +rationale; the Decision/Consequences reflect what shipped. ## Context @@ -226,48 +233,71 @@ structurally). ## Decision -*Pending team ratification (status: Proposed).* - -Recommended: **Option A + Option B.** Each endpoint keeps a single -immutable request object (`builder(required…)` / `of(required…)`) as the -canonical form feeding both sync and async surfaces, and additionally -exposes a `foo(String required, Consumer)` overload -(plus a bare `foo(String required)` for the no-optional case) as the -ergonomic front door. The sealed mutually-exclusive filter groups -(`ExpirationFilter`, `StrikeFilter`) are retained unchanged. Universal -parameters (§3, deferred to `stocks`) retrofit as a second request-object -overload, not as additional builder state. - -Options C (transport-bound fluent terminal) and D (flat params object) -were considered and are not recommended — C because it sacrifices the -decoupled request object, introduces an un-enforceable -dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK -call-shape outlier; D because it discards the compile-time -mutual-exclusivity guarantee that is the Java SDK's one advantage over -its siblings. +**Option A — one immutable request object per endpoint** (`builder(required…)` / +`of(required…)`), feeding both the sync and async surfaces. **No `String` convenience +overloads and no `Consumer` overload (Option B):** the team chose the minimal, +uniform surface — the "counter-recommendation" the analysis above weighed — over the lambda +front door. B stays purely additive, so it can be added later if call-site ceremony bites; it +was not worth the overload-count cost up front. The sealed mutually-exclusive filter groups +(`ExpirationFilter`, `StrikeFilter`) are retained unchanged. + +Two things resolved **differently from the original recommendation**, because a request-object +overload turned out to be the wrong axis for them: + +- **Universal parameters are not a second request-object overload.** They are set fluently on + the **resource** — `client.options().dateFormat(…).mode(…).limit(…).offset(…).columns(…)`, + an immutable configured value carried across endpoints via `RequestConfig`. Type-preserving + params (`dateFormat`/`mode`/`limit`/`offset`/`columns`) sit on the typed resource; the + output-shaping `human`/`headers` live only on the CSV facet (they reshape the payload, so they + don't cohere with the typed decode). +- **The output/format axis** — which a request-object parameter *cannot* express in a + statically-typed language (the return type would have to vary at runtime) — is selected via a + **typed resource facet** (`.asCsv()` → `CsvResponse`; `.asHtml()` built but not yet exposed), + not a parameter. + +Options C (transport-bound fluent terminal) and D (flat params object) were considered and +rejected — C because it sacrifices the decoupled request object, introduces an un-enforceable +dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK call-shape outlier; D +because it discards the compile-time mutual-exclusivity guarantee that is the Java SDK's one +advantage over its siblings. + +### Nullable response fields + Option A (the cost of `columns`) + +Exposing `columns` on the **typed** path forced a response-side decision. A projected response +omits the columns you didn't ask for, so the typed model can't keep "always present" fields: +**every response-row field is `@Nullable`** (boxed) — including structural ones like `strike` +and `bid`, not just the legitimately-optional values (the option greeks / `iv`). + +To stop that from degrading into *silent* failures, the deserializer reads every column +leniently but then enforces **Option A**: it cross-references the columns the consumer actually +requested (threaded in via a parser attribute) and raises a `ParseError` if a **required** +column that was requested — or wasn't projected away (no `columns` filter ⇒ all required +columns are implicitly requested) — is missing from the response. The contract that buys: + +- a `null` a consumer sees means **only** "I projected it away with `columns`" or "a + legitimately-optional model value (greek/IV)"; +- it **never** means "the backend silently dropped a required field" — that is always a + `ParseError`. + +So the nullable fields do **not** weaken the strict-by-default decode; they just make +projection representable. (Greek presence is additionally inspectable via +`OptionQuote.presentGreeks()` / `greek(Greek)`.) ## Consequences -Follow-on work implied by each option. The recommended option is marked. - -- **A (request object only):** One signature per endpoint. Call sites - always name `FooRequest.builder(…).build()` (or `of(…)`). No lambda - overloads. The convention already shipped in the `options` PR. -- **A + B (recommended):** Every endpoint additionally gets - `foo(String, Consumer)` and `foo(String)` overloads - delegating to `foo(FooRequest)`. Applied uniformly across `options` - and every future resource. Docs name `foo(FooRequest)` as canonical - (reuse / conditional construction) and the lambda overload as the - quick path. Each new endpoint adds ~6 lines of delegating overloads. -- **C (fluent terminal):** All six endpoints return transport-bound - builders with `fetch()` / `fetchAsync()` terminals; ADR-006 amended so - the terminal pair is the documented endpoint surface; ErrorProne - `@CheckReturnValue` added on builder setters to mitigate the - dangling-terminal footgun; the inert request object is dropped or - maintained as a second surface. -- **D (flat params object):** Sealed `ExpirationFilter` / `StrikeFilter` - collapse into independent optional fields on a mutable params object; - mutual-exclusivity moves to `build()`/wire-time validation. +What actually shipped, and what was left out. + +- **A (shipped):** One signature per endpoint; call sites always name + `FooRequest.builder(…).build()` (or `of(…)`); no lambda overloads. Shipped for `options` and + is the template every future resource (`stocks`/`funds`/`markets`) copies. +- **B (not adopted):** the `foo(String, Consumer)` / `foo(String)` overloads were + considered and deliberately left out — purely additive, so revisitable without a breaking + change if call-site ceremony proves annoying in practice. +- **Universal parameters:** fluent setters on the **resource** (`RequestConfig`), applied + across endpoints — *not* the "second request-object overload" the recommendation originally + envisioned. Implemented for `options`; `human`/`headers` and the format facet land on the CSV + view. +- **C (fluent terminal) / D (flat params object):** not adopted (rejected as above). ## References From 90ed9de81ad9667665cddce5cf904cc5a31bef08 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 9 Jun 2026 10:01:30 -0300 Subject: [PATCH 14/14] add tests --- .../marketdata/sdk/OptionsResourceTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 950c3d0..03fe3d1 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -833,6 +833,64 @@ void asHtmlFacetSendsFormatHtml() { assertThat(client.captured.get(0).uri().toString()).contains("format=html"); } + @Test + void csvFacetUniversalAndShapingParamsReachTheWire() { + CapturingClient client = okWith("optionSymbol,strike\nAAPL250117C00150000,150"); + OptionsResource options = resourceWith(client); + + options + .asCsv() + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .columns("optionSymbol", "strike") + .human(true) // output-shaping — CSV-only + .headers(true) // output-shaping — CSV-only + .chain(OptionsChainRequest.of("AAPL")); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url) + .contains("format=csv") + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("limit=50") + .contains("offset=10") + .contains("columns=optionSymbol,strike") + .contains("human=true") + .contains("headers=true"); + } + + @Test + void csvFacetCoversEveryEndpoint() { + CapturingClient client = okWith("a,b\n1,2"); + OptionsCsvResource csv = resourceWith(client).asCsv(); + + assertThat(csv.chain(OptionsChainRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).csv()).contains("a,b"); + assertThat(csv.strikes(OptionsStrikesRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.expirations(OptionsExpirationsRequest.of("AAPL")).csv()).contains("a,b"); + + Map fanout = + csv.quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + assertThat(fanout).hasSize(2); + assertThat(fanout.values()).allSatisfy(r -> assertThat(r.csv()).contains("a,b")); + } + + @Test + void htmlFacetCoversEveryEndpoint() { + CapturingClient client = okWith("x"); + OptionsHtmlResource html = resourceWith(client).asHtml(); + + assertThat(html.chain(OptionsChainRequest.of("AAPL")).html()).contains(""); + assertThat(html.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).html()).contains(""); + assertThat(html.strikes(OptionsStrikesRequest.of("AAPL")).html()).contains(""); + assertThat(html.expirations(OptionsExpirationsRequest.of("AAPL")).html()).contains(""); + } + // ---------- response metadata (§13.5 / §16) ---------- @Test