{ "cells": [ { "cell_type": "markdown", "id": "b0e8ab17", "metadata": {}, "source": [ "# DES Hub Netbacks - Historical Moneyness Forward Curve\n", "\n", "Calling the Spark v1.0 DES Hub Netbacks endpoint and persisting SparkNWE / SparkSWE forward curves to produce a daily DES LNG forward curve per terminal. Moneyness = DES Hub Netback (Variable Regas Costs Only) minus the relevant regional marker.\n", "\n", "For a full explanation of how to import our DES Hub Netbacks or SparkNWE data, please refer to our Python Jupyter Notebook Code Samples:\n", "\n", "https://www.sparkcommodities.com/api/code-examples/jupyter.html\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Have any questions?\n", "\n", "If you have any questions regarding our API, or need help accessing specific datasets, please contact us at:\n", "\n", "__data@sparkcommodities.com__\n", "\n", "or refer to our API website for more information about this endpoint: https://www.sparkcommodities.com/api/lng-access/des-hub-netbacks.html" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Importing Data\n", "\n", "Here we define the functions that allow us to retrieve the valid credentials to access the Spark API.\n", "\n", "This section can remain unchanged for most Spark API users." ] }, { "cell_type": "code", "execution_count": null, "id": "91bf114a", "metadata": {}, "outputs": [], "source": [ "import json\n", "import os\n", "import sys\n", "import pandas as pd\n", "import numpy as np\n", "from base64 import b64encode\n", "from urllib.parse import urljoin\n", "from io import StringIO\n", "import datetime\n", "\n", "try:\n", " from urllib import request, parse\n", " from urllib.error import HTTPError\n", "except ImportError:\n", " raise RuntimeError(\"Python 3 required\")" ] }, { "cell_type": "code", "execution_count": null, "id": "9eb7cb84", "metadata": {}, "outputs": [], "source": [ "API_BASE_URL = \"https://api.sparkcommodities.com\"\n", "\n", "\n", "def retrieve_credentials(file_path=None):\n", " \"\"\"\n", " Find credentials either by reading the client_credentials file or reading\n", " environment variables\n", " \"\"\"\n", " if file_path is None:\n", " client_id = os.getenv(\"SPARK_CLIENT_ID\")\n", " client_secret = os.getenv(\"SPARK_CLIENT_SECRET\")\n", " if not client_id or not client_secret:\n", " raise RuntimeError(\n", " \"SPARK_CLIENT_ID and SPARK_CLIENT_SECRET environment vars required\"\n", " )\n", " else:\n", " # Parse the file\n", " if not os.path.isfile(file_path):\n", " raise RuntimeError(\"The file {} doesn't exist\".format(file_path))\n", "\n", " with open(file_path) as fp:\n", " lines = [l.replace(\"\\n\", \"\") for l in fp.readlines()]\n", "\n", " if lines[0] in (\"clientId,clientSecret\", \"client_id,client_secret\"):\n", " client_id, client_secret = lines[1].split(\",\")\n", " else:\n", " print(\"First line read: '{}'\".format(lines[0]))\n", " raise RuntimeError(\n", " \"The specified file {} doesn't look like to be a Spark API client \"\n", " \"credentials file\".format(file_path)\n", " )\n", "\n", " print(\">>>> Found credentials!\")\n", " \n", " return client_id, client_secret\n", "\n", "\n", "def do_api_post_query(uri, body, headers):\n", " \"\"\"\n", " OAuth2 authentication requires a POST request with client credentials before accessing the API.\n", " This POST request will return an Access Token which will be used for the API GET request.\n", " \"\"\"\n", " url = urljoin(API_BASE_URL, uri)\n", "\n", " data = json.dumps(body).encode(\"utf-8\")\n", "\n", " # HTTP POST request\n", " req = request.Request(url, data=data, headers=headers)\n", " try:\n", " response = request.urlopen(req)\n", " except HTTPError as e:\n", " print(\"HTTP Error: \", e.code)\n", " print(e.read())\n", " sys.exit(1)\n", "\n", " resp_content = response.read()\n", "\n", " # The server must return HTTP 201. Raise an error if this is not the case\n", " assert response.status == 201, resp_content\n", "\n", " # The server returned a JSON response\n", " content = json.loads(resp_content)\n", "\n", " return content\n", "\n", "\n", "def do_api_get_query(uri, access_token, format='json'):\n", " \"\"\"\n", " After receiving an Access Token, we can request information from the API.\n", " Supports both JSON (default) and CSV responses.\n", " \"\"\"\n", " url = urljoin(API_BASE_URL, uri)\n", "\n", " if format == 'json':\n", " headers = {\n", " \"Authorization\": \"Bearer {}\".format(access_token),\n", " \"Accept\": \"application/json\",\n", " }\n", " elif format == 'csv':\n", " headers = {\n", " \"Authorization\": \"Bearer {}\".format(access_token),\n", " \"Accept\": \"text/csv\",\n", " }\n", " else:\n", " raise ValueError(\"The format parameter only takes 'csv' or 'json' as inputs\")\n", "\n", " print(\"Fetching {}\".format(url))\n", "\n", " # HTTP GET request\n", " req = request.Request(url, headers=headers)\n", " try:\n", " response = request.urlopen(req)\n", " except HTTPError as e:\n", " print(\"HTTP Error: \", e.code)\n", " print(e.read())\n", " sys.exit(1)\n", "\n", " resp_content = response.read()\n", "\n", " # The server must return HTTP 200. Raise an error if this is not the case\n", " assert response.status == 200, resp_content\n", "\n", " # Storing response based on requested format\n", " if format == 'json':\n", " content = json.loads(resp_content)\n", " elif format == 'csv':\n", " content = resp_content\n", "\n", " return content\n", "\n", "\n", "def get_access_token(client_id, client_secret):\n", " \"\"\"\n", " Get a new access_token. Access tokens are the thing that applications use to make\n", " API requests. Access tokens must be kept confidential in storage.\n", "\n", " # Procedure:\n", "\n", " Do a POST query with `grantType` and `scopes` in the body. A basic authorization\n", " HTTP header is required. The \"Basic\" HTTP authentication scheme is defined in\n", " RFC 7617, which transmits credentials as `clientId:clientSecret` pairs, encoded\n", " using base64.\n", " \"\"\"\n", "\n", "\n", " payload = \"{}:{}\".format(client_id, client_secret).encode()\n", " headers = {\n", " \"Authorization\": b64encode(payload).decode(),\n", " \"Accept\": \"application/json\",\n", " \"Content-Type\": \"application/json\",\n", " }\n", " body = {\n", " \"grantType\": \"clientCredentials\",\n", " \"scopes\": \"read:access,read:prices\"\n", " }\n", "\n", " content = do_api_post_query(uri=\"/oauth/token/\", body=body, headers=headers)\n", "\n", " print(\n", " \">>>> Successfully fetched an access token {}****, valid {} seconds.\".format(\n", " content[\"accessToken\"][:5], content[\"expiresIn\"]\n", " )\n", " )\n", "\n", " return content[\"accessToken\"]" ] }, { "cell_type": "markdown", "id": "25180e07", "metadata": {}, "source": [ "## N.B. Credentials\n", "\n", "Here we call the above functions, and input the file path to our credentials.\n", "\n", "N.B. You must have downloaded your client credentials CSV file before proceeding. Please refer to the API documentation if you have not downloaded them already. Instructions for downloading your credentials can be found here:\n", "\n", "https://app.sparkcommodities.com/data-integrations/api" ] }, { "cell_type": "code", "execution_count": null, "id": "f1df3ec6", "metadata": {}, "outputs": [], "source": [ "# Insert file path to your client credentials here\n", "client_id, client_secret = retrieve_credentials(file_path=\"/tmp/client_credentials.csv\")\n", "\n", "# Authenticate:\n", "access_token = get_access_token(client_id, client_secret)" ] }, { "cell_type": "markdown", "id": "3b61a1ae", "metadata": {}, "source": [ "## 2. Reference Data\n", "\n", "The reference-data endpoint lists every DES Hub Netbacks terminal, along with its `TerminalUUID` and the Gas Hubs used as the front-month and forward-curve price sources. Use the UUID whenever you want to filter subsequent calls to a single terminal." ] }, { "cell_type": "code", "execution_count": null, "id": "a81712ef", "metadata": {}, "outputs": [], "source": [ "## Defining the reference-data calling function\n", "\n", "def fetch_deshub_reference_data(access_token, format='json'):\n", "\n", " uri = \"/v1.0/lng/access/des-hub-netbacks/reference-data/\"\n", " print(uri)\n", "\n", " content = do_api_get_query(uri=uri, access_token=access_token, format=format)\n", "\n", " if format == 'json':\n", " data = content\n", " elif format == 'csv':\n", " if len(content) == 0:\n", " data = content\n", " print('No Data to Show')\n", " else:\n", " data = content.decode('utf-8')\n", " data = pd.read_csv(StringIO(data))\n", "\n", " return data" ] }, { "cell_type": "code", "execution_count": null, "id": "6e454199", "metadata": {}, "outputs": [], "source": [ "# fetching in CSV format (easier to browse the terminal list)\n", "ref_data = fetch_deshub_reference_data(access_token, format='csv')\n", "ref_data" ] }, { "cell_type": "markdown", "id": "5b147ac3", "metadata": {}, "source": [ "## 3. Historical Data\n", "\n", "The historical endpoint returns DES Hub Netbacks releases for any date range (max 365 days per call)." ] }, { "cell_type": "code", "execution_count": null, "id": "de266a8e", "metadata": {}, "outputs": [], "source": [ "## Defining the function\n", "\n", "def fetch_historical_price_releases(access_token, unit, start=None, end=None,\n", " terminal_uuid=None, limit=None, offset=None,\n", " format='json'):\n", "\n", " query_params = \"?unit={}\".format(unit)\n", "\n", " if start is not None:\n", " query_params += \"&start={}\".format(start)\n", " if end is not None:\n", " query_params += \"&end={}\".format(end)\n", " if terminal_uuid is not None:\n", " query_params += \"&terminal-uuid={}\".format(terminal_uuid)\n", " if limit is not None:\n", " query_params += \"&limit={}\".format(limit)\n", " if offset is not None:\n", " query_params += \"&offset={}\".format(offset)\n", "\n", " uri = \"/v1.0/lng/access/des-hub-netbacks/{}\".format(query_params)\n", " print(uri)\n", "\n", " content = do_api_get_query(uri=uri, access_token=access_token, format=format)\n", "\n", " if format == 'json':\n", " data = content\n", " elif format == 'csv':\n", " if len(content) == 0:\n", " data = content\n", " print('No Data to Show')\n", " else:\n", " data = content.decode('utf-8')\n", " data = pd.read_csv(StringIO(data))\n", "\n", " for c in list(data.columns)[8:]:\n", " data[c] = pd.to_numeric(data[c], errors='coerce')\n", " data['ReleaseDate'] = pd.to_datetime(data['ReleaseDate'])\n", " data['DeliveryMonth'] = pd.to_datetime(data['DeliveryMonth'])\n", "\n", " return data" ] }, { "cell_type": "markdown", "id": "cfebef96", "metadata": {}, "source": [ "## N.B. Historical Data Limits\n", "\n", "A single call to the historical endpoint is limited to 365 days of data. If you need a longer range, use the loop function below, which calls the endpoint one year at a time and concatenates the results into a single Pandas DataFrame." ] }, { "cell_type": "code", "execution_count": null, "id": "104e3455", "metadata": {}, "outputs": [], "source": [ "def loop_historical_data(access_token, hist_unit, hist_start, hist_end):\n", "\n", " # input validation\n", " start_dt = datetime.datetime.strptime(hist_start, '%Y-%m-%d')\n", " end_dt = datetime.datetime.strptime(hist_end, '%Y-%m-%d')\n", "\n", " if start_dt >= end_dt:\n", " raise ValueError(\n", " \"hist_start ({}) must be strictly before hist_end ({})\".format(hist_start, hist_end)\n", " )\n", "\n", " hist_diff = (end_dt - start_dt).days\n", " t = 0\n", " starts = []\n", " ends = []\n", "\n", " # window size - 1 year per call\n", " w = 365\n", "\n", " while t < hist_diff:\n", " # initialising dataframe\n", " if t == 0:\n", " # bound the first window by hist_end so we never request beyond it\n", " diff_end = min(start_dt + pd.Timedelta(days=w), end_dt).strftime('%Y-%m-%d')\n", " hist_df = fetch_historical_price_releases(\n", " access_token, unit=hist_unit, start=hist_start, end=diff_end, format='csv'\n", " )\n", " starts.append(hist_start)\n", " ends.append(diff_end)\n", "\n", " # appending additional historical data\n", " else:\n", " diff_start_dt = start_dt + pd.Timedelta(days=t + 1)\n", " diff_start = diff_start_dt.strftime('%Y-%m-%d')\n", " # bound every subsequent window by hist_end\n", " diff_end = min(diff_start_dt + pd.Timedelta(days=w), end_dt).strftime('%Y-%m-%d')\n", "\n", " historical_addition = fetch_historical_price_releases(\n", " access_token, unit=hist_unit, start=diff_start, end=diff_end, format='csv'\n", " )\n", " try:\n", " hist_df = pd.concat([hist_df, historical_addition])\n", " except Exception as e:\n", " # fallback if the first slice came back empty\n", " print(e)\n", " hist_df = historical_addition.copy()\n", " starts.append(diff_start)\n", " ends.append(diff_end)\n", "\n", " t += w\n", "\n", " hist_df = hist_df.drop_duplicates().reset_index(drop=True)\n", "\n", " return hist_df" ] }, { "cell_type": "code", "execution_count": null, "id": "93293cb1", "metadata": {}, "outputs": [], "source": [ "# Input start and end dates\n", "today = datetime.datetime.now().strftime('%Y-%m-%d')\n", "#hist_start = (datetime.datetime.now() - datetime.timedelta(days=365)).strftime('%Y-%m-%d')\n", "hist_start = '2026-06-01'\n", "\n", "# Call Data\n", "hist_df = loop_historical_data(access_token, hist_unit='usd-per-mmbtu',\n", " hist_start=hist_start, hist_end=today)\n", "\n", "hist_df.head(5)" ] }, { "cell_type": "markdown", "id": "084d744e", "metadata": {}, "source": [ "## 4. Sinking Regas Costs Components\n", "\n", "Output columns added to `hist_df`:\n", "- `NetbackTTFBasisSunk`\n", "- `NetbackOutrightSunk`\n" ] }, { "cell_type": "code", "execution_count": null, "id": "9d997060", "metadata": {}, "outputs": [], "source": [ "# Global sunk slot components - added back for every terminal\n", "sunk_slot_cols = ['SlotLtCapacityEstimate', 'SlotUnloadStorageRegas', 'SlotBerth', 'SlotBerthUnloadStorageRegas', 'EntryCapacity']\n", "\n", "# Per-terminal extra sunk costs - layered on top of the global sunk costs defined above\n", "# Value is a list of cost columns to treat as sunk for that terminal only\n", "# Edit this dict to suit your analysis. Unlisted terminals use the global set.\n", "\n", "sunk_terminal_costs = {\n", " # 'Isle of Grain': ['EntryCapacity', 'EntryVariable'],\n", " # 'South Hook': ['EntryCapacity'],\n", "}\n", "\n", "# Sink global sunk costs across all terminals\n", "sunk_add_back = hist_df[sunk_slot_cols].fillna(0).sum(axis=1)\n", "\n", "# Sink terminal-specific sunk costs\n", "for terminal, cols in sunk_terminal_costs.items():\n", " if not cols:\n", " continue\n", " missing = [c for c in cols if c not in hist_df.columns]\n", " if missing:\n", " raise KeyError('sunk_terminal_costs[{!r}] references missing columns: {}'.format(terminal, missing))\n", " mask = hist_df['TerminalName'] == terminal\n", " if mask.any():\n", " sunk_add_back.loc[mask] += hist_df.loc[mask, cols].fillna(0).sum(axis=1)\n", "\n", "hist_df['NetbackTTFBasisSunk'] = hist_df['NetbackTTFBasis'] + sunk_add_back\n", "hist_df['NetbackOutrightSunk'] = hist_df['NetbackOutright'] + sunk_add_back\n", "\n", "hist_df[['ReleaseDate', 'TerminalName', 'DeliveryMonthIndex', 'NetbackTTFBasis',\n", " 'NetbackTTFBasisSunk']].head(10)\n" ] }, { "cell_type": "markdown", "id": "36eff6fe", "metadata": {}, "source": [ "### Pivot into a wide DataFrame - one column per terminal\n", "\n", "For the moneyness merge downstream we want a wide-format table: one row per (ReleaseDate, DeliveryMonth) with a column per terminal containing its `NetbackTTFBasisSunk`." ] }, { "cell_type": "code", "execution_count": null, "id": "b8dfd892", "metadata": {}, "outputs": [], "source": [ "# one row per (ReleaseDate, DeliveryMonth), one column per terminal\n", "terminal_list = list(hist_df['TerminalName'].unique())\n", "\n", "id_cols = ['ReleaseDate', 'DeliveryMonth', 'DeliveryMonthIndex', 'DeliveryMonthName']\n", "value_col = 'NetbackTTFBasisSunk'\n", "\n", "deshub_final = (\n", " hist_df\n", " .pivot_table(index=id_cols, columns='TerminalName', values=value_col, aggfunc='first')\n", " .reset_index()\n", ")\n", "deshub_final.columns.name = None\n", "\n", "# restore terminal-appearance ordering (pivot_table sorts alphabetically by default)\n", "deshub_final = deshub_final[id_cols + terminal_list]\n", "deshub_final.head(5)" ] }, { "cell_type": "markdown", "id": "9cd3c12e", "metadata": {}, "source": [ "## 5. Classification Dictionaries\n", "\n", "Maps each terminal (keyed by its v1.0 `TerminalName`) to either `nwe` or `swe`, which in turn controls which Spark regional marker gets subtracted in the moneyness calculation.\n", "\n", "A second dictionary maps terminals to countries for optional country-level aggregation." ] }, { "cell_type": "code", "execution_count": null, "id": "4b3bee7f", "metadata": {}, "outputs": [], "source": [ "# Region mapping \n", "terminal_region_dict = {\n", " 'Gate': 'nwe',\n", " 'Isle of Grain': 'nwe',\n", " 'Zeebrugge': 'nwe',\n", " 'South Hook': 'nwe',\n", " 'Dunkerque': 'nwe',\n", " 'Le Havre': 'nwe',\n", " 'Montoir': 'nwe',\n", " 'EemsEnergyTerminal': 'nwe',\n", " 'Brunsbuttel': 'nwe',\n", " 'Deutsche Ostsee': 'nwe',\n", " 'Deutsche Ostsee Phase 1': 'nwe',\n", " 'Wilhelmshaven 1': 'nwe',\n", " 'Wilhelmshaven 2': 'nwe',\n", " 'Stade': 'nwe',\n", " 'Fos Cavaou': 'swe',\n", " 'Adriatic': 'swe',\n", " 'OLT Toscana': 'swe',\n", " 'Piombino': 'swe',\n", " 'Ravenna': 'swe',\n", " 'Spain TVB': 'swe',\n", "}\n", "\n", "# Country mapping\n", "terminal_country_dict = {\n", " 'Netherlands': ['Gate', 'EemsEnergyTerminal'],\n", " 'UK': ['Isle of Grain', 'South Hook'],\n", " 'Belgium': ['Zeebrugge'],\n", " 'France': ['Dunkerque', 'Le Havre', 'Montoir', 'Fos Cavaou'],\n", " 'Germany': ['Brunsbuttel', 'Deutsche Ostsee',\n", " 'Wilhelmshaven 1', 'Wilhelmshaven 2', 'Stade'],\n", " 'Italy': ['Adriatic', 'OLT Toscana', 'Piombino', 'Ravenna'],\n", " 'Spain': ['Spain TVB'],\n", "}" ] }, { "cell_type": "markdown", "id": "53659b47", "metadata": {}, "source": [ "## 6. SparkNWE & SparkSWE - Data Import\n", "\n", "Calling the SparkNWE and SparkSWE front-month + forward-curve cargo contracts (TTF basis) to compare against the terminal DES Hub netbacks. Moneyness = DES Hub Netback (Var Regas Only) - regional marker." ] }, { "cell_type": "code", "execution_count": null, "id": "91545e77", "metadata": {}, "outputs": [], "source": [ "# Defining function to call contracts endpoint to import cargo data\n", "def fetch_cargo_releases(access_token, ticker, limit=4, offset=None):\n", "\n", " query_params = \"?limit={}\".format(limit)\n", " if offset is not None:\n", " query_params += \"&offset={}\".format(offset)\n", "\n", " uri = \"/v1.0/contracts/{}/price-releases/{}\".format(ticker, query_params)\n", " print(uri)\n", "\n", " content = do_api_get_query(uri=uri, access_token=access_token)\n", "\n", " return content['data']\n", "\n", "\n", "# call front month and forward curve, combine into one DataFrame per release date\n", "def cargo_focurve(access_token, ticker, limit):\n", "\n", " f_tick = ticker + '-b-f'\n", " fo_tick = ticker + '-b-fo'\n", "\n", " f_data = fetch_cargo_releases(access_token, f_tick, limit)\n", " fo_data = fetch_cargo_releases(access_token, fo_tick, limit)\n", "\n", " release_dates = []\n", " period_start = []\n", " contract_ids = []\n", " spark = []\n", "\n", " # single-release convenience path (latest only)\n", " if limit == 1:\n", " release_date = f_data[0][\"releaseDate\"]\n", " contract_ids.append(f_data[0]['contractId'])\n", " release_dates.append(release_date)\n", "\n", " period_start_at = f_data[0]['data'][0]['dataPoints'][0][\"deliveryPeriod\"][\"startAt\"]\n", " period_start.append(period_start_at)\n", " spark.append(f_data[0]['data'][0]['dataPoints'][0]['derivedPrices']['usdPerMMBtu']['spark'])\n", "\n", " for release in fo_data:\n", " for data_point in release['data'][0]['dataPoints']:\n", " contract_ids.append(release['contractId'])\n", " release_dates.append(release[\"releaseDate\"])\n", " period_start.append(data_point[\"deliveryPeriod\"][\"startAt\"])\n", " spark.append(data_point['derivedPrices']['usdPerMMBtu']['spark'])\n", "\n", " # full historical path: align each front month release with the most recent\n", " # forward curve release on or before it\n", " else:\n", " cl = 0\n", "\n", " for l in range(limit):\n", " try:\n", " release_date = f_data[l][\"releaseDate\"]\n", " except (IndexError, KeyError):\n", " break\n", "\n", " contract_ids.append(f_data[l]['contractId'])\n", " release_dates.append(release_date)\n", " release_datetime = datetime.datetime.strptime(release_date, '%Y-%m-%d')\n", "\n", " front_period_start_at = f_data[l]['data'][0]['dataPoints'][0][\"deliveryPeriod\"][\"startAt\"]\n", " period_start.append(front_period_start_at)\n", " spark.append(f_data[l]['data'][0]['dataPoints'][0]['derivedPrices']['usdPerMMBtu']['spark'])\n", "\n", " # guard against running off the end of fo_data\n", " if cl >= len(fo_data):\n", " continue\n", "\n", " fo_datetime = datetime.datetime.strptime(fo_data[cl]['releaseDate'], '%Y-%m-%d')\n", "\n", " # assessment date - front month AND forward curve both published today\n", " if release_datetime == fo_datetime:\n", " for data_point in fo_data[cl]['data'][0]['dataPoints']:\n", " contract_ids.append(fo_data[cl]['contractId'])\n", " release_dates.append(release_date)\n", " period_start.append(data_point[\"deliveryPeriod\"][\"startAt\"])\n", " spark.append(data_point['derivedPrices']['usdPerMMBtu']['spark'])\n", " cl += 1\n", "\n", " # non-assessment date - persist most recent published forward curve\n", " # but drop the data point that overlaps with today's front month\n", " else:\n", " for data_point in fo_data[cl]['data'][0]['dataPoints']:\n", " period_start_at = data_point[\"deliveryPeriod\"][\"startAt\"]\n", " if period_start_at != front_period_start_at:\n", " contract_ids.append(fo_data[cl]['contractId'])\n", " release_dates.append(release_date)\n", " period_start.append(period_start_at)\n", " spark.append(data_point['derivedPrices']['usdPerMMBtu']['spark'])\n", "\n", " hist_cargo_df = pd.DataFrame({\n", " 'Release Date': release_dates,\n", " 'ticker': contract_ids,\n", " 'Period Start': period_start,\n", " 'Price': spark,\n", " })\n", "\n", " hist_cargo_df['Price'] = pd.to_numeric(hist_cargo_df['Price'])\n", " hist_cargo_df['Release Date'] = pd.to_datetime(hist_cargo_df['Release Date']).dt.tz_localize(None)\n", "\n", " return hist_cargo_df" ] }, { "cell_type": "code", "execution_count": null, "id": "6f3a9010", "metadata": {}, "outputs": [], "source": [ "# calling the data\n", "nwe_fo_df = cargo_focurve(access_token, 'sparknwe', limit=5000)\n", "swe_fo_df = cargo_focurve(access_token, 'sparkswe', limit=5000)" ] }, { "cell_type": "code", "execution_count": null, "id": "b803bec7", "metadata": {}, "outputs": [], "source": [ "# Merging the NWE and SWE datasets into a single dataframe\n", "cargo_df = pd.merge(nwe_fo_df, swe_fo_df, on=['Release Date', 'Period Start'], how='left')\n", "cargo_df = cargo_df.rename(columns={\n", " 'Price_x': 'SparkNWE',\n", " 'Price_y': 'SparkSWE',\n", "})\n", "cargo_df = cargo_df.drop(columns=['ticker_x', 'ticker_y'])\n", "\n", "# cut off data prior to daily front-month assessments for NWE (switchover 2023-11-23)\n", "switch_date = '2023-11-23' # Do not edit (value should be 2023-11-23)\n", "date_limit = cargo_df[cargo_df['Release Date'] == switch_date].index[-1]\n", "cargo_df = cargo_df.iloc[:date_limit + 1].copy()\n", "\n", "# Persist SparkSWE forward curves across non-assessment days (SWE fo is weekly, NWE is daily) \n", "# Groupby 'Period Start' to make sure data fills match against Period Start\n", "# limit=14 covers potential bank holiday gaps.\n", "cargo_df['Period Start'] = pd.to_datetime(cargo_df['Period Start'])\n", "cargo_df = cargo_df.sort_values(['Period Start', 'Release Date']).reset_index(drop=True)\n", "cargo_df['SparkSWE'] = cargo_df.groupby('Period Start')['SparkSWE'].ffill(limit=14)\n", "\n", "# Order by release date\n", "cargo_df = cargo_df.sort_values(['Release Date', 'Period Start'], ascending=[False, True]).reset_index(drop=True)" ] }, { "cell_type": "markdown", "id": "f0920c79", "metadata": {}, "source": [ "## 7. Final Moneyness Table\n", "\n", "Moneyness = DES Hub Netback (w/ or w/o Sunk Costs) - Spark regional marker, where the marker is SparkNWE (NWE terminals) or SparkSWE (SWE terminals)." ] }, { "cell_type": "code", "execution_count": null, "id": "cdbcd586", "metadata": {}, "outputs": [], "source": [ "# Vectorised moneyness calculation.\n", "\n", "# only consider terminals mapped to a region\n", "new_terminal_list = [t for t in terminal_list if t in terminal_region_dict]\n", "unmapped = [t for t in terminal_list if t not in terminal_region_dict]\n", "if unmapped:\n", " print('Skipping terminals not mapped to a region in terminal_region_dict: {}'.format(unmapped))\n", "\n", "# merge DES Hub netbacks with NWE/SWE data \n", "combined_df = pd.merge(\n", " deshub_final,\n", " cargo_df[['Release Date', 'Period Start', 'SparkNWE', 'SparkSWE']],\n", " left_on=['ReleaseDate', 'DeliveryMonth'],\n", " right_on=['Release Date', 'Period Start'],\n", " how='left',\n", ")" ] }, { "cell_type": "code", "execution_count": null, "id": "7aa61e82", "metadata": {}, "outputs": [], "source": [ "# Filtering data to view latest forward curve\n", "latest = combined_df['ReleaseDate'].unique()[-1]\n", "\n", "combined_df['Min'] = combined_df[new_terminal_list].min(axis=1)\n", "combined_df['Max'] = combined_df[new_terminal_list].max(axis=1)" ] }, { "cell_type": "markdown", "id": "e127e2ca", "metadata": {}, "source": [ "# 8. Plotting" ] }, { "cell_type": "markdown", "id": "6eb84b93", "metadata": {}, "source": [ "## 8.0 Plotting Functions\n", "\n", "Defining functions to enable elements of the final plot, or to calculate metrics such as Marginal Terminals\n", "\n", "### 8.0.1 Graded stepped-shading helper\n", "\n", "Function to plot stepped-shading range for Terminal DES Hub Netbakcs. Set **`quartiles=True`** for a less noisy view: the per-terminal steps are collapsed into quartiles instead (i.e. bands for 0-25%, 25-50%, 50-75% and 75-100% of terminals covered)." ] }, { "cell_type": "code", "execution_count": null, "id": "de71b02d", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "from matplotlib.patches import Patch\n", "from matplotlib.colors import ListedColormap, BoundaryNorm, LinearSegmentedColormap, Normalize\n", "import matplotlib.dates as mdates\n", "import seaborn as sns\n", "\n", "def shade_terminal_steps(ax, x, value_df, cmap_name='Oranges',\n", " shade_range=(0.12, 0.95), alpha=1.0,\n", " zorder=1, add_colorbar=True, cbar_label=None,\n", " quartiles=False):\n", " \"\"\"Fill the Min->Max terminal spread with graded, stepped shading.\n", "\n", " Parameters\n", " ----------\n", " ax : matplotlib Axes to draw on.\n", " x : 1-D array-like of x positions (e.g. release dates).\n", " value_df : DataFrame / 2-D array, one column per terminal, one row per x.\n", " cmap_name : sequential matplotlib colormap name.\n", " shade_range : (lo, hi) slice of the colormap; keeps the lightest band visibly\n", " coloured (not white) and the darkest from going fully black.\n", " add_colorbar: draw a discrete colorbar decoding darkness -> terminal coverage.\n", " quartiles : if True, draw just 4 solid bands split at the 0/25/50/75/100th\n", " coverage percentiles (0-25%, 25-50%, 50-75%, 75-100% of terminals\n", " covered) for a less noisy view instead of one shade per terminal.\n", " \"\"\"\n", " x = np.asarray(x)\n", " vals = np.asarray(value_df, dtype=float) # (T, N)\n", " n_cols = vals.shape[1]\n", " cmap = plt.get_cmap(cmap_name)\n", " lo, hi = shade_range\n", "\n", " if quartiles:\n", " # 4 clean bands between the coverage percentiles\n", " edges = np.nanpercentile(vals, [0, 25, 50, 75, 100], axis=1) # (5, T)\n", " seg_colors = [cmap(lo + (hi - lo) * (b + 1) / 4.0) for b in range(4)]\n", " for b in range(4):\n", " lower, upper = edges[b], edges[b + 1]\n", " ax.fill_between(x, lower, upper, where=~np.isnan(upper),\n", " color=seg_colors[b], alpha=alpha, linewidth=0, zorder=zorder)\n", " else:\n", " # one shade per terminal step\n", " sorted_vals = np.sort(vals, axis=1) # NaNs pushed to the end\n", " n_bands = max(n_cols - 1, 1)\n", " for j in range(n_cols - 1):\n", " lower, upper = sorted_vals[:, j], sorted_vals[:, j + 1]\n", " color = cmap(lo + (hi - lo) * (j + 1) / n_bands)\n", " ax.fill_between(x, lower, upper, where=~np.isnan(upper),\n", " color=color, alpha=alpha, linewidth=0, zorder=zorder)\n", "\n", " if add_colorbar and n_cols > 1:\n", " if quartiles:\n", " seg_colors = [cmap(lo + (hi - lo) * (b + 1) / 4.0) for b in range(4)]\n", " listed = ListedColormap(seg_colors)\n", " norm = BoundaryNorm([0, 25, 50, 75, 100], listed.N)\n", " sm = plt.cm.ScalarMappable(cmap=listed, norm=norm)\n", " cbar = ax.figure.colorbar(sm, ax=ax, ticks=[0, 25, 50, 75, 100],\n", " pad=0.01, fraction=0.045)\n", " cbar.set_label(cbar_label or '% of terminals at/below price level', fontsize=9)\n", " else:\n", " n = n_cols - 1\n", " seg_colors = [cmap(lo + (hi - lo) * (j + 1) / n) for j in range(n)]\n", " listed = ListedColormap(seg_colors)\n", " norm = BoundaryNorm(np.arange(0.5, n + 1.5), listed.N)\n", " sm = plt.cm.ScalarMappable(cmap=listed, norm=norm)\n", " ticks = sorted({1, max(1, n // 4), max(1, n // 2), max(1, (3 * n) // 4), n})\n", " cbar = ax.figure.colorbar(sm, ax=ax, ticks=ticks, pad=0.01, fraction=0.045)\n", " cbar.set_label(cbar_label or 'Terminals at/below price level', fontsize=9)\n", " cbar.ax.tick_params(labelsize=8)\n", "\n", " return ax\n" ] }, { "cell_type": "markdown", "id": "221b1d0d", "metadata": {}, "source": [ "### 8.0.2 Pre-Plotting Functions\n", "\n", "Defining 3 functions, in order: \n", "- to retrieve the most marginal terminals vs SparkNWE\n", "- plot these marginal terminals as bar charts\n", "- to calculate the % of terminals that are above SparkNWE (ITM)" ] }, { "cell_type": "code", "execution_count": null, "id": "1c791151", "metadata": {}, "outputs": [], "source": [ "# Retrieving marginal terminals\n", "def marginal_terminal(value_df, marker, marginal_type='either'):\n", " \"\"\"Per row, return the label of the terminal closest to `marker`.\n", "\n", " marginal_type : 'either' (nearest on either side, default),\n", " 'below' (nearest terminal at or below the marker),\n", " 'above' (nearest terminal at or above the marker).\n", " Rows with no qualifying terminal (all-NaN, or none on the requested\n", " side) return None.\n", " \"\"\"\n", " cols = list(value_df.columns)\n", " diff = value_df.to_numpy(float) - np.asarray(marker, float)[:, None]\n", " if marginal_type == 'either':\n", " score, pick = np.abs(diff), 'min'\n", " elif marginal_type == 'below':\n", " score, pick = np.where(diff <= 0, diff, np.nan), 'max' # closest from below\n", " elif marginal_type == 'above':\n", " score, pick = np.where(diff >= 0, diff, np.nan), 'min' # closest from above\n", " else:\n", " raise ValueError(\"marginal_type must be 'either', 'below' or 'above'\")\n", "\n", " out = []\n", " for row in score:\n", " if np.all(np.isnan(row)):\n", " out.append(None)\n", " else:\n", " j = np.nanargmax(row) if pick == 'max' else np.nanargmin(row)\n", " out.append(cols[j])\n", " return np.array(out, dtype=object)\n", "\n", "\n", "# Function to plot the marginal terminals\n", "def add_marginal_terminal_strip(ax_strip, x, value_df, marker, marginal_type='either',\n", " terminal_colours=None, cmap_name='tab20', na_colour='#dddddd',\n", " ylabel=None, country_bool=False, terminal_country_dict=terminal_country_dict):\n", " \"\"\"Draw a coloured strip on `ax_strip` marking, per x, the terminal closest\n", " to `marker` (per `marginal_type`: 'above' | 'below' | 'either').\n", "\n", " country_bool : if True, each marginal terminal is mapped to its country via\n", " terminal_country_dict (country -> [terminals]) and the strip is\n", " coloured/labelled by country instead of terminal. Requires\n", " terminal_country_dict.\n", " terminal_colours : takes dict of terminal names to colours, defaulty cmap used if\n", " left as None\n", " \n", " \"\"\"\n", " labels = marginal_terminal(value_df, marker, marginal_type)\n", "\n", " if country_bool:\n", " if terminal_country_dict is None:\n", " raise ValueError(\"country_bool=True requires terminal_country_dict\")\n", " t2c = {t: c for c, terms in terminal_country_dict.items() for t in terms}\n", " labels = np.array([t2c.get(t) if t is not None else None for t in labels], dtype=object)\n", " base_cmap = plt.get_cmap('tab10') # distinct hues for ~7 countries\n", " universe = list(terminal_country_dict.keys())\n", " else:\n", " base_cmap = plt.get_cmap(cmap_name)\n", " universe = list(value_df.columns)\n", "\n", " if terminal_colours is None:\n", " terminal_colours = {cat: base_cmap(i % base_cmap.N) for i, cat in enumerate(universe)}\n", " else:\n", " # extend (never clobber) so every label present has a colour -> no KeyError\n", " for lab in [l for l in pd.unique(labels) if l is not None and l not in terminal_colours]:\n", " terminal_colours[lab] = base_cmap(len(terminal_colours) % base_cmap.N)\n", "\n", " colours = [terminal_colours[l] if l is not None else na_colour for l in labels]\n", "\n", " if ylabel is None:\n", " ylabel = 'Marginal\\ncountry' if country_bool else 'Marginal\\nterminal'\n", "\n", " x = np.asarray(x)\n", " is_date = np.issubdtype(x.dtype, np.datetime64)\n", " xnum = mdates.date2num(pd.to_datetime(x)) if is_date else x.astype(float)\n", " order = np.argsort(xnum)\n", " xnum = xnum[order]\n", " colours = [colours[i] for i in order]\n", "\n", " if len(xnum) == 1:\n", " lefts, widths = xnum - 0.5, np.array([1.0])\n", " else:\n", " mid = (xnum[:-1] + xnum[1:]) / 2\n", " edges = np.concatenate([[xnum[0] - (xnum[1] - xnum[0]) / 2], mid,\n", " [xnum[-1] + (xnum[-1] - xnum[-2]) / 2]])\n", " lefts, widths = edges[:-1], np.diff(edges)\n", "\n", " ax_strip.bar(lefts, height=1.0, width=widths, bottom=0, align='edge',\n", " color=colours, linewidth=0)\n", " ax_strip.set_ylim(0, 1)\n", " ax_strip.set_yticks([])\n", " ax_strip.grid(False)\n", " ax_strip.set_ylabel(ylabel, rotation=0, ha='right', va='center', labelpad=28)\n", " if is_date:\n", " ax_strip.xaxis_date()\n", "\n", " return labels, terminal_colours\n", "\n", "# Function to calculate the % of terminals above SparkNWE \n", "def pct_terminals_above(value_df, marker):\n", " \"\"\"Per row, % of (non-NaN) terminals strictly above `marker`.\"\"\"\n", " V = value_df.to_numpy(float)\n", " m = np.asarray(marker, float)[:, None]\n", " above = np.sum(V > m, axis=1) # change to \"<\" to switch to % of terminals below SparkNWE (OTM)\n", " valid = np.sum(~np.isnan(V), axis=1)\n", " with np.errstate(invalid='ignore', divide='ignore'):\n", " return np.where(valid > 0, above / valid * 100.0, np.nan)" ] }, { "cell_type": "markdown", "id": "dbcf91ff", "metadata": {}, "source": [ "## 8.1. Setting Plotting Params - User Inputs" ] }, { "cell_type": "code", "execution_count": null, "id": "b0e52534", "metadata": {}, "outputs": [], "source": [ "# Setting to Seaborn Whitegrid format - non-essential\n", "sns.set_style('whitegrid')\n", "\n", "# Data & Charting Settings\n", "country_bool = True # Set to True to chart Marginal Countries instead of Marginal Terminals in the bar charts\n", "quartiles_bool = True # Set to True to shade DES HUb Netbacks in quartiles instead of per-terminal shading\n", "shade_alpha = 0.5 # alpha value for the shaded region\n", "fo_curve_bool = True # Set to True to include the Forward curve in the chart, instead of just the historical Front Month\n", " # fo_curve_bool not applicable to Section 8.3\n", "\n", "# Explicitly defining positioning of each axis \n", "# main chart / above-strip / below-strip / % panel / cbar\n", "L, W = 0.06, 0.84\n", "main_top, main_bot = 0.90, 0.55\n", "above_top, above_bot = 0.525, 0.498\n", "below_top, below_bot = 0.482, 0.455\n", "pct_top, pct_bot = 0.370, 0.220" ] }, { "cell_type": "markdown", "id": "b4d47bfd", "metadata": {}, "source": [ "## 8.2. Plotting Front Month Historical Evolution + Latest Forwards Curve" ] }, { "cell_type": "code", "execution_count": null, "id": "1ddf59ec", "metadata": {}, "outputs": [], "source": [ "# Front month and forward curve dataframes for terminals\n", "frontmonth_term = combined_df[combined_df['DeliveryMonthIndex'] == 'M+1'].copy()\n", "latest_term = combined_df[combined_df['ReleaseDate'] == latest].copy()" ] }, { "cell_type": "code", "execution_count": null, "id": "46f43ea1", "metadata": {}, "outputs": [], "source": [ "# Setting up figure and axes\n", "fig = plt.figure(figsize=(14, 10))\n", "ax = fig.add_axes([L, main_bot, W, main_top - main_bot])\n", "ax_above = fig.add_axes([L, above_bot, W, above_top - above_bot], sharex=ax)\n", "ax_below = fig.add_axes([L, below_bot, W, below_top - below_bot], sharex=ax)\n", "ax_pct = fig.add_axes([L, pct_bot, W, pct_top - pct_bot], sharex=ax)\n", "cax = fig.add_axes([L + W + 0.012, main_bot, 0.015, main_top - main_bot])\n", "\n", "print(\"Latest Assessment: \" + str(frontmonth_term['ReleaseDate'].iloc[-1]))\n", "\n", "# ── 3. Shaded terminal spread (history + forward), colorbars OFF here ───────\n", "shade_terminal_steps(ax, frontmonth_term['ReleaseDate'], frontmonth_term[new_terminal_list],\n", " quartiles=quartiles_bool, alpha=shade_alpha, add_colorbar=False)\n", "shade_terminal_steps(ax, latest_term['DeliveryMonth'], latest_term[new_terminal_list],\n", " quartiles=quartiles_bool, alpha=shade_alpha, add_colorbar=False)\n", "\n", "# Zero line and SparkNWE marker on top of the shading\n", "ax.axhline(0, color='grey', zorder=3)\n", "ax.plot(frontmonth_term['ReleaseDate'], frontmonth_term['SparkNWE'],\n", " color='#4F41F4', label='SparkNWE', zorder=4, linewidth=2.0)\n", "ax.plot(latest_term['DeliveryMonth'].iloc[1:], latest_term['SparkNWE'].iloc[1:],\n", " linewidth=2.0, marker='s', linestyle='--', color='#4F41F4', zorder=4)\n", "\n", "# ── 4. Two country strips: closest ABOVE and closest BELOW ─────────────────\n", "# Front Month and Forward Curve, Above and Below\n", "la_h, country_colours = add_marginal_terminal_strip(\n", " ax_above, frontmonth_term['ReleaseDate'], frontmonth_term[new_terminal_list],\n", " frontmonth_term['SparkNWE'], marginal_type='above', country_bool=country_bool)\n", "la_f, country_colours = add_marginal_terminal_strip(\n", " ax_above, latest_term['DeliveryMonth'], latest_term[new_terminal_list],\n", " latest_term['SparkNWE'], marginal_type='above', country_bool=country_bool,\n", " terminal_colours=country_colours)\n", "\n", "lb_h, country_colours = add_marginal_terminal_strip(\n", " ax_below, frontmonth_term['ReleaseDate'], frontmonth_term[new_terminal_list],\n", " frontmonth_term['SparkNWE'], marginal_type='below', country_bool=country_bool,\n", " terminal_colours=country_colours)\n", "lb_f, country_colours = add_marginal_terminal_strip(\n", " ax_below, latest_term['DeliveryMonth'], latest_term[new_terminal_list],\n", " latest_term['SparkNWE'], marginal_type='below', country_bool=country_bool,\n", " terminal_colours=country_colours)\n", "\n", "# ── 5. Lower panel: % of terminals above the SparkNWE level ────────────────\n", "pct_h = pct_terminals_above(frontmonth_term[new_terminal_list], frontmonth_term['SparkNWE'])\n", "pct_f = pct_terminals_above(latest_term[new_terminal_list], latest_term['SparkNWE'])\n", "\n", "ax_pct.axhline(50, color='#cccccc', lw=0.8, zorder=0)\n", "ax_pct.fill_between(frontmonth_term['ReleaseDate'], 0, pct_h, color='#1A3A6B', alpha=0.08, zorder=1)\n", "ax_pct.plot(frontmonth_term['ReleaseDate'], pct_h, color='#1A3A6B', lw=2.0, zorder=2)\n", "ax_pct.plot(latest_term['DeliveryMonth'].iloc[1:], pct_f[1:],\n", " color='#1A3A6B', lw=2.0, ls='--', marker='s', ms=4, zorder=2)\n", "ax_pct.set_ylim(0, 100)\n", "ax_pct.set_yticks([0, 25, 50, 75, 100])\n", "\n", "# ── 6. Quartile colorbar in its own axis ───────────────────────────────────\n", "_c = plt.get_cmap('Oranges'); _lo, _hi = 0.12, 0.95\n", "_seg = [_c(_lo + (_hi - _lo) * (b + 1) / 4.0) for b in range(4)]\n", "_sm = plt.cm.ScalarMappable(cmap=ListedColormap(_seg), norm=BoundaryNorm([0, 25, 50, 75, 100], 4))\n", "cbar = fig.colorbar(_sm, cax=cax, ticks=[0, 25, 50, 75, 100])\n", "cbar.set_label('% of terminals at/below', fontsize=9)\n", "cbar.ax.tick_params(labelsize=8)\n", "cbar.solids.set_alpha(0.5)\n", "\n", "# ── 7. Axis limits & legend (between the strips and the % panel) ───────────\n", "if fo_curve_bool == True:\n", " ax.set_xlim([frontmonth_term['ReleaseDate'].iloc[0] - pd.Timedelta(days=1),\n", " latest_term['DeliveryMonth'].iloc[-1] + pd.Timedelta(days=2)])\n", "else:\n", " ax.set_xlim([frontmonth_term['ReleaseDate'].iloc[0] - pd.Timedelta(days=1),\n", " frontmonth_term['ReleaseDate'].iloc[-1] + pd.Timedelta(days=2)])\n", "\n", "for a in (ax, ax_above, ax_below):\n", " plt.setp(a.get_xticklabels(), visible=False) # dates only on the % panel\n", "\n", "_seen = [l for l in pd.unique(np.concatenate([la_h, la_f, lb_h, lb_f])) if l is not None]\n", "handles = [plt.Line2D([], [], color='#4F41F4', lw=2, label='SparkNWE'),\n", " plt.Line2D([], [], color='#1A3A6B', lw=2, label='% terminals above SparkNWE')] + \\\n", " [Patch(facecolor=country_colours[c], label=c) for c in _seen]\n", "fig.legend(handles=handles, loc='center', bbox_to_anchor=(0.48, 0.415),\n", " ncol=min(len(handles), 6), frameon=True, edgecolor='#dddddd',\n", " fontsize=9, columnspacing=1.4, handlelength=1.6)\n", "\n", "for a in (ax_above, ax_below):\n", " a.yaxis.set_label_position('right')\n", "\n", "if country_bool == True:\n", " ax_above.set_ylabel('Marginal Country\\n(above)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", " ax_below.set_ylabel('Marginal Country\\n(below)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", "elif country_bool == False:\n", " ax_above.set_ylabel('Marginal Terminal\\n(above)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", " ax_below.set_ylabel('Marginal Terminal\\n(below)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", "\n", "ax_pct.yaxis.set_label_position('right')\n", "ax_pct.set_ylabel('% terminals\\nabove SparkNWE', rotation=0, ha='left', va='center', labelpad=10, fontsize=11)\n", "\n", "ax.tick_params(axis='y', labelsize=12)\n", "ax_pct.tick_params(axis='both', labelsize=12)\n", "\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "3180a1cd", "metadata": {}, "source": [ "## 8.3. Plotting Custom Month Historical Evolution" ] }, { "cell_type": "code", "execution_count": null, "id": "e038654a", "metadata": {}, "outputs": [], "source": [ "# Choose Delivery Month of interest\n", "month = 'Sep26'\n", "\n", "month_term = combined_df[combined_df['DeliveryMonthName'] == month].copy()\n", "month_term.head(3)" ] }, { "cell_type": "code", "execution_count": null, "id": "3514a693", "metadata": {}, "outputs": [], "source": [ "# Setting up figure and axes\n", "fig = plt.figure(figsize=(14, 10))\n", "ax = fig.add_axes([L, main_bot, W, main_top - main_bot])\n", "ax_above = fig.add_axes([L, above_bot, W, above_top - above_bot], sharex=ax)\n", "ax_below = fig.add_axes([L, below_bot, W, below_top - below_bot], sharex=ax)\n", "ax_pct = fig.add_axes([L, pct_bot, W, pct_top - pct_bot], sharex=ax)\n", "cax = fig.add_axes([L + W + 0.012, main_bot, 0.015, main_top - main_bot])\n", "\n", "print(\"Latest Assessment: \" + str(month_term['ReleaseDate'].iloc[-1]))\n", "\n", "# ── 3. Shaded terminal spread (single delivery-month history) ──────────────\n", "shade_terminal_steps(ax, month_term['ReleaseDate'], month_term[new_terminal_list],\n", " quartiles=quartiles_bool, alpha=shade_alpha, add_colorbar=False)\n", "\n", "# Zero line and SparkNWE marker on top of the shading\n", "ax.axhline(0, color='grey', zorder=3)\n", "ax.plot(month_term['ReleaseDate'], month_term['SparkNWE'],\n", " color='#4F41F4', label='SparkNWE', zorder=4, linewidth=2.0)\n", "\n", "# ── 4. Two strips: closest ABOVE and closest BELOW ─────────────────────────\n", "la, country_colours = add_marginal_terminal_strip(\n", " ax_above, month_term['ReleaseDate'], month_term[new_terminal_list],\n", " month_term['SparkNWE'], marginal_type='above', country_bool=country_bool)\n", "lb, country_colours = add_marginal_terminal_strip(\n", " ax_below, month_term['ReleaseDate'], month_term[new_terminal_list],\n", " month_term['SparkNWE'], marginal_type='below', country_bool=country_bool,\n", " terminal_colours=country_colours)\n", "\n", "# ── 5. Lower panel: % of terminals above the SparkNWE level ────────────────\n", "pct = pct_terminals_above(month_term[new_terminal_list], month_term['SparkNWE'])\n", "\n", "ax_pct.axhline(50, color='#cccccc', lw=0.8, zorder=0)\n", "ax_pct.fill_between(month_term['ReleaseDate'], 0, pct, color='#1A3A6B', alpha=0.08, zorder=1)\n", "ax_pct.plot(month_term['ReleaseDate'], pct, color='#1A3A6B', lw=2.0, zorder=2)\n", "ax_pct.set_ylim(0, 100)\n", "ax_pct.set_yticks([0, 25, 50, 75, 100])\n", "\n", "# ── 6. Quartile colorbar in its own axis ───────────────────────────────────\n", "_c = plt.get_cmap('Oranges'); _lo, _hi = 0.12, 0.95\n", "_seg = [_c(_lo + (_hi - _lo) * (b + 1) / 4.0) for b in range(4)]\n", "_sm = plt.cm.ScalarMappable(cmap=ListedColormap(_seg), norm=BoundaryNorm([0, 25, 50, 75, 100], 4))\n", "cbar = fig.colorbar(_sm, cax=cax, ticks=[0, 25, 50, 75, 100])\n", "cbar.set_label('% of terminals at/below', fontsize=9)\n", "cbar.ax.tick_params(labelsize=8)\n", "cbar.solids.set_alpha(0.5)\n", "\n", "# ── 7. Axis limits & legend (between the strips and the % panel) ───────────\n", "ax.set_xlim([month_term['ReleaseDate'].iloc[0] - pd.Timedelta(days=1),\n", " month_term['ReleaseDate'].iloc[-1] + pd.Timedelta(days=2)])\n", "for a in (ax, ax_above, ax_below):\n", " plt.setp(a.get_xticklabels(), visible=False) # dates only on the % panel\n", "\n", "_seen = [l for l in pd.unique(np.concatenate([la, lb])) if l is not None]\n", "handles = [plt.Line2D([], [], color='#4F41F4', lw=2, label='SparkNWE'),\n", " plt.Line2D([], [], color='#1A3A6B', lw=2, label='% terminals above SparkNWE')] + \\\n", " [Patch(facecolor=country_colours[c], label=c) for c in _seen]\n", "fig.legend(handles=handles, loc='center', bbox_to_anchor=(0.48, 0.415),\n", " ncol=min(len(handles), 6), frameon=True, edgecolor='#dddddd',\n", " fontsize=9, columnspacing=1.4, handlelength=1.6)\n", "\n", "for a in (ax_above, ax_below):\n", " a.yaxis.set_label_position('right')\n", "\n", "if country_bool == True:\n", " ax_above.set_ylabel('Marginal Country\\n(above)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", " ax_below.set_ylabel('Marginal Country\\n(below)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", "elif country_bool == False:\n", " ax_above.set_ylabel('Marginal Terminal\\n(above)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", " ax_below.set_ylabel('Marginal Terminal\\n(below)', rotation=0, ha='left', va='center', labelpad=10, fontsize=12)\n", "\n", "ax_pct.yaxis.set_label_position('right')\n", "ax_pct.set_ylabel('% terminals\\nabove SparkNWE', rotation=0, ha='left', va='center', labelpad=10, fontsize=11)\n", "\n", "ax.tick_params(axis='y', labelsize=12)\n", "ax_pct.tick_params(axis='both', labelsize=12)\n", "\n", "plt.show()" ] } ], "metadata": { "kernelspec": { "display_name": "base", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.9" } }, "nbformat": 4, "nbformat_minor": 5 }