diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc9da6..1288eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `OptionsChainInput.days_to_expiration` also accepts `str`, in the forms the API documents — comma-list, closed range, and comparison operators, matching `strike` and `delta` (API release 2.0.0). An `int` works as before. - `marketdata.get_extra(model)` reads the columns an `OutputFormat.INTERNAL` answer carried that its model does not declare, keyed as the API sent them (#111) - `StrikeFilter` builds the `strike` filter of `options.chain` from its shapes instead of from a hand-written string: `exact`, `any_of`, `between`, `at_least`, `at_most`, `above`, `below`, and `expression` for anything the others do not name, the way sdk-go and sdk-csharp give them. `DeltaFilter` does the same for `delta`, which the API reads with the same grammar, with `nearest` in place of `exact`: the API answers a single delta with the contract nearest to it. Every `DeltaFilter` constructor but `expression` raises `ValueError` for a delta outside -1 to 1. Both parameters also take a plain `int`, `float` or `Decimal` now, which `strike` refused before while the public docs typed it `float`, so a strike read from an INTERNAL chain can be passed straight back. A negative **number** is refused rather than sent: the API reads a number by its absolute value, so `strike=-250` used to answer with the strikes at `250` and no error. A string is still passed through as written, since it is the expression form and the API is what reads it. A negative delta is kept, since there the absolute value is the documented filter and both sides come back either way. A number travels as plain digits, never in exponent form: the API reads a query of `delta=0.00005`, and answers `delta=5e-05` with a `400`, since it splits that text at its minus. A number a float cannot hold is refused, since the API reads each one with `float()` and would take it for an infinity or for 0 (#101) - The API's IP headers, for accounts that restrict access by IP (#44). A 403 that blocks an address raises `ForbiddenError` with `authorized_ip`, the address the account is authorized for, taken from `X-API-Authorized-IP` and repeated in the message, so a caller who prints the error knows what to allow. `marketdata.get_meta(result).detected_ip` is the address the call came from, taken from `X-API-Detected-IP` on every answer the API serves. Both read `None` when the API sent no header: an account allowed to call from several addresses, a staff token, the Sheets add-on, or an address it could not resolve. The legacy `X-API-BLOCKED-IP` is not read: the API is removing it (MarketData-App/api#202) diff --git a/docs/options.md b/docs/options.md index 648cd37..9a8c53a 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` (int | str, optional): Days to expiry. A number is a + single value, the closest expiration to it. A string is the API's + expression: `"15,30,45"` for a comma-separated list, the closest expiration + per value, deduplicated; `"0-45"` for an inclusive range; `">=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