Get Free Forex Data in Python — No API Key Required
Most "free" forex APIs cap you at a few hundred requests a day and make you register for a key first. biquote is different: every endpoint is anonymous, and the limit is 15,000 requests per minute. Here is everything you need.
pip install biquote — that wraps everything below (mid-as-price, OHLC envelope, liveOnly). The raw-requests path stays here so you can see exactly what's on the wire.1. A live quote in one line
import requests
tick = requests.get("https://biquote.io/api/EURUSD").json()
print(tick["mid"], tick["dayDiffPercent"]) # 1.15884 0.51
Or with the client:
from biquote import Biquote
bq = Biquote()
print(bq.tick("EURUSD")["mid"])
mid as the price. last and volume are always 0 on FX/CFD feeds — there is no central exchange tape for forex.2. Many symbols in one request
Polling one symbol per call wastes your quota. The batch endpoint takes up to 100 symbols (repeat the parameter — the comma form is not supported):
r = requests.get(
"https://biquote.io/api/latest",
params=[("symbols", s) for s in ("EURUSD", "XAUUSD", "BTCUSD", "USDTRY")],
).json()
for sym, t in r.items():
print(f'{sym:8} {t["mid"]:>12} {t["dayDiffPercent"]:+.2f}%')
3. OHLC candles into pandas
import pandas as pd
env = requests.get(
"https://biquote.io/api/XAUUSD/ohlc",
params={"interval": "1h", "limit": 500},
).json()
df = pd.DataFrame(env["bars"]) # envelope: {symbol, interval, bars}
df["openTime"] = pd.to_datetime(df["openTime"])
df = df.set_index("openTime").sort_index() # bars arrive newest-first
print(df[["open", "high", "low", "close"]].tail())
Intervals: 1m 5m 15m 30m 1h 4h 1d, up to 1,000 bars per call.
4. Streaming instead of polling
For continuous updates, skip HTTP entirely — the SignalR hub pushes every tick with no rate limit:
pip install pysignalr
import asyncio
from pysignalr.client import SignalRClient
async def main():
client = SignalRClient("https://biquote.io/hubs/tick")
client.on("ReceiveTick", lambda t: print(t[0]["symbol"], t[0]["mid"]))
client.on_open(lambda: client.send("Subscribe", [["EURUSD", "XAUUSD"]]))
await client.run()
asyncio.run(main())
Which symbols are live?
The catalogue lists ~1,700 instruments but only ~280 quote at any moment. Always filter:
live = requests.get("https://biquote.io/api/symbols",
params={"quotedWithinDays": 7}).json()
print(len(live), "instruments")
quotedWithinDays rather than liveOnly: the latter means "quoting this second", so at a weekend it returns almost nothing and any list built on it comes back empty.
Rate limits & errors
| Limit | Value |
|---|---|
| Anonymous quota | 15,000 requests / minute / IP |
| Over the limit | HTTP 429 with Retry-After: 60 |
| Unknown symbol | HTTP 404 {"message": "..."} |
| Market closed | HTTP 200 with the last price and "marketState": "closed" |
That last row is the one that catches people. Forex stops quoting from Friday night to Sunday night, and a closed market is not an error — you get the last known price plus marketState, stale and quoteAgeSeconds. Show it as a last price rather than a live one, and hide the day change, which belongs to a session that has already ended:
tick = requests.get("https://biquote.io/api/EURUSD").json()
if tick["marketState"] == "closed":
print(f"{tick['mid']} (market closed, {tick['quoteAgeSeconds'] // 3600}h ago)")
else:
print(f"{tick['mid']} {tick['dayDiffPercent']:+.2f}%")
Writing a trading client instead? Pass allowStale=false and you get a 404 the moment a quote is more than five minutes old — the right behaviour when acting on a stale price is worse than acting on none.