Contents Lesson 1 of 16

4 min read · practitioner

Why does one stock turn into fourteen thousand rows?

A share of Apple is one instrument. Ask an API for its history and you get one row per trading day. Ask the same API for Apple's options and something very different happens: the published example in EODHD's own OpenAPI specification for /mp/unicornbay/options/contracts, filtered to filter[underlying_symbol]=AAPL, reports meta.total: 14394.

Fourteen thousand contracts. One company. Understanding why is the whole of this unit.

A contract has four coordinates, not one

A stock is identified by a ticker. An option contract is identified by four things at once:

  • the underlying it references (AAPL)
  • the expiration date (2027-12-17)
  • the strike price (420)
  • the right — call or put

Change any one and it is a different contract with its own price, its own volume and its own row. The chain is not a list; it is a grid with four axes, and grids multiply. Thirty expirations times two hundred and forty strikes times two rights is 14,400 — within a rounding error of what the API actually returns for Apple.

This is why options data is measured in gigabytes where equity data is measured in megabytes, and why every design decision in the endpoint is about narrowing before you fetch.

Filters are the interface, not pagination

The contracts endpoint gives you filters for exactly the coordinates above:

  • filter[underlying_symbol] and filter[contract] (an exact contract name)
  • filter[exp_date_eq], filter[exp_date_from], filter[exp_date_to]
  • filter[strike_eq], filter[strike_from], filter[strike_to]
  • filter[type], whose only permitted values are put and call
  • filter[tradetime_eq], filter[tradetime_from], filter[tradetime_to]

Plus sort (exp_date, strike, -exp_date, -strike), fields[options-contracts] to name the columns you want as a comma-separated list, and fmt with values json or xml — note that unlike /eod, there is no CSV here.

The ceiling that forces you to filter

Now read the pagination parameters carefully, because they contain a hard limit that is easy to miss.

page[limit] has a default and a maximum of 1000. page[offset] has a maximum of 10000.

So the furthest record you can address is offset 10,000 plus a page of 1,000, which is record 11,000. Apple's chain has 14,394 rows. Paging cannot reach the end of it. No amount of looping over page[offset] will retrieve the last three thousand contracts, and a script that pages until the response is empty will terminate quietly having silently dropped 3,394 rows — 23.6% of the chain, nearly a quarter.

The fix is not a bigger page. It is to partition the request by a filter — one expiration at a time, or one strike band at a time — so that no single query has more than 11,000 matches. Getting this wrong produces a dataset that looks complete and is not, which is the most expensive kind of data bug.

One honest caveat about access

Everything under /mp/ is a marketplace endpoint: a separate entitlement from the core API, not part of a standard subscription. On a key without the options add-on, /mp/unicornbay/options/contracts returns 403 Forbidden with an HTML error page rather than JSON; on a key that has it, the same call returns 200 and JSON. Both were checked, on two different keys. So the first thing to establish about this family is not its syntax but your own entitlement, and an HTML body where you expected JSON is itself the diagnostic.

One shape note for when it does answer: rows come back JSON:API-style, as {id, type: "options-contracts", attributes: {…}}, with the fields below nested under attributes rather than sitting at the top level.

Try it now

  1. This is marketplace data, so the page cannot render it; here is what the live endpoint answered for Apple on 28 September 2026. With page[limit]=1 the meta block held only offset and limit, plus a links.next URL: no meta.total, unlike the specification's example. So the size of a chain is something you count, not something you read. Counted by walking pages of 1,000:
Request for filter[underlying_symbol]=AAPL Rows returned
No expiry filter, paged to the offset ceiling 11,000, then no further page
Split into 24 requests, one per expiry month, summed 15,908

Say which of the two is the chain, and what share of it the unsplit walk lost without an error. 2. The split in step 1 used filter[exp_date_from] and filter[exp_date_to], one month at a time, and its largest part, August 2026, was 1,748 rows. Explain why every part came back whole while the whole did not, and at what size one month's part would start losing rows too. 3. The same count found 129 distinct expiration dates and 146 distinct strikes. Multiply the tidy estimate, expirations times strikes times two, and compare it with the 15,908. The gap tells you how ragged the real strike grid is. Then open the chain in the Terminal and see the raggedness: far expiries list far fewer strikes than near ones.

Open AAPL.US — options in the EODHD Terminal