Skip to content
Closed
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 @@ -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

Expand Down
27 changes: 26 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` (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)
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/marketdata/input_types/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
124 changes: 124 additions & 0 deletions src/tests/integration/test_options_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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="<bound"` drops the bound itself, unlike the closed range."""
bound, later = live_chain.dte[0], live_dte_45.dte[0]
assert bound <= later

chain = live_client.options.chain(
UNDERLYING,
days_to_expiration=f"<{later}",
strike_limit=2,
output_format=OutputFormat.INTERNAL,
)

assert isinstance(chain, OptionsChain), chain
assert later not in chain.dte
assert all(dte < later for dte in chain.dte)
if bound < later:
assert bound in chain.dte


def test_quotes_return_expected_shape(
live_client: MarketDataClient, live_chain: OptionsChain
):
Expand Down
30 changes: 26 additions & 4 deletions src/tests/test_options_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,20 +383,42 @@ def test_options_chain_strike_limit_is_int_on_wire(load_json, respx_mock, client
assert params.get("strikeLimit") == "10"


def test_options_chain_input_days_to_expiration_alias_on_wire(
load_json, respx_mock, client
@pytest.mark.parametrize(
("passed", "sent"),
[
(30, "30"),
("30", "30"),
("15,30,45", "15,30,45"),
("0-45", "0-45"),
(">=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
)

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
Loading