Free Stock Market Data API in Python
Call a free stock market data API in Python with requests and pandas: an Ubuntu tutorial that loads JSON into a DataFrame and prints 20-day average volume.
Calling a free stock market data API from Python takes one HTTP request and two libraries most people already have: requests to fetch the JSON and pandas to turn it into a DataFrame. No API key is involved. This walkthrough runs start to finish on a fresh Ubuntu container and ends by printing the 20-session average volume for a single ticker. What the endpoints serve is covered in the free stock market data API guide; this page is the Python side of the same API.
Setting up Python, requests and pandas on a fresh Ubuntu container
A stock Ubuntu image ships without Python's venv module, and recent releases refuse to install packages into the system interpreter. A virtual environment sidesteps both problems and behaves the same on every current Ubuntu release. Run these as root, or prefix the apt-get lines with sudo.
export DEBIAN_FRONTEND=noninteractive
apt-get update && apt-get install -y python3 python3-venv
python3 -m venv .venv
. .venv/bin/activate
pip install "requests>=2.31,<3" "pandas>=2.0,<4"
The version ranges are deliberate. Each accepts any maintained release of the library and excludes only a future major version whose behaviour is unknown, which keeps the same line installing cleanly as Ubuntu moves its default Python forward. Nothing is pinned to a build that happens to exist today. Everything below assumes the environment is active.
How do I call a free stock market data API in Python?
The demo endpoints answer plain GET requests with no key and no signup. The first script asks for one of the curated queries and prints the shape of what comes back.
import json
import requests
BASE = "https://ai.strasmore.com/api/demo"
resp = requests.get(BASE, params={"q": "dividend_yield_leaders"}, timeout=30)
resp.raise_for_status()
payload = resp.json()
print(list(payload.keys()))
print(payload["columns"])
print(json.dumps(payload["rows"][0], indent=2))
requests.get builds the query string from params, raise_for_status() turns any 4xx or 5xx status into an exception, and .json() parses the body into a dictionary. Trimmed to its shape, that dictionary looks like this:
{
"key": "dividend_yield_leaders",
"label": "...",
"nl": "Which large-cap US stocks currently have the highest dividend yields?",
"sql": "SELECT ...",
"columns": ["as_of", "ticker", "dividend_yield_pct", "price", "market_cap_bn", "price_to_earnings"],
"rows": [{"as_of": "<date>", "ticker": "<symbol>", "dividend_yield_pct": <number>, ...}, ...],
"elapsed": "...",
"source": "Strasmore Research",
"more": "..."
}
Two keys matter for pandas. columns names the fields in order, and rows holds one JSON object per record with those names as keys. The sql key is the exact query that produced the numbers, returned with every response, which is what makes the data auditable rather than a black box. A GET to /api/demo/catalog lists every curated key plus the SQL endpoint used next.
How do I load the JSON into a pandas DataFrame?
The curated queries answer fixed questions. Daily bars for a ticker of your choosing come from the no-signup SQL endpoint at /api/demo/sql, which takes a read-only query in a sql parameter and returns the same columns and rows pair. Its limits, as of September 2026, are printed in every response: 500 rows, 20 seconds, one year of history, no key. The free SQL API with a key raises the ceiling to 100 queries a day over deeper history; the request code is identical.
Two guards in the SQL keep the average honest. A table that updates during the session can hold more than one row for the current day, and that day's volume is partial while the market is open. The query stops at yesterday with date < today() and collapses any duplicate rows for a date with GROUP BY date and max(volume). If you have not looked at how a daily bar is assembled from the tape, how OHLCV bars are built explains where those rows come from.
import time
import requests
import pandas as pd
SQL_URL = "https://ai.strasmore.com/api/demo/sql"
TICKER = "AAPL"
SQL = f"""
SELECT date, max(volume) AS volume
FROM stocks_daily_aggs
WHERE ticker = '{TICKER}'
AND date < today()
GROUP BY date
ORDER BY date DESC
LIMIT 20
"""
def fetch(sql, attempts=4):
"""GET the SQL endpoint. Back off on 429 and 5xx; stop on any other 4xx."""
delay = 2.0
for attempt in range(1, attempts + 1):
resp = requests.get(SQL_URL, params={"sql": " ".join(sql.split())}, timeout=30)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 429 or resp.status_code >= 500:
try:
wait = max(1.0, float(resp.headers.get("Retry-After")))
except (TypeError, ValueError):
wait = delay
print(f"HTTP {resp.status_code}; waiting {wait:.0f}s (attempt {attempt} of {attempts})")
time.sleep(wait)
delay *= 2
continue
raise SystemExit(f"HTTP {resp.status_code}: {resp.text[:400]}")
raise SystemExit("gave up after repeated 429 or 5xx responses")
payload = fetch(SQL)
df = pd.DataFrame(payload["rows"], columns=payload["columns"])
df["date"] = pd.to_datetime(df["date"])
df["volume"] = pd.to_numeric(df["volume"])
df = df.sort_values("date").reset_index(drop=True)
print(df.to_string(index=False))
avg_20 = df["volume"].mean()
first, last = df["date"].iloc[0], df["date"].iloc[-1]
print(f"{TICKER} 20-session average volume: {avg_20 / 1e6:.1f}M shares "
f"({first:%Y-%m-%d} to {last:%Y-%m-%d}, {len(df)} sessions)")
pd.DataFrame(rows, columns=columns) builds the table straight from the list of objects, and passing columns keeps the API's field order. Two conversions follow: dates arrive as strings and become real timestamps, and volume is coerced to a number in case a response ever serializes it as text. Sorting oldest to newest puts the frame in the order a chart expects. df["volume"].mean() over exactly twenty rows is the 20-session average, and the last line prints it in millions alongside the date range it covers.
What does 20-day average volume actually measure?
Average volume smooths a noisy daily series. A single session's share count swings with index rebalances, option expirations, earnings dates and headlines; twenty sessions is about one calendar month, long enough to damp those spikes and short enough to track a change in how actively a stock trades. The panel below computes the same statistic the script prints, from the same daily table, across the trailing few months, with the raw sessions and the rolling average side by side.
| session | volume_millions | avg_20d_millions |
|---|---|---|
| 2026-07-31 | 132.5 | 53.6 |
| 2026-08-03 | 75.1 | 54.7 |
| 2026-08-04 | 68 | 56 |
| 2026-08-05 | 49.4 | 56.4 |
| 2026-08-06 | 46.1 | 56.3 |
| 2026-08-07 | 34.4 | 56.3 |
| 2026-08-10 | 44.8 | 56.4 |
| 2026-08-11 | 37.5 | 56.5 |
| 2026-08-12 | 41.7 | 55.5 |
| 2026-08-13 | 40.3 | 54.4 |
| 2026-08-14 | 28.2 | 52.6 |
| 2026-08-17 | 38.2 | 51.9 |
| 2026-08-18 | 53.4 | 52.5 |
| 2026-08-19 | 50.5 | 53 |
| 2026-08-20 | 41 | 53.1 |
| 2026-08-21 | 46.9 | 53 |
| 2026-08-24 | 34.7 | 52.3 |
| 2026-08-25 | 25.9 | 51 |
| 2026-08-26 | 34 | 49.9 |
| 2026-08-27 | 32.4 | 47.8 |
The exact SQL behind every number
SELECT
session,
volume_millions,
avg_20d_millions
FROM
(
SELECT
toString(d) AS session,
round(vol / 1e6, 1) AS volume_millions,
round(avg(vol) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) / 1e6, 1) AS avg_20d_millions,
count() OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS sessions_in_window
FROM
(
SELECT
date AS d,
toFloat64(max(volume)) AS vol
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'AAPL'
AND date >= today() - 75
AND date < today()
GROUP BY date
)
)
WHERE sessions_in_window = 20
ORDER BY sessionThe daily series is one point per session; the smoother series is the average of that session and the nineteen before it. The panel runs from 2026-07-31 to 2026-09-17, 34 sessions that each have a full twenty-session window behind them. On the last of those sessions AAPL traded 36.7 million shares against a 20-session average of 41.2 million. That average is the number the script printed on the day this page was generated. Run it today and the twenty sessions have moved on, and the figure with them.
Swapping the ticker
Change TICKER and the script works for any US-listed symbol in the table. The comparison below runs the same 20-session calculation for four household names, a quick sense of scale for what "average volume" means across an index ETF and three mega-caps.
| ticker | avg_20d_millions | first_session | last_session |
|---|---|---|---|
| NVDA | 129.8 | 2026-08-20 | 2026-09-17 |
| AAPL | 41.2 | 2026-08-20 | 2026-09-17 |
| SPY | 39.8 | 2026-08-20 | 2026-09-17 |
| MSFT | 20.1 | 2026-08-20 | 2026-09-17 |
The exact SQL behind every number
SELECT
ticker,
round(avg(vol) / 1e6, 1) AS avg_20d_millions,
toString(min(d)) AS first_session,
toString(max(d)) AS last_session
FROM
(
SELECT
ticker,
d,
vol,
row_number() OVER (PARTITION BY ticker ORDER BY d DESC) AS rn
FROM
(
SELECT
ticker,
date AS d,
toFloat64(max(volume)) AS vol
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'NVDA', 'AAPL', 'MSFT')
AND date >= today() - 45
AND date < today()
GROUP BY ticker, date
)
)
WHERE rn <= 20
GROUP BY ticker
HAVING count() = 20
ORDER BY avg_20d_millions DESCNVDA carries the heaviest 20-session average of the four at 129.8 million shares a session, and MSFT the lightest at 20.1 million, all measured over sessions from 2026-08-20 to 2026-09-17. AAPL's figure here is the same number the trace above ends on: one definition, computed once, wherever it appears.
What does a 429 look like, and how do I retry politely?
A rate limit arrives as an HTTP status code rather than as data. The response carries status 429 Too Many Requests and often a Retry-After header holding the number of seconds to wait; the body is not the data you asked for, which is why the fetch helper checks status_code before parsing anything. Its rules, in order:
200: parse and return the JSON.429or any5xx: wait forRetry-Afterwhen present, otherwise back off (2, 4, 8, then 16 seconds), and try again, four attempts in total.- Any other
4xx: stop. The body says exactly why the query was rejected, a table that does not exist or a statement the read-only gate refused, and no amount of retrying changes the answer. An unknown table, for instance, comes back as a400.
Two habits keep you clear of the limit. Curated results refresh at most every ten minutes; a loop polling faster than that fetches identical bytes, and caching the payload locally costs nothing. And ask only for what you need: LIMIT 20 for a 20-session average, not a year of rows to discard. Network failures (a dropped connection, a DNS hiccup) surface as requests.RequestException, which the helper does not catch; wrap the call in try/except if the script runs unattended.
FAQ
Is there a free stock market data API for Python?
Yes. The demo endpoints in this guide answer unauthenticated GET requests from requests with JSON that loads directly into pandas: curated queries at /api/demo?q=<key> and hand-written read-only SQL at /api/demo/sql, capped at 500 rows and one year of history. No key, no signup.
Do I need an API key to get stock data in Python?
Not for the demo tier used here. A free key raises the ceiling to 100 queries a day with deeper history, and the Python code does not change. Builders wrapping the same endpoints as tools for an LLM can start from market data skills for AI agents.
How do I convert a JSON API response to a pandas DataFrame?
Parse the body with resp.json(), then pass the list of row objects to pd.DataFrame(rows, columns=columns). Convert date strings with pd.to_datetime and numeric strings with pd.to_numeric before doing arithmetic; JSON has no date type, and some APIs serialize decimals as text.
What does a 429 error mean when calling a stock API?
HTTP 429 means Too Many Requests: the server is rate-limiting the caller. Wait the number of seconds given in the Retry-After header when one is sent, otherwise back off exponentially, and cache responses instead of polling a result that refreshes only every ten minutes.
How do you calculate average volume in pandas?
Load one row per session with a volume column, keep the last twenty complete sessions, and call df["volume"].mean(). For a rolling version over a longer history use df["volume"].rolling(20).mean(), which is what the trace panel above draws.
Every panel above ships with the exact SQL beneath it, and the script is the same idea with a requests call in front. Free API key. 100 queries a day, 22 years of history, SQL over the raw tape. No card. Get an API key