Using outside data in an indicator¶
An indicator is plain Python, so it can download data from any web API: a funding rate, an economic series, news scores, your own database. Pine Script cannot call outside APIs; LumiBot indicators can.
The example below downloads the funding rate history of a perpetual swap from OKX’s public API (no key needed) and shows, for each bar, the funding rate that was already settled at that bar’s time.
"""Perpetual futures funding rate from a public exchange API, as an indicator.
This shows how an indicator can use outside data. It downloads the funding
rate history of a perpetual swap from OKX's public REST API (no API key) and,
for each bar, returns the latest funding rate that was already settled at the
bar's time. It never uses a funding rate from after the bar, so the same code
is safe in a backtest and live.
The download is cached on the indicator object (not in ``state``): it is
outside data, not something computed from bars, so forming-bar copies do not
need to copy it. To use another exchange or a recorded file, pass a different
``loader`` (any function returning ``[(epoch_ms, rate), ...]``).
"""
from __future__ import annotations
import bisect
import time
import requests
from lumibot.indicators import Indicator, InputSpec, Level, OutputSpec, register
OKX_FUNDING_HISTORY_URL = "https://www.okx.com/api/v5/public/funding-rate-history"
def fetch_okx_funding_history(inst_id: str, pages: int = 3, session=None) -> list:
"""Funding history for ``inst_id`` (for example ``BTC-USDT-SWAP``), oldest
first, as ``[(funding_time_ms, rate), ...]``. Each page holds up to 100
funding events (about 33 days at 8-hour funding)."""
http = session or requests
rows, cursor = [], None
for _ in range(pages):
params = {"instId": inst_id, "limit": 100}
if cursor is not None:
params["after"] = cursor # OKX: return records older than this time
response = http.get(OKX_FUNDING_HISTORY_URL, params=params, timeout=10)
response.raise_for_status()
payload = response.json()
if str(payload.get("code")) != "0":
raise RuntimeError(f"OKX funding history error: {payload.get('msg') or payload.get('code')}")
page = payload.get("data") or []
if not page:
break
for item in page:
rate = item.get("realizedRate") or item.get("fundingRate")
if rate not in (None, ""):
rows.append((int(item["fundingTime"]), float(rate)))
cursor = page[-1]["fundingTime"]
return sorted(set(rows))
@register
class FundingRate(Indicator):
key = "funding_rate"
version = 1
title = "Funding Rate (OKX)"
short_title = "FUND"
overlay = False
inputs = [
InputSpec("inst_id", "string", "BTC-USDT-SWAP", label="Perpetual swap"),
InputSpec("pages", "int", 3, min=1, max=50, label="History pages"),
]
outputs = [OutputSpec("rate_pct", "Funding %", "histogram", "#F97316")]
levels = [Level(0, "Zero", "solid")]
#: Where funding history comes from. Replace it (in a subclass or with
#: ``indicator.loader = fn``) for another exchange or for tests.
loader = staticmethod(fetch_okx_funding_history)
#: Live charts refresh the download at most this often (seconds).
refresh_seconds = 300
def init_state(self):
self._times, self._rates, self._fetched_at = [], [], None
return {}
def _ensure_history(self, bar_ms: int) -> None:
stale = self._fetched_at is None or (
(not self._times or bar_ms > self._times[-1] + 8 * 3600 * 1000)
and time.monotonic() - self._fetched_at >= self.refresh_seconds
)
if stale:
rows = self.loader(self.inputs["inst_id"], self.inputs["pages"])
self._times = [t for t, _ in rows]
self._rates = [r for _, r in rows]
self._fetched_at = time.monotonic()
def step(self, state, bar):
if bar.time is None:
return {"rate_pct": None}
bar_ms = bar.time.value // 1_000_000
self._ensure_history(bar_ms)
i = bisect.bisect_right(self._times, bar_ms) - 1 # latest funding at or before the bar
return {"rate_pct": None if i < 0 else 100.0 * self._rates[i]}
Rules for outside data¶
Only use data that existed at the bar’s time. The example looks up the latest funding event at or before
bar.time. In a backtest this keeps the future out; live, it is simply the latest value.Cache downloads on the indicator, not in
state.stateis copied for every forming bar; a download does not need to be copied. Data you compute from bars still belongs instate.Do not download on every bar. Fetch history once, then refresh on a timer (
refresh_secondsin the example).Make the source swappable. The example’s
loadercan be replaced by another exchange or a recorded file, which is also how its tests run without the network.Bring your own keys for APIs that need one, through environment variables. Never put a key in the indicator’s code or settings.
Check the data’s terms. Some providers allow personal use only.
Try it:
"""Show the last few hours of a perpetual swap's funding rate.
Needs the internet (OKX public API, no key)::
python -m lumibot.example_indicators.funding_rate_demo ETH-USDT-SWAP
"""
import sys
import pandas as pd
from lumibot.example_indicators.funding_rate import FundingRate
def main(inst_id="BTC-USDT-SWAP", hours=48, indicator_cls=FundingRate):
# Hourly bars ending now. Prices do not matter here; only bar times do.
times = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=hours, freq="1h")
bars = pd.DataFrame({"open": 1.0, "high": 1.0, "low": 1.0, "close": 1.0}, index=times)
rates = indicator_cls(inst_id=inst_id).compute(bars)["rate_pct"]
for when, rate in list(zip(times, rates))[-5:]:
print(f"{when:%Y-%m-%d %H:%M} UTC funding {rate:+.4f}%" if rate is not None else f"{when} no data")
return rates
if __name__ == "__main__": # pragma: no cover - needs the network
main(*sys.argv[1:2])
LumiBot’s tests run this indicator against a response in OKX’s recorded shape (offline) and once by hand against the live public endpoint.