I run a small fleet of automated spread-betting bots on IG from a cheap Linux box. Getting the first one to talk to IG took far longer than it should have, because the official docs are thin on Python and most examples online are years old.
This is the guide I wanted at the start: the minimum code to log in, read live prices, place and confirm a trade, and stay inside IG's limits. It ends with the three mistakes that cost me the most time, so you can skip them.
Everything here also works on an IG demo account. Use one. There is no reason to point untested code at real money.
What you need
- An IG account (demo is fine) and an API key, which you generate in My IG under Settings.
- Python 3.10+ and three packages:
pip install requests lightstreamer-client-lib redis
- The REST base URL for your environment:
DEMO = "https://demo-api.ig.com/gateway/deal"
LIVE = "https://api.ig.com/gateway/deal"
BASE_URL = DEMO
Step 1: Log in
You POST /session with your username, password and API key. The catch that trips everyone up: the two session tokens come back in the response headers, not the body.
import requests
API_KEY = "your-api-key"
USERNAME = "your-username"
PASSWORD = "your-password"
def login():
headers = {
"VERSION": "2",
"X-IG-API-KEY": API_KEY,
"Content-Type": "application/json",
"Accept": "application/json; charset=UTF-8",
}
r = requests.post(f"{BASE_URL}/session", headers=headers,
json={"identifier": USERNAME, "password": PASSWORD}, timeout=10)
r.raise_for_status()
body = r.json()
return {
"cst": r.headers["CST"],
"xst": r.headers["X-SECURITY-TOKEN"],
"account_id": body["currentAccountId"],
"stream_url": body["lightstreamerEndpoint"],
}
def auth_headers(s, version="2"):
return {
"VERSION": version,
"X-IG-API-KEY": API_KEY,
"CST": s["cst"],
"X-SECURITY-TOKEN": s["xst"],
"Content-Type": "application/json",
"Accept": "application/json; charset=UTF-8",
}
Send CST and X-SECURITY-TOKEN on every request after that. IG's FAQ says they stay valid for 12 hours after your last call, but weekend maintenance wipes every token, so any long-running process has to treat a 401 as "log in again", not as a crash.
Keep your credentials in environment variables or a .env file, never in the code. I once found my own Telegram bot token hard-coded as a "fallback" in two files. That is how secrets leak.
Step 2: Read positions, place a trade, confirm it
Reading your open positions is one call:
def positions(s):
r = requests.get(f"{BASE_URL}/positions", headers=auth_headers(s), timeout=10)
r.raise_for_status()
return r.json()["positions"]
Opening a spread bet is a POST /positions/otc. For a daily funded bet the expiry is DFB and size is your stake per point:
def open_position(s, epic, direction, size, stop_distance=None):
body = {
"epic": epic, # e.g. "IX.D.FTSE.DAILY.IP"
"expiry": "DFB",
"direction": direction, # "BUY" or "SELL"
"size": size, # stake per point
"orderType": "MARKET",
"guaranteedStop": False,
"forceOpen": True,
"currencyCode": "GBP",
}
if stop_distance:
body["stopDistance"] = stop_distance
r = requests.post(f"{BASE_URL}/positions/otc", headers=auth_headers(s), json=body, timeout=10)
r.raise_for_status()
return r.json()["dealReference"]
The important part: a 200 here does not mean you have a position. It means IG received the request. You only know the outcome by asking /confirms:
import time
def confirm(s, deal_ref):
for _ in range(10):
r = requests.get(f"{BASE_URL}/confirms/{deal_ref}", headers=auth_headers(s, "1"), timeout=10)
if r.ok and r.json().get("dealStatus") in ("ACCEPTED", "REJECTED"):
return r.json()
time.sleep(1)
return {"dealStatus": "UNKNOWN"}
A bot that skips this step will happily log trades that were rejected (market closed, stop too close, insufficient funds) and its records will drift away from reality.
Closing is odd. IG wants a DELETE with a body, which many HTTP clients will not send, so you POST with a _method: DELETE header instead, and direction is the closing direction (SELL to close a long):
def close_position(s, deal_id, direction, size):
h = {**auth_headers(s, "1"), "_method": "DELETE"}
body = {"dealId": deal_id, "direction": direction, "size": size, "orderType": "MARKET"}
r = requests.post(f"{BASE_URL}/positions/otc", headers=h, json=body, timeout=10)
r.raise_for_status()
return r.json()["dealReference"] # confirm this too
Step 3: Respect the rate limits
IG's published defaults, from their FAQ:
| Limit | Default |
|---|---|
| Non-trading requests per account | 30 a minute |
| Non-trading requests per API key (all logins) | 60 a minute |
| Trading requests (open, close, amend) | 100 a minute |
| Historical price data points | 10,000 a week |
Demo limits are lower and change without notice, and IG will not raise them for you. A tiny limiter in front of every call keeps you safe:
from collections import deque
class RateLimiter:
def __init__(self, max_calls, per_seconds):
self.max, self.per, self.calls = max_calls, per_seconds, deque()
def wait(self):
now = time.time()
while self.calls and now - self.calls[0] > self.per:
self.calls.popleft()
if len(self.calls) >= self.max:
time.sleep(self.per - (now - self.calls[0]) + 0.1)
self.calls.append(time.time())
GENERAL = RateLimiter(25, 60) # inside the 30/min account limit
Call GENERAL.wait() before each request. The first two limits are easy. The fourth one is the trap, and it gets its own section below.
Step 4: Stream live prices
Do not poll REST for prices. IG streams them over Lightstreamer, and the stream is free. You connect to the endpoint from the login response and pass both tokens as the password:
from lightstreamer.client import LightstreamerClient, Subscription, SubscriptionListener
class Prices(SubscriptionListener):
def onItemUpdate(self, update):
# item names look like "MARKET:IX.D.FTSE.DAILY.IP"
epic = update.getItemName().split(":", 1)[1]
bid, offer = update.getValue("BID"), update.getValue("OFFER")
if bid and offer:
print(epic, float(bid), float(offer))
s = login()
client = LightstreamerClient(s["stream_url"], "DEFAULT")
client.connectionDetails.setUser(s["account_id"])
client.connectionDetails.setPassword(f"CST-{s['cst']}|XST-{s['xst']}")
client.connect()
sub = Subscription("MERGE", ["MARKET:IX.D.FTSE.DAILY.IP"], ["BID", "OFFER", "UPDATE_TIME"])
sub.addListener(Prices())
client.subscribe(sub)
Look closely at that split(":", 1)[1]. An earlier version of my code used split(":", 2)[2], which throws on every single tick. Because listener errors were caught and logged at debug level, the stream would have looked perfectly connected while every price was thrown away. My offline replay tests never touched that line; reading it against IG's real item names caught it. Log listener errors loudly.
Turn ticks into bars yourself
Most strategies want candles, not ticks. Building one-minute bars from the stream takes a few lines, costs nothing, and (this matters, see below) never touches your historical allowance:
bars = {} # epic -> current bar
def on_tick(epic, bid, offer):
mid = (bid + offer) / 2
minute = int(time.time()) // 60
b = bars.get(epic)
if b is None or b["minute"] != minute:
if b:
save_closed_bar(epic, b) # write to disk / Redis / a database
bars[epic] = {"minute": minute, "o": mid, "h": mid, "l": mid, "c": mid}
else:
b["h"] = max(b["h"], mid); b["l"] = min(b["l"], mid); b["c"] = mid
Append every closed bar to a CSV as well. After a few months you own a one-minute archive for backtesting that you would otherwise have to buy.
The three mistakes that cost me the most
1. Burning the historical allowance
My DAX bot pulled candles from /prices every 30 seconds for four markets to recompute its indicators. Within days every request came back 403 exceeded-account-historical-data-allowance, and every other bot that needed history went blind with it.
10,000 data points a week sounds like a lot until you realise a single request for 200 one-minute candles uses 200 of them. The fix is a rule: nothing ever calls /prices on a schedule. Indicator history comes from the bars you build from the stream, and for longer lookbacks or warm-up you use a free source such as Yahoo Finance. IG is used only for what only IG can do: live prices, positions and orders.
2. One stream per bot
With a dozen bots it is tempting to let each one open its own connection. That is the fastest way to hit limits, and IG warns that opening multiple concurrent streaming connections can get your API key suspended. You get one connection with up to 40 subscriptions on it.
The fix is architectural: one process owns the connection. A single "data hub" logs in, streams prices, builds bars and writes everything to Redis. Every bot reads from Redis and never talks to IG for data at all. Adding a tenth strategy costs zero extra API calls.
3. Trusting a connection that looks alive
Streams go quiet without disconnecting. The client says it is connected, no prices arrive, and a bot keeps trading on a price from twenty minutes ago. Two guards fixed it for me:
- A watchdog: if no tick arrives for 120 seconds on a weekday, tear the connection down and rebuild it. (Allow longer at weekends, when silence is normal.)
- A scheduled rebuild: log in fresh and reconnect every few hours, so you never find out about expired tokens from a failed order.
Each bot should also check the age of the price it is about to act on, and refuse to trade if it is stale.
Where to go from here
With the code above you can log in, stream prices, build your own bars and place a confirmed trade on demo. That is the plumbing. The harder question is whether the strategy you plug into it has any edge after spread and funding, and in my experience most do not.
If you want to see that for yourself, I published a free tool that backtests 76 classic indicator rules across 14 markets net of costs and filters out the ones that only look good by luck. On my last run, 982 configurations went in and 5 came out: Sweep Lite (free, MIT licence).
And if you would rather start from the full framework than build it yourself (the data hub with the watchdog and bar archive, the Redis bus, a bot base class with paper and live behind one interface, a trade ledger that tells you when to promote or kill a strategy, and a walkthrough of a year of running it), that is the Quant Desk Starter Kit.
Questions about the IG API? Ask in the comments and I will answer what I can.
Educational content, not financial advice. Spread betting and CFDs are leveraged and carry a high risk of losing money rapidly. Not affiliated with IG Group; rate limits and token lifetimes are from IG's public FAQ at the time of writing and can change.
Top comments (0)