Lesson 1.3.3 · 55 min
Retail and prosumer market data APIs
Between free sources and institutional providers there is a tier of APIs with plans costing tens of dollars a month. This lesson compares seven using their own pages, reads the format of a real response and writes a client that paginates and respects the request limit against a local server.
- Module
- 1.3 · Market data sources
- Duration
- 55 min
- Prerequisites
- 1.3.1 · Types of market data: EOD, bars, ticks, order book and reference, 1.3.2 · Free market data sources
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
The tier between free and institutional
The sources in lesson 1.3.2 are free, but either unofficial (yfinance) or they do not cover the market in detail (FRED, EDGAR, central banks). The providers in lesson 1.3.4 cover everything, at institutional prices. In between there is a group of companies that sell data APIs to individuals, developers and small firms: the retail and prosumer market. They have three things in common:
- A free plan, with short history or few requests, for trying them out.
- Paid plans of tens of dollars a month, which extend history, remove limits and reduce the delay.
- A documented REST API, sometimes websockets for real time, and an official Python client.
This lesson compares seven using what their own pages say. All the prices and limits that follow were read on each provider's page as of October 21, 2026. They change often: before paying you have to read them again.
Massive, formerly Polygon.io
Polygon.io changed its name to Massive on October 30, 2025. According to the announcement, the old API
(api.polygon.io) keeps working “for an extended period” in parallel with the new one (api.massive.com), and the
old pages redirect to the new ones1Note 1 · Massive, “Polygon.io is Now Massive”, October 30, 2025, and pricing page, accessed on October 21, 2026.. The official Python client is now published as massive; the old package,
polygon-api-client, is still on PyPI but its latest version is from the day of the name change.
Its stocks plans are a clear ladder:
| Plan | Price per month | History | Delay | Requests |
|---|---|---|---|---|
| Stocks Basic | 0 USD | 2 years | End of day | 5 per minute |
| Stocks Starter | 29 USD | 5 years | 15 minutes | Unlimited |
| Stocks Developer | 79 USD | 10 years | 15 minutes | Unlimited; adds trades |
| Stocks Advanced | 199 USD | 20+ years | Real time | Unlimited; adds quotes |
The figure in the header summarizes the four plans. Prices with monthly billing; the page offers a 20% discount with the annual plan. Real time is only in Advanced, and the page marks it as for non-professional users (“Non-pros only”): the difference between professional and non-professional is the one in the exchanges' licenses (lesson 1.3.7). What the plans add is not just history: Developer adds trades (tick by tick) and Advanced, quotes (bid and ask), which are the data of lessons 1.1.3 and 1.3.1.
The rest, plan by plan
| Provider | Free plan | Cheapest paid plan with US stocks | Detail |
|---|---|---|---|
| Alpaca | Basic: real time from IEX only, history since 2016 without the last 15 minutes, 200 calls per minute | Algo Trader Plus: 99 USD a month, all US exchanges, 10,000 calls per minute | The broker provides the data with the account; pagination with next_page_token |
| Tiingo | Starter: 50 requests per hour, 1,000 a day, 500 symbols a month | Power: 30 USD a month (300 a year) for individuals; 50 USD for internal commercial use | End of day with more than 30 years, with adjusted prices, dividends and splits in the same response |
| EODHD | 20 calls a day, 1 year of history | EOD Historical All-World: 19.99 USD a month | Charges each call in units: 1 for end of day, 5 for intraday, 10 for fundamentals |
| Alpha Vantage | 25 requests a day | 49.99 USD a month: 75 requests per minute and US data with a 15-minute delay | Real-time and delayed data are paid |
| Twelve Data | Basic: 8 credits per minute, 800 a day | Grow: from 29 USD a month (55 credits per minute) | Billed annually, cheaper |
| Financial Modeling Prep | Basic: 250 calls a day, 5 years of end of day | Starter: 29 USD a month (19 USD a month with annual billing), 300 per minute | Strong in fundamentals |
Sources: each provider's pricing or plans page, read on October 21, 20262Note 2 · Pricing pages accessed on October 21, 2026: Alpaca, Tiingo, EODHD, Alpha Vantage, Twelve Data, Financial Modeling Prep.. The plans are not equivalent: some are end of day, others delayed intraday, others real time from a single exchange. Comparing only the price would be comparing different things.
Two details that do not show in the table and that matter:
- Which exchange provides the “real time”. Alpaca's free plan provides real time from IEX only, one of the US exchanges: the price is real, but the volume is a fraction of the total and the best price may not be that of the consolidated market (lesson 1.1.1). For the consolidated data you need the paid plan.
- Credits, not requests. EODHD and Twelve Data count credits, and not all calls cost the same. A limit of “100,000 calls a day” runs out sooner if each intraday call costs five.
Comparing limits in the same unit
The free plans give their limits in different units. To compare them you have to convert them to a single one, for example requests per day, knowing that it is a theoretical ceiling: nobody makes requests nonstop for 24 hours.
import math
import pandas as pd
limites = pd.DataFrame([
("Massive Basic", 5, "minute"),
("Alpaca Basic", 200, "minute"),
("Tiingo Starter", 1000, "day"),
("Twelve Data Basic", 800, "day"),
("FMP Basic", 250, "day"),
("Alpha Vantage", 25, "day"),
("EODHD free", 20, "day"),
], columns=["plan", "limite", "por"]).set_index("plan")
por_dia = {"minute": 24 * 60, "hour": 24, "day": 1}
limites["al_dia"] = limites["limite"] * limites["por"].map(por_dia)
limites.sort_values("al_dia", ascending=False) limite por al_dia
plan
Alpaca Basic 200 minute 288000
Massive Basic 5 minute 7200
Tiingo Starter 1000 day 1000
Twelve Data Basic 800 day 800
FMP Basic 250 day 250
Alpha Vantage 25 day 25
EODHD free 20 day 20Tiingo has two limits, one per hour (50) and one per day (1,000); here the more restrictive of the two counts, the daily one, because 50 per hour for 24 hours would be 1,200. Twelve Data also has two (8 per minute and 800 a day). With this ceiling you can already estimate how long the download that matters takes:
acciones, por_peticion = 500, 1 # one request per stock
peticiones = acciones * por_peticion
limites["dias"] = (peticiones / limites["al_dia"]).apply(math.ceil)
limites[["al_dia", "dias"]].sort_values("dias") al_dia dias
plan
Massive Basic 7200 1
Alpaca Basic 288000 1
Tiingo Starter 1000 1
Twelve Data Basic 800 1
FMP Basic 250 2
Alpha Vantage 25 20
EODHD free 20 25With Massive, Alpaca, Tiingo or Twelve Data, the daily history of 500 stocks fits in one day (with Massive, at 5 per minute, it is 100 minutes); with FMP, in two. With Alpha Vantage or EODHD you need 20 or 25 days. That does not make these plans useless: it forces you to design the download so that it stores what it has fetched and continues the next day without repeating (lesson 1.4.8). And there is the other half of the problem: the Massive Basic plan only has 2 years of history, and EODHD's free plan, 1. The limit tells you how long it takes; the history, whether what you need exists in that plan.
Anatomy of a bars response
The Massive bars endpoint is /v2/aggs/ticker/{ticker}/range/{multiplier}/{timespan}/{from}/{to}. The response
carries metadata (status, request_id, resultsCount, adjusted), the results list with one bar per element
and, if there are more pages, next_url. Each bar has o, h, l, c (prices), v (volume), vw
(volume-weighted average price), n (number of trades) and t, the start of the bar in Unix milliseconds; limit
accepts up to 50,000 bars per request, with 5,000 by default3Note 3 · Massive, Custom Bars (OHLC) and REST API Quickstart, accessed on October 21, 2026..
An illustrative sample with that structure (the numbers are from NOVA, the course's fictitious stock):
import json
texto = """{
"ticker": "NOVA", "adjusted": false, "queryCount": 2,
"resultsCount": 2, "status": "OK", "request_id": "ej-0001",
"results": [
{"t": 1748836800000, "o": 53.35, "h": 53.94, "l": 52.55,
"c": 53.12, "v": 8888714, "vw": 53.21, "n": 50946},
{"t": 1748923200000, "o": 53.05, "h": 53.4, "l": 52.6,
"c": 52.92, "v": 6151230, "vw": 52.98, "n": 41210}
],
"next_url": "https://api.massive.com/v2/aggs/...?cursor=ej"
}"""
respuesta = json.loads(texto)
barras = pd.DataFrame(respuesta["results"])
barras["t"] = pd.to_datetime(barras["t"], unit="ms", utc=True)
print(respuesta["status"], respuesta["resultsCount"],
"next_url" in respuesta)
barras.set_index("t")OK 2 True
o h l c v vw n
t
2025-06-02 04:00:00+00:00 53.35 53.94 52.55 53.12 8888714 53.21 50946
2025-06-03 04:00:00+00:00 53.05 53.40 52.60 52.92 6151230 52.98 41210Two things worth noting. The timestamp of a daily bar is midnight in New York expressed in UTC: 04:00 in summer
and 05:00 in winter, as in the documentation's example. In a daily bar, the UTC date matches the New York date; in
intraday bars it does not: a 21:00 New York bar is already the next day in UTC. That is why you convert to the
market's time zone (.tz_convert("America/New_York")) before keeping the date. And adjusted says whether the
prices are adjusted for splits; lesson 1.3.8 explains why you always need to know it.
A client that paginates and respects the limit
A REST client for these APIs has to do three things well: authenticate without leaving the key in the code, follow the pagination to the end and react to limit errors (HTTP 429) by waiting as long as the server says.
Mermaid source
flowchart TB
U[Initial URL] --> G[GET with key]
G --> C{Status code?}
C -- 429 --> W[Retry-After]
W --> G
C -- 200 --> R[Store results]
R --> N{next_url?}
N -- yes --> G
N -- no --> F[End]To run it for real without a network or a key, the next cell starts a local server in a thread that mimics the
API: it serves NOVA's 502 sessions of 2024 and 2025 in the format of the previous section, in pages of limit bars
with a cursor in next_url, requires the key in apiKey and responds 429 with Retry-After: 1 if it receives more
than 5 requests in one second.
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
nova = pd.read_csv("nova_diario_sin_ajustar.csv",
parse_dates=["fecha"])
nova = nova[nova["fecha"] >= "2024-01-01"]
abre_ny = (nova["fecha"].dt.tz_localize("America/New_York")
.dt.tz_convert("UTC"))
BARRAS = [
{"t": int(ts.timestamp() * 1000), "o": f.apertura,
"h": f.maximo, "l": f.minimo, "c": f.cierre, "v": f.volumen}
for ts, f in zip(abre_ny, nova.itertuples())
]
class ApiFalsa(BaseHTTPRequestHandler):
recientes = deque() # times of the accepted requests
contador = {"200": 0, "401": 0, "429": 0}
def responder(self, codigo, cuerpo, cabeceras=()):
self.contador[str(codigo)] += 1
self.send_response(codigo)
self.send_header("Content-Type", "application/json")
for k, v in cabeceras:
self.send_header(k, v)
self.end_headers()
self.wfile.write(json.dumps(cuerpo).encode())
def do_GET(self):
q = parse_qs(urlparse(self.path).query)
if q.get("apiKey") != ["test-key"]:
return self.responder(401, {"status": "ERROR"})
ahora = time.monotonic()
while self.recientes and ahora - self.recientes[0] > 1:
self.recientes.popleft()
if len(self.recientes) >= 5:
return self.responder(429, {"status": "ERROR"},
[("Retry-After", "1")])
self.recientes.append(ahora)
inicio = int(q.get("cursor", ["0"])[0])
limite = int(q.get("limit", ["50"])[0])
pagina = BARRAS[inicio:inicio + limite]
cuerpo = {"status": "OK", "resultsCount": len(pagina),
"results": pagina}
if inicio + limite < len(BARRAS):
base = f"http://127.0.0.1:{self.server.server_port}"
cuerpo["next_url"] = (f"{base}/v2/aggs/ticker/NOVA?"
f"cursor={inicio + limite}"
f"&limit={limite}")
self.responder(200, cuerpo)
def log_message(self, *args): # no logging in the output
pass
servidor = ThreadingHTTPServer(("127.0.0.1", 0), ApiFalsa)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{servidor.server_port}/v2/aggs/ticker/NOVA"
print(len(BARRAS), "bars served")502 bars servedPort 0 asks the system for a free one, so the cell does not clash with anything that is already listening. Now the
client, with the standard library (urllib): without the key, the server responds 401 and urlopen raises an
exception.
from urllib.error import HTTPError
from urllib.request import urlopen
urlopen(f"{URL}?limit=50", timeout=5)urllib.error.HTTPError: HTTP Error 401: UnauthorizedThe key is read from an environment variable. In the cell it is set by hand for the local server; in a real program
it would be in the environment or in a .env file outside the repository (lesson 0.2.4):
import os
from urllib.parse import urlencode
os.environ.setdefault("API_KEY", "test-key") # only here
def get_json(url, intentos=5):
sep = "&" if "?" in url else "?"
clave = urlencode({"apiKey": os.environ["API_KEY"]})
firmada = f"{url}{sep}{clave}"
for _ in range(intentos):
try:
with urlopen(firmada, timeout=5) as r:
return json.load(r)
except HTTPError as e:
if e.code != 429:
raise
time.sleep(float(e.headers.get("Retry-After", "1")))
raise RuntimeError(f"still 429 after {intentos} attempts")
def descargar_todo(url):
filas, paginas = [], 0
while url:
datos = get_json(url)
filas += datos["results"]
paginas += 1
url = datos.get("next_url")
return filas, paginas
filas, paginas = descargar_todo(f"{URL}?limit=50")
print(len(filas), "bars in", paginas, "pages;",
ApiFalsa.contador["429"], "responses with code 429")502 bars in 11 pages; 2 responses with code 429502 bars in 11 pages of 50 (the last one, of 2). The server only accepts 5 requests per second, so the client
receives two 429s along the way: after the fifth page and after the tenth. On each one it waits the second that
Retry-After asks for and repeats the same request; it does not skip a page or start from scratch. If the server
were still returning 429 after several attempts, the client gives up with a clear error instead of staying in a
loop. Lesson 1.4.1 adds what is missing for production: exponential backoff with jitter, network errors and 5xx, and
websockets.
df = pd.DataFrame(filas)
df["t"] = pd.to_datetime(df["t"], unit="ms", utc=True)
print(df["t"].is_unique, df["t"].is_monotonic_increasing)
print(df["t"].iloc[0], "->", df["t"].iloc[-1])
servidor.shutdown()True True
2024-01-02 05:00:00+00:00 -> 2025-12-31 05:00:00+00:00The final check is the usual one: unique and increasing timestamps, and the first and the last where they were expected. Badly done pagination usually leaves duplicates at the page boundaries or loses the last page.
The official client
With the official client, all of the above stays inside the library: the pagination is walked with an iterator and the key goes in the constructor. The example in the Massive documentation has this shape (it needs a network and a key):
import os
from massive import RESTClient # uv add massive
client = RESTClient(os.environ["MASSIVE_API_KEY"])
barras = list(client.list_aggs(
"AAPL", 1, "day", "2025-01-01", "2025-12-31",
adjusted="true", sort="asc", limit=50000,
))
print(len(barras), barras[0])Official clients save work, but it is worth having written one by hand once: when a download fails at three in the morning, you need to know what a 429 is, what a cursor is and where the last page got lost.
How much an intraday download costs
With the format and the limits you can already budget a large download. One-minute bars of the US regular session (390 a day, 252 sessions a year), 500 stocks and 5 years, with a maximum of 50,000 bars per request:
barras_ano = 390 * 252
acciones, anos, max_barras = 500, 5, 50_000
por_accion = math.ceil(barras_ano * anos / max_barras)
total = acciones * por_accion
print(f"{barras_ano * anos * acciones:,} bars;"
f" {por_accion} requests per stock; {total:,} in total")
for plan, por_min in [("5 per minute", 5), ("200 per minute", 200)]:
print(f"{plan}: {total / por_min / 60:.1f} hours")245,700,000 bars; 10 requests per stock; 5,000 in total
5 per minute: 16.7 hours
200 per minute: 0.4 hoursAlmost 246 million bars fit in 5,000 requests, because each one carries 50,000 bars. At 5 requests per minute it would be almost 17 hours; at 200, 25 minutes. But the limit is only one constraint: the Massive Basic plan does not have minute bars from 5 years ago (its history is 2), so the real calculation starts by checking which plan has that data. And then there is storage: 246 million rows in CSV are tens of gigabytes; in Parquet, much less (lesson 1.4.7).
Exercises
- Repeat the estimate of days for the daily history of 500 stocks, but assuming you can only use 50% of the daily limit (your production system needs the rest). Which plans still fit in one day?
Solution
limites["dias_50"] = (peticiones / (0.5 * limites["al_dia"])
).apply(math.ceil)
limites.loc[limites["dias_50"] == 1].index.tolist()['Massive Basic', 'Alpaca Basic', 'Tiingo Starter']With half the limit, Massive Basic (3,600 a day), Alpaca Basic and, just barely, Tiingo (500 of 500) still fit in one day; the others do not. A production system consumes quota (quotes, checks), and a historical download that eats it all leaves the system without data. It is best to keep separate keys or plans for the two things.
- Modify the local server so that it accepts only 3 requests per second and download again with
limit=50. How many 429s does the client receive? And withlimit=200?
Solution
def probar(maximo, limit):
ApiFalsa.contador["429"] = 0
ApiFalsa.recientes.clear()
original = ApiFalsa.do_GET
def do_get_estricto(self):
while len(self.recientes) > maximo - 1:
if time.monotonic() - self.recientes[0] > 1:
self.recientes.popleft()
else:
return self.responder(429, {}, [("Retry-After", "1")])
original(self)
ApiFalsa.do_GET = do_get_estricto
srv = ThreadingHTTPServer(("127.0.0.1", 0), ApiFalsa)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_port}/v2/aggs/ticker/NOVA"
filas, paginas = descargar_todo(f"{url}?limit={limit}")
srv.shutdown()
ApiFalsa.do_GET = original
return len(filas), paginas, ApiFalsa.contador["429"]
print(probar(3, 50), probar(3, 200))(502, 11, 3) (502, 3, 0)With 3 requests per second and pages of 50, the client receives a 429 every three pages; with pages of 200, 3 requests are enough and it receives none. Requesting large pages (up to the maximum the API accepts) is the simplest way to spend less quota and to wait less.
- Your system trades 50 US stocks daily with closing data and needs 10 years of adjusted history. Choose among the plans in the lesson and justify what you would check before paying.
Solution
End of day is enough, so neither real time nor intraday is needed. Ten years are needed: that rules out the Massive Basic plan (2 years), Starter (5 years), EODHD's free plan (1 year) and FMP's Starter (up to 5). What remains, among others, are the end-of-day plans with long history (Tiingo, more than 30 years; EODHD All-World, more than 30) or Massive's Developer (10 years, at a higher price). Before paying, with the free plan: whether it provides both raw and adjusted prices, and the dividends and splits (lesson 1.3.8); whether it has delisted stocks; the real limits against 50 stocks; and the license, if the system is going to publish anything or be used with third-party money (lesson 1.3.7).
Footnotes
-
Massive, “Polygon.io is Now Massive”, October 30, 2025, and pricing page, accessed on October 21, 2026. ↩
-
Pricing pages accessed on October 21, 2026: Alpaca, Tiingo, EODHD, Alpha Vantage, Twelve Data, Financial Modeling Prep. ↩
-
Massive, Custom Bars (OHLC) and REST API Quickstart, accessed on October 21, 2026. ↩
Each answer is folded under its question
Self-assessment
Which Massive stocks plan provides real-time data, according to its pricing page as of October 21, 2026?
- ABasic (free)
- BAdvanced (199 USD a month)
- CStarter (29 USD a month)
- DDeveloper (79 USD a month)
Show answer
Answer B. Advanced (199 USD a month)
Basic provides end-of-day data; Starter and Developer, with a 15-minute delay; only Advanced provides real time (and, according to the page, only for non-professional users).
An API responds with
next_urlin the JSON. What should the client do?- ARequest that URL (adding the key) until the response no longer carries
next_url - BIgnore it if it already has data
- CIncrease
limituntil it disappears - DRequest the same URL again
Show answer
Answer A. Request that URL (adding the key) until the response no longer carries
next_urlnext_urlcarries a cursor with the point where the query continues. If it is not followed, the download stays on the first page without any error.- ARequest that URL (adding the key) until the response no longer carries
The server responds 429 with the header
Retry-After: 1. What is the right thing to do?- ARetry immediately in a loop
- BMove on to the next page
- CAbort the download
- DWait at least one second and repeat the same request
Show answer
Answer D. Wait at least one second and repeat the same request
429 means too many requests.
Retry-Aftersays how long to wait. Retrying in a loop only prolongs the block; skipping a page loses data.With the Alpha Vantage free plan (25 requests a day), how many days does it take to download the daily history of 500 stocks if each one fits in one request?
- AOne
- BFive
- CTwenty
- DOne hundred
Show answer
Answer C. Twenty
500 requests divided by 25 a day is 20 days. A daily limit changes the architecture: you need to store what has been downloaded and continue the next day (lesson 1.4.8).
Why should the API key not be written in the code?
- ABecause it takes up a lot of space
- BBecause the code ends up in a repository, a shared notebook or a traceback, and the key gives access to your account and your quota
- CBecause APIs do not accept keys in the code
- DBecause it changes every day
Show answer
Answer B. Because the code ends up in a repository, a shared notebook or a traceback, and the key gives access to your account and your quota
It is read from an environment variable or from a file outside version control (lesson 0.2.4). A key that has been published must be revoked.
Key points
- The retail and prosumer tier costs from zero to a few tens of dollars a month; what changes from one plan to another is the history, the delay (end of day, 15 minutes, real time) and the request limit.
- Polygon.io has been called Massive since October 30, 2025; the old domain redirects and the official Python client is now
massive. - Limits come in different units (per minute, per hour, per day): you have to convert them to the same one before comparing, and use them to estimate how long the download takes.
- A serious client follows the pagination (
next_url,next_page_token), respects 429s andRetry-After, and reads the key from an environment variable. - Prices and plans change: cite them with the date on which you read them and read them again before paying.