Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/marketdata/input_types/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
132 changes: 132 additions & 0 deletions src/tests/integration/test_options_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
"""`<N` leaves `N` itself out, the way `strike` and `delta` read it."""
boundary = live_chain_dte_nearest.dte[0]

before = live_client.options.chain(
UNDERLYING,
days_to_expiration=f"<{boundary}",
output_format=OutputFormat.INTERNAL,
)

assert before.s == "ok"
assert all(dte < boundary for dte in before.dte)
assert boundary not in set(before.dte)


def test_lookup_resolves_a_human_readable_contract(
live_client: MarketDataClient, live_chain: OptionsChain
):
Expand Down
37 changes: 37 additions & 0 deletions src/tests/test_chain_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,40 @@ def test_a_delta_is_checked_the_same_under_the_callers_decimal_context(
message = "delta " + OUT_OF_RANGE.format(outside)
assert str(refusal.value) == message
assert message in str(bare_refusal.value)


@pytest.mark.parametrize(
("dte", "sent"),
[
(30, "30"),
("30", "30"),
("15,30,45", "15,30,45"),
("0-45", "0-45"),
(">=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
Loading