Apple's closing price on any given day is a fact, and nobody owns a fact. Obtaining it costs between nothing and seventy-five thousand dollars a month, and the spread has almost nothing to do with the number itself. Nasdaq's US equities price list, effective 1 January 2026, charges $1.00 per month per non-professional subscriber for Nasdaq Basic and $28.50 for a professional one. The same document charges $75,000 per month, flat, for non-display use of TotalView across 250 or more servers. You are not buying the price. You are buying permission, a headcount, and a promise about who gets to look.
That is the whole difference between this article and its companion on scraping currency exchange rates. The engineering carries over almost unchanged: the same decimal discipline, the same caching, the same suspicion of sudden jumps. The law does not carry over at all. A central bank publishes a reference rate because publishing it is the point. An exchange sells its quotes because selling them is a material revenue line, and the fee schedule is filed with the SEC.
Everything below was read from vendor pricing pages, API documentation and exchange fee schedules on 13 August 2026.
1. Why the licence is the product
Start with what is not protected, because it clears away most of the confusion.
Facts are not copyrightable. Feist Publications v. Rural Telephone Service settled that in the US Supreme Court in 1991: a compilation of facts earns protection only through original selection and arrangement, and an alphabetical phone book has neither. A closing price is about as unoriginal a fact as exists.
Neither is the freshness of a fact, mostly. In Barclays Capital v. Theflyonthewall.com, decided by the Second Circuit on 20 June 2011, banks tried to stop a news site from republishing their analyst recommendations under the old "hot news" misappropriation tort. The court held the claim preempted by the Copyright Act. Reporting a fact that somebody else generated, even a commercially valuable one, is generally not actionable on its own.
So why does market data cost money.
Because the control is contractual and regulatory, not proprietary. You get quotes through an agreement, and the agreement is what binds you. In the US the terms are not even purely private: exchange fee schedules are filed with the SEC as rule changes, which is why the numbers in this article have effective dates rather than being quoted on request. In the EU there is a second layer, the sui generis database right created by Directive 96/9/EC, which protects substantial investment in obtaining and verifying a database independently of whether anything in it is original.
The practical consequence is that the question "is this data free" is the wrong question. Three questions replace it.
Who sees the output. Every market data licence in existence turns on this. Nasdaq's US equities and options data policies define Display Use as "Any method of accessing Exchange Information that involves the display of such data on a screen or other mechanism designed for access or use by a natural person or persons," and Non-Display Use as "Any method of accessing Exchange Information other than Display Usage." A dashboard on one analyst's laptop and an automated signal generator consuming the identical bytes are different products at different prices.
Who you are. A Non-Professional Subscriber, in Nasdaq's definition, is a natural person not registered with a securities regulator and not employed to perform a regulated function, and for that person "Information is licensed only for personal use." Everyone else pays the professional rate, which for Nasdaq Basic is 28.5 times higher.
Whether anything leaves your building. Redistribution is a separate licence with separate fees, and under Nasdaq's policies a distributor needs prior approval, must control entitlements and display, reports subscriber counts monthly through the Data-Client Portal, and carries liability for what its recipients do. Audit rights are explicit, and underreported usage is billed back over the audit period.
Three questions, and they decide your budget before you write a line of code.
2. What a "quote" actually is
"Quote" means five different things, and pinning down which one you need is the first task:
- Last / current price. The price of the most recent trade. This is what the "share price now" widget shows, and it is the most licence-encumbered field on the list when it is genuinely current.
- OHLCV bar (candle). Open, High, Low, Close and Volume over an interval. The basis of charts, screeners and backtests, and the format almost every free tier gives you. A daily bar is cheap because it is history by the time you can have it; a one-minute bar of the current session is not history, and vendors price it accordingly.
- Bid/Ask and depth of book. The best buy and sell prices, and behind them the resting orders at each level. Needed for anything execution-related, and the most expensive tier at every source. Nasdaq TotalView, the full depth product, is $84 per month per professional subscriber against $28.50 for Nasdaq Basic's top-of-book.
- EOD (end-of-day). The day's official closing prices. Cheap or free, and sufficient for analytics, portfolio accounting and most research.
- Adjusted close. The close corrected for splits and dividends so a long history stays continuous. Note where this sits commercially: at Alpha Vantage,
TIME_SERIES_DAILYis a free endpoint andTIME_SERIES_DAILY_ADJUSTEDis marked premium. The arithmetic is trivial and the corporate-action database behind it is not.
The axis that prices all of this is freshness: real-time → 15-minute delayed → end-of-day. Alpha Vantage states the reason plainly in its documentation: "Realtime and 15-minute delayed US market data is regulated by the stock exchanges, FINRA, and the SEC." Its own quote endpoint "is updated at the end of each trading day for all users" unless you buy a premium plan. That is not a Alpha Vantage policy invented to upsell you. It is the shape of the entitlement they hold.
3. Exchange feeds: the actual price list
Most articles on this subject say real-time exchange data is "expensive" and stop. The numbers are public, filed, and worth reading once, because they explain every free tier in the next section.
From Nasdaq's US equities price list effective 1 January 2026:
| Product | Professional | Non-professional | Distribution | Non-display |
|---|---|---|---|---|
| Nasdaq Basic (top of book) | $28.50 / subscriber | $1.00 / subscriber | $1,680 internal, $2,140 external, per firm | not offered at this level |
| Nasdaq TotalView (full depth) | $84.00 / subscriber | $15.00 / subscriber | $810 internal, $1,620 external, $1,120 direct access, per firm | $412 / subscriber up to 39; $16,490 / firm for 40–99; $32,990 for 100–249; $75,000 for 250+ |
Nasdaq also sells an enterprise licence for Nasdaq Basic at $155,000 per month covering unlimited internal professional display use, which is the point where per-seat accounting stops being worth the effort.
The NYSE market data price list effective 14 May 2026 has the same architecture with a heavier fixed component. NYSE Integrated runs an $8,400 per month access fee before a single user is counted, then $78 per professional device and $16 per non-professional device, with redistribution at $4,400 per month and non-display at $22,400 per month for categories 1 and 2, capped at $67,200 for category 3. The thinner products are cheaper in the obvious way: NYSE BBO is a $1,500 access fee, $4 per professional and $0.20 per non-professional device. NYSE Arca Integrated sits between them at a $3,200 access fee, $64 professional, $20 non-professional.
Read those two tables together and the economics of every aggregator become obvious. The access and distribution fees are fixed, and the per-user fees are small. An aggregator pays the fixed cost once, spreads it over tens of thousands of customers, and sells you a slice. That is also why aggregators are so restrictive about redistribution: their own licence forbids them from letting you become a distributor for free.
If you want the raw feed without an aggregator's terms in the middle, Databento is the current answer for that shape of requirement. It bills historical data per gigabyte, offers a $199 per month standard plan, gives new accounts $125 of credit that expires after six months, and states that it passes exchange licence fees through with no upcharge. That last phrase is the honest version of every market data quote: the vendor's margin and the exchange's rent are separate line items whether or not you see them separately.
4. The lane with no licence problem: SEC EDGAR and OpenFIGI
There is exactly one category of US financial data that is free, machine-readable, unlicensed and redistributable, and most scraping guides skip it because it contains no prices.
SEC EDGAR publishes company filings and structured XBRL financials through data.sec.gov with no key and no registration. Four endpoints cover most needs: submissions for filing history, companyconcept for one tag on one company, companyfacts for everything XBRL for a company in a single call, and frames for one concept across all filers in one period. The SEC's access page states the terms in two lines: "Current max request rate: 10 requests/second," and a declared User-Agent in the form Sample Company Name AdminContact@<sample company domain>.com. Send that header, stay under the rate, and you are a well-behaved client rather than a target.
Ten requests a second is 864,000 requests a day. Compare that to the aggregator free tiers in the next section, which top out at 800 a day, and the reason is not generosity. It is that this data was never anybody's revenue.
The ticker universe comes from the same place. https://www.sec.gov/files/company_tickers.json maps every EDGAR filer's ticker to its Central Index Key, as records of cik_str, ticker and title. It is the correct starting point for building an instrument table, because the CIK is a stable identifier that survives ticker changes, and because the file costs one request.
OpenFIGI, run by Bloomberg, closes the identifier gap. It maps between tickers, ISINs, CUSIPs, SEDOLs and FIGIs, and it is free with published limits: without an API key, 25 requests per minute at 10 mapping jobs per request; with a key, 25 requests per 6 seconds at 100 jobs per request. Search is tighter at 5 requests per minute without a key and 20 with one. At 100 jobs per request and 25 requests per 6 seconds, you can map a 40,000-instrument universe in about 96 seconds of wall clock.
What this lane does not give you is a price. EDGAR has revenue, EPS, share counts and every number a company reported; it has no quotes, because the SEC does not run a market. Pair the two lanes and you have most of what a research pipeline needs, with the licensed part reduced to the price series alone.
5. Aggregator APIs: what the free tiers actually give you
There is no "just hit the endpoint" source for equities the way there is for a central-bank FX feed. Everything below needs a key, enforces limits, and puts real-time behind payment.
Prices and limits read from each vendor's own pricing page or documentation on 13 August 2026. Free-tier numbers are the published ones, not observed behaviour.
| Service | Free tier | Entry paid tier | What the licence says |
|---|---|---|---|
| Nasdaq Data Link | anonymous 20 calls / 10 min, 50 / day; free key 300 / 10 s, 2,000 / 10 min, 50,000 / day | per dataset | most datasets are premium and per-feed licensed |
| Alpha Vantage | 25 requests / day | $49.99 / mo for 75 req/min | commercial use defined broadly; US real-time needs a premium plan |
| Twelve Data | 800 credits / day, 8 / min | $79 / mo (Grow) | free tier explicitly barred from commercial use |
| Financial Modeling Prep | 250 calls / day | $22 / mo (Starter, annual) | display or redistribution needs a signed agreement |
| EODHD | 20 API calls / day, 1 year of history | $19.99 / mo (All World EOD) | personal plans are personal; commercial is a separate product |
| Tiingo | 500 unique symbols / mo, 50 req/hr, 1,000 / day, 1 GB | $30 / mo (Power) | "you may only use the data for your own personal use" |
| Marketstack | 100 requests / month, EOD only, 1 year | $9.99 / mo (Basic) | commercial use excluded from the free plan entirely |
| Massive (formerly Polygon.io) | 5 calls / min, EOD, 2 years | $29 / mo, 15-min delayed, 5 years | real-time starts at $199 / mo |
| Alpaca | 200 calls / min, 7+ years, IEX feed | $99 / mo for full SIP | brokerage account model rather than a data licence |
| Finnhub | see note below | see note below | see note below |
Four of those entries deserve unpacking, because they are where published summaries go wrong.
Polygon.io is now Massive. The company announced the rebrand on 30 October 2025. api.polygon.io still answers and existing keys still work, with api.massive.com available in parallel and 301 redirects from the old documentation, but support for the old endpoints is slated to phase out with notice. Any guide that lists polygon.io without mentioning this is at least a year stale. The free tier is genuinely usable for research at 5 calls a minute, two years of end-of-day history; the $29 Starter tier buys unlimited calls at a 15-minute delay; real-time begins at $199.
Alpaca is the most generous free real-time option, with an asterisk. Its free plan gives 200 API calls a minute and 7+ years of history, but the feed is IEX rather than the consolidated SIP tape. IEX is a real exchange with a small share of consolidated volume, so an IEX-only last price is a real trade at a real venue that may not be the national best bid or offer. For charting and research that is fine. For anything where the NBBO matters, it is not, and the $99 Algo Trader Plus tier is what buys the full tape.
Marketstack's free tier is 100 requests a month, not a day. That is three requests a day averaged out, and the plan explicitly excludes commercial use. It appears in roundups as a general-purpose free option and it is not one.
Finnhub could not be verified. Its pricing page and API documentation are JavaScript applications that return no content to a plain HTTP client, so the widely repeated "60 calls per minute" free-tier figure has no primary source we could open on 13 August 2026. The API endpoints themselves work and are documented in its client libraries; the commercial terms are not readable without a browser. Treat any number you see quoted for Finnhub, including in older versions of this article, as unconfirmed until you read it in your own dashboard.
IEX Cloud is gone. It shut down on 31 August 2024, and its own successor guidance points at other vendors. If a tutorial still recommends it, everything else in that tutorial is the same age. This is the single most common dead reference in stock-data write-ups, and it is now two years stale.
On storing and republishing. The pattern is consistent across every vendor above, and it is worth reading in their words rather than in summary. Twelve Data's terms permit you to "Redistribute or provide external display of Data only if and as expressly authorized by a Redistribution Rights Add-On or separate written agreement," permit "Non-Display Use only as permitted by your subscription tier," prohibit storing or caching "Data beyond permitted timeframes specified in the Documentation," and forbid outright any attempt to "Use Free Tier data for commercial purposes." Financial Modeling Prep states that "Displaying or redistributing data sourced from FMP requires a specific Data Display and Licensing Agreement with FMP." Tiingo defines internal use as meaning "you may only use the data for your own personal use and you may not display or share the data with another person or organization."
Read that last one carefully, because it is stricter than most people assume. A free Tiingo key does not let you put a chart on a public webpage. Building a cache is fine; building a cache and serving it to your users is redistribution, and redistribution is the thing every one of these contracts reserves.
6. Yahoo, yfinance, and the terms nobody reads
yfinance is how most people actually get stock data, so it deserves more than a footnote and a warning.
The library is alive and healthy. It reached 1.0.0 on 22 December 2025 after years in the 0.2.x range, and the current release on PyPI is 1.5.2, dated 23 July 2026, with 1.5.1 in June, 1.4.x in May and 1.3.0 in April. The repository carries 23.7k stars. This is not a maintenance-mode project.
Its dependency list tells you what changed. yfinance now requires curl_cffi>=0.15 alongside requests and websockets>=13.0. curl_cffi exists to impersonate a browser's TLS and HTTP/2 fingerprint. A library that needs browser impersonation to reach an endpoint is describing the endpoint's attitude toward it more clearly than any changelog could.
Yahoo started rate-limiting in earnest on 13 November 2024. Issue #2128 in the yfinance repository opens with a user whose nightly job pulled seven days of one-minute bars for 7,000 tickers without trouble for a year, then began returning HTTP 429 after roughly 950 tickers. A figure of 360 requests per hour circulates in that thread. Yahoo publishes no rate limit of its own, so that number is a community observation, not a documented cap, and it should be treated as such. The issue was closed as not planned, which is the correct outcome: nothing in the library can fix a limit imposed by the server.
And the terms are not ambiguous. Yahoo's terms of service prohibit, in section 2.4.ix, any attempt to "access or collect data, or attempt to access or collect data, from our Services using any automated means, devices, programs, algorithms or methodologies, including but not limited to robots, spiders, scrapers, data mining tools, or data gathering or extraction tools, for any purpose without our express, prior permission." Section 2.4.x forbids using the material "to create any database, archive, mobile application, data feed, widget or any other aggregated data source that competes with or constitutes a material substitute for the Services." Section 2.5 says "Unless otherwise expressly stated, you may not access or reuse the Services, or any portion thereof, for any commercial purpose."
yfinance's own README says the same thing from the other side: the project "is not affiliated, endorsed, or vetted by Yahoo, Inc.," it "uses Yahoo's publicly available APIs, and is intended for research and educational purposes," and, in the maintainers' own words, "Remember - the Yahoo! finance API is intended for personal use only."
There is a further wrinkle that changes what the data is even good for. Yahoo does not own these quotes. Its data disclaimer names ICE Data Services as the provider for most exchanges, lists which venues are real-time and which are delayed by 10, 15, 20 or 30 minutes, and states that everything is "provided for informational purposes only, and is not intended for trading or investing purposes." So the redistribution question is not settled between you and Yahoo alone. It runs through to ICE, whose licence Yahoo holds and you do not.
None of this makes yfinance useless. It makes it a research and prototyping tool, which is exactly what its authors say it is. The failure mode is not a lawyer. It is building six months of product on an undocumented endpoint that answers to a rate limit nobody published, behind a TLS-impersonation shim, under terms that forbid the thing you are building.
7. Why stock quotes are harder than FX rates
Five differences break a scraper written for currency rates.
A ticker is not an identifier. The same symbol trades on several venues, and the same company has different symbols in different countries. The minimum instrument key is the pair exchange plus ticker. The correct one is an ISIN or a FIGI, which is why OpenFIGI exists and why it is worth wiring in on day one rather than after your first ticker collision. Tickers also get reused: when a company delists, its symbol goes back in the pool.
Sessions and holidays, and the definition of a session is currently moving. Every exchange has its own calendar, time zone, and pre- and post-market windows. The exchange_calendars package, at 4.13.2 as of 10 March 2026, ships more than 60 exchange calendars and states plainly that they "are maintained by user contributions" — which is a reason to use it and a reason not to trust it blindly for an obscure venue. The old rule that a weekend "last price" is Friday's price is also eroding. 24X National Exchange, approved by the SEC in November 2024, opened for trading on 15 October 2025 and expects to run 23 hours a day, five days a week, in the second half of 2026. The SEC approved Nasdaq's equivalent proposal on 10 April 2026: a Day Session from 04:00 to 20:00 ET, a Night Session from 21:00 to 04:00, and a one-hour maintenance window between them. The approval carries a condition worth noting if you consume consolidated data, which is that the equity data plans must first be able to collect, consolidate and disseminate quotes and trades during the Night Session. "What date does this bar belong to" is becoming a genuinely hard question.
Corporate actions. A 1:10 split divides the price by ten overnight without a single share changing hands. This is the direct analogue of the denomination trap when scraping FX rates: treat it as a market move and you have manufactured a 90% crash in your own database. Splits and dividends are folded into the adjusted close, which is why the adjusted series is a derived product with a maintained corporate-action database behind it, and why vendors charge for it separately.
Instrument currency. A security is quoted in its venue's currency, so a multi-currency portfolio needs an FX conversion at a defined time on a defined date, from a source you also have to license. This is where the companion guide on exchange rates stops being a nice-to-have.
Volume overflows. Daily volume for a liquid US name runs to hundreds of millions of shares, and an aggregate across a market runs to billions. A 32-bit signed integer tops out at 2,147,483,647. Use a 64-bit type and stop thinking about it.
8. Five sources, five shapes, five languages
Each example hits a different source, because the shape of the response is the part worth learning. Two rules run through all of them: prices go in a decimal type and never a binary float, and volume goes in a 64-bit integer.
8.1. PHP — Nasdaq Data Link, and the columnar response
Nasdaq Data Link returns time series column-oriented: one array of column names, one array of rows. To read a field you map the name to its index, because field order is not guaranteed and a magic [4] breaks the day the response gains a column.
Before the code, a correction this article owes its readers. Earlier versions of this piece used WIKI/AAPL as the example dataset. That dataset is dead. Nasdaq's own help article says "The free Wiki Prices data feed was deprecated in 2018 and therefore only provides data going up to March 2018," that "one of the main sources of that data is no longer available," and that "we no longer recommend using it for investment or analysis." The replacements it names are "the premium End of Day US Stock Prices (EOD) and Sharadar Equity Prices (SEP) data feeds." There is no free EOD equities database on Nasdaq Data Link any more, and code that pulls one has been returning 2018 prices to somebody for eight years.
<?php
declare(strict_types=1);
/**
* Latest close from a Nasdaq Data Link time-series dataset.
* Response is column-oriented: column_names + rows.
* Free key: 300 calls/10s, 2,000/10min, 50,000/day. Anonymous: 50/day.
*/
function nasdaqLastClose(string $database, string $code): ?string
{
$key = getenv('NASDAQ_DATA_LINK_KEY');
$url = sprintf(
'https://data.nasdaq.com/api/v3/datasets/%s/%s.json?rows=1&api_key=%s',
rawurlencode($database),
rawurlencode($code),
rawurlencode($key)
);
$raw = @file_get_contents($url);
if ($raw === false) {
// 429 and 403 both arrive here with the default stream wrapper
throw new RuntimeException('Nasdaq Data Link request failed: ' . error_get_last()['message']);
}
$json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// Errors come back as JSON with HTTP 200 in some cases. Check first.
if (isset($json['quandl_error'])) {
throw new RuntimeException($json['quandl_error']['code'] . ': ' . $json['quandl_error']['message']);
}
$columns = $json['dataset']['column_names'] ?? [];
$rows = $json['dataset']['data'] ?? [];
if (!$rows) {
return null; // valid dataset, no rows in range
}
$idx = array_flip($columns); // column name -> index
if (!isset($idx['Close'])) {
throw new RuntimeException('No Close column; got: ' . implode(', ', $columns));
}
// Cast to string, not float: the JSON decoder already made a float,
// but at least stop it from becoming a second one downstream.
$close = $rows[0][$idx['Close']] ?? null;
return $close === null ? null : (string) $close;
}
// 'EOD' is the premium End of Day US Stock Prices feed.
// 'WIKI' still answers and still stops at March 2018.
echo 'Close: ' . (nasdaqLastClose('EOD', 'AAPL') ?? 'no data') . " USD\n";This breaks when the column set changes. array_flip plus an explicit isset check turns a silent wrong-column read into an exception with the actual column list in the message. A crawler that reads index 4 forever will keep running, keep writing, and give you no signal at all.
For bit-exact prices out of PHP, request CSV and keep the raw text. json_decode has already turned the number into a float before your code sees it, and JSON_BIGINT_AS_STRING does nothing for decimals.
8.2. Python — Finnhub, with the decoder fixed
The usual advice is Decimal(str(value)), which works because Python's repr has produced the shortest round-tripping string since 3.1. There is a cleaner route that removes the float entirely.
import json
import os
from decimal import Decimal
import requests
FINNHUB_TOKEN = os.environ["FINNHUB_TOKEN"]
def finnhub_quote(symbol: str) -> dict[str, Decimal]:
"""Current quote. parse_float=Decimal keeps the wire value exactly."""
resp = requests.get(
"https://finnhub.io/api/v1/quote",
params={"symbol": symbol, "token": FINNHUB_TOKEN},
timeout=10,
)
resp.raise_for_status()
# Not resp.json(): that builds floats and then we would be converting
# a float back to Decimal, which is a lossy round trip done twice.
d = json.loads(resp.text, parse_float=Decimal)
# Finnhub answers 200 with all-zero fields for an unknown symbol.
if d.get("c") in (0, Decimal(0)):
raise LookupError(f"no quote for {symbol!r}: unknown symbol or no entitlement")
return {
"current": d["c"],
"open": d["o"],
"high": d["h"],
"low": d["l"],
"prev_close": d["pc"],
}
if __name__ == "__main__":
q = finnhub_quote("AAPL")
print(f"AAPL: {q['current']} USD (open {q['open']}, high {q['high']})")This breaks when a symbol is wrong. Finnhub returns HTTP 200 with zeros rather than a 404, so raise_for_status() passes and a naive loop writes a row of zeros into your database for every typo in your ticker list. A price of exactly zero is never a real quote for an equity, so guard on it.
8.3. JavaScript / Node.js — Twelve Data, strings all the way down
// Basic tier: 800 API credits/day, 8/min. Key required.
const API_KEY = process.env.TWELVE_DATA_KEY;
async function twelveQuote(symbol) {
const url = new URL("https://api.twelvedata.com/quote");
url.searchParams.set("symbol", symbol);
url.searchParams.set("apikey", API_KEY);
const res = await fetch(url);
const d = await res.json();
if (d.status === "error") throw new Error(`${d.code}: ${d.message}`);
return {
symbol: d.symbol,
open: d.open, // strings on the wire; leave them as strings
high: d.high,
low: d.low,
close: d.close,
volume: BigInt(d.volume), // exact above 2^53
exchange: d.exchange,
isMarketOpen: d.is_market_open,
};
}
twelveQuote("MSFT").then((q) =>
console.log(`${q.symbol} (${q.exchange}): close ${q.close}, vol ${q.volume}`)
);JavaScript has no decimal type and Number is an IEEE 754 double, so prices stay strings and arithmetic goes through decimal.js or big.js. Volume goes through the native BigInt, which matters above 2^53 — a threshold that aggregate market volume crosses routinely.
This breaks when you exceed the credit budget. Twelve Data counts credits, not requests, and different endpoints cost different amounts. Eight credits a minute on the free tier is not eight symbols a minute if you are calling a multi-symbol endpoint.
8.4. Go — Alpha Vantage, and the throttle that looks like data
Alpha Vantage returns prices as strings, so decimal precision survives the wire intact as long as you never call strconv.ParseFloat on them. It also has a trap that has broken a great many crawlers, including the version of this code that appeared here before.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"time"
)
// Free tier: 25 requests/day. TIME_SERIES_DAILY is free;
// TIME_SERIES_DAILY_ADJUSTED is a premium endpoint.
type avDaily struct {
Note string `json:"Note"`
Information string `json:"Information"`
ErrorMsg string `json:"Error Message"`
Series map[string]struct {
Open string `json:"1. open"`
High string `json:"2. high"`
Low string `json:"3. low"`
Close string `json:"4. close"`
Volume string `json:"5. volume"`
} `json:"Time Series (Daily)"`
}
func dailyBars(symbol string) (avDaily, error) {
key := os.Getenv("ALPHAVANTAGE_KEY")
url := fmt.Sprintf(
"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=%s&apikey=%s",
symbol, key,
)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(url)
if err != nil {
return avDaily{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return avDaily{}, err
}
var out avDaily
if err := json.Unmarshal(body, &out); err != nil {
return avDaily{}, err
}
// HTTP 200 with an explanation instead of data.
switch {
case out.Note != "":
return out, fmt.Errorf("throttled: %s", out.Note)
case out.Information != "":
return out, fmt.Errorf("no data: %s", out.Information)
case out.ErrorMsg != "":
return out, fmt.Errorf("bad request: %s", out.ErrorMsg)
case len(out.Series) == 0:
return out, fmt.Errorf("empty series for %s", symbol)
}
return out, nil
}
func main() {
bars, err := dailyBars("IBM")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
dates := make([]string, 0, len(bars.Series))
for d := range bars.Series {
dates = append(dates, d)
}
sort.Strings(dates) // ISO dates sort correctly as strings
last := dates[len(dates)-1]
b := bars.Series[last]
// Strings throughout; for arithmetic use shopspring/decimal.
fmt.Printf("IBM %s: O=%s H=%s L=%s C=%s V=%s\n",
last, b.Open, b.High, b.Low, b.Close, b.Volume)
}This breaks on request 26. When you exceed 25 requests in a day, Alpha Vantage answers HTTP 200 with a JSON object containing a Note or Information field and no time series at all. json.Unmarshal succeeds. out.Series is an empty map. dates is an empty slice, and dates[len(dates)-1] panics with an index out of range. The earlier version of this code had exactly that hole, and so does most sample code on the internet. Four fields and a switch statement close it.
8.5. C# / .NET — Yahoo's undocumented chart endpoint
This is the honest look at the scraping route, with one correction. The endpoint path is /v8/finance/chart/, on query1 or query2. An earlier version of this article printed /v8/financial-data-services/chart/, which is not a path Yahoo serves. The authoritative reference is yfinance's own source, which builds f"{_BASE_URL_}/v8/finance/chart/{self.ticker}" against https://query2.finance.yahoo.com.
using System.Text.Json;
// Unofficial Yahoo Finance endpoint - the one yfinance uses.
// Yahoo's ToS section 2.4.ix prohibits automated collection without
// prior permission. Prototypes and personal research only.
public static class YahooChart
{
private static readonly HttpClient Http = new();
public static async Task<(string Date, decimal Close)> LastDailyCloseAsync(string symbol)
{
var url = $"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range=5d";
var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.UserAgent.ParseAdd("Mozilla/5.0"); // Yahoo requires a User-Agent
var resp = await Http.SendAsync(req);
if ((int)resp.StatusCode == 429)
throw new HttpRequestException("Rate limited by Yahoo; back off and retry later.");
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStreamAsync());
var result = doc.RootElement.GetProperty("chart").GetProperty("result")[0];
var timestamps = result.GetProperty("timestamp");
var closes = result.GetProperty("indicators")
.GetProperty("quote")[0].GetProperty("close");
// Walk backwards: the last close is null on halts and on some
// pre-open snapshots, and GetDecimal() throws on a JSON null.
for (var i = timestamps.GetArrayLength() - 1; i >= 0; i--)
{
if (closes[i].ValueKind == JsonValueKind.Null) continue;
var unix = timestamps[i].GetInt64();
var date = DateTimeOffset.FromUnixTimeSeconds(unix).ToString("yyyy-MM-dd");
return (date, closes[i].GetDecimal());
}
throw new InvalidOperationException($"No non-null close in range for {symbol}");
}
}
class Program
{
static async Task Main()
{
var (date, close) = await YahooChart.LastDailyCloseAsync("AAPL");
Console.WriteLine($"AAPL close {date}: {close} USD");
}
}This breaks on halted names and on the last bar of an incomplete session. The close array is parallel to timestamp and contains null where no trade printed. GetDecimal() on a null throws InvalidOperationException, and the naive version that takes the last index does it at the least convenient moment: during a trading halt on the one symbol you care about. Walking backwards to the first non-null is three lines.
If you run this at any scale, expect 429s and plan for rotating proxies and request pacing. If you are scraping a broker's or a data portal's web pages rather than an API, expect to deal with CAPTCHAs and IP blocks as well. Neither changes the licensing position, which stays exactly where section 6 left it.
9. Precision: what the JSON decoder already did to your price
Every guide says "use Decimal." Few say where the damage happens, which is before your code runs.
json.loads('{"c": 185.64}') gives you a Python float. Ask what that float really is and you get 185.6399999999999863575794734060764312744140625. The string "185.64" was exact; the double is not, because 185.64 has no finite binary representation. Decimal(d["c"]) faithfully preserves the error. Decimal(str(d["c"])) recovers "185.64", because repr of a float produces the shortest string that round-trips — a guarantee Python has made since 3.1 and which is easy to rely on without knowing you are relying on it.
Where the str() route loses is when the source sends more precision than a double can hold:
import json
from decimal import Decimal
wire = '{"p": 185.6400000000000005684341886080801486968994140625}'
a = json.loads(wire) # -> 185.64 (float)
b = json.loads(wire, parse_float=Decimal) # -> Decimal('185.6400000000000005684341886080801486968994140625')
print(Decimal(str(a["p"]))) # 185.64 <- the extra digits are gone
print(b["p"]) # full value intactparse_float=Decimal costs one keyword argument and removes the float from the pipeline entirely. Use it, and reach for resp.text rather than resp.json() so you control the decoder.
How much drift does this actually cause. Measured on a single machine with CPython 3.11, summing price * shares over a synthetic book of 1,259 daily bars (five trading years) across 500 instruments — 629,500 additions in total, prices drawn uniformly from $5 to $500 and rounded to cents. The float total came to 390,507,090,375.0494. The exact decimal total was 390,507,090,375.00. The gap is 4.94 cents on $390 billion, which is nothing until it is a reconciliation break that somebody has to explain. Over a single 1,259-bar series the drift was 2 × 10⁻⁷ dollars. Your numbers will differ; the shape will not.
The cost of avoiding it is small. A million Decimal additions took 0.082 seconds against 0.044 for floats on the same machine: 1.9 times slower on an operation that is not your bottleneck. Your bottleneck is the network, and on a free tier your bottleneck is the rate limit.
There is one more place the float sneaks back in. yfinance hands you a pandas DataFrame, and pandas price columns are float64. If you want decimal discipline end to end, it starts at the raw JSON, not at the DataFrame — and a library that returns a DataFrame has already made the decision for you.
10. Storage
The base rule is the one from the currency-rate guide: prices go in a decimal type, never float or double.
| Field | Type | Why |
|---|---|---|
| Price (open/high/low/close, last) | NUMERIC(18,6) / Decimal / decimal |
lossless; 6 decimals covers sub-penny quoting |
| Volume | BIGINT / int64 |
billions of shares overflow a 32-bit int |
| Adjusted close | NUMERIC(18,6) |
store alongside the raw price, never instead of it |
| Bar timestamp | TIMESTAMPTZ |
must carry the venue's zone, not the server's |
| Instrument currency | CHAR(3) (ISO 4217) |
needed for portfolio conversion |
| Source and entitlement | VARCHAR |
which licence this row arrived under |
That last row is the one most schemas skip, and it is the one that costs the most to add later. If half your history came from a free tier that forbids redistribution and half from a paid feed that permits it, and you cannot tell which is which, then in practice none of it is redistributable.
CREATE TABLE quotes (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(16) NOT NULL, -- 'NASDAQ_DL', 'FINNHUB', 'AV', 'YAHOO'
entitlement VARCHAR(16) NOT NULL, -- 'eod' | 'delayed' | 'realtime'
licence VARCHAR(16) NOT NULL, -- 'free-personal' | 'paid-internal' | 'redistributable'
exchange VARCHAR(16) NOT NULL, -- 'XNAS', 'XNYS', 'XLON' (ISO 10383 MIC)
ticker VARCHAR(20) NOT NULL,
isin CHAR(12),
figi CHAR(12), -- from OpenFIGI; survives ticker churn
ccy CHAR(3) NOT NULL,
ts TIMESTAMPTZ NOT NULL, -- bar open, in UTC
interval VARCHAR(8) NOT NULL DEFAULT '1d',
open NUMERIC(18,6) NOT NULL,
high NUMERIC(18,6) NOT NULL,
low NUMERIC(18,6) NOT NULL,
close NUMERIC(18,6) NOT NULL,
adj_close NUMERIC(18,6),
volume BIGINT NOT NULL DEFAULT 0,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source, exchange, ticker, interval, ts)
);
CREATE TABLE corporate_actions (
id BIGSERIAL PRIMARY KEY,
figi CHAR(12),
exchange VARCHAR(16) NOT NULL,
ticker VARCHAR(20) NOT NULL,
action VARCHAR(16) NOT NULL, -- 'split' | 'dividend' | 'symbol_change'
ex_date DATE NOT NULL,
ratio_num INTEGER, -- 10 for a 10:1 split
ratio_den INTEGER, -- 1
cash_amount NUMERIC(18,6), -- dividend per share
ccy CHAR(3),
source VARCHAR(16) NOT NULL,
UNIQUE (exchange, ticker, action, ex_date)
);Two design decisions in there are worth defending. Store the raw close and the adjusted close in separate columns. Every new split rewrites the entire adjusted history behind it, so an adjusted-only table has no fixed point and no way to verify itself. The raw close for 3 March 2019 is the same number forever. Store corporate actions as their own rows with ratios rather than pre-baked factors, so you can recompute the adjustment when a vendor corrects an ex-date, which they do.
Use the ISO 10383 MIC for the venue rather than a house string. XNAS and XNYS mean one thing everywhere; NASDAQ means whatever the last integration meant by it.
11. What breaks at ten thousand tickers
A script that fetches one symbol tells you nothing about a job that fetches ten thousand overnight. Five things change.
The rate limit becomes the schedule. Alpha Vantage's free tier is 25 requests a day. Backfilling 500 tickers at one request each takes 20 calendar days, assuming nothing fails. EODHD's free tier at 20 calls a day takes 25 days for the same job. Tiingo's free tier caps at 500 unique symbols a month, so a 3,000-name universe is not a slow backfill on that plan, it is impossible. This is the arithmetic that decides whether you are buying a $19.99 plan or writing a queue.
Everything becomes incremental. Pull the full history once, store it, then append. A daily job for 10,000 tickers is 10,000 requests if you ask for one bar each, and one request if the vendor has a bulk endpoint — which is precisely what the paid EOD tiers sell. Bulk EOD for a whole exchange in a single call is the single biggest reason to leave a free tier.
Failures stop being exceptional. At 10,000 requests a night, a 0.5% failure rate is 50 gaps every night. You need per-symbol status, a retry queue with backoff, and a nightly reconciliation that asks "which symbol-days should exist and do not" against an exchange calendar. Without the last one you will not notice a missing week until a backtest produces a number that flatters you.
Corporate actions become a rewrite job, not a read. A split with an ex-date of tomorrow means every adjusted close for that instrument, for its entire history, changes tonight. If your adjusted series is materialised, that is a bulk update; if it is computed on read, that is a cache invalidation. Either way it needs to be a deliberate, logged operation, because the alternative is a silent 10x discontinuity in a chart somebody is about to show a client.
And the licence becomes an architecture question. Non-display fees exist precisely because automated consumption at scale is a different product from a person looking at a screen. If the pipeline feeds a model rather than a monitor, read your agreement's non-display clause before you scale, not after. This is the point where a licensed feed through a data-as-a-service arrangement stops being an expense and starts being cheaper than the compliance work, and where sites that fight back are where a managed extraction service earns its keep.
A useful sanity check costs nothing: a "change against previous close exceeds N percent" alarm catches splits, parsing errors and bad ticks with the same rule. Set N around 25% for liquid large caps and expect real hits during earnings season.
12. Where this approach stops working
Some honesty about the boundaries.
Anything execution-related. If a trading decision depends on the price, you need a real feed with a real SLA, and no free tier and no scraped endpoint qualifies. Yahoo says so itself: its data is "not intended for trading or investing purposes."
Anything you show to other people. Free tiers are personal-use licences almost without exception, and Tiingo's wording is the clearest: "you may only use the data for your own personal use and you may not display or share the data with another person or organization." A public dashboard is redistribution.
Anything that needs the NBBO. An IEX-only feed, a single-venue feed, or a last price from an aggregator that does not say which venue it came from will disagree with the consolidated tape, and the disagreement is largest exactly when the market is moving.
Depth of book. Bid/ask beyond the top level is not available free from any source in this article, and the fee schedule in section 3 explains why.
And there is a boundary on our own knowledge. Finnhub's commercial terms are behind a JavaScript application we could not read on 13 August 2026. Several vendors publish "personal" and "commercial" plan families without publishing the contract that distinguishes them. Where the terms are not readable without signing up, the honest answer is that they are not readable, and any article that states them confidently is repeating a summary of a summary.
13. Practical rules
- Decide what freshness you actually need before you shop. For portfolios, analytics, dashboards and research, end-of-day is enough, it is cheap, and it is the easiest thing in this article to license.
- Read the redistribution clause before you write the schema. Storing data and serving data are different permissions at every vendor here, and the second one is what people accidentally build.
- Cache first, fetch second. With 25 requests a day there is no alternative: pull history once, append incrementally, serve everything else from your own database.
- Treat a sudden N-fold move as a corporate action until proven otherwise. The same check catches split events and parser bugs.
- Key instruments on exchange plus ticker at minimum, on FIGI or ISIN if you can. OpenFIGI is free and fast enough to map a whole universe in under two minutes.
- Take the date from the response, not from your request. An empty payload on a market holiday is correct behaviour, and a job that assumes today's date will write a phantom bar.
- Record which source and which licence every row arrived under. Retrofitting that is worse than it sounds.
- Do not build production on Yahoo. It is an excellent prototype tool with a genuine rate limit, an undocumented endpoint and terms that forbid the use.
14. Where this gets used
Portfolio trackers and accounting systems need end-of-day marks and an FX conversion, and nothing more. Dashboards, screeners and backtests need OHLCV plus a trustworthy adjusted series, which is the point where a paid EOD plan usually pays for itself. Robo-advisors and any product that shows prices to a customer need a display licence, which is a different conversation entirely.
Algorithmic trading is the outlier. It needs real-time and depth of book, which means an exchange feed or a vendor with a real-time entitlement, which means the numbers in section 3 rather than the numbers in section 5.
Wrapping up
Technically, stock quotes are close to FX rates: same decimal types, same cache-first design, same suspicion of jumps that turn out to be corporate actions rather than crashes. Three things are genuinely different. The ticker is not an identifier, so the instrument key is exchange plus symbol or, better, a FIGI. The history needs adjusting for splits and dividends, which makes the adjusted series a maintained product rather than a calculation. And the data is licensed, in a regime where the exchange fee schedule is a public filing and the free tier is a personal-use grant.
The good news is that the free lane is wider than most write-ups suggest, as long as you stay inside it. SEC EDGAR gives you fundamentals and a ticker universe at 10 requests a second with no key and no licence. OpenFIGI gives you identifiers. A $19.99 EOD plan gives you thirty years of global closing prices. Add a local cache and the FX rates from the companion article on exchange rates, and a multi-currency portfolio system costs less per month than one professional TotalView seat.
The trap is not technical and it is not expensive. It is building on a free tier for six months and then discovering that showing the result to anyone was the one thing the licence never allowed.
Prices, limits and terms verified against vendor documentation and exchange fee schedules on 13 August 2026.