{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Are credit spreads and bond returns telling the same story?\n", "Put high-yield option-adjusted spreads beside high-yield versus investment-grade ETF returns, using only common dates.\n", "Common available monthly observations in 2023\u20132025. HY OAS is a spread level; HYG minus LQD is a relative adjusted return. These have different units and risks." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "\"\"\"Put high-yield option-adjusted spreads beside high-yield versus investment-grade ETF returns, using only common dates.\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", " \"credit\": qj.fred.get_fred_data_series_by_id(search_id=\"BAMLH0A0HYM2\", start=\"2020-01-01\", end=\"2025-12-31\"),\n", " \"prices\": qj.eod.get_historical_prices(\n", " symbols=[\"HYG\", \"LQD\"], start_date=\"2020-01-01\", end_date=\"2025-12-31\", frequency=\"1d\"\n", " ),\n", " }\n", "\n", "\n", "def analyze(raw):\n", " spread = macro(raw[\"credit\"], \"BAMLH0A0HYM2\").resample(pd.offsets.MonthEnd()).last().rename(\"HY OAS %\")\n", " monthly = {}\n", " for symbol in [\"HYG\", \"LQD\"]:\n", " prices = history(raw[\"prices\"], symbol)[\"price\"].resample(pd.offsets.MonthEnd()).last()\n", " monthly[symbol] = change(prices)\n", " result = pd.DataFrame(monthly).join(spread, how=\"inner\").dropna()\n", " if result.empty:\n", " raise ValueError(\"No common credit-spread and ETF-return observations\")\n", " result[\"HYG minus LQD pp\"] = 100 * (result[\"HYG\"] - result[\"LQD\"])\n", " fig, (top, bottom) = canvas(\"Credit \u00b7 spread level and relative bond returns\", \"HY OAS %\", panels=2)\n", " top.plot(result.index, result[\"HY OAS %\"], color=BLUE)\n", " bottom.bar(result.index, result[\"HYG minus LQD pp\"], width=20, color=PINK)\n", " bottom.set_ylabel(\"Monthly HYG \u2212 LQD \u00b7 pp\")\n", " bottom.axhline(0, color=\"#afc1dc\", lw=0.8)\n", " return result.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 change(values, periods=1):\n", " previous = values.shift(periods)\n", " return pd.Series(\n", " [ratio(x, y) - 1 if pd.notna(x) and pd.notna(y) else np.nan for x, y in zip(values, previous, strict=True)],\n", " index=values.index,\n", " )\n", "\n", "\n", "def history(response, symbol, adjusted=True):\n", " values = payload(response)\n", " if not isinstance(values, dict) or symbol not in values:\n", " raise ValueError(f\"No price coverage for {symbol}\")\n", " result = frame(values[symbol]).set_index(\"date\")\n", " field = \"adjusted_close\" if adjusted else \"close\"\n", " result[\"price\"] = result[field].map(decimal)\n", " result[\"volume\"] = result[\"volume\"].map(decimal)\n", " if result.index.has_duplicates:\n", " raise ValueError(\"Duplicate trading dates\")\n", " return result\n", "\n", "\n", "def macro(response, column):\n", " values = frame(response).set_index(\"date\")[column]\n", " values = pd.to_numeric(values, errors=\"coerce\").dropna()\n", " if values.empty:\n", " raise ValueError(f\"No numeric observations for {column}\")\n", " return values\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_credit.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 }