Basis is a price gap, not a payment. For a dated future it is usually written as the difference between the contract’s price and a spot reference, then scaled by time so two expiries can be compared. A perpetual, by contrast, has no expiry; its everyday carry is a funding rate that can change every interval. This tutorial pulls live marks and expiries for OKX’s linear USD-margined BTC dated futures (BTC-USD_UM) from public HTTP endpoints (no API key), annualizes each basis on an ACT/365 convention you can see in the code, and prints the current funding rate on BTC-USDT-SWAP beside it so the two objects are not blended.
The script will not tell you whether a spread is attractive. Dated books can be thin. Annualizing a two-week gap is arithmetic, not a forecast of what funding will be, and not a return.
Not financial advice. This is a programming / market-structure exercise. Derivatives trading can liquidate your margin. Verify every convention against the venue you use.
What we will compute
For each live dated contract in the family, with mark (P_f), index (P_i), and calendar time to expiry (T) in days:
basis = (P_f - P_i) / P_i
ann_365 = basis * 365 / T
Choices that are easy to leave implicit, and that this script makes explicit:
- Price: venue mark versus the index, not last versus a single exchange’s spot. Last can be stale on a thin dated book.
-
Index:
BTC-USD, because these instruments have underlyingBTC-USD. Do not silently mix in aBTC-USDTindex. -
Day count: ACT/365 using the mark timestamp and the instrument’s
expTime. Other desks use 365.25 or 360; the code uses 365 and says so. -
Family:
BTC-USD_UMis USD-margined and linear (ctVal0.01 BTC). It is not the USDT perpetual, and it is not the inverseBTC-USDdated book. Mixing families is a measurement error, not a better average.
Funding is handled separately: read the current fundingRate and the timestamps of this interval and the next, then form rate × (365 × 24 / interval_hours). That is a simple annualization of one printed rate. It is not the dated basis, and it is not a promise that the rate will stay put. For a conceptual comparison of the two carry measures, see greenhaven365.com/perpetual-funding-rate-vs-futures-basis/.
Fetch the dated book and the index
import json
import urllib.request
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
FAMILY = "BTC-USD_UM"
INDEX_INST = "BTC-USD"
SWAP_INST = "BTC-USDT-SWAP"
SWAP_INDEX = "BTC-USDT"
UA = {"User-Agent": "basis-demo/1.0"}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
YEAR_DAYS = 365.0 # ACT/365, stated convention — not a yield forecast
def get_payload(url: str) -> list:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=20) as resp:
payload = json.loads(resp.read().decode())
if str(payload.get("code")) != "0":
raise RuntimeError(payload)
return payload["data"]
def parse_ts(ms: str | int) -> datetime:
return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc).astimezone(LOCAL_TZ)
def list_live_dated() -> list[dict]:
rows = get_payload(
"https://www.okx.com/api/v5/public/instruments"
f"?instType=FUTURES&instFamily={FAMILY}"
)
live = [r for r in rows if r.get("state") == "live"]
live.sort(key=lambda r: int(r["expTime"]))
return live
def annualize(frac: float, days: float) -> float | None:
if days <= 0:
return None
return frac * YEAR_DAYS / days
get_payload keeps the User-Agent header and checks code == "0" before indexing data. The f prefixes on the instrument and mark-price URLs matter: without them, {FAMILY} and {inst_id} are sent literally and the request fails or returns the wrong book.
Print a basis table, then a funding comparison
def report() -> None:
instruments = list_live_dated()
if not instruments:
raise RuntimeError(f"no live instruments in family {FAMILY}")
idx = get_payload(
f"https://www.okx.com/api/v5/market/index-tickers?instId={INDEX_INST}"
)[0]
index_px = float(idx["idxPx"])
index_when = parse_ts(idx["ts"])
print(f"Family {FAMILY} index {INDEX_INST}={index_px:.1f} "
f"at {index_when.isoformat()}")
print(f"Convention: basis = (mark - index) / index; "
f"annualized = basis * {YEAR_DAYS:.0f} / days_to_expiry")
print(f"{'instId':<22} {'alias':<14} {'exp (CST)':<20} "
f"{'mark':>10} {'days':>7} {'basis':>9} {'ann365':>9}")
for inst in instruments:
inst_id = inst["instId"]
mark = get_payload(
"https://www.okx.com/api/v5/public/mark-price"
f"?instType=FUTURES&instId={inst_id}"
)[0]
mark_px = float(mark["markPx"])
mark_when = parse_ts(mark["ts"])
exp = parse_ts(inst["expTime"])
days = (exp - mark_when).total_seconds() / 86400.0
lag_s = abs((mark_when - index_when).total_seconds())
basis_frac = (mark_px - index_px) / index_px
ann = annualize(basis_frac, days)
ann_s = f"{ann*100:8.2f}%" if ann is not None else " n/a"
warn = f" ts-lag={lag_s:.1f}s" if lag_s > 2 else ""
if days < 7:
warn += " short-dated: annualization is noisy"
print(
f"{inst_id:<22} {inst['alias']:<14} "
f"{exp.strftime('%Y-%m-%d %H:%M'):<20} "
f"{mark_px:10.1f} {days:7.2f} {basis_frac*100:8.3f}% "
f"{ann_s}{warn}"
)
swap_mark = get_payload(
"https://www.okx.com/api/v5/public/mark-price"
f"?instType=SWAP&instId={SWAP_INST}"
)[0]
swap_idx = get_payload(
f"https://www.okx.com/api/v5/market/index-tickers?instId={SWAP_INDEX}"
)[0]
funding = get_payload(
f"https://www.okx.com/api/v5/public/funding-rate?instId={SWAP_INST}"
)[0]
sm = float(swap_mark["markPx"])
si = float(swap_idx["idxPx"])
prem = (sm - si) / si
rate = float(funding["fundingRate"])
t0 = parse_ts(funding["fundingTime"])
t1 = parse_ts(funding["nextFundingTime"])
hours = (t1 - t0).total_seconds() / 3600.0
periods = (YEAR_DAYS * 24.0) / hours if hours > 0 else None
fund_ann = rate * periods if periods else None
print()
print(
f"{SWAP_INST} mark={sm:.1f} {SWAP_INDEX} index={si:.1f} "
f"premium={(prem*100):+.3f}%"
)
print(
f"current fundingRate={rate:.8f} interval={hours:.0f}h "
f"simple ann={fund_ann*100:.2f}% "
f"(rate × {periods:.0f} periods/{YEAR_DAYS:.0f}d; not a forecast)"
)
if __name__ == "__main__":
report()
On 13 November 2026 at 03:41:23 (Asia/Shanghai), a live run printed:
Family BTC-USD_UM index BTC-USD=71393.4 at 2026-11-13T03:41:23.685000+08:00
Convention: basis = (mark - index) / index; annualized = basis * 365 / days_to_expiry
instId alias exp (CST) mark days basis ann365
BTC-USD_UM-261127 this_month 2026-11-27 16:00 71698.0 14.51 0.427% 10.73%
BTC-USD_UM-261225 next_month 2026-12-25 16:00 72169.0 42.51 1.086% 9.33%
BTC-USD_UM-270326 quarter 2027-03-26 16:00 73245.9 133.51 2.595% 7.09%
BTC-USD_UM-270625 next_quarter 2027-06-25 16:00 73865.3 224.51 3.462% 5.63%
BTC-USDT-SWAP mark=71452.0 BTC-USDT index=71475.9 premium=-0.033%
current fundingRate=0.00008787 interval=8h simple ann=9.62%
(rate × 1095 periods/365d; not a forecast)
A few readings of this minute, none of which should be generalized into a rule:
- The raw basis widens with tenor (0.43% to 3.46%). The annualized basis is downward-sloping in this snapshot (10.73% to 5.63%). Those are different shapes of the same curve; quoting only one of them hides the other.
- The simple annualization of the current USDT-perpetual funding rate (9.62%) sits near the front of that dated curve. That is a coincidence of this interval, not evidence that the two carry measures are interchangeable. Funding can reprint in eight hours; the March 2027 expiry cannot.
- The perpetual premium used a different index (
BTC-USDT) than the dated table (BTC-USD). A residual between those two indexes is not dated basis. Keep the labels.
If a mark timestamp lags the index by more than two seconds, the script flags ts-lag. If expiry is inside seven days, it warns that annualization is noisy. Both warnings are there because a gap in clocks or a short (T) will move ann_365 more than the market did.
What this script does not cover
Do not treat the printed annualized basis as a carry return. It ignores trading fees, residual basis at expiry, margin costs, and the fact that a thin BTC-USD_UM book may not be tradable at the mark. Open interest on some of these dated contracts can be small; pull public/open-interest before you assume depth.
Do not average marks across the inverse BTC-USD family and this linear USD-margined family, and do not substitute last price for mark without saying so. If you change the day-count, change the column label at the same time.
Useful follow-ups: record a second snapshot at a later clock and compare the same instId at matching timestamps; or read ctVal / settleCcy from the instruments payload and refuse to compare rows that disagree.
Sources: OKX public API public/instruments, public/mark-price, market/index-tickers, and public/funding-rate, retrieved 2026-11-13 03:41 (Asia/Shanghai). Day-count and family choices are this tutorial’s, not a venue “official yield.”
AI disclosure: This article was created with AI assistance (drafting/editing). The numeric snapshot was checked against a live public API response on the date above. Re-run the script and verify the venue’s current instrument list and mark formula before relying on any figure.
Disclaimer: Not financial advice. Educational / developer content only.
Top comments (0)