{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Does perp funding agree with the spot move?\n", "Aggregate actual funding observations by UTC day and align them with Bitcoin spot returns from a separate source.\n", "BTC perpetual funding and BTC/USD spot, common completed UTC days in July\u2013October 2026. Funding for day D is paired with the spot move from D 00:00 to D+1 00:00. Three funding records are required; no trade-profit claim." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "\"\"\"Aggregate actual funding observations by UTC day and align them with Bitcoin spot returns from a separate source.\n", "\n", "Research example. Live observations can change.\n", "\"\"\"\n", "\n", "from datetime import UTC, datetime\n", "from decimal import Decimal\n", "from textwrap import fill\n", "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pandas as pd\n", "\n", "AS_OF = datetime.now(UTC).date()\n", "NAVY, BLUE, PINK = \"#132244\", \"#8fb9ff\", \"#e7a2cc\"\n", "\n", "\n", "def fetch(qj):\n", " return {\n", " \"funding\": qj.ccxt.get_historical_funding_rates(exchange=\"binance\", symbol=\"BTC/USDT:USDT\", limit=300),\n", " \"crypto\": qj.coingecko.get_historical_prices(coin_id=\"bitcoin\", vs_currency=\"usd\", days=365, interval=\"daily\"),\n", " }\n", "\n", "\n", "def analyze(raw):\n", " funding = frame(raw[\"funding\"], \"datetime\")\n", " funding[\"Day\"] = funding[\"datetime\"].dt.floor(\"D\")\n", " funding[\"Rate\"] = pd.to_numeric(funding[\"fundingRate\"], errors=\"raise\")\n", " daily = funding.groupby(\"Day\")[\"Rate\"].agg([\"sum\", \"count\"])\n", " daily = daily.loc[(daily[\"count\"] == 3) & (daily.index.date < AS_OF)]\n", " spot = frame(raw[\"crypto\"]).set_index(\"date\")[\"price\"].map(decimal)\n", " spot = spot.loc[spot.index == spot.index.floor(\"D\")]\n", " spot = spot.loc[~spot.index.duplicated(keep=\"last\")]\n", " next_close = spot.reindex(spot.index + pd.Timedelta(days=1))\n", " returns = pd.Series(\n", " [ratio(end, start) - 1 if pd.notna(end) else np.nan for start, end in zip(spot, next_close, strict=True)],\n", " index=spot.index,\n", " name=\"Spot return\",\n", " )\n", " joined = daily.join(returns, how=\"inner\").dropna()\n", " if joined.empty:\n", " raise ValueError(\"No common completed funding and spot days\")\n", " joined[\"Funding bp\"] = joined[\"sum\"] * 10000\n", " joined[\"Spot return %\"] = joined[\"Spot return\"] * 100\n", " fig, (top, bottom) = canvas(\"Bitcoin \u00b7 perp funding and the spot move\", \"Observed daily funding \u00b7 bp\", panels=2)\n", " top.bar(joined.index, joined[\"Funding bp\"], width=0.8, color=BLUE)\n", " bottom.plot(joined.index, joined[\"Spot return %\"], color=PINK, lw=1)\n", " bottom.set_ylabel(\"BTC/USD daily return %\")\n", " bottom.axhline(0, color=\"#afc1dc\", lw=0.8)\n", " return joined[[\"Funding bp\", \"Spot return %\", \"count\"]].reset_index(), fig\n", "\n", "\n", "def payload(response):\n", " if hasattr(response, \"to_dict\"):\n", " response = response.to_dict()\n", " while isinstance(response, dict):\n", " if response.get(\"error\") or response.get(\"error_code\") or response.get(\"success\") is False:\n", " raise ValueError(\"Provider request failed\")\n", " if \"data\" in response:\n", " response = response[\"data\"]\n", " elif \"value\" in response:\n", " response = response[\"value\"]\n", " else:\n", " break\n", " return response\n", "\n", "\n", "def frame(response, date=\"date\"):\n", " rows = payload(response)\n", " if not isinstance(rows, list) or not rows:\n", " raise ValueError(\"No provider observations returned\")\n", " result = pd.DataFrame(rows)\n", " if date:\n", " result[date] = pd.to_datetime(result[date], utc=True)\n", " result = result.sort_values(date)\n", " return result\n", "\n", "\n", "def decimal(value):\n", " if pd.isna(value):\n", " raise ValueError(\"Required monetary value or quantity is missing\")\n", " return Decimal(str(value))\n", "\n", "\n", "def ratio(numerator, denominator):\n", " denominator = decimal(denominator)\n", " if denominator == 0:\n", " raise ValueError(\"Required denominator is zero\")\n", " return float(decimal(numerator) / denominator)\n", "\n", "\n", "def canvas(title, ylabel, panels=1):\n", " plt.rcParams.update(\n", " {\n", " \"figure.facecolor\": NAVY,\n", " \"axes.facecolor\": NAVY,\n", " \"savefig.facecolor\": NAVY,\n", " \"text.color\": \"#e9eef8\",\n", " \"axes.labelcolor\": \"#afc1dc\",\n", " \"xtick.color\": \"#afc1dc\",\n", " \"ytick.color\": \"#afc1dc\",\n", " \"axes.edgecolor\": \"#34486b\",\n", " \"grid.color\": \"#34486b\",\n", " \"font.size\": 10,\n", " \"axes.prop_cycle\": plt.cycler(color=[BLUE, PINK, \"#ba9dea\"]),\n", " }\n", " )\n", " fig, axes = plt.subplots(panels, 1, figsize=(9, 6.1), squeeze=False, layout=\"constrained\")\n", " fig.suptitle(fill(title, width=58), fontsize=17, x=0.04, ha=\"left\", weight=\"medium\")\n", " for ax in axes.flat:\n", " ax.grid(axis=\"y\", alpha=0.45)\n", " ax.set_axisbelow(True)\n", " ax.spines[[\"top\", \"right\"]].set_visible(False)\n", " axes.flat[0].set_ylabel(ylabel)\n", " return fig, list(axes.flat)\n", "\n", "\n", "def run():\n", " from quantjourney.sdk import QuantJourneyAPI\n", "\n", " qj = QuantJourneyAPI.from_env()\n", " raw = fetch(qj)\n", " table, fig = analyze(raw)\n", " print(table.to_string(index=False))\n", " fig.savefig(OUTPUT, dpi=180)\n", " plt.show()\n", "\n", "\n", "OUTPUT = \"sdk_crypto_funding.png\"\n", "\n", "if __name__ == \"__main__\":\n", " run()\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }