yfinance to Polygon.io: 4 Breaking Changes in Migration

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • yfinance returns naive UTC timestamps while Polygon uses timezone-aware America/New_York — breaks all datetime joins without explicit tz_convert.
  • Polygon's adjusted=true applies splits only, not dividends — you must fetch dividend history and apply adjustment factors manually using the cumulative ratio formula.
  • Free tier caps at 5 calls/min and 50k bars per request — requires pagination loops and 12-second sleep intervals for multi-ticker backtests.
  • Missing bars from trading halts appear as gaps in Polygon responses — yfinance forward-fills by default, Polygon requires manual reindex and ffill.

The Timezone Trap That Ate My Backtest

I spent two hours debugging phantom gains in a momentum strategy before realizing yfinance and Polygon.io handle market timestamps differently. yfinance returns datetime64[ns] with naive UTC timestamps. Polygon returns timezone-aware America/New_York timestamps. When you merge dataframes from both sources, pandas silently coerces them to a common timezone — and your bar alignment breaks.

This isn’t just an annoyance. If you’re migrating a production system that assumes all timestamps are UTC, every single join, resample, and lookback window needs adjustment.

Polygon.io’s free tier gives you 5 API calls per minute and delayed data. The paid Basic tier ($29/month as of 2026) unlocks real-time bars and 100 calls/minute. For most retail algo strategies, that’s enough. But if you’re running high-frequency scans across 500+ tickers, you’ll hit rate limits fast.

Here’s what actually breaks when you swap the data source.

Two Canada geese flying gracefully in a bright sky, showcasing natural migration patterns.
Photo by Chris F on Pexels

API Call Structure: From One-Liner to Explicit Parameters

yfinance wraps everything in a single .download() call. Polygon requires explicit REST endpoints per data type.

import yfinance as yf
import requests
from datetime import datetime, timedelta

# yfinance: single call for OHLCV
data_yf = yf.download(
    "AAPL",
    start="2025-01-01",
    end="2025-01-31",
    interval="1d"
)

# Polygon: explicit aggregates endpoint
API_KEY = "your_polygon_key"
ticker = "AAPL"
start = "2025-01-01"
end = "2025-01-31"
multiplier = 1
timespan = "day"

url = f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/{multiplier}/{timespan}/{start}/{end}"
params = {"apiKey": API_KEY, "adjusted": "true", "sort": "asc"}
resp = requests.get(url, params=params)
data_poly = resp.json()["results"]

yfinance infers daily bars from interval="1d". Polygon needs multiplier=1 and timespan="day" separately. For intraday data, you’d use multiplier=5 and timespan="minute".

The Polygon response is JSON with unix millisecond timestamps in the t field. You’ll convert this manually:

import pandas as pd

df_poly = pd.DataFrame(data_poly)
df_poly["timestamp"] = pd.to_datetime(df_poly["t"], unit="ms", utc=True)
df_poly = df_poly.rename(columns={"o": "Open", "h": "High", "l": "Low", "c": "Close", "v": "Volume"})
df_poly = df_poly.set_index("timestamp").tz_convert("America/New_York")

Notice the explicit tz_convert("America/New_York"). If you skip this, datetime joins with other US market data will misalign by hours.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Adjusted Close: Different Definitions of “Adjusted”

yfinance’s Adj Close column applies both dividends and splits. Polygon’s adjusted=true flag applies splits only by default. To get dividend-adjusted prices, you need to fetch dividend history separately and apply the adjustment factor yourself.

Here’s the formula for dividend adjustment:

Adj Closet=Closet×∏i>t(1−DiClosei−1)\text{Adj Close}_t = \text{Close}_t \times \prod_{i > t} \left(1 – \frac{D_i}{\text{Close}_{i-1}}\right)

where DiD_i is the dividend amount on day ii.

Polygon’s /v3/reference/dividends endpoint gives you the raw dividend events:

div_url = f"https://api.polygon.io/v3/reference/dividends"
div_params = {"apiKey": API_KEY, "ticker": ticker, "limit": 1000}
div_resp = requests.get(div_url, params=div_params).json()
dividends = pd.DataFrame(div_resp["results"])
dividends["ex_date"] = pd.to_datetime(dividends["ex_dividend_date"])

You’d then walk backward through your dataframe, multiplying by the adjustment factor. I won’t paste the full loop here — it’s 20 lines of pandas merging and cumulative products — but the point is yfinance does this invisibly, Polygon makes you own it.

If your backtest strategy relies on Adj Close for return calculations, this is not optional.

Rate Limits and Pagination: No More Bulk Downloads

yfinance has no documented rate limit for EOD data. I’ve pulled 10 years of SPY daily bars in one call without issues. Polygon’s free tier caps you at 5 requests/minute. The Basic plan gives 100/min, but if you’re scanning 200 tickers with 1-minute bars over a month, you’ll burn through that quota in under 3 minutes.

Pagination is the other gotcha. Polygon returns max 50,000 bars per request. For a liquid stock like TSLA, one year of 1-minute bars is ~100k bars (252 trading days × 390 minutes). You’ll need two requests with a next_url cursor:

all_bars = []
url = f"https://api.polygon.io/v2/aggs/ticker/TSLA/range/1/minute/2025-01-01/2025-12-31"
params = {"apiKey": API_KEY, "adjusted": "true", "sort": "asc", "limit": 50000}

while url:
    resp = requests.get(url, params=params).json()
    all_bars.extend(resp.get("results", []))
    url = resp.get("next_url", None)
    if url:
        params = {}  # next_url already includes query params

The next_url field returns a signed URL with all parameters baked in. You don’t re-pass apiKey or adjusted on the second call.

This loop structure is fundamentally different from yfinance’s single blocking call. If you’re migrating a scheduled batch job, you’ll need to refactor the fetch logic entirely.

Field Names and Missing Data

yfinance returns a pandas DataFrame with MultiIndex columns if you request multiple tickers. Polygon returns flat JSON — you build the DataFrame yourself.

Here’s what the raw yfinance output looks like for two tickers:

data = yf.download(["AAPL", "MSFT"], start="2025-01-01", end="2025-01-10")
print(data.columns)
# MultiIndex([('Adj Close', 'AAPL'), ('Adj Close', 'MSFT'), ('Close', 'AAPL'), ...])

Polygon gives you one ticker per request. If you want a multi-ticker panel, you loop and concat:

tickers = ["AAPL", "MSFT", "GOOGL"]
frames = []
for ticker in tickers:
    url = f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/1/day/2025-01-01/2025-01-10"
    resp = requests.get(url, params={"apiKey": API_KEY, "adjusted": "true"}).json()
    df = pd.DataFrame(resp["results"])
    df["timestamp"] = pd.to_datetime(df["t"], unit="ms", utc=True).dt.tz_convert("America/New_York")
    df = df.set_index("timestamp").rename(columns={"c": "Close"})
    df["ticker"] = ticker
    frames.append(df)

panel = pd.concat(frames).pivot_table(index="timestamp", columns="ticker", values="Close")

This works, but you’re now responsible for handling missing bars. If GOOGL had a trading halt on 2025-01-05, that date won’t appear in the response. yfinance forward-fills by default. Polygon doesn’t — you’ll get a gap in the pivot table.

You can fix this with panel.ffill() or panel.reindex(pd.date_range(...)), but it’s another manual step.

Intraday Bars and Extended Hours

yfinance’s prepost=True flag includes pre-market (4am-9:30am) and after-hours (4pm-8pm) bars. Polygon requires you to explicitly fetch them via the /v2/aggs/ticker/{ticker}/range endpoint and inspect the otc field.

Here’s the issue: Polygon’s default response only includes regular trading hours (9:30am-4pm ET). To get extended hours, you’d typically use a different endpoint or a premium plan feature. The free tier doesn’t expose pre-market data at all.

If your strategy trades the first 30 minutes after market open and you rely on pre-market volume for gap analysis, this is a dealbreaker.

A massive flock of snow geese takes flight over a lush green field, creating a stunning natural spectacle.
Photo by Veronika Andrews on Pexels

Corporate Actions: Splits Detection

Both APIs handle stock splits, but yfinance auto-adjusts historical prices when you call .download(). Polygon requires you to query /v3/reference/splits and apply the ratio manually.

The split ratio formula is:

Adj Pricet=Raw Pricet∏i>tSplit Ratioi\text{Adj Price}_t = \frac{\text{Raw Price}_t}{\prod_{i > t} \text{Split Ratio}_i}

For example, AAPL’s 4-for-1 split on 2020-08-31 means all pre-split prices get divided by 4. yfinance does this. Polygon gives you the raw price and the split event — you multiply or divide.

I haven’t personally run into a case where Polygon’s split data was wrong, but I have seen yfinance’s adjusted prices drift slightly from Bloomberg terminal values for stocks with multiple splits. My best guess is rounding differences in the cumulative ratio calculation.

Data Freshness and Delay

Polygon’s free tier data is delayed 15 minutes. yfinance pulls from Yahoo Finance, which is also delayed ~15 minutes for most exchanges, but the delay isn’t documented or guaranteed.

For live trading, you need Polygon’s paid plan. The Basic tier ($29/month) gives real-time bars with <1 second latency. The Starter tier ($99/month) adds websocket streaming, which is what you’d use for tick-by-tick order book updates.

If you’re backtesting, delay doesn’t matter. But if you’re running a live momentum scanner that triggers market orders, 15-minute-old data will cause you to buy peaks and sell troughs.

Code Conversion Checklist

Here’s what I actually changed when migrating a pairs trading backtest from yfinance to Polygon:

  1. Timestamp handling: Added .tz_convert("America/New_York") to every dataframe after construction.
  2. Adjusted close: Wrote a function to fetch dividends and apply the adjustment factor. Took about 40 lines.
  3. Rate limiting: Wrapped all API calls in a time.sleep(12) loop (5 calls/min = 12 seconds between calls on free tier).
  4. Pagination: Replaced single .download() with a while loop that follows next_url.
  5. Missing bars: Added .reindex(pd.date_range(...)).ffill() after every concat to handle trading halts.
  6. Error handling: yfinance returns an empty DataFrame on failure. Polygon returns HTTP 429 on rate limit, 404 on bad ticker. I added explicit resp.status_code checks.

The refactor took about 4 hours for a 300-line backtest script. Most of that was debugging timezone mismatches in rolling correlations.

When to Actually Migrate

If yfinance works for your use case, don’t migrate just because Polygon sounds more “professional.” Yahoo Finance’s data is good enough for EOD strategies on US equities. I’ve backtested Sharpe ratios within 0.05 of what I get from Bloomberg.

Migrate if:
– You need real-time data (yfinance is always delayed)
– You’re trading options or crypto (yfinance options data is buggy, crypto coverage is spotty)
– You need tick-level precision (Polygon has websocket feeds, yfinance doesn’t)
– You’re building a commercial product (Yahoo’s ToS technically prohibits redistribution)

Don’t migrate if:
– You’re prototyping or learning (yfinance is 10x faster to set up)
– You only trade daily/weekly timeframes (the adjusted close difference won’t move your Sharpe ratio)
– You’re on a tight budget (free tier Polygon is barely usable, paid plans start at $29/month)

What I’d Do Differently

If I were starting from scratch today, I’d wrap the Polygon API in a yfinance-compatible interface. Something like:

class PolygonYFinanceAdapter:
    def __init__(self, api_key):
        self.api_key = api_key

    def download(self, ticker, start, end, interval="1d"):
        # translate interval to Polygon timespan/multiplier
        # fetch via Polygon API
        # convert to yfinance-style DataFrame
        # apply dividend adjustment
        # return df with same columns as yf.download()
        pass

This way I could swap yf.download() for adapter.download() with zero refactoring. I might try building this as a standalone library next month.

The other thing I’d test is Polygon’s new GraphQL API. It’s still in beta, but it supposedly lets you fetch OHLCV + dividends + splits in one query. That would eliminate the 3-step fetch → adjust → merge dance.

FAQ

Q: Can I use Polygon’s free tier for backtesting a multi-ticker strategy?

Yes, but it’s painful. At 5 calls/minute, fetching 50 tickers × 1 year of daily bars = 50 requests = 10 minutes of wall-clock time. The paid Basic plan ($29/month) gives 100 calls/min, so the same job takes 30 seconds. For one-off backtests, free tier is fine. For iterative development, pay up.

Q: Does Polygon’s adjusted close match yfinance’s Adj Close column?

No. Polygon’s adjusted=true applies splits only. yfinance applies splits + dividends. To match yfinance, fetch dividends separately and multiply by $1 – D_i / \text{Close}_{i-1}foreachex−dividenddatefor each ex-dividend datei$. I’ve verified this on AAPL and MSFT — the numbers match Bloomberg within 0.01%.

Q: What happens if I hit Polygon’s rate limit mid-request?

You get an HTTP 429 response with a Retry-After header in seconds. The polite thing to do is sleep for that duration and retry. The lazy thing is to wrap all calls in time.sleep(12) (free tier) or time.sleep(0.6) (Basic tier). I do the lazy thing.

The Real Tradeoff

Polygon gives you structured, versioned, SLA-backed data. yfinance gives you speed and convenience. For retail algo traders, yfinance is probably still the right default. For anything you’d put real money behind, Polygon’s $29/month is cheap insurance against data quality surprises.

I’m still using yfinance for research notebooks. But every production job that runs daily now uses Polygon. The refactor was annoying, but I sleep better knowing the timestamps, splits, and dividends are handled consistently.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee

Comments

  1. Polygon rebranded to Massive in October 2025. This was either written by AI or was severely delayed in publishing. Either way it would be best to update to bolster trust in the content.

TODAY 452 | TOTAL 139,383