From b4054b7c82134c617a500daa29724f14a5e4d521 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Fri, 30 Oct 2026 10:10:34 -0400 Subject: [PATCH] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Added tests for the `dte` query parameter of `GET /v1/options/chain/{underlying}/`, widened from integer to a string expression matching strike/delta syntax. Wire tests in test_chain_filters.py cover the single-value regression plus the three new forms (comma list, closed range, comparison operator `>=`/`<`) across all four output formats. Live tests in test_options_live.py cover the same forms against the real API, deriving boundary values from actual expirations rather than guessed constants, and assert inclusive/exclusive boundary behavior for the range and operator forms. No validation tests were added since `days_to_expiration` is an existing field and the delta adds no new public symbol; wrote build/sdk-sync-compat/probe.py covering the existing `OptionsChainInput(dte=...)` constructor with today's `int` type. implement: Widened `OptionsChainInput.days_to_expiration` (alias `dte`) from `int | None` to `int | str | None`: the plain int form still works unchanged, and a string now carries the API's expression syntax (comma list, closed range, comparison operator), matching `strike`/`delta`. - Doc comment/example: updated the field's `description` and added a "Filter by days to expiration" example in docs/options.md, mirroring the strike/delta style. - Every new public member documented: no new method/class was added, only the existing field's description, which is updated. - Wire test per form: test_chain_filters.py covers single value (regression), comma list, closed range, `>=`, `<`, each across all four output formats. - New-type validation on every path, empty/null elements: not applicable, `dte` is a scalar (int/str), not a collection. - Coverage: no `# pragma: no cover` added; full suite at 100% coverage. - Examples/docs still correct: the strike/delta sections are untouched and still accurate. - No unrequested client-side check: the field only widened its annotation, no new local validation, so an out-of-range or malformed value still reaches the API exactly as before. - Existing parameter keeps its old form: `days_to_expiration=30` (int) still type-checks and sends `dte=30`; confirmed with the compatibility probe against the widened field. Left as-is: README.md and the other options-chain tests/files only mention the unrelated response-side `dte` field (`list[int]`) or unrelated resources, not this query parameter, so nothing there was wrong or incomplete. review fix 1: Fixed the two major findings: reworded the `days_to_expiration` description in docs/options.md and src/marketdata/input_types/options.py to the repo's own grammar — a closed range is inclusive, `>=`/`<=`/`>`/`<` are a one-sided bound, dropping the false claim that operators are inclusive — and added `test_dte_closed_range_spans_every_expiration_within_its_bounds` to test_options_live.py, which builds its range from two independent single-value baseline calls (dte=15, dte=45) and asserts every returned dte is within [low, high], both bounds are present, and more than one expiration came back, so a server that only matched the nearest value can no longer pass it. Resolved both actionable minor notes: added a `days_to_expiration="30"` (str) row to the wire-test parametrization in test_chain_filters.py, and added the "at most 36500" cap to both the field description and docs/options.md. Resolved the flakiness note by making `live_chain_dte_nearest` function-scoped instead of module-scoped, so each of the boundary tests (single-value, collapsed range, `>=`, `<`) derives its own boundary from its own fresh `dte=90` call instead of reusing one call's value across several later live calls. Left the question-for-a-person finding (the API's own description implying `=30"` or + `"<45"` for a one-sided bound (the syntax matches `strike` and `delta`). + Values are whole numbers of days, at most 36500. Should not be used + together with `from_date` and `to_date`; take care combining with + `weekly`, `monthly` or `quarterly`, since that limits the expirations + `dte` can return. With `date`, `dte` is relative to the date given - `from_date` (datetime.date, optional): Filter expirations from this date - `to_date` (datetime.date, optional): Filter expirations to this date - `month` (int, optional): Filter by expiration month (1-12) @@ -296,6 +304,21 @@ chain = client.options.chain( print(chain) ``` +**Filter by days to expiration:** + +```python +from marketdata import MarketDataClient + +client = MarketDataClient() +# symbol can be passed positionally or as keyword argument +# a number is the closest expiration to it; a string is an expression, with +# the same syntax as strike and delta: "15,30,45", "0-45", ">=30", "<45" +chain = client.options.chain("AAPL", days_to_expiration=30) +# or +chain = client.options.chain("AAPL", days_to_expiration="15,30,45") +print(chain) +``` + **Filter by strike and side:** ```python diff --git a/src/marketdata/input_types/options.py b/src/marketdata/input_types/options.py index b41881d..89debc8 100644 --- a/src/marketdata/input_types/options.py +++ b/src/marketdata/input_types/options.py @@ -35,8 +35,18 @@ class OptionsChainInput(BaseInputType): expiration: datetime.date | Literal["all", "ALL"] | None = Field( description="The expiration date to filter by", default=None ) - days_to_expiration: int | None = Field( - description="The number of days to expiration to filter by", + days_to_expiration: int | str | None = Field( + description=( + "Days to expiry: a single value (the closest expiration to it), a " + "comma-separated list such as '15,30,45' (the closest expiration " + "per value, deduplicated), '0-45' for an inclusive range, or " + "'>=30' or '<45' for a one-sided bound (the syntax matches " + "strike and delta). Values are whole numbers of days, at most " + "36500. Should not be used together with from_date and to_date; " + "take care combining with weekly, monthly or quarterly, since " + "that limits the expirations dte can return. With date, dte is " + "relative to the date given" + ), alias="dte", default=None, ) diff --git a/src/tests/integration/test_options_live.py b/src/tests/integration/test_options_live.py index bb5dd0f..102859f 100644 --- a/src/tests/integration/test_options_live.py +++ b/src/tests/integration/test_options_live.py @@ -117,6 +117,138 @@ def test_quotes_return_expected_shape( assert isinstance(quotes.openInterest[0], int) +@pytest.fixture +def live_chain_dte_nearest(live_client: MarketDataClient) -> OptionsChain: + # Far enough out (about three months) that AAPL always has both a nearer + # and a farther expiration to tell the boundary tests below apart. + # Function-scoped (unlike `live_chain` above) so each test derives its + # boundary from its own fresh call: a module-scoped boundary reused by + # several later calls would drift by one if the run crossed midnight + # US/Eastern between them. + chain = live_client.options.chain( + UNDERLYING, days_to_expiration=90, output_format=OutputFormat.INTERNAL + ) + assert isinstance(chain, OptionsChain), chain + assert chain.s == "ok" + return chain + + +def test_dte_single_value_narrows_to_the_nearest_expiration( + live_chain_dte_nearest: OptionsChain, +): + """2.0.0: a single `dte` still limits the chain to the one expiration + closest to the value given, the way the plain integer did before the + API widened the parameter to a string.""" + assert len(set(live_chain_dte_nearest.expiration)) == 1 + assert len(set(live_chain_dte_nearest.dte)) == 1 + + +def test_dte_comma_list_unions_the_nearest_expiration_per_value( + live_client: MarketDataClient, +): + """A comma-separated `dte` selects the closest expiration for each value, + deduplicated: the union of three single-value calls.""" + targets = (15, 30, 45) + expected_expirations: set[datetime.datetime] = set() + for target in targets: + single = live_client.options.chain( + UNDERLYING, days_to_expiration=target, output_format=OutputFormat.INTERNAL + ) + assert single.s == "ok" + expected_expirations |= set(single.expiration) + + combined = live_client.options.chain( + UNDERLYING, + days_to_expiration=",".join(str(target) for target in targets), + output_format=OutputFormat.INTERNAL, + ) + + assert combined.s == "ok" + assert set(combined.expiration) == expected_expirations + + +def test_dte_closed_range_collapsed_to_a_point_includes_that_point( + live_client: MarketDataClient, live_chain_dte_nearest: OptionsChain +): + """A closed range is inclusive: collapsed to a single boundary (`N-N`) it + must return exactly the expiration a bare `dte=N` returns.""" + boundary = live_chain_dte_nearest.dte[0] + + collapsed = live_client.options.chain( + UNDERLYING, + days_to_expiration=f"{boundary}-{boundary}", + output_format=OutputFormat.INTERNAL, + ) + + assert collapsed.s == "ok" + assert set(collapsed.expiration) == set(live_chain_dte_nearest.expiration) + + +def test_dte_closed_range_spans_every_expiration_within_its_bounds( + live_client: MarketDataClient, +): + """A closed range is not only the nearest expiration to either end: it + returns every expiration whose `dte` falls between the two, inclusive.""" + low_chain = live_client.options.chain( + UNDERLYING, days_to_expiration=15, output_format=OutputFormat.INTERNAL + ) + high_chain = live_client.options.chain( + UNDERLYING, days_to_expiration=45, output_format=OutputFormat.INTERNAL + ) + assert low_chain.s == "ok" + assert high_chain.s == "ok" + low, high = low_chain.dte[0], high_chain.dte[0] + assert low < high, "the two baseline calls landed on the same expiration" + + ranged = live_client.options.chain( + UNDERLYING, + days_to_expiration=f"{low}-{high}", + output_format=OutputFormat.INTERNAL, + ) + + assert ranged.s == "ok" + assert all(low <= dte <= high for dte in ranged.dte) + assert low in set(ranged.dte) + assert high in set(ranged.dte) + # A chunk ignoring the range would still answer with the nearest + # expiration alone; the range has to bring back more than that one. + assert len(set(ranged.expiration)) > 1 + + +def test_dte_at_least_includes_the_boundary( + live_client: MarketDataClient, live_chain_dte_nearest: OptionsChain +): + """`>=N` is inclusive of `N` itself, the way `strike` and `delta` read it.""" + boundary = live_chain_dte_nearest.dte[0] + + at_or_after = live_client.options.chain( + UNDERLYING, + days_to_expiration=f">={boundary}", + output_format=OutputFormat.INTERNAL, + ) + + assert at_or_after.s == "ok" + assert all(dte >= boundary for dte in at_or_after.dte) + assert boundary in set(at_or_after.dte) + + +def test_dte_below_excludes_the_boundary( + live_client: MarketDataClient, live_chain_dte_nearest: OptionsChain +): + """`=30", ">=30"), + ("<45", "<45"), + ], + ids=[ + "single_value_int", + "single_value_str", + "comma_list", + "closed_range", + "at_least", + "below", + ], +) +@EVERY_FORMAT +def test_the_dte_query_string_carries_the_expression_the_api_reads( + respx_mock, client, dte, sent, output_format +): + """2.0.0: `dte` widened from an integer to an expression string, with the + same syntax as `strike` and `delta`: a single value (as an int or as the + same digits in a str), a comma-separated list, a closed range and a + comparison operator.""" + respx_mock.get(url__startswith=CHAIN_URL).respond( + json={"s": "no_data"}, status_code=404 + ) + + client.options.chain("AAPL", days_to_expiration=dte, output_format=output_format) + + params = respx_mock.calls.last.request.url.params + assert params["dte"] == sent