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