From d0f444171b18ead830a7a9736501c71b1eb81ada Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 29 Oct 2026 11:47:25 -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 wire tests in test_options_chain.py for the four new `dte` forms the API now accepts (comma-separated list, closed range, `>=` and `<` operators), plus the pre-existing single-value int/str forms as regression, parametrizing `test_options_chain_input_days_to_expiration_forms_on_wire`. Added live integration tests in test_options_live.py (single-value narrowing, comma-list dedup against per-value ground truth, closed-range inclusive endpoints, `>=` and `<` boundary inclusion/exclusion), using module-scoped fixtures at dte 15/30/45 to anchor assertions in real data instead of server defaults. Wrote the compatibility probe calling `OptionsChainInput(symbol=..., dte=)`, the only existing public member this delta touches. implement: Widened `OptionsChainInput.days_to_expiration` (alias `dte`) from `int | None` to `str | int | None`, since the API's `dte` query parameter now also reads a string expression (comma list, closed range, comparison operator), matching `strike`/`delta` syntax; a bare int is still sent unchanged. - Docs with example: updated `docs/options.md`'s `days_to_expiration` bullet and added a "Filter by days to expiration" example showing all four forms. - New member docstrings: none added; only the existing field's `Field(description=...)` was updated to state the new forms. - Wire test per new form: four phase-1 tests (comma list, closed range, `>=`, `<`) plus the regression single-value int/str case all pass against the new union type. - Validation of a new type on every construction path, empty/null handling: not applicable, no new type was introduced; the field stays a plain `str | int | None`, pass-through to the API as `strike`/`delta` already do. - Coverage: confirmed 100% (`--cov=marketdata`), no new branches added. - Examples/docs correctness: re-checked `README.md` and `examples/`, neither mentions `days_to_expiration`/`dte`, so neither was touched. - Existing parameter keeps accepting its old type: `int` still type-checks and serializes identically (`dte=30`), confirmed by the full suite; the phase-1 probe (which only used today's `int`) has since been removed from the checkout by the pipeline, as expected. - No disagreement between live API and documentation to report; I could not run the live suite here, and the new docs/tests only restate the API's own published description of `dte`. Files named in the brief but left unchanged, with reason: `src/marketdata/output_types/options_chain.py` and `options_quotes.py` (the response's `dte` field is still `list[int]`, unaffected — this delta is request-side only); `src/marketdata/resources/options/chain.py` and `lookup.py` (matched on the word "options", not `dte`); `README.md`, the `stocks_news` fixtures, `test_fan_out_retry.py`, `test_client.py`, `test_response_meta*.py`, `test_status_mapping.py`, `test_money_decimal.py`, `test_options_expirations.py`, `test_options_lookup.py`, `test_options_quotes.py` (all match on unrelated "options"/"dte-as-response-field" content, none on the request parameter). Outside my limits and left as-is: `CHANGELOG.md` (pipeline writes the entry), `.github/BUG_FINDING.md`, `.github/ISSUE_WORKFLOW.md`, `.github/ISSUE_TEMPLATE/bug.yml` — none describe this parameter, so nothing there is left incorrect by this change. review fix 1: Fixed the major finding: the `days_to_expiration` field description in src/marketdata/input_types/options.py claimed the comparison-operator form is inclusive, which contradicts the API's own `<`/`>` semantics and the live test that proves `=`/`<=` and exclusive with `>`/`<` — matching `filters.py`'s existing strike/delta wording and the already-correct phrasing in `docs/options.md`, which needed no change. The disagreement to name for the API team: its description reads "(>=30, <45) returns every expiration whose dte falls within the bounds, inclusive", but a live `<45` call excludes dte=45; the SDK's docs now state only the operator-symbol semantics both sides agree on, not the blanket "inclusive" claim. Resolved the first minor finding: deleted the now-redundant pre-existing `test_options_chain_input_days_to_expiration_alias_on_wire` in src/tests/test_options_chain.py, since its one assertion (`dte=30` sent, `days_to_expiration` absent) is exactly the `single_int` case of the new parametrized test. Left the second minor finding (a checked `DteFilter` builder, parallel to `StrikeFilter`/`DeltaFilter`) unresolved, as the reviewer offered no fix and flagged it as a maintainer decision, not a defect in this change. Re-ran the full suite and lint: 2169 passed (one fewer than before, from the deletion), coverage still 100% (3297/3297), ruff check and format both clean. changelog: Added changelog entry under `### Added` documenting `OptionsChainInput.days_to_expiration` (alias `dte`) parameter type widening from `int | None` to `str | int | None` to accept comma-separated days, closed ranges, and comparison operators matching the `strike` and `delta` syntax (API release 2.0.0), with `int` forms unchanged. --- CHANGELOG.md | 1 + docs/options.md | 27 ++++- src/marketdata/input_types/options.py | 15 ++- src/tests/integration/test_options_live.py | 124 +++++++++++++++++++++ src/tests/test_options_chain.py | 30 ++++- 5 files changed, 190 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc9da6..3cd3924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **404 `no_data` is no longer an error.** A valid question with an empty answer returns the empty value for the output format: a DataFrame with the model's columns and no rows, `[]` or `None` for `INTERNAL`, the `{"s": "no_data"}` body for `JSON`, a header-only file for `CSV`. In the fan-out calls a chunk or symbol with no data is left out of the merge (#62) - Request-scoped response metadata: `marketdata.get_meta(result)` returns a `ResponseMeta` (`status_code`, `request_id`, `rate_limits`, `responses`) on every output format; for a call made of several requests (candle chunks, option symbols, retried attempts) the credits add up, the balance is the lowest count of the newest reset window, and `status_code` / `request_id` come from the last response that could have contributed to the result. `get_meta()` also takes the exception a failed call raised, so the credits a failure was billed are readable; there `status_code` / `request_id` are those of the request the exception is about, as on the exception itself (`None` where it says `"N/A"`, and `0` / `None` when the SDK has no response for it), and an exception that is not the SDK's own keeps the rule of a successful call (#104). `ResponseMeta`, `get_meta` and `UserRateLimits` are exported from the package root (#49) - A `Lint` check on every pull request: `ruff` replaces black and isort as linter, import sorter and formatter, and adds unused-import and undefined-name detection that neither of them did. Nothing on the path to merge checked formatting or lint before, so an import left in the wrong order and a dead import both reached `main` (#85). The pre-commit hooks run the same ruff, the one `uv.lock` pins, through uv, so bumping ruff in `uv.lock` needs no other change (#152) +- `OptionsChainInput.days_to_expiration` (alias `dte`) now accepts string expressions for expiration filters—comma-separated days, closed ranges, or comparison operators (API release 2.0.0); an `int` works as before. ### Fixed diff --git a/docs/options.md b/docs/options.md index 648cd37..557a6fd 100644 --- a/docs/options.md +++ b/docs/options.md @@ -189,7 +189,15 @@ Fetches the options chain for a given symbol with extensive filtering options. T - `date` (datetime.date, optional): Filter by specific expiration date - `expiration` (datetime.date, optional): Filter by expiration date -- `days_to_expiration` (int, optional): Filter by days until expiration +- `days_to_expiration` (str | int, optional): Filter by days until expiration. A + number is a single value: the closest expiration to it. A string is the API's + expression, the same syntax as `strike` and `delta`: `"15,30,45"` picks the + closest expiration to each value, deduplicated; `"0-45"` is an inclusive + closed range; `">=30"` or `"<45"` is a one-sided bound. Values are whole + numbers of days, at most 36500. Should not be used together with `from_date` + and `to_date`. Take care before combining with `weekly`, `monthly` or + `quarterly`, since that limits the expirations this can return. Relative to + `date` when `date` is 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,23 @@ chain = client.options.chain( print(chain) ``` +**Filter by days to expiration:** + +```python +from marketdata import MarketDataClient + +client = MarketDataClient() +# a single value: the expiration closest to 30 days out +chain = client.options.chain("AAPL", days_to_expiration=30) +# a comma-separated list: the closest expiration to each value, deduplicated +chain = client.options.chain("AAPL", days_to_expiration="15,30,45") +# a closed range, inclusive on both ends +chain = client.options.chain("AAPL", days_to_expiration="0-45") +# a comparison operator +chain = client.options.chain("AAPL", days_to_expiration=">=30") +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..9d7ed59 100644 --- a/src/marketdata/input_types/options.py +++ b/src/marketdata/input_types/options.py @@ -35,8 +35,19 @@ 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: str | int | None = Field( + description=( + "The days to expiration to filter by: a whole number of days (at " + "most 36500), or an expression the API reads the same way as " + "`strike` and `delta`: a comma-separated list such as " + "'15,30,45' picks the closest expiration to each value, " + "deduplicated; a closed range such as '0-45' returns every " + "expiration whose dte falls within the bounds, inclusive; a " + "comparison operator such as '>=30' or '<45' is a one-sided " + "bound, inclusive with `>=`/`<=` and exclusive with `>`/`<`. " + "Should not be used together with from_date and to_date. If " + "date is given, this is relative to it" + ), alias="dte", default=None, ) diff --git a/src/tests/integration/test_options_live.py b/src/tests/integration/test_options_live.py index bb5dd0f..f35550e 100644 --- a/src/tests/integration/test_options_live.py +++ b/src/tests/integration/test_options_live.py @@ -57,6 +57,33 @@ def live_chain(live_client: MarketDataClient) -> OptionsChain: return chain +@pytest.fixture(scope="module") +def live_dte_15(live_client: MarketDataClient) -> OptionsChain: + # A second single-value `dte` point, nearer than `live_chain`'s 30, used + # to prove the range and comparison-operator forms against real data. + chain = live_client.options.chain( + UNDERLYING, + days_to_expiration=15, + strike_limit=2, + output_format=OutputFormat.INTERNAL, + ) + assert isinstance(chain, OptionsChain), chain + return chain + + +@pytest.fixture(scope="module") +def live_dte_45(live_client: MarketDataClient) -> OptionsChain: + # A third single-value `dte` point, farther than `live_chain`'s 30. + chain = live_client.options.chain( + UNDERLYING, + days_to_expiration=45, + strike_limit=2, + output_format=OutputFormat.INTERNAL, + ) + assert isinstance(chain, OptionsChain), chain + return chain + + def test_expirations_return_future_dates(live_client: MarketDataClient): expirations = live_client.options.expirations( UNDERLYING, output_format=OutputFormat.INTERNAL @@ -94,6 +121,103 @@ def test_chain_returns_expected_shape(live_chain: OptionsChain): assert not any(isinstance(v, Decimal) for v in getattr(live_chain, field)) +def test_dte_single_value_narrows_to_one_expiration(live_chain: OptionsChain): + """`days_to_expiration=30` (2.0.0: `dte` reads a string expression, a + single value included) keeps only the expiration nearest to 30 days out, + so every contract in the answer shares one `dte`.""" + assert len(set(live_chain.dte)) == 1 + + +def test_dte_comma_list_selects_the_closest_expiration_per_value( + live_client: MarketDataClient, + live_dte_15: OptionsChain, + live_chain: OptionsChain, + live_dte_45: OptionsChain, +): + """`dte="15,30,45"` selects, for each value, the expiration nearest to + it, deduplicated; the ground truth is each value queried on its own.""" + expected = {live_dte_15.dte[0], live_chain.dte[0], live_dte_45.dte[0]} + + chain = live_client.options.chain( + UNDERLYING, + days_to_expiration="15,30,45", + strike_limit=2, + output_format=OutputFormat.INTERNAL, + ) + + assert isinstance(chain, OptionsChain), chain + assert set(chain.dte) == expected + + +def test_dte_closed_range_includes_both_endpoints( + live_client: MarketDataClient, + live_dte_15: OptionsChain, + live_chain: OptionsChain, +): + """`dte="low-high"` is a closed range, inclusive on both ends. `low` and + `high` are two expirations the SDK already knows exist (nearest to 15 and + to 30 days), so both must come back in the ranged answer.""" + low, high = sorted((live_dte_15.dte[0], live_chain.dte[0])) + + chain = live_client.options.chain( + UNDERLYING, + days_to_expiration=f"{low}-{high}", + strike_limit=2, + output_format=OutputFormat.INTERNAL, + ) + + assert isinstance(chain, OptionsChain), chain + assert low in chain.dte + assert high in chain.dte + assert all(low <= dte <= high for dte in chain.dte) + + +def test_dte_at_least_operator_is_inclusive_of_the_bound( + live_client: MarketDataClient, + live_dte_15: OptionsChain, + live_chain: OptionsChain, +): + """`dte=">=bound"` keeps the bound itself and drops anything nearer.""" + earlier, bound = live_dte_15.dte[0], live_chain.dte[0] + assert earlier <= bound + + chain = live_client.options.chain( + UNDERLYING, + days_to_expiration=f">={bound}", + strike_limit=2, + output_format=OutputFormat.INTERNAL, + ) + + assert isinstance(chain, OptionsChain), chain + assert bound in chain.dte + assert all(dte >= bound for dte in chain.dte) + if earlier < bound: + assert earlier not in chain.dte + + +def test_dte_below_operator_excludes_the_bound( + live_client: MarketDataClient, + live_dte_45: OptionsChain, + live_chain: OptionsChain, +): + """`dte="=30", ">=30"), + ("<45", "<45"), + ], + ids=[ + "single_int", + "single_str", + "comma_list", + "closed_range", + "at_least_operator", + "below_operator", + ], +) +def test_options_chain_input_days_to_expiration_forms_on_wire( + load_json, respx_mock, client, passed, sent ): + """`dte` now reads a string expression (2.0.0): a single value, a + comma-separated list, a closed range, or a comparison operator, the same + syntax as `strike` and `delta`. A bare int is still sent as before.""" mock_data = load_json("options_chain_response_200") respx_mock.get("https://api.marketdata.app/v1/options/chain/AAPL/").respond( json=mock_data, status_code=200 @@ -393,10 +415,10 @@ def test_options_chain_input_days_to_expiration_alias_on_wire( client.options.chain( "AAPL", - days_to_expiration=30, + days_to_expiration=passed, output_format=OutputFormat.INTERNAL, ) params = respx_mock.calls.last.request.url.params - assert params.get("dte") == "30" + assert params.get("dte") == sent assert params.get("days_to_expiration") is None