diff --git a/implementations/pko/01_pko_data_exploration.ipynb b/implementations/pko/01_pko_data_exploration.ipynb new file mode 100644 index 00000000..0ae932a7 --- /dev/null +++ b/implementations/pko/01_pko_data_exploration.ipynb @@ -0,0 +1,7246 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d4809da6", + "metadata": {}, + "source": [ + "# Palm Oil — Data Exploration\n", + "\n", + "A visual tour of the palm oil price series behind the PKO experiment, aimed at two\n", + "decisions: **which series to forecast**, and **which 7 cutoffs to forecast from**.\n", + "\n", + "The target is FRED `PPOILUSDM` — the IMF global benchmark palm oil price, monthly,\n", + "USD per metric ton, registered with true publication dates by `pko.data`.\n", + "\n", + "See [`DATA.md`](DATA.md) for the full survey of what FRED carries and why this series\n", + "was chosen. Every chart below is interactive: **click a legend entry to hide a series**,\n", + "drag to zoom, double-click to reset.\n" + ] + }, + { + "cell_type": "markdown", + "id": "e3e658a7", + "metadata": {}, + "source": [ + "---\n", + "## 1. Load the price series\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91f708b7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:01.529992Z", + "iopub.status.busy": "2026-08-06T19:18:01.529679Z", + "iopub.status.idle": "2026-08-06T19:18:02.193121Z", + "shell.execute_reply": "2026-08-06T19:18:02.191177Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "414 monthly observations\n", + "span : 1992-01 -> 2026-06\n", + "price : $185 to $1653 per tonne\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
timestampvaluereleased_at
4092026-02-011033.7683942026-03-24
4102026-03-011121.1778972026-04-15
4112026-04-011137.4108622026-06-05
4122026-05-011130.0267572026-06-05
4132026-06-011108.6810962026-07-13
\n", + "
" + ], + "text/plain": [ + " timestamp value released_at\n", + "409 2026-02-01 1033.768394 2026-03-24\n", + "410 2026-03-01 1121.177897 2026-04-15\n", + "411 2026-04-01 1137.410862 2026-06-05\n", + "412 2026-05-01 1130.026757 2026-06-05\n", + "413 2026-06-01 1108.681096 2026-07-13" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from __future__ import annotations\n", + "\n", + "import sys\n", + "from datetime import datetime, timezone\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "from dotenv import load_dotenv\n", + "\n", + "\n", + "ROOT = Path.cwd().resolve().parents[1]\n", + "sys.path.insert(0, str(ROOT / \"implementations\"))\n", + "load_dotenv(ROOT / \".env\")\n", + "\n", + "from pko.data import PALM_OIL_SERIES_ID, build_palm_oil_service\n", + "from pko.plots import (\n", + " DEFAULT_CUTOFFS,\n", + " plot_cutoff_windows,\n", + " plot_information_gap,\n", + " plot_monthly_changes,\n", + " plot_oil_complex,\n", + " plot_price_history,\n", + ")\n", + "\n", + "\n", + "svc = build_palm_oil_service(cache_dir=ROOT / \"data\" / \"fred\")\n", + "as_of = datetime.now(tz=timezone.utc).replace(tzinfo=None)\n", + "prices = svc.get_series(PALM_OIL_SERIES_ID, as_of=as_of)\n", + "\n", + "print(f\"{len(prices)} monthly observations\")\n", + "print(f\"span : {prices.timestamp.min():%Y-%m} -> {prices.timestamp.max():%Y-%m}\")\n", + "print(f\"price : ${prices.value.min():.0f} to ${prices.value.max():.0f} per tonne\")\n", + "prices.tail()" + ] + }, + { + "cell_type": "markdown", + "id": "58434dd4", + "metadata": {}, + "source": [ + "The `released_at` column is the point of this whole setup — it is the date FRED\n", + "*published* each price, not the month the price refers to. June 2026's price was\n", + "published on 2026-07-13, six weeks after the timestamp says.\n", + "\n", + "That column is what stops the harness handing a model a price that did not exist yet.\n" + ] + }, + { + "cell_type": "markdown", + "id": "b389d408", + "metadata": {}, + "source": [ + "---\n", + "## 2. The signal itself\n", + "\n", + "Full history since 2015, with the 7 candidate cutoffs marked and the two publication\n", + "blackouts shaded in red. Drag the range slider at the bottom to zoom into any period.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4de7760f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:02.198528Z", + "iopub.status.busy": "2026-08-06T19:18:02.197812Z", + "iopub.status.idle": "2026-08-06T19:18:04.437350Z", + "shell.execute_reply": "2026-08-06T19:18:04.435861Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{x|%b %Y}
$%{y:.0f}/tonne", + "line": { + "color": "#2a78d6", + "width": 2 + }, + "mode": "lines", + "name": "Palm oil", + "type": "scatter", + "x": [ + "2015-01-01T00:00:00.000000", + "2015-02-01T00:00:00.000000", + "2015-03-01T00:00:00.000000", + "2015-04-01T00:00:00.000000", + "2015-05-01T00:00:00.000000", + "2015-06-01T00:00:00.000000", + "2015-07-01T00:00:00.000000", + "2015-08-01T00:00:00.000000", + "2015-09-01T00:00:00.000000", + "2015-10-01T00:00:00.000000", + "2015-11-01T00:00:00.000000", + "2015-12-01T00:00:00.000000", + "2016-01-01T00:00:00.000000", + "2016-02-01T00:00:00.000000", + "2016-03-01T00:00:00.000000", + "2016-04-01T00:00:00.000000", + "2016-05-01T00:00:00.000000", + "2016-06-01T00:00:00.000000", + "2016-07-01T00:00:00.000000", + "2016-08-01T00:00:00.000000", + "2016-09-01T00:00:00.000000", + "2016-10-01T00:00:00.000000", + "2016-11-01T00:00:00.000000", + "2016-12-01T00:00:00.000000", + "2017-01-01T00:00:00.000000", + "2017-02-01T00:00:00.000000", + "2017-03-01T00:00:00.000000", + "2017-04-01T00:00:00.000000", + "2017-05-01T00:00:00.000000", + "2017-06-01T00:00:00.000000", + "2017-07-01T00:00:00.000000", + "2017-08-01T00:00:00.000000", + "2017-09-01T00:00:00.000000", + "2017-10-01T00:00:00.000000", + "2017-11-01T00:00:00.000000", + "2017-12-01T00:00:00.000000", + "2018-01-01T00:00:00.000000", + "2018-02-01T00:00:00.000000", + "2018-03-01T00:00:00.000000", + "2018-04-01T00:00:00.000000", + "2018-05-01T00:00:00.000000", + "2018-06-01T00:00:00.000000", + "2018-07-01T00:00:00.000000", + "2018-08-01T00:00:00.000000", + "2018-09-01T00:00:00.000000", + "2018-10-01T00:00:00.000000", + "2018-11-01T00:00:00.000000", + "2018-12-01T00:00:00.000000", + "2019-01-01T00:00:00.000000", + "2019-02-01T00:00:00.000000", + "2019-03-01T00:00:00.000000", + "2019-04-01T00:00:00.000000", + "2019-05-01T00:00:00.000000", + "2019-06-01T00:00:00.000000", + "2019-07-01T00:00:00.000000", + "2019-08-01T00:00:00.000000", + "2019-09-01T00:00:00.000000", + "2019-10-01T00:00:00.000000", + "2019-11-01T00:00:00.000000", + "2019-12-01T00:00:00.000000", + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "Wg8vIscMhECSxX5mBtODQAkoCio9/YJAwM8/qU1+gkBXmUQdLMuCQO0PdGQ884JAvaRiAHX9gUDDHP9p10p+QNlzGwPMN35ArDZIHvmRgEDqlwQwnnJ/QET/9g7SRIBAwbhOnvScgEAEXWiZNJ+CQJSLsNGKyINAvl0y/wBDhUABplSwhCSEQIx2W9CfU4NAbIZmZIVBgkC7Z0tXBMOEQLH8I9ZAo4VAfxn00ZZbhEBtGIoP+++EQFnNv8UMPoZAFY0LkOizhkBrn6xIWRaGQMIFa2pluoRAX6EnTKl5g0ASXwC+FXyEQMFm2uNzaYNAeAlJCURJg0AGwAVEjm6DQMmWCHINrYRAk5hBFktBhED/i/usgtyDQJGTGeNhhoJAw52vrsfKg0BAEVy4jBeEQABOcKp4goNAVFLEwwFQg0B/F5hSLPCCQO54pBUvN4JAqODLUqrRgEAvz/mv0rKAQJIfOSzTZoBAU6vGWLKtf0ASiUH/RsF7QPW5YM8F53xAdH5AOjk5gEBAxc2dTVKAQOhdQfzeyn5A/JAN0o90f0ACfEZrkIF9QGhaAXwIqn1Ae4dU/G4jfUDRBWMGvsl/QNlP1XezrX9Aob/YOilcgEDSFesWbCaDQBpJsWlEaoVARQsNUK+0hkCqQEpqBBKEQJ1XoAI5b4FAx7HSEcszgEDXe2fytid/QBEyvOfJ6YFA2gHX+/YTg0ACXWdzmBWFQLcrDy2aGoZAodS1PJnXhkARjNcSHk2KQA9VI+wvdYxA3K3d45hDjUAPvA9/8kaOQEJFaIpoHo9AUFgkdCwxkEBMPqj4VkKRQICtwoCzx4xAhxDuc8qRj0D09+VYH9eQQMRUx8fpR5FA90JQAbFYk0AU5RgxkP6TQGhT8Anb3ZJAgCw8gGE2lEC8sqZzR8iWQBbMEpbd0plAHsHYI0w0mUDZLSpv4wSZQAkQZA29qJRApo0iEiE3jECJoxxwmPKMQGXaUznp04hAXeuDGWnliEDaRqSMR12LQIHFOCXbuItAk0XPnks6jEASA3n/STGMQBR38oQ+cYxAfJV5Ono2jUCkH91FBxWKQGA2Ga7JCIhAGjsl7tBnikDaRpyK3byJQLJHyX6ytYhAHcYqxtn7h0CREhxI59+IQGbmFnHAeohAYEnfVep6iUDlwVZTtsiJQKgKD/RZGIxASBv+HvsGjEAgkWQpVNGJQKREHKLVMYpAaa9PZL7kikCjnM4qn8GLQKyzPabbdI1AVbAMHzUokEATdAP1fbORQCkubbMbn5FA9iYDBicYkEDNp2uYTK2QQDghZdeWhJBALJRYymupjkDkr9IqTjaMQF2aS/oGNI1AMI2kIlMYjUCCl5M2DQiQQJziUeCZK5BABxCaHyA5kEAmMK/uc4iOQOEV/h/MqY5Aiu5kDLxij0CYzNzVEieQQDduqiq2hJFAH8USuaTFkUBUwkdmG6iRQLPAKHG5UpFA", + "dtype": "f8" + } + } + ], + "layout": { + "annotations": [ + { + "font": { + "size": 10 + }, + "showarrow": false, + "text": "no data published", + "x": "2021-12-01", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 10 + }, + "showarrow": false, + "x": "2025-07-01", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2021-05 (event)", + "x": "2021-05-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2022-01 (event)", + "x": "2022-01-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2023-04 (event)", + "x": "2023-04-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2024-09 (event)", + "x": "2024-09-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2023-07 (quiet)", + "x": "2023-07-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2024-11 (quiet)", + "x": "2024-11-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2025-08 (quiet)", + "x": "2025-08-01T00:00:00", + "xanchor": "center", + "xref": "x", + "y": 1, + "yanchor": "bottom", + "yref": "y domain" + } + ], + "font": { + "color": "#52514e", + "size": 12 + }, + "height": 520, + "hovermode": "x unified", + "legend": { + "orientation": "h", + "x": 0, + "xanchor": "left", + "y": 1.02, + "yanchor": "bottom" + }, + "margin": { + "b": 55, + "l": 70, + "r": 110, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "shapes": [ + { + "fillcolor": "rgba(227, 73, 72, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2021-12-01", + "x1": "2022-08-01", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(227, 73, 72, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2025-07-01", + "x1": "2026-01-01", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2021-05-01T00:00:00", + "x1": "2021-05-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2022-01-01T00:00:00", + "x1": "2022-01-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2023-04-01T00:00:00", + "x1": "2023-04-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2024-09-01T00:00:00", + "x1": "2024-09-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2023-07-01T00:00:00", + "x1": "2023-07-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2024-11-01T00:00:00", + "x1": "2024-11-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1 + }, + "type": "line", + "x0": "2025-08-01T00:00:00", + "x1": "2025-08-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + } + ], + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 17 + }, + "text": "Palm oil price (FRED PPOILUSDM)" + }, + "xaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "rangeslider": { + "thickness": 0.06, + "visible": true + }, + "showspikes": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "title": { + "text": "USD / metric ton" + }, + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_price_history(prices, cutoffs=DEFAULT_CUTOFFS)" + ] + }, + { + "cell_type": "markdown", + "id": "c3ca1611", + "metadata": {}, + "source": [ + "Things to look for:\n", + "\n", + "- The **2021–2022 spike** to \\$1,653 and the crash back to \\$903 — the Indonesian export ban.\n", + "- The **red bands** are periods when FRED published nothing at all. Note that the first\n", + " one covers the entire export-ban episode.\n", + "- The recent climb through 2026 to around \\$1,100.\n" + ] + }, + { + "cell_type": "markdown", + "id": "a1e0ea83", + "metadata": {}, + "source": [ + "---\n", + "## 3. Month-over-month change\n", + "\n", + "The same series as returns, which is what a forecaster is really trying to predict.\n", + "Blue is up, red is down.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f991dd0b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.440836Z", + "iopub.status.busy": "2026-08-06T19:18:04.440483Z", + "iopub.status.idle": "2026-08-06T19:18:04.485793Z", + "shell.execute_reply": "2026-08-06T19:18:04.483963Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{x|%b %Y}
%{y:+.1f}%", + "marker": { + "color": [ + "#2a78d6", + "#e34948", + "#e34948", + "#e34948", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#e34948", + "#e34948", + "#e34948", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#e34948", + "#e34948", + "#2a78d6", + "#e34948", + "#e34948", + "#e34948", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#e34948", + "#2a78d6", + "#e34948", + "#e34948", + "#e34948", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#2a78d6", + "#e34948", + "#e34948" + ] + }, + "name": "Monthly change", + "showlegend": false, + "type": "bar", + "x": [ + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "zi+0QKwbGEC8PfZxsDYnwJ7rbWVIRCrAYpH4++pEHMBXSrPM8NgOwLZNUm7T/C1AGLj1oToCGkBUqhyziQglQNcW7X2qVxNA1pLm0Ei4CkC4vQh9XUkuQPgprgYNZiBAshb0tJCqBkB6RcaR7bELQDYzTasJPQZAfSnqAedDEEBfXVmHI1waQOx0iD2nnzDAC8l+QmNiI0DKjh0Zzb8aQAzNj80g7gRAocwjssvnJ0A+d9QBAssKQBRsA/rFjxbAVrPRVXCIHECwZCr51m0pQIbk2vb/sipAl8dun1QwA8BUdQfdKoPnv9mxjDMxbTHAs7j99lC2P8A+3qCgTMMEQOr9kbXXdizAIP7rg/Oe0T88qhNdCtQjQKCn529/6vQ/6OpByK4u/T9gH4Ip8Oe/v3D8xK4+W+w/JOdOYZerBUBnfZim+G4lwMC6N+3WZx/ARncxKFi7I0AFwLF5PTsEwPGyetD88w/AvBNFGvKAB8CKyoFl5bYNQMiUw/9Wavm/f5vOJMRZEECAPnTXLBXzP2qQnMIw7SFAwCgZ+eLpzr8VR0of4ogfwBRCRHLAXPc/LIn4N/1XBUAsiMKPcaoJQDQ1JXI+gBhAVGi1ZglnI0Ajwz5tBh0jQMDsPoLxydy/YLEgK0FVIcB0NIeHn/UMQDy7aO1+g+6/bzh14MK+HMC4U793EfUfwLKH56awGgxAGBB9ahy317+00U1BvWYkQFjbyb/xt+s/AKqmL8jo1D+clL9DQ5YXwHDKiwhQTds/KnBCpfvYAkB6F7VUgnAHQLDfGl0t6SBA2Jbj6WMq9z848tlERcbkv8CKB5olOf6/", + "dtype": "f8" + } + } + ], + "layout": { + "annotations": [ + { + "font": { + "size": 10 + }, + "showarrow": false, + "text": "no data published", + "x": "2021-12-01", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 10 + }, + "showarrow": false, + "x": "2025-07-01", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + } + ], + "font": { + "color": "#52514e", + "size": 12 + }, + "height": 420, + "hovermode": "x unified", + "legend": { + "orientation": "h", + "x": 0, + "xanchor": "left", + "y": 1.02, + "yanchor": "bottom" + }, + "margin": { + "b": 55, + "l": 70, + "r": 110, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "shapes": [ + { + "fillcolor": "rgba(227, 73, 72, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2021-12-01", + "x1": "2022-08-01", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(227, 73, 72, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2025-07-01", + "x1": "2026-01-01", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "line": { + "color": "#52514e", + "width": 1 + }, + "type": "line", + "x0": 0, + "x1": 1, + "xref": "x domain", + "y0": 0, + "y1": 0, + "yref": "y" + } + ], + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 17 + }, + "text": "Palm oil, month-over-month change" + }, + "xaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "title": { + "text": "% change" + }, + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_monthly_changes(prices, start=\"2020-01-01\")" + ] + }, + { + "cell_type": "markdown", + "id": "8e994d5a", + "metadata": {}, + "source": [ + "July 2022 is **−31.7%** — the largest single month in the data. It sits inside a\n", + "blackout, so nobody could see it happening at the time.\n" + ] + }, + { + "cell_type": "markdown", + "id": "dadc6dab", + "metadata": {}, + "source": [ + "---\n", + "## 4. Do the candidate cutoffs actually look right?\n", + "\n", + "Each shaded band is one cutoff's 6-month forecast window. Orange bands are the\n", + "\"event\" cutoffs, blue are \"quiet\".\n", + "\n", + "**This chart is the check on the cutoff choice.** An event window should visibly\n", + "contain a shock; a quiet window should look flat. If one doesn't, swap it.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82ec403c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.490381Z", + "iopub.status.busy": "2026-08-06T19:18:04.489991Z", + "iopub.status.idle": "2026-08-06T19:18:04.569579Z", + "shell.execute_reply": "2026-08-06T19:18:04.568497Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{x|%b %Y}
$%{y:.0f}/tonne", + "line": { + "color": "#2a78d6", + "width": 2 + }, + "mode": "lines", + "name": "Palm oil", + "type": "scatter", + "x": [ + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "EYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikBgNhmuyQiIQBo7Je7QZ4pA2kacit28iUCyR8l+srWIQB3GKsbZ+4dAkRIcSOffiEBm5hZxwHqIQGBJ31XqeolA5cFWU7bIiUCoCg/0WRiMQEgb/h77BoxAIJFkKVTRiUCkRByi1TGKQGmvT2S+5IpAo5zOKp/Bi0Cssz2m23SNQFWwDB81KJBAE3QD9X2zkUApLm2zG5+RQPYmAwYnGJBAzadrmEytkEA4IWXXloSQQCyUWMprqY5A5K/SKk42jEBdmkv6BjSNQDCNpCJTGI1AgpeTNg0IkECc4lHgmSuQQAcQmh8gOZBAJjCv7nOIjkDhFf4fzKmOQIruZAy8Yo9AmMzc1RInkEA3bqoqtoSRQB/FErmkxZFAVMJHZhuokUCzwChxuVKRQA==", + "dtype": "f8" + } + } + ], + "layout": { + "annotations": [ + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2021-05
event", + "x": "2021-05-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2022-01
event", + "x": "2022-01-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2023-04
event", + "x": "2023-04-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2024-09
event", + "x": "2024-09-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2023-07
quiet", + "x": "2023-07-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2024-11
quiet", + "x": "2024-11-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + }, + { + "font": { + "size": 9 + }, + "showarrow": false, + "text": "2025-08
quiet", + "x": "2025-08-01T00:00:00", + "xanchor": "left", + "xref": "x", + "y": 1, + "yanchor": "top", + "yref": "y domain" + } + ], + "font": { + "color": "#52514e", + "size": 12 + }, + "height": 520, + "hovermode": "x unified", + "legend": { + "orientation": "h", + "x": 0, + "xanchor": "left", + "y": 1.02, + "yanchor": "bottom" + }, + "margin": { + "b": 55, + "l": 70, + "r": 110, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "shapes": [ + { + "fillcolor": "rgba(235, 104, 52, 0.13)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2021-05-01T00:00:00", + "x1": "2021-11-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(235, 104, 52, 0.13)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2022-01-01T00:00:00", + "x1": "2022-07-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(235, 104, 52, 0.13)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2023-04-01T00:00:00", + "x1": "2023-10-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(235, 104, 52, 0.13)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2024-09-01T00:00:00", + "x1": "2025-03-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(42, 120, 214, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2023-07-01T00:00:00", + "x1": "2024-01-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(42, 120, 214, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2024-11-01T00:00:00", + "x1": "2025-05-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + }, + { + "fillcolor": "rgba(42, 120, 214, 0.10)", + "layer": "below", + "line": { + "width": 0 + }, + "type": "rect", + "x0": "2025-08-01T00:00:00", + "x1": "2026-02-01T00:00:00", + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "y domain" + } + ], + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 17 + }, + "text": "Candidate cutoffs and their 6-month forecast windows" + }, + "xaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "title": { + "text": "USD / metric ton" + }, + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_cutoff_windows(prices)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b88359ce", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.574898Z", + "iopub.status.busy": "2026-08-06T19:18:04.574607Z", + "iopub.status.idle": "2026-08-06T19:18:04.587116Z", + "shell.execute_reply": "2026-08-06T19:18:04.585524Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cutoffkindreason
02021-05eventJune 2021 crash, -16.6%
12022-01eventIndonesia export ban, -29.4% over 6mo
22023-04eventMay 2023 correction, -10.7%
32024-09eventOct 2024 rally, +9.7%
42023-07quietcalmest window, max move 4.1%
52024-11quietmax move 8.7%
62025-08quietmax move 5.9%
\n", + "
" + ], + "text/plain": [ + " cutoff kind reason\n", + "0 2021-05 event June 2021 crash, -16.6%\n", + "1 2022-01 event Indonesia export ban, -29.4% over 6mo\n", + "2 2023-04 event May 2023 correction, -10.7%\n", + "3 2024-09 event Oct 2024 rally, +9.7%\n", + "4 2023-07 quiet calmest window, max move 4.1%\n", + "5 2024-11 quiet max move 8.7%\n", + "6 2025-08 quiet max move 5.9%" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The cutoffs and why each was picked — edit this list to try alternatives.\n", + "pd.DataFrame([{\"cutoff\": c.date[:7], \"kind\": c.kind, \"reason\": c.label} for c in DEFAULT_CUTOFFS])" + ] + }, + { + "cell_type": "markdown", + "id": "9e87c51d", + "metadata": {}, + "source": [ + "---\n", + "## 5. What the model can actually see\n", + "\n", + "This is the chart that makes the publication lag concrete.\n", + "\n", + "The dotted grey line is what really happened. Each coloured line is the history that\n", + "was **published** as of one cutoff — where it stops is the newest price a forecaster\n", + "had on that date.\n", + "\n", + "The horizontal distance between where a coloured line ends and its cutoff is the\n", + "information gap. Normally 2 months; far worse inside a blackout.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc67c86c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.590144Z", + "iopub.status.busy": "2026-08-06T19:18:04.589809Z", + "iopub.status.idle": "2026-08-06T19:18:04.648707Z", + "shell.execute_reply": "2026-08-06T19:18:04.646945Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{x|%b %Y}
actual $%{y:.0f}", + "line": { + "color": "#52514e", + "dash": "dot", + "width": 1.5 + }, + "mode": "lines", + "name": "What actually happened", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikBgNhmuyQiIQBo7Je7QZ4pA2kacit28iUCyR8l+srWIQB3GKsbZ+4dAkRIcSOffiEBm5hZxwHqIQGBJ31XqeolA5cFWU7bIiUCoCg/0WRiMQEgb/h77BoxAIJFkKVTRiUCkRByi1TGKQGmvT2S+5IpAo5zOKp/Bi0Cssz2m23SNQFWwDB81KJBAE3QD9X2zkUApLm2zG5+RQPYmAwYnGJBAzadrmEytkEA4IWXXloSQQCyUWMprqY5A5K/SKk42jEBdmkv6BjSNQDCNpCJTGI1AgpeTNg0IkECc4lHgmSuQQAcQmh8gOZBAJjCv7nOIjkDhFf4fzKmOQIruZAy8Yo9AmMzc1RInkEA3bqoqtoSRQB/FErmkxZFAVMJHZhuokUA=", + "dtype": "f8" + } + }, + { + "hovertemplate": "as of 2021-05
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#2a78d6", + "width": 2 + }, + "mode": "lines", + "name": "2021-05 (event)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQA==", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2021-05
$%{y:.0f}", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2021-05" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2021-03-01T00:00:00" + ], + "y": [ + 995.8010452409874 + ] + }, + { + "hovertemplate": "as of 2022-01
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#eb6834", + "width": 2 + }, + "mode": "lines", + "name": "2022-01 (event)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0A=", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2022-01
$%{y:.0f}", + "marker": { + "color": "#eb6834", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2022-01" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2021-11-01T00:00:00" + ], + "y": [ + 1279.640812291128 + ] + }, + { + "hovertemplate": "as of 2023-04
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#1baf7a", + "width": 2 + }, + "mode": "lines", + "name": "2023-04 (event)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEA=", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2023-04
$%{y:.0f}", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2023-04" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2023-02-01T00:00:00" + ], + "y": [ + 902.161131806761 + ] + }, + { + "hovertemplate": "as of 2024-09
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#eda100", + "width": 2 + }, + "mode": "lines", + "name": "2024-09 (event)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikBgNhmuyQiIQBo7Je7QZ4pA2kacit28iUCyR8l+srWIQB3GKsbZ+4dAkRIcSOffiEBm5hZxwHqIQGBJ31XqeolA5cFWU7bIiUCoCg/0WRiMQEgb/h77BoxAIJFkKVTRiUCkRByi1TGKQGmvT2S+5IpA", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2024-09
$%{y:.0f}", + "marker": { + "color": "#eda100", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2024-09" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2024-07-01T00:00:00" + ], + "y": [ + 860.5929647660643 + ] + }, + { + "hovertemplate": "as of 2023-07
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#2a78d6", + "width": 2 + }, + "mode": "lines", + "name": "2023-07 (quiet)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikA=", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2023-07
$%{y:.0f}", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2023-07" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2023-05-01T00:00:00" + ], + "y": [ + 834.6285512233048 + ] + }, + { + "hovertemplate": "as of 2024-11
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#eb6834", + "width": 2 + }, + "mode": "lines", + "name": "2024-11 (quiet)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikBgNhmuyQiIQBo7Je7QZ4pA2kacit28iUCyR8l+srWIQB3GKsbZ+4dAkRIcSOffiEBm5hZxwHqIQGBJ31XqeolA5cFWU7bIiUCoCg/0WRiMQEgb/h77BoxAIJFkKVTRiUCkRByi1TGKQGmvT2S+5IpAo5zOKp/Bi0Cssz2m23SNQA==", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2024-11
$%{y:.0f}", + "marker": { + "color": "#eb6834", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2024-11" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2024-09-01T00:00:00" + ], + "y": [ + 942.6072506733376 + ] + }, + { + "hovertemplate": "as of 2025-08
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#1baf7a", + "width": 2 + }, + "mode": "lines", + "name": "2025-08 (quiet)", + "type": "scatter", + "x": [ + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000" + ], + "y": { + "bdata": "13tn8rYnf0ARMrznyemBQNoB1/v2E4NAAl1nc5gVhUC3Kw8tmhqGQKHUtTyZ14ZAEYzXEh5NikAPVSPsL3WMQNyt3eOYQ41AD7wPf/JGjkBCRWiKaB6PQFBYJHQsMZBATD6o+FZCkUCArcKAs8eMQIcQ7nPKkY9A9PflWB/XkEDEVMfH6UeRQPdCUAGxWJNAFOUYMZD+k0BoU/AJ292SQIAsPIBhNpRAvLKmc0fIlkAWzBKW3dKZQB7B2CNMNJlA2S0qb+MEmUAJEGQNvaiUQKaNIhIhN4xAiaMccJjyjEBl2lM56dOIQF3rgxlp5YhA2kakjEddi0CBxTgl27iLQJNFz55LOoxAEgN5/0kxjEAUd/KEPnGMQHyVeTp6No1ApB/dRQcVikBgNhmuyQiIQBo7Je7QZ4pA2kacit28iUCyR8l+srWIQB3GKsbZ+4dAkRIcSOffiEBm5hZxwHqIQGBJ31XqeolA5cFWU7bIiUCoCg/0WRiMQEgb/h77BoxAIJFkKVTRiUCkRByi1TGKQGmvT2S+5IpAo5zOKp/Bi0Cssz2m23SNQFWwDB81KJBAE3QD9X2zkUApLm2zG5+RQPYmAwYnGJBAzadrmEytkEA4IWXXloSQQCyUWMprqY5A5K/SKk42jEBdmkv6BjSNQA==", + "dtype": "f8" + } + }, + { + "hovertemplate": "newest price available at 2025-08
$%{y:.0f}", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " 2025-08" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2025-06-01T00:00:00" + ], + "y": [ + 934.5034070879444 + ] + } + ], + "layout": { + "font": { + "color": "#52514e", + "size": 12 + }, + "height": 560, + "hovermode": "x unified", + "legend": { + "orientation": "h", + "x": 0, + "xanchor": "left", + "y": 1.02, + "yanchor": "bottom" + }, + "margin": { + "b": 55, + "l": 70, + "r": 110, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 17 + }, + "text": "What the model can see at each cutoff, vs what really happened" + }, + "xaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "title": { + "text": "USD / metric ton" + }, + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_information_gap(svc, PALM_OIL_SERIES_ID)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9e40a49", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.652672Z", + "iopub.status.busy": "2026-08-06T19:18:04.652338Z", + "iopub.status.idle": "2026-08-06T19:18:04.679538Z", + "shell.execute_reply": "2026-08-06T19:18:04.677568Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cutoffkindnewest pricegap (months)h=1 really means
02021-05event2021-0323 months past last data
12022-01event2021-1123 months past last data
22023-04event2023-0223 months past last data
32024-09event2024-0723 months past last data
42023-07quiet2023-0523 months past last data
52024-11quiet2024-0923 months past last data
62025-08quiet2025-0623 months past last data
\n", + "
" + ], + "text/plain": [ + " cutoff kind newest price gap (months) h=1 really means\n", + "0 2021-05 event 2021-03 2 3 months past last data\n", + "1 2022-01 event 2021-11 2 3 months past last data\n", + "2 2023-04 event 2023-02 2 3 months past last data\n", + "3 2024-09 event 2024-07 2 3 months past last data\n", + "4 2023-07 quiet 2023-05 2 3 months past last data\n", + "5 2024-11 quiet 2024-09 2 3 months past last data\n", + "6 2025-08 quiet 2025-06 2 3 months past last data" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The gap at every candidate cutoff, as a table.\n", + "rows = []\n", + "for c in DEFAULT_CUTOFFS:\n", + " seen = svc.get_series(PALM_OIL_SERIES_ID, as_of=c.timestamp.to_pydatetime())\n", + " last = seen.timestamp.max()\n", + " gap = (c.timestamp.year - last.year) * 12 + (c.timestamp.month - last.month)\n", + " rows.append(\n", + " {\n", + " \"cutoff\": c.date[:7],\n", + " \"kind\": c.kind,\n", + " \"newest price\": f\"{last:%Y-%m}\",\n", + " \"gap (months)\": gap,\n", + " \"h=1 really means\": f\"{gap + 1} months past last data\",\n", + " }\n", + " )\n", + "pd.DataFrame(rows)" + ] + }, + { + "cell_type": "markdown", + "id": "5831096c", + "metadata": {}, + "source": [ + "A nominal horizon of 1 is really a **3-month** extrapolation once the 2-month gap is\n", + "counted. Horizons 1–6 therefore span 3 to 8 months of real forecast distance — worth\n", + "stating explicitly in any writeup, or `h=1` reads as an easy nowcast when it isn't.\n" + ] + }, + { + "cell_type": "markdown", + "id": "fe848bd4", + "metadata": {}, + "source": [ + "---\n", + "## 6. The rest of the edible-oil complex\n", + "\n", + "Palm oil against the three other IMF oils. Same units, same release calendar, same\n", + "leak-safe handling — so they are cheap covariates if they carry signal.\n", + "\n", + "Click legend entries to isolate pairs and judge whether they move together.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36e12882", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.683156Z", + "iopub.status.busy": "2026-08-06T19:18:04.682848Z", + "iopub.status.idle": "2026-08-06T19:18:04.736109Z", + "shell.execute_reply": "2026-08-06T19:18:04.734618Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "Palm oil
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#2a78d6", + "width": 2 + }, + "mode": "lines", + "name": "Palm oil", + "type": "scatter", + "x": [ + "2015-01-01T00:00:00.000000", + "2015-02-01T00:00:00.000000", + "2015-03-01T00:00:00.000000", + "2015-04-01T00:00:00.000000", + "2015-05-01T00:00:00.000000", + "2015-06-01T00:00:00.000000", + "2015-07-01T00:00:00.000000", + "2015-08-01T00:00:00.000000", + "2015-09-01T00:00:00.000000", + "2015-10-01T00:00:00.000000", + "2015-11-01T00:00:00.000000", + "2015-12-01T00:00:00.000000", + "2016-01-01T00:00:00.000000", + "2016-02-01T00:00:00.000000", + "2016-03-01T00:00:00.000000", + "2016-04-01T00:00:00.000000", + "2016-05-01T00:00:00.000000", + "2016-06-01T00:00:00.000000", + "2016-07-01T00:00:00.000000", + "2016-08-01T00:00:00.000000", + "2016-09-01T00:00:00.000000", + "2016-10-01T00:00:00.000000", + "2016-11-01T00:00:00.000000", + "2016-12-01T00:00:00.000000", + "2017-01-01T00:00:00.000000", + "2017-02-01T00:00:00.000000", + "2017-03-01T00:00:00.000000", + "2017-04-01T00:00:00.000000", + "2017-05-01T00:00:00.000000", + "2017-06-01T00:00:00.000000", + "2017-07-01T00:00:00.000000", + "2017-08-01T00:00:00.000000", + "2017-09-01T00:00:00.000000", + "2017-10-01T00:00:00.000000", + "2017-11-01T00:00:00.000000", + "2017-12-01T00:00:00.000000", + "2018-01-01T00:00:00.000000", + "2018-02-01T00:00:00.000000", + "2018-03-01T00:00:00.000000", + "2018-04-01T00:00:00.000000", + "2018-05-01T00:00:00.000000", + "2018-06-01T00:00:00.000000", + "2018-07-01T00:00:00.000000", + "2018-08-01T00:00:00.000000", + "2018-09-01T00:00:00.000000", + "2018-10-01T00:00:00.000000", + "2018-11-01T00:00:00.000000", + "2018-12-01T00:00:00.000000", + "2019-01-01T00:00:00.000000", + "2019-02-01T00:00:00.000000", + "2019-03-01T00:00:00.000000", + "2019-04-01T00:00:00.000000", + "2019-05-01T00:00:00.000000", + "2019-06-01T00:00:00.000000", + "2019-07-01T00:00:00.000000", + "2019-08-01T00:00:00.000000", + "2019-09-01T00:00:00.000000", + "2019-10-01T00:00:00.000000", + "2019-11-01T00:00:00.000000", + "2019-12-01T00:00:00.000000", + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "Wg8vIscMhECSxX5mBtODQAkoCio9/YJAwM8/qU1+gkBXmUQdLMuCQO0PdGQ884JAvaRiAHX9gUDDHP9p10p+QNlzGwPMN35ArDZIHvmRgEDqlwQwnnJ/QET/9g7SRIBAwbhOnvScgEAEXWiZNJ+CQJSLsNGKyINAvl0y/wBDhUABplSwhCSEQIx2W9CfU4NAbIZmZIVBgkC7Z0tXBMOEQLH8I9ZAo4VAfxn00ZZbhEBtGIoP+++EQFnNv8UMPoZAFY0LkOizhkBrn6xIWRaGQMIFa2pluoRAX6EnTKl5g0ASXwC+FXyEQMFm2uNzaYNAeAlJCURJg0AGwAVEjm6DQMmWCHINrYRAk5hBFktBhED/i/usgtyDQJGTGeNhhoJAw52vrsfKg0BAEVy4jBeEQABOcKp4goNAVFLEwwFQg0B/F5hSLPCCQO54pBUvN4JAqODLUqrRgEAvz/mv0rKAQJIfOSzTZoBAU6vGWLKtf0ASiUH/RsF7QPW5YM8F53xAdH5AOjk5gEBAxc2dTVKAQOhdQfzeyn5A/JAN0o90f0ACfEZrkIF9QGhaAXwIqn1Ae4dU/G4jfUDRBWMGvsl/QNlP1XezrX9Aob/YOilcgEDSFesWbCaDQBpJsWlEaoVARQsNUK+0hkCqQEpqBBKEQJ1XoAI5b4FAx7HSEcszgEDXe2fytid/QBEyvOfJ6YFA2gHX+/YTg0ACXWdzmBWFQLcrDy2aGoZAodS1PJnXhkARjNcSHk2KQA9VI+wvdYxA3K3d45hDjUAPvA9/8kaOQEJFaIpoHo9AUFgkdCwxkEBMPqj4VkKRQICtwoCzx4xAhxDuc8qRj0D09+VYH9eQQMRUx8fpR5FA90JQAbFYk0AU5RgxkP6TQGhT8Anb3ZJAgCw8gGE2lEC8sqZzR8iWQBbMEpbd0plAHsHYI0w0mUDZLSpv4wSZQAkQZA29qJRApo0iEiE3jECJoxxwmPKMQGXaUznp04hAXeuDGWnliEDaRqSMR12LQIHFOCXbuItAk0XPnks6jEASA3n/STGMQBR38oQ+cYxAfJV5Ono2jUCkH91FBxWKQGA2Ga7JCIhAGjsl7tBnikDaRpyK3byJQLJHyX6ytYhAHcYqxtn7h0CREhxI59+IQGbmFnHAeohAYEnfVep6iUDlwVZTtsiJQKgKD/RZGIxASBv+HvsGjEAgkWQpVNGJQKREHKLVMYpAaa9PZL7kikCjnM4qn8GLQKyzPabbdI1AVbAMHzUokEATdAP1fbORQCkubbMbn5FA9iYDBicYkEDNp2uYTK2QQDghZdeWhJBALJRYymupjkDkr9IqTjaMQF2aS/oGNI1AMI2kIlMYjUCCl5M2DQiQQJziUeCZK5BABxCaHyA5kEAmMK/uc4iOQOEV/h/MqY5Aiu5kDLxij0CYzNzVEieQQDduqiq2hJFAH8USuaTFkUBUwkdmG6iRQLPAKHG5UpFA", + "dtype": "f8" + } + }, + { + "hoverinfo": "skip", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " Palm oil" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2026-06-01T00:00:00" + ], + "y": [ + 1108.681095730554 + ] + }, + { + "hovertemplate": "Soybean oil
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#eb6834", + "width": 2 + }, + "mode": "lines", + "name": "Soybean oil", + "type": "scatter", + "x": [ + "2015-01-01T00:00:00.000000", + "2015-02-01T00:00:00.000000", + "2015-03-01T00:00:00.000000", + "2015-04-01T00:00:00.000000", + "2015-05-01T00:00:00.000000", + "2015-06-01T00:00:00.000000", + "2015-07-01T00:00:00.000000", + "2015-08-01T00:00:00.000000", + "2015-09-01T00:00:00.000000", + "2015-10-01T00:00:00.000000", + "2015-11-01T00:00:00.000000", + "2015-12-01T00:00:00.000000", + "2016-01-01T00:00:00.000000", + "2016-02-01T00:00:00.000000", + "2016-03-01T00:00:00.000000", + "2016-04-01T00:00:00.000000", + "2016-05-01T00:00:00.000000", + "2016-06-01T00:00:00.000000", + "2016-07-01T00:00:00.000000", + "2016-08-01T00:00:00.000000", + "2016-09-01T00:00:00.000000", + "2016-10-01T00:00:00.000000", + "2016-11-01T00:00:00.000000", + "2016-12-01T00:00:00.000000", + "2017-01-01T00:00:00.000000", + "2017-02-01T00:00:00.000000", + "2017-03-01T00:00:00.000000", + "2017-04-01T00:00:00.000000", + "2017-05-01T00:00:00.000000", + "2017-06-01T00:00:00.000000", + "2017-07-01T00:00:00.000000", + "2017-08-01T00:00:00.000000", + "2017-09-01T00:00:00.000000", + "2017-10-01T00:00:00.000000", + "2017-11-01T00:00:00.000000", + "2017-12-01T00:00:00.000000", + "2018-01-01T00:00:00.000000", + "2018-02-01T00:00:00.000000", + "2018-03-01T00:00:00.000000", + "2018-04-01T00:00:00.000000", + "2018-05-01T00:00:00.000000", + "2018-06-01T00:00:00.000000", + "2018-07-01T00:00:00.000000", + "2018-08-01T00:00:00.000000", + "2018-09-01T00:00:00.000000", + "2018-10-01T00:00:00.000000", + "2018-11-01T00:00:00.000000", + "2018-12-01T00:00:00.000000", + "2019-01-01T00:00:00.000000", + "2019-02-01T00:00:00.000000", + "2019-03-01T00:00:00.000000", + "2019-04-01T00:00:00.000000", + "2019-05-01T00:00:00.000000", + "2019-06-01T00:00:00.000000", + "2019-07-01T00:00:00.000000", + "2019-08-01T00:00:00.000000", + "2019-09-01T00:00:00.000000", + "2019-10-01T00:00:00.000000", + "2019-11-01T00:00:00.000000", + "2019-12-01T00:00:00.000000", + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "MHQ2Lg0fhkALF9F/fc+FQKYKRiV1W4VAoP5f4GKdhUCMJyd/7GOGQK4oJQRLEIdAp+mzA06+hUA4rxgd+qWDQPaQA4kAcoJAke18P3V+g0DZbzfS4zWDQCDrqdWXKYVADNlbzTSfhEAB3CxePHeFQIWWdf/YToZAnggjeEBkh0D0mYXOQRiGQIWy8PXV/IVAW3XC8ObuhEDHB8qc5j2GQF71Pz7klIZAKpWzNguqh0Cb/GqjUSOIQAosFWsMAolAKa1kbA0fiEDqxs2FYDeHQDRBqi05m4ZAVbRLMGu6hUBsfCb7h1CGQC6thsSdBoZAOaAA5coVh0Ba/NAAGlWHQNSmoJE+nYdA1EZ1OvAlh0AVLn1A9rSHQArdgCqJ1IZAtadPW0+1hkDg3iBLBCuGQI52HSzg8YVAXHtiXO+YhUDW5CmriVuFQLMM3GqCi4RAzwUyfEaBg0DKw3FYb3iDQA5t9Em3QYNAxpExchLsg0BhOhia1xODQKLyCp8Ib4NAmv6+12bng0B2UesP6dKEQK97KxKTKIRA+Ar4Ioesg0BnYORlLayCQJVP6iZsMINA662BrfJJg0C/DMaIRKmDQNoZS/0BA4RAh6XfdeDVhEBKStXRDFSFQDxV1R/2coZAn0AZZTrAhkA96Vbk5taEQFcHQNx1h4JA9YXRLmMNgkBmxGBZJEuCQHU+PEswN4NAPQ/uzvoXhEAGLeMk0OmFQOdUx0GdJ4dAkGeXb135hkAU7SqkbGWJQKqdYWpLiotA3fEa09zkjUDOv3KT8yuQQBUcSWhNoZJA58Qe2gf3k0D4y5LpnAyXQDwUBfp0eZZA9UtwVcKulkAXSianxm2VQKT6D2uOtZNAkfE7YHEOlUA8fawKm2eUQFNaf0tQAJNAo6yEkyHglED9C/XZfhWXQEIG8uyyIZpAYWgKtC9Hm0CjWdk+dLucQLFqEOb2QJpAN7nbtNdslUAm/QbD5bqXQIdT5uYrlZdAtZvcrhUwmED8VYDvZuCZQEAXDRmfe5ZAOWBXkxeSlUCTDVr+WvOUQKPoyj0phpNABuD6C3CVkkCA7vXp9BiRQP9AuW1vTZNAkvAofvhql0DwwvBvx/eWQObrMvwHdZVAGttrQZ/UkkBsBOJ1XdSRQDXXwZd7HZFAkZx7hzBUkEAUKpCvFnGPQHhg7RVDRZBA4GjHDT+zj0D3wtwDToyOQDTolfzJIY5A8Q7wpAX0j0BSmzi5f4SMQGTGg1e504xAMP1G99rcjUD/ltZ4QaWOQDlGskfIOYxAPARFVjI0jkBCmgwi/ZOPQK65Dr4cRo1AKZXwhL5fkEDeE/UPZ82QQNq39PkmQpJAXKZPnu1ukkAXFxGb9/uRQAQ6OxHFT5FAsxLZ4KAmkUCEU/KDxCuRQKJRofa04ZBA0K5r0GNakUDAMeOov2WTQEH5fkHTL5ZAswpVaGNWl0BHs3IFsz+ZQC18cllutZhA", + "dtype": "f8" + } + }, + { + "hoverinfo": "skip", + "marker": { + "color": "#eb6834", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " Soybean oil" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2026-06-01T00:00:00" + ], + "y": [ + 1581.357763089017 + ] + }, + { + "hovertemplate": "Sunflower oil
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#1baf7a", + "width": 2 + }, + "mode": "lines", + "name": "Sunflower oil", + "type": "scatter", + "x": [ + "2015-01-01T00:00:00.000000", + "2015-02-01T00:00:00.000000", + "2015-03-01T00:00:00.000000", + "2015-04-01T00:00:00.000000", + "2015-05-01T00:00:00.000000", + "2015-06-01T00:00:00.000000", + "2015-07-01T00:00:00.000000", + "2015-08-01T00:00:00.000000", + "2015-09-01T00:00:00.000000", + "2015-10-01T00:00:00.000000", + "2015-11-01T00:00:00.000000", + "2015-12-01T00:00:00.000000", + "2016-01-01T00:00:00.000000", + "2016-02-01T00:00:00.000000", + "2016-03-01T00:00:00.000000", + "2016-04-01T00:00:00.000000", + "2016-05-01T00:00:00.000000", + "2016-06-01T00:00:00.000000", + "2016-07-01T00:00:00.000000", + "2016-08-01T00:00:00.000000", + "2016-09-01T00:00:00.000000", + "2016-10-01T00:00:00.000000", + "2016-11-01T00:00:00.000000", + "2016-12-01T00:00:00.000000", + "2017-01-01T00:00:00.000000", + "2017-02-01T00:00:00.000000", + "2017-03-01T00:00:00.000000", + "2017-04-01T00:00:00.000000", + "2017-05-01T00:00:00.000000", + "2017-06-01T00:00:00.000000", + "2017-07-01T00:00:00.000000", + "2017-08-01T00:00:00.000000", + "2017-09-01T00:00:00.000000", + "2017-10-01T00:00:00.000000", + "2017-11-01T00:00:00.000000", + "2017-12-01T00:00:00.000000", + "2018-01-01T00:00:00.000000", + "2018-02-01T00:00:00.000000", + "2018-03-01T00:00:00.000000", + "2018-04-01T00:00:00.000000", + "2018-05-01T00:00:00.000000", + "2018-06-01T00:00:00.000000", + "2018-07-01T00:00:00.000000", + "2018-08-01T00:00:00.000000", + "2018-09-01T00:00:00.000000", + "2018-10-01T00:00:00.000000", + "2018-11-01T00:00:00.000000", + "2018-12-01T00:00:00.000000", + "2019-01-01T00:00:00.000000", + "2019-02-01T00:00:00.000000", + "2019-03-01T00:00:00.000000", + "2019-04-01T00:00:00.000000", + "2019-05-01T00:00:00.000000", + "2019-06-01T00:00:00.000000", + "2019-07-01T00:00:00.000000", + "2019-08-01T00:00:00.000000", + "2019-09-01T00:00:00.000000", + "2019-10-01T00:00:00.000000", + "2019-11-01T00:00:00.000000", + "2019-12-01T00:00:00.000000", + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "KjMzM5Nhj0DBHoXrIQSOQKmqqqoK6o1Ax7u7uxtQj0AekzEZk+eQQM3MzMxsO5FA0J46GQ61kEByQzd0A6KOQEAzMzNzrY5Aa2ZmZjaMkEAuy7IsC1SQQPwCXCQn8Y9ApBd5kRfhj0CJUAd1UD6QQESs2Dxer49AP/ZjP7YRkEAvMzMz40CQQAAAAABgyo9Amxd5kdd4jkCUWvbUia+OQG5mZmZm6I5A2EEd1EEzj0BdG+i04UOPQL+7u7v7oY9Ae2ZmZuamjkAkS36xxDeOQOfGFIQ+Ro1AveQXS55fjUBWdTIcqq6NQOXMzMzsQY1AJ1IhFZK/jUDrDsy1wz6OQM0JnMCJXo5AsZmZmfmJjUDdzMzMLKSNQGlv9VbvYo1AUAJFW807jUDZ9Shcr7+NQFdERETklI1Aq9qpnZrvjUA1lQ/j/leNQLNP+qQPfYxA3czMzCwhjUCkPXUyHMmLQAXGkl9czIpAtJPhUMu9ikCxqqqqipCJQOagDupgc4lAUfyMEj/ciUAz/mmSwXmKQEF0Qzf0RopAKCIiIsJFikD8XoCLJDyLQFDhehQerotAxF+XVD6JjECWiIiIaGWMQJxyKZdyQIxAWchCFrKni0AREREREYWMQE9EREREKI5ANT9VELpBj0Bcj8L1eI2MQJqZmZn5d4pAlt93d/dxi0Bxo87mbCCMQDuEh4iIH45AxNoT4OJwjkADEqLNuSWQQCBEUVU1kpJA7RAeIoK5kkB4zf9AUBCVQBv365KKYZZALzXuOE48mEA8ZdqjMNaZQMHIKfHz0J5APISHiMj+nUDGUyhj8kGeQKGdtLvbyZhAh/fw7g5hmEDfbnV31zqaQLnd6u7OBJlAcVRb1/Udm0A90cC7e0ibQAacoAlrVppAQsP3Fd+dmkDugEZEFJecQFDwu3agb6NAk/bFIzzRo0BNc2lmpoWjQIr38O7O1KBAkSRJkmS/nEBN1uzawX2cQFTEvbsbqZhALxJBRIRsmUCj6u3uTkaZQIaqt7sbo5dAHEA2M/NBl0BKEAk6XVCWQI4fEIo2C5RAyhOtgR7Pk0Ab1scURKaRQKvuCBFxU5FAvDhF3MOVk0Ci8N6VJniSQJfK2xHe8ZBAMzMzM7M+kUDOGQYAAOmRQLLHSDd01ZFA+YUTP2PYkUAvi3YCp1eRQAMjHKABupFArYh7d1dMkkAEV6itptaSQDxl2qMQgZNAQUgwM5MflEC22c/MrKmTQGEaH74iEJRAJsgdUDRxlkAcLPSXf7aXQEXV2929mJZAsI+SsKKqlkB4yrRHQdOWQLVIlWU5+ZZAF/P8/8/+lkA7N05VxcKWQOIA+n3fmpZA1osUm8c7l0C+PAjqIAuYQH1ZY2amo5hAaQDn2kGtmUA2ZshnqMSZQDarnK3m35lA5L/JzAzqm0AUrkfhAhWbQEXV292NK5tACVFUVfXom0COVYsKKT6cQIf38O6ON5xA", + "dtype": "f8" + } + }, + { + "hoverinfo": "skip", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " Sunflower oil" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2026-06-01T00:00:00" + ], + "y": [ + 1805.889583363636 + ] + }, + { + "hovertemplate": "Rapeseed oil
%{x|%b %Y} $%{y:.0f}", + "line": { + "color": "#eda100", + "width": 2 + }, + "mode": "lines", + "name": "Rapeseed oil", + "type": "scatter", + "x": [ + "2015-01-01T00:00:00.000000", + "2015-02-01T00:00:00.000000", + "2015-03-01T00:00:00.000000", + "2015-04-01T00:00:00.000000", + "2015-05-01T00:00:00.000000", + "2015-06-01T00:00:00.000000", + "2015-07-01T00:00:00.000000", + "2015-08-01T00:00:00.000000", + "2015-09-01T00:00:00.000000", + "2015-10-01T00:00:00.000000", + "2015-11-01T00:00:00.000000", + "2015-12-01T00:00:00.000000", + "2016-01-01T00:00:00.000000", + "2016-02-01T00:00:00.000000", + "2016-03-01T00:00:00.000000", + "2016-04-01T00:00:00.000000", + "2016-05-01T00:00:00.000000", + "2016-06-01T00:00:00.000000", + "2016-07-01T00:00:00.000000", + "2016-08-01T00:00:00.000000", + "2016-09-01T00:00:00.000000", + "2016-10-01T00:00:00.000000", + "2016-11-01T00:00:00.000000", + "2016-12-01T00:00:00.000000", + "2017-01-01T00:00:00.000000", + "2017-02-01T00:00:00.000000", + "2017-03-01T00:00:00.000000", + "2017-04-01T00:00:00.000000", + "2017-05-01T00:00:00.000000", + "2017-06-01T00:00:00.000000", + "2017-07-01T00:00:00.000000", + "2017-08-01T00:00:00.000000", + "2017-09-01T00:00:00.000000", + "2017-10-01T00:00:00.000000", + "2017-11-01T00:00:00.000000", + "2017-12-01T00:00:00.000000", + "2018-01-01T00:00:00.000000", + "2018-02-01T00:00:00.000000", + "2018-03-01T00:00:00.000000", + "2018-04-01T00:00:00.000000", + "2018-05-01T00:00:00.000000", + "2018-06-01T00:00:00.000000", + "2018-07-01T00:00:00.000000", + "2018-08-01T00:00:00.000000", + "2018-09-01T00:00:00.000000", + "2018-10-01T00:00:00.000000", + "2018-11-01T00:00:00.000000", + "2018-12-01T00:00:00.000000", + "2019-01-01T00:00:00.000000", + "2019-02-01T00:00:00.000000", + "2019-03-01T00:00:00.000000", + "2019-04-01T00:00:00.000000", + "2019-05-01T00:00:00.000000", + "2019-06-01T00:00:00.000000", + "2019-07-01T00:00:00.000000", + "2019-08-01T00:00:00.000000", + "2019-09-01T00:00:00.000000", + "2019-10-01T00:00:00.000000", + "2019-11-01T00:00:00.000000", + "2019-12-01T00:00:00.000000", + "2020-01-01T00:00:00.000000", + "2020-02-01T00:00:00.000000", + "2020-03-01T00:00:00.000000", + "2020-04-01T00:00:00.000000", + "2020-05-01T00:00:00.000000", + "2020-06-01T00:00:00.000000", + "2020-07-01T00:00:00.000000", + "2020-08-01T00:00:00.000000", + "2020-09-01T00:00:00.000000", + "2020-10-01T00:00:00.000000", + "2020-11-01T00:00:00.000000", + "2020-12-01T00:00:00.000000", + "2021-01-01T00:00:00.000000", + "2021-02-01T00:00:00.000000", + "2021-03-01T00:00:00.000000", + "2021-04-01T00:00:00.000000", + "2021-05-01T00:00:00.000000", + "2021-06-01T00:00:00.000000", + "2021-07-01T00:00:00.000000", + "2021-08-01T00:00:00.000000", + "2021-09-01T00:00:00.000000", + "2021-10-01T00:00:00.000000", + "2021-11-01T00:00:00.000000", + "2021-12-01T00:00:00.000000", + "2022-01-01T00:00:00.000000", + "2022-02-01T00:00:00.000000", + "2022-03-01T00:00:00.000000", + "2022-04-01T00:00:00.000000", + "2022-05-01T00:00:00.000000", + "2022-06-01T00:00:00.000000", + "2022-07-01T00:00:00.000000", + "2022-08-01T00:00:00.000000", + "2022-09-01T00:00:00.000000", + "2022-10-01T00:00:00.000000", + "2022-11-01T00:00:00.000000", + "2022-12-01T00:00:00.000000", + "2023-01-01T00:00:00.000000", + "2023-02-01T00:00:00.000000", + "2023-03-01T00:00:00.000000", + "2023-04-01T00:00:00.000000", + "2023-05-01T00:00:00.000000", + "2023-06-01T00:00:00.000000", + "2023-07-01T00:00:00.000000", + "2023-08-01T00:00:00.000000", + "2023-09-01T00:00:00.000000", + "2023-10-01T00:00:00.000000", + "2023-11-01T00:00:00.000000", + "2023-12-01T00:00:00.000000", + "2024-01-01T00:00:00.000000", + "2024-02-01T00:00:00.000000", + "2024-03-01T00:00:00.000000", + "2024-04-01T00:00:00.000000", + "2024-05-01T00:00:00.000000", + "2024-06-01T00:00:00.000000", + "2024-07-01T00:00:00.000000", + "2024-08-01T00:00:00.000000", + "2024-09-01T00:00:00.000000", + "2024-10-01T00:00:00.000000", + "2024-11-01T00:00:00.000000", + "2024-12-01T00:00:00.000000", + "2025-01-01T00:00:00.000000", + "2025-02-01T00:00:00.000000", + "2025-03-01T00:00:00.000000", + "2025-04-01T00:00:00.000000", + "2025-05-01T00:00:00.000000", + "2025-06-01T00:00:00.000000", + "2025-07-01T00:00:00.000000", + "2025-08-01T00:00:00.000000", + "2025-09-01T00:00:00.000000", + "2025-10-01T00:00:00.000000", + "2025-11-01T00:00:00.000000", + "2025-12-01T00:00:00.000000", + "2026-01-01T00:00:00.000000", + "2026-02-01T00:00:00.000000", + "2026-03-01T00:00:00.000000", + "2026-04-01T00:00:00.000000", + "2026-05-01T00:00:00.000000", + "2026-06-01T00:00:00.000000" + ], + "y": { + "bdata": "wU5HH6gyiED2JRsPlluHQMXQCVZpO4dAzONcAU8yh0BBIdSdPd6HQKaWjoT5XYlAwBLQL7DjiECzMpxvRZOHQGtdADmW+YdASAvDvmcpiUAwOjPU+1KIQDrgR2RXVYlAAOACNzpPiEC+rHY6fVeIQPqnrYT/+odAzvlTEdYsiUAfdh0U9faIQBTIguExpohAldggfNTih0DC6Kk4CZeJQCyt/QcZhYpAEhQ/xhwjjEAQBejL1AKMQH9xyLu3qoxAtpKUXvyqjEAr3PKRpEKLQJiYd3JSP4pAzEHQ0Tq4iUB/uj2vBQ2KQKZ30g2A84lABzltiZsQi0BCdppzfVaLQPP6vXsv1otA+FSZVfYUjEDQNsBiLFeNQDjADbMUbItAKggZhKBUikDvqZz2tLGJQAQkBIzZ3IhAvgVAhCK7iEC2MvY/HkGJQFkWt7oIlYlAqSj0GcVUikCraKeiX6aKQCtQi8FjSYpAf3Py8+cxi0B1urXiWpaKQBoDba9Gm4lAcp0xsoY/ikDK669Cu5OJQB9HCKZuDYlAy5HByJUBiUAHkC5lGHSJQGMpkq+EAIpALkQ+EupFikAzC+CkM4yLQCIhIgtqLYxAdcazcrzOi0B0r+cAGCiMQJKzsKd90oxAgyf5VZJijUBoP1JEVtCLQGr7V1bayIhA6quMkbi6h0Dek4eF2sWIQPRklIZPgYpAI0p7g6/ai0CnpzfNaeeMQBKz1U9gTI1AbzW/I8D2jED/TlbLm1yQQMuFgRFv95BA0fzG7laMkUC28/3UeISTQAYv0LoKCpRALlhAwA8GlUAlBoGVQ7yYQKGe1LYW7ZhAf4WtJGWqlkBXJJDzSSWXQLouG1K5XZlAQZexI5Jvm0DE0uz4mWOcQGoxL+pOV5xAZMLqzP6XnECJ0t7g6w6bQGNRaqIg6KBAtcyN0lHloUAdoNwB6kGhQFxJSi8OGqBAY4YeB3dynUD3zXUvUQGaQNXs+JmbgZVABQe4YRaVlUBD5IqqVBuVQFJEDxKh/pJA7+XzXh6CkkCkGYumMxWSQBb365JK0Y9Ah+EjYkobj0B9HjA+xACMQFYOLbIdxo1AjUQBM/UYkUAi/fZ14CiQQPDiqbJYB5BAqFI8xF4WkUCTjBPJuDKQQI3WaI3Wlo5A/ePSEgknjUCNTRi6/wCNQEBp/FftDo9AoWZ0flQUkEAo8PVCcAyRQIKtEizeG5FARngyYto2kUApJW72EkKQQCjp9ql+UpBAPurJWjqZkUDwabUvyiOTQKJgp6PPDpJAdAu738uGkUDd71AUyJGRQObiw4GnPZJAhT6joFI2k0Czs1LpEueSQJmH+PLrxpNAN1WZcttjk0Ah9chNPP6SQBKlvcHX5pNA7Ex3o9N5lED7y+7Jk+qTQKg01WTOoZNASGvtyAQik0BQwkzbj+aTQOTzXj6PmZRAbqLCTybwlEDAi6fKogGXQA0dHDfI2JdA", + "dtype": "f8" + } + }, + { + "hoverinfo": "skip", + "marker": { + "color": "#eda100", + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 9 + }, + "mode": "markers+text", + "showlegend": false, + "text": [ + " Rapeseed oil" + ], + "textfont": { + "color": "#0b0b0b", + "size": 10 + }, + "textposition": "middle right", + "type": "scatter", + "x": [ + "2026-06-01T00:00:00" + ], + "y": [ + 1526.195522727273 + ] + } + ], + "layout": { + "font": { + "color": "#52514e", + "size": 12 + }, + "height": 520, + "hovermode": "x unified", + "legend": { + "orientation": "h", + "x": 0, + "xanchor": "left", + "y": 1.02, + "yanchor": "bottom" + }, + "margin": { + "b": 55, + "l": 70, + "r": 110, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 17 + }, + "text": "The IMF edible-oil complex" + }, + "xaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e6e5e1", + "linecolor": "#e6e5e1", + "showspikes": false, + "title": { + "text": "USD / metric ton" + }, + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from aieng.forecasting.data.adapters import FREDAdapter\n", + "\n", + "\n", + "FRED_CACHE = ROOT / \"data\" / \"fred\"\n", + "COMPLEX = {\"Soybean oil\": \"PSOILUSDM\", \"Sunflower oil\": \"PSUNOUSDM\", \"Rapeseed oil\": \"PROILUSDM\"}\n", + "\n", + "oils = {\"Palm oil\": prices}\n", + "for name, fred_id in COMPLEX.items():\n", + " oils[name] = FREDAdapter(fred_id, cache_dir=FRED_CACHE).fetch()\n", + "\n", + "plot_oil_complex(oils)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f700bf7c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-06T19:18:04.739731Z", + "iopub.status.busy": "2026-08-06T19:18:04.739442Z", + "iopub.status.idle": "2026-08-06T19:18:04.761306Z", + "shell.execute_reply": "2026-08-06T19:18:04.759063Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Palm oilSoybean oilSunflower oilRapeseed oil
Palm oil1.000.540.600.44
Soybean oil0.541.000.550.55
Sunflower oil0.600.551.000.62
Rapeseed oil0.440.550.621.00
\n", + "
" + ], + "text/plain": [ + " Palm oil Soybean oil Sunflower oil Rapeseed oil\n", + "Palm oil 1.00 0.54 0.60 0.44\n", + "Soybean oil 0.54 1.00 0.55 0.55\n", + "Sunflower oil 0.60 0.55 1.00 0.62\n", + "Rapeseed oil 0.44 0.55 0.62 1.00" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Correlation of monthly returns — does the complex actually co-move?\n", + "returns = pd.DataFrame(\n", + " {name: frame.set_index(\"timestamp\")[\"value\"].pct_change() for name, frame in oils.items()}\n", + ").dropna()\n", + "returns.loc[\"2015-01-01\":].corr().round(2)" + ] + }, + { + "cell_type": "markdown", + "id": "00c65b63", + "metadata": {}, + "source": [ + "---\n", + "## 7. Where this leaves us\n", + "\n", + "| Decision | Status |\n", + "|---|---|\n", + "| Target series | `PPOILUSDM` — palm oil, monthly, 414 observations |\n", + "| Leak safety | True publication dates attached; verified in `pko.data` |\n", + "| Horizons | 1–6 months, which is 3–8 months past the last known price |\n", + "| Cutoffs | 7 candidates in `DEFAULT_CUTOFFS` — check them in section 4 |\n", + "\n", + "**Next:** a naive `LastValuePredictor` baseline over these cutoffs, to establish the\n", + "floor every other model has to beat.\n", + "\n", + "**Still open:** FRED has no palm *kernel* oil series, so this use case forecasts palm\n", + "oil. See [`DATA.md`](DATA.md).\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv (3.12.3)", + "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.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/implementations/pko/DATA.md b/implementations/pko/DATA.md new file mode 100644 index 00000000..5595761a --- /dev/null +++ b/implementations/pko/DATA.md @@ -0,0 +1,137 @@ +# PKO Data Survey — What FRED Actually Has + +Survey of FRED's palm and edible-oil coverage, run 2026-08-06 with +`scripts/explore_fred_oils.py`. Reproduce with: + +```bash +uv run python scripts/explore_fred_oils.py +uv run python scripts/explore_fred_oils.py --lag PPOILUSDM +``` + +--- + +## Headline: FRED has no palm kernel oil + +Searching FRED for **"palm kernel oil" returns zero series.** The term is not in +the catalogue. The closest available is palm *oil*, which is a related but +genuinely different commodity with a different price. + +**This needs a team decision before anyone builds on it** — see +[Open decision](#open-decision) below. + +## Headline: everything on FRED is monthly + +The survey covered 9 search terms and found 66 unique series. **All 66 are +monthly.** There are no daily or weekly edible-oil series on FRED. + +This breaks the original plan's assumption of a weekly price series matched to +weekly GDELT aggregation. Horizons have to be in months. + +--- + +## The usable series + +Of the 66 hits, only **4 are actual prices** in dollars per tonne. The other 62 +are Producer Price or Consumer Price *indices* — base-year-relative numbers, not +prices, and not forecastable as dollars. + +All four come from the same IMF release (Primary Commodity Prices), so they +share a calendar, a lag, and a leak-safety fix. + +| FRED ID | Commodity | Freq | Units | Coverage | Samples | +|---|---|---|---|---|---| +| `PPOILUSDM` | Palm oil | Monthly | USD/tonne | 1992-01 → 2026-06 | 414 | +| `PSOILUSDM` | Soybean oil | Monthly | USD/tonne | 1992-01 → 2026-06 | 414 | +| `PSUNOUSDM` | Sunflower oil | Monthly | USD/tonne | 1992-01 → 2026-06 | 414 | +| `PROILUSDM` | Rapeseed oil | Monthly | USD/tonne | 1992-01 → 2026-06 | 414 | + +### Price ranges observed + +| FRED ID | Min | Max | Latest (2026-06) | +|---|---|---|---| +| `PPOILUSDM` | 185 | 1,653 | 1,109 | +| `PSOILUSDM` | 321 | 1,839 | 1,581 | +| `PSUNOUSDM` | 333 | 2,537 | 1,806 | +| `PROILUSDM` | 315 | 2,291 | 1,526 | + +**Proposal:** `PPOILUSDM` as the forecast target; the other three as covariates. +They are close substitutes, cost nothing extra to add, and inherit the same +leak-safe release handling. + +--- + +## Release dates and leakage + +FRED stamps each observation with the **start of its reference period** — the +June 2026 average is stamped `2026-06-01`. It is not published until weeks +later. June 2026 appeared on **2026-07-13**. + +The library's `FREDAdapter` assumes `released_at = timestamp`, which would tell +the harness the June price was knowable on June 1 — **42 days early, at every +origin.** `implementations/pko/data.py` fixes this by fetching each +observation's true first-publication date from FRED's real-time archive. + +### Publication lag, measured + +| FRED ID | Median lag | 90th pct | Vintages | Archive starts | Obs with exact release date | +|---|---|---|---|---|---| +| `PPOILUSDM` | 10 days | 28 days | 90 | 2015-11-06 | 128 of 414 | +| `PSOILUSDM` | 10 days | 28 days | 90 | 2015-11-06 | 128 of 414 | +| `PSUNOUSDM` | 9 days | 27 days | 90 | 2015-11-06 | 128 of 414 | +| `PROILUSDM` | 9 days | 27 days | 90 | 2015-11-06 | 128 of 414 | + +"Lag" is days from the **end of the reference month** to the publication date. + +The 286 observations before 2015-11 are absent from FRED's archive and fall back +to `month end + 29 days`. They serve as warmup history only — keep every +forecast origin after 2015-11 and the fallback never affects a score. + +### The release calendar is irregular + +The IMF announces **no future release dates** to FRED. Recent releases: + +| Release date | Gap since previous | +|---|---| +| 2025-06-26 | — | +| 2025-07-14 | 18 days | +| 2026-01-22 | **192 days** | +| 2026-02-12 | 21 days | +| 2026-03-24 | 40 days | +| 2026-04-15 | 22 days | +| 2026-06-05 | 51 days | +| 2026-07-13 | 38 days | + +Two consequences for experiment design: + +1. **There is a publication blackout from mid-July 2025 to late January 2026.** + Any forecast cutoff in that window sees prices frozen at roughly mid-2025. A + "quiet" cutoff there is quiet because no data existed, not because the market + was calm. Avoid the window, or choose it deliberately as a stress case. + +2. **The information set varies by cutoff.** Sometimes last month's price is + available at an origin, sometimes it isn't — 2026-06-05 published April and + May together. Baselines must tolerate a ragged edge. + +--- + +## Open decision + +FRED has no palm kernel oil. The options: + +| Option | Consequence | +|---|---| +| **Forecast palm oil** (`PPOILUSDM`) | Stay on FRED, Vector-verifiable. Rename the use case from PKO. | +| **Keep palm kernel oil** | Needs a non-FRED source (World Bank Pink Sheet has it, monthly). Vector would have to verify a new source. | + +The baseline work is nearly identical either way, so it is not blocking — but +the target should be settled before notebooks and specs are written against it. + +--- + +## Status + +- [x] Find the right FRED series — done, with the caveat above +- [x] Load the price data — `implementations/pko/data.py`, leak-safe +- [ ] Pull news from GDELT +- [ ] Build a simple baseline forecast +- [ ] Build an agent forecast and compare diff --git a/implementations/pko/README.md b/implementations/pko/README.md new file mode 100644 index 00000000..8cb1db74 --- /dev/null +++ b/implementations/pko/README.md @@ -0,0 +1,19 @@ +# Palm Kernel Oil (PKO) Price Forecasting + +Forecasting the palm kernel oil price using price history plus news. + +**Status:** just started — nothing built yet. + +## Plan + +- **Price data** — from FRED (monthly). Need to confirm which series. +- **News** — from GDELT, filtered so we never use articles published after the forecast date. +- **Copy from** — [`../energy_oil_forecasting/`](../energy_oil_forecasting/), which does the same thing for crude oil. + +## TODO + +- [ ] Find the right FRED series for palm kernel oil +- [ ] Load the price data +- [ ] Pull news from GDELT +- [ ] Build a simple baseline forecast +- [ ] Build an agent forecast and compare diff --git a/implementations/pko/__init__.py b/implementations/pko/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/implementations/pko/data.py b/implementations/pko/data.py new file mode 100644 index 00000000..e0847c4b --- /dev/null +++ b/implementations/pko/data.py @@ -0,0 +1,428 @@ +"""Data-service setup for the palm oil price forecasting experiment. + +:func:`build_palm_oil_service` registers the IMF global palm oil price series +(FRED ``PPOILUSDM``) with **honest release dates**, so the forecast harness can +never hand a predictor a price that had not been published yet. + +Why release dates need fixing +----------------------------- +FRED stamps a monthly observation with the *start* of its reference period: the +June 2026 average is stamped ``2026-06-01``. It is not actually published until +weeks later -- June 2026 appeared on 2026-07-13. + +:class:`~aieng.forecasting.data.adapters.FREDAdapter` approximates +``released_at = timestamp``, which would tell the harness the June price was +knowable on June 1 -- 42 days before it existed. Across a monthly backtest that +is a leak at *every* origin, and it would flatter any predictor we score. + +:class:`~aieng.forecasting.data.cutoff.CutoffEnforcer` already does the right +thing when a ``released_at`` column is present, so the fix is to supply one: +fetch the true first-publication date of every observation from FRED's real-time +archive, attach it, and register the corrected frame via +:class:`~aieng.forecasting.data.features.StaticFrameAdapter`. + +The release dates come from the FRED API's ``output_type=4`` (initial releases +only), where each observation's ``realtime_start`` is the date it first became +public. See ``scripts/explore_fred_oils.py`` for the survey that measured these +lags and chose the fallback constant below. + +Two caveats, both handled here: + +- FRED's archive for this series starts 2015-11-06; observations first published + before then are omitted from the response entirely. Those fall back to + ``period_end + FALLBACK_RELEASE_LAG_DAYS``. They are warmup history only -- + keep every forecast origin after 2015-11 and the fallback never binds. +- A few recorded "initial releases" are FRED batch backfills (2019-06-18 + published 22 periods at once), which look like multi-year lags. We use them + verbatim anyway: a release date later than the real one hides data, which is + conservative, never leaky. + +**Prerequisite:** ``FRED_API_KEY`` in the repo-root ``.env``. A free key is +available at https://fred.stlouisfed.org/docs/api/api_key.html. + +Usage +----- +:: + + from pko.data import build_palm_oil_service, PALM_OIL_SERIES_ID + + svc = build_palm_oil_service() + ctx = svc.context(as_of=datetime(2026, 7, 1)) + df = ctx.get_series(PALM_OIL_SERIES_ID) # June 2026 correctly absent +""" + +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pandas as pd +from aieng.forecasting.data import DataService, SeriesMetadata +from aieng.forecasting.data.adapters import FREDAdapter +from aieng.forecasting.data.adapters.yfinance import YFinanceDailyAdapter +from aieng.forecasting.data.features import StaticFrameAdapter + + +PALM_OIL_SERIES_ID = "palm_oil_price" +"""Canonical series ID used by specs, notebooks, and predictors. + +Note this is palm *oil*, not palm *kernel* oil -- FRED carries no palm kernel +oil series at all (``scripts/explore_fred_oils.py`` returns zero hits for it). +If the team switches targets, change :data:`FRED_SERIES_ID` and this ID together. +""" + +FRED_SERIES_ID = "PPOILUSDM" +"""FRED id: IMF Global price of Palm Oil, monthly, USD per metric ton.""" + +FALLBACK_RELEASE_LAG_DAYS = 29 +"""Assumed lag for observations older than FRED's real-time archive. + +The 90th percentile of genuine (non-backfill) publication lags measured over +2015-11 to 2026-06, rounded up. Median lag is 10 days; this is deliberately +conservative. +""" + +DEFAULT_CACHE_DIR = Path("data/fred") +"""Parquet cache directory, shared with :class:`FREDAdapter` and fetch scripts.""" + +_FRED_API_BASE = "https://api.stlouisfed.org/fred" +_FRED_MIN_REALTIME = "1776-07-04" +_FRED_MAX_REALTIME = "9999-12-31" + + +def naive_utc_now() -> datetime: + """Return the current UTC time as a timezone-naive :class:`datetime`. + + :class:`~aieng.forecasting.data.service.DataService` and + :class:`~aieng.forecasting.data.cutoff.CutoffEnforcer` require naive + ``as_of`` values; tz-aware timestamps raise on comparison with cached + series timestamps. + + Returns + ------- + datetime + Current UTC time with ``tzinfo`` stripped. + """ + return datetime.now(tz=timezone.utc).replace(tzinfo=None) + + +def fetch_release_dates( + fred_series_id: str = FRED_SERIES_ID, + *, + cache_dir: Path | None = None, + refresh: bool = False, +) -> pd.DataFrame: + """Fetch each observation's true first-publication date from FRED. + + Uses the FRED API's ``output_type=4`` (initial releases only). Results are + cached to ``{cache_dir}/{fred_series_id}_release_dates.parquet`` so repeated + runs need no network access. + + Parameters + ---------- + fred_series_id : str + FRED series identifier, e.g. ``"PPOILUSDM"``. + cache_dir : Path or None + Parquet cache directory. Defaults to :data:`DEFAULT_CACHE_DIR`. + refresh : bool + Force a network fetch and overwrite the cache. + + Returns + ------- + pd.DataFrame + Columns ``timestamp`` (observation period start) and ``released_at`` + (date first published), sorted ascending. Covers only the periods + present in FRED's real-time archive. + + Raises + ------ + RuntimeError + If no API key is available and the cache is empty. + """ + resolved_dir = cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR + cache_path = resolved_dir / f"{fred_series_id}_release_dates.parquet" + + if cache_path.exists() and not refresh: + return pd.read_parquet(cache_path) + + api_key = os.environ.get("FRED_API_KEY") + if not api_key or api_key == "your_fred_api_key": + raise RuntimeError( + f"FRED_API_KEY is required to fetch release dates for {fred_series_id} " + f"(no cache at {cache_path}). Add it to the repo-root .env -- not .env.example, " + "which is tracked by git." + ) + + query = urllib.parse.urlencode( + { + "series_id": fred_series_id, + "output_type": 4, + "realtime_start": _FRED_MIN_REALTIME, + "realtime_end": _FRED_MAX_REALTIME, + "api_key": api_key, + "file_type": "json", + } + ) + with urllib.request.urlopen(f"{_FRED_API_BASE}/series/observations?{query}", timeout=60) as response: # noqa: S310 + payload: dict[str, Any] = json.load(response) + + rows = [ + {"timestamp": pd.Timestamp(obs["date"]), "released_at": pd.Timestamp(obs["realtime_start"])} + for obs in payload.get("observations", []) + if obs.get("value") not in (None, ".") + ] + frame = pd.DataFrame(rows).sort_values("timestamp").reset_index(drop=True) + + resolved_dir.mkdir(parents=True, exist_ok=True) + frame.to_parquet(cache_path, index=False) + return frame + + +def attach_release_dates( + observations: pd.DataFrame, + release_dates: pd.DataFrame, + *, + fallback_lag_days: int = FALLBACK_RELEASE_LAG_DAYS, +) -> pd.DataFrame: + """Overwrite ``released_at`` with true publication dates where known. + + Observations absent from FRED's real-time archive (those first published + before the archive begins) fall back to ``period_end + fallback_lag_days``, + where ``period_end`` is the last day of the observation's month. + + Parameters + ---------- + observations : pd.DataFrame + Canonical frame from :class:`FREDAdapter` with ``timestamp`` and + ``value`` columns. + release_dates : pd.DataFrame + Frame from :func:`fetch_release_dates`. + fallback_lag_days : int + Days after period end to assume for archive-less observations. + + Returns + ------- + pd.DataFrame + Columns ``timestamp``, ``value``, ``released_at``, sorted ascending. + """ + frame = observations.loc[:, ["timestamp", "value"]].copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + + lookup = release_dates.copy() + lookup["timestamp"] = pd.to_datetime(lookup["timestamp"]) + frame = frame.merge(lookup, on="timestamp", how="left") + + period_end = frame["timestamp"] + pd.offsets.MonthEnd(0) + fallback = period_end + pd.Timedelta(days=fallback_lag_days) + frame["released_at"] = frame["released_at"].fillna(fallback) + + return frame.sort_values("timestamp").reset_index(drop=True) + + +def build_palm_oil_service( + cache_dir: Path | None = None, + *, + refresh: bool = False, +) -> DataService: + """Return a :class:`DataService` with the palm oil price series registered. + + The registered series carries a true ``released_at`` column, so + :class:`~aieng.forecasting.data.cutoff.CutoffEnforcer` withholds each + observation until the date FRED actually published it. + + Parameters + ---------- + cache_dir : Path or None + Parquet cache directory for both the observations and the release + dates. Defaults to :data:`DEFAULT_CACHE_DIR`, resolved relative to the + current working directory. + refresh : bool + Force fresh network fetches for both the observations and the release + dates, overwriting the caches. + + Returns + ------- + DataService + Ready to hand to :func:`~aieng.forecasting.evaluation.backtest.backtest` + or :func:`~aieng.forecasting.evaluation.eval.evaluate`. + """ + resolved_dir = cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR + + observations = FREDAdapter(FRED_SERIES_ID, cache_dir=resolved_dir, refresh=refresh).fetch() + release_dates = fetch_release_dates(FRED_SERIES_ID, cache_dir=resolved_dir, refresh=refresh) + corrected = attach_release_dates(observations, release_dates) + + svc = DataService() + svc.register( + PALM_OIL_SERIES_ID, + StaticFrameAdapter(corrected), + SeriesMetadata( + series_id=PALM_OIL_SERIES_ID, + description=( + "IMF global benchmark price of palm oil, monthly average " + "(FRED PPOILUSDM), with true FRED publication dates as released_at" + ), + source="FRED (IMF Primary Commodity Prices)", + units="USD per metric ton", + frequency="MS", + table_id=f"fred:{FRED_SERIES_ID}", + ), + ) + return svc + + +# ── Daily futures target (primary) ─────────────────────────────────────────── +# +# The FRED service above is a monthly, publication-lagged view: a price is +# stamped with the start of its reference month but not released for ~2 months, +# and FRED twice stopped publishing for half a year at a stretch -- including +# straight through the 2022 Indonesian export ban. That caps the newest usable +# forecast origin at 2025-08 and rules out weekly news alignment entirely. +# +# The daily CME Crude Palm Oil settlement price has neither problem: the +# exchange publishes it the same day, so ``timestamp`` *is* the release date and +# no ``released_at`` correction is needed. It tracks the FRED series at 0.92 +# correlation on monthly returns, with the peak strictly at zero lag. +# +# Caveats, recorded here so they stay attached to the data: +# +# - The contract is thinly traded (volume is reported on ~10% of days). CME +# cash-settles it against the Bursa Malaysia FCPO benchmark, so the daily +# number is an exchange settlement reference rather than a traded price. +# Prices still move on zero-volume days (mean 0.87%), so the series is live, +# not stale -- but do not describe it as a liquid market price. +# - Yahoo keeps no vintage archive, so we assume history is never revised. The +# repo's WTI implementation makes the same assumption for ``CL=F``. +# - Jan--Jun 2016 is missing from Yahoo's history. Start backtests at 2017. + +PALM_OIL_DAILY_SERIES_ID = "palm_oil_futures_daily" +"""Daily CME Crude Palm Oil settlement price.""" + +PALM_OIL_WEEKLY_SERIES_ID = "palm_oil_futures_weekly" +"""Weekly (Friday-close) resampling of the daily series. + +Weekly is the frequency that matches GDELT news aggregation, and the one the +backtest specs target. +""" + +YAHOO_TICKER = "CPO=F" +"""Yahoo Finance ticker: CME Crude Palm Oil futures, continuous front month.""" + +YAHOO_CACHE_DIR = Path("data/yfinance") +"""Default yfinance cache directory, shared with the repo's other use cases.""" + +_YAHOO_HISTORY_START = "2004-01-01" + +#: Yahoo's history for this contract has a hole in the first half of 2016. +#: Backtests should start after it; recorded here so the reason is not lost. +YAHOO_HISTORY_GAP = ("2016-01", "2016-06") + + +def to_weekly(daily: pd.DataFrame) -> pd.DataFrame: + """Resample a daily price frame to Friday-close weekly observations. + + Resampling with ``W-FRI`` labels every bin on its Friday even when that + Friday was a holiday, so the weekly grid has no missing labels. The + evaluation harness resolves ground truth by exact timestamp match, so a + complete, regular grid is what makes weekly forecast dates resolvable. + + Parameters + ---------- + daily : pd.DataFrame + Frame with ``timestamp`` and ``value`` columns. + + Returns + ------- + pd.DataFrame + Columns ``timestamp``, ``value``, ``released_at``. ``released_at`` + equals ``timestamp`` -- an exchange settlement is public the same day. + """ + series = daily.set_index("timestamp")["value"].resample("W-FRI").last().dropna() + return pd.DataFrame( + {"timestamp": series.index, "value": series.to_numpy(), "released_at": series.index} + ).reset_index(drop=True) + + +def build_palm_oil_futures_service( + cache_dir: Path | None = None, + *, + start: str = _YAHOO_HISTORY_START, +) -> DataService: + """Return a :class:`DataService` with daily *and* weekly palm oil prices. + + Registers :data:`PALM_OIL_DAILY_SERIES_ID` (business-daily) and + :data:`PALM_OIL_WEEKLY_SERIES_ID` (Friday close). Both carry + ``released_at == timestamp``, which is correct for an exchange settlement + price and means the cutoff enforcer needs no correction. + + Parameters + ---------- + cache_dir : Path or None + yfinance cache directory. Defaults to :data:`YAHOO_CACHE_DIR`. + start : str + Earliest date requested from Yahoo Finance. + + Returns + ------- + DataService + Ready for the backtest and evaluation harnesses. + """ + resolved_dir = cache_dir if cache_dir is not None else YAHOO_CACHE_DIR + adapter = YFinanceDailyAdapter(ticker=YAHOO_TICKER, start=start, cache_dir=resolved_dir) + daily = adapter.fetch()[["timestamp", "value"]].copy() + daily["timestamp"] = pd.to_datetime(daily["timestamp"]).dt.normalize() + daily = daily.dropna(subset=["value"]).sort_values("timestamp").reset_index(drop=True) + daily["released_at"] = daily["timestamp"] + + svc = DataService() + svc.register( + PALM_OIL_DAILY_SERIES_ID, + StaticFrameAdapter(daily), + SeriesMetadata( + series_id=PALM_OIL_DAILY_SERIES_ID, + description=( + "CME Crude Palm Oil futures daily settlement price, cash-settled against " + "the Bursa Malaysia FCPO benchmark (Yahoo Finance CPO=F)" + ), + source="yfinance", + units="USD per metric ton", + frequency="B", + table_id=f"yahoo:{YAHOO_TICKER}:close", + ), + ) + svc.register( + PALM_OIL_WEEKLY_SERIES_ID, + StaticFrameAdapter(to_weekly(daily)), + SeriesMetadata( + series_id=PALM_OIL_WEEKLY_SERIES_ID, + description="CME Crude Palm Oil settlement price, Friday close (Yahoo Finance CPO=F, resampled)", + source="yfinance, derived", + units="USD per metric ton", + frequency="W-FRI", + table_id=f"yahoo:{YAHOO_TICKER}:close-w-fri", + ), + ) + return svc + + +__all__ = [ + "DEFAULT_CACHE_DIR", + "FALLBACK_RELEASE_LAG_DAYS", + "FRED_SERIES_ID", + "PALM_OIL_DAILY_SERIES_ID", + "PALM_OIL_SERIES_ID", + "PALM_OIL_WEEKLY_SERIES_ID", + "YAHOO_CACHE_DIR", + "YAHOO_HISTORY_GAP", + "YAHOO_TICKER", + "attach_release_dates", + "build_palm_oil_futures_service", + "build_palm_oil_service", + "fetch_release_dates", + "naive_utc_now", +] diff --git a/implementations/pko/plots.py b/implementations/pko/plots.py new file mode 100644 index 00000000..14c5ef22 --- /dev/null +++ b/implementations/pko/plots.py @@ -0,0 +1,506 @@ +"""Interactive Plotly charts for palm oil price exploration. + +Every chart here is built for *deciding things* — which cutoffs to forecast from, +whether the covariate oils move with palm, and how much the publication lag +actually costs you — rather than for decoration. + +All figures are interactive: click a legend entry to hide a series, drag to zoom, +double-click to reset, and hover for a shared crosshair readout. + +The palette is the validated four-slot categorical set (blue / orange / aqua / +yellow). Aqua and yellow fall below 3:1 contrast on a light surface, so every +chart that uses them also carries direct end-of-line labels — identity is never +carried by colour alone. + +Usage +----- +:: + + from pko.data import build_palm_oil_service, PALM_OIL_SERIES_ID + from pko.plots import plot_price_history, DEFAULT_CUTOFFS + + svc = build_palm_oil_service() + prices = svc.get_series(PALM_OIL_SERIES_ID, as_of=datetime.now()) + plot_price_history(prices, cutoffs=DEFAULT_CUTOFFS).show() +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +import pandas as pd +import plotly.graph_objects as go + + +# ── Palette ────────────────────────────────────────────────────────────────── +# Validated categorical slots 1-4 plus surfaces and ink, for light and dark. +# Swapping a whole dict swaps the theme; no chart code references raw hex. + +LIGHT_THEME: dict[str, str] = { + "surface": "#fcfcfb", + "text": "#0b0b0b", + "muted": "#52514e", + "grid": "#e6e5e1", + "series_1": "#2a78d6", # blue — palm oil (the target) + "series_2": "#eb6834", # orange — soybean oil + "series_3": "#1baf7a", # aqua — sunflower oil + "series_4": "#eda100", # yellow — rapeseed oil + "up": "#2a78d6", + "down": "#e34948", + "event": "rgba(235, 104, 52, 0.13)", + "quiet": "rgba(42, 120, 214, 0.10)", + "blackout": "rgba(227, 73, 72, 0.10)", +} + +DARK_THEME: dict[str, str] = { + "surface": "#1a1a19", + "text": "#ffffff", + "muted": "#c3c2b7", + "grid": "#383835", + "series_1": "#3987e5", + "series_2": "#d95926", + "series_3": "#199e70", + "series_4": "#c98500", + "up": "#3987e5", + "down": "#e66767", + "event": "rgba(217, 89, 38, 0.18)", + "quiet": "rgba(57, 135, 229, 0.15)", + "blackout": "rgba(230, 103, 103, 0.15)", +} + + +@dataclass(frozen=True) +class Cutoff: + """One forecast origin under consideration. + + Parameters + ---------- + date : str + Month-start cutoff date, ``YYYY-MM-DD``. + kind : str + ``"event"``, ``"quiet"``, or ``"stress"`` -- drives the shading colour. + label : str + Short human-readable reason this cutoff was chosen. + """ + + date: str + kind: str + label: str + + @property + def timestamp(self) -> pd.Timestamp: + """Return the cutoff as a :class:`pandas.Timestamp`.""" + return pd.Timestamp(self.date) + + +#: Candidate cutoffs from the volatility scan. All have a 2-month information +#: gap and resolve fully at horizons 1-6. Override in the notebook to explore. +DEFAULT_CUTOFFS: list[Cutoff] = [ + Cutoff("2021-05-01", "event", "June 2021 crash, -16.6%"), + Cutoff("2022-01-01", "event", "Indonesia export ban, -29.4% over 6mo"), + Cutoff("2023-04-01", "event", "May 2023 correction, -10.7%"), + Cutoff("2024-09-01", "event", "Oct 2024 rally, +9.7%"), + Cutoff("2023-07-01", "quiet", "calmest window, max move 4.1%"), + Cutoff("2024-11-01", "quiet", "max move 8.7%"), + Cutoff("2025-08-01", "quiet", "max move 5.9%"), +] + +#: Periods when FRED published no new palm oil prices, from the release-date +#: analysis in ``scripts/explore_fred_oils.py``. +BLACKOUT_PERIODS: list[tuple[str, str]] = [ + ("2021-12-01", "2022-08-01"), + ("2025-07-01", "2026-01-01"), +] + +_HORIZONS = 6 + + +def _theme(dark: bool) -> dict[str, str]: + """Return the colour dict for the requested mode.""" + return DARK_THEME if dark else LIGHT_THEME + + +def _style(fig: go.Figure, theme: dict[str, str], *, title: str, ylabel: str, height: int = 500) -> go.Figure: + """Apply shared layout: recessive grid, unified hover, legend, sane margins. + + Parameters + ---------- + fig : go.Figure + Figure to restyle in place. + theme : dict + Colour dict from :func:`_theme`. + title : str + Chart title. + ylabel : str + Y-axis label. + height : int + Figure height in pixels. + + Returns + ------- + go.Figure + The same figure, restyled. + """ + fig.update_layout( + title={"text": title, "font": {"size": 17, "color": theme["text"]}}, + paper_bgcolor=theme["surface"], + plot_bgcolor=theme["surface"], + font={"color": theme["muted"], "size": 12}, + height=height, + margin={"l": 70, "r": 110, "t": 70, "b": 55}, + hovermode="x unified", + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, + ) + axis = {"gridcolor": theme["grid"], "zeroline": False, "linecolor": theme["grid"], "showspikes": False} + fig.update_xaxes(**axis) + fig.update_yaxes(**axis, title=ylabel) + return fig + + +def _add_blackouts(fig: go.Figure, theme: dict[str, str], *, annotate: bool = True) -> None: + """Shade the periods when FRED published nothing.""" + for i, (start, end) in enumerate(BLACKOUT_PERIODS): + fig.add_vrect( + x0=start, + x1=end, + fillcolor=theme["blackout"], + line_width=0, + layer="below", + annotation_text="no data published" if annotate and i == 0 else None, + annotation_position="top left", + annotation_font_size=10, + ) + + +def plot_price_history( + prices: pd.DataFrame, + *, + cutoffs: list[Cutoff] | None = None, + start: str | None = "2015-01-01", + dark: bool = False, +) -> go.Figure: + """Plot the palm oil price history with candidate cutoffs marked. + + Parameters + ---------- + prices : pd.DataFrame + Frame with ``timestamp`` and ``value`` columns. + cutoffs : list of Cutoff or None + Cutoffs to mark with vertical lines. ``None`` marks none. + start : str or None + Clip the chart to this start date. ``None`` shows full history. + dark : bool + Render for a dark surface. + + Returns + ------- + go.Figure + Interactive line chart with a range slider. + """ + theme = _theme(dark) + df = prices.copy() + if start is not None: + df = df[df["timestamp"] >= start] + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=df["timestamp"], + y=df["value"], + mode="lines", + name="Palm oil", + line={"color": theme["series_1"], "width": 2}, + hovertemplate="%{x|%b %Y}
$%{y:.0f}/tonne", + ) + ) + + _add_blackouts(fig, theme) + + for cut in cutoffs or []: + fig.add_vline( + x=cut.timestamp, + line={"color": theme["muted"], "width": 1, "dash": "dot"}, + annotation_text=f"{cut.date[:7]} ({cut.kind})", + annotation_position="top", + annotation_font_size=9, + ) + + _style(fig, theme, title="Palm oil price (FRED PPOILUSDM)", ylabel="USD / metric ton", height=520) + fig.update_xaxes(rangeslider={"visible": True, "thickness": 0.06}) + return fig + + +def plot_cutoff_windows( + prices: pd.DataFrame, + *, + cutoffs: list[Cutoff] | None = None, + horizons: int = _HORIZONS, + dark: bool = False, +) -> go.Figure: + """Shade each cutoff's forecast window over the price line. + + Lets you check by eye whether a cutoff labelled "event" really has a shock in + its forecast window, and whether a "quiet" one really is flat. + + Parameters + ---------- + prices : pd.DataFrame + Frame with ``timestamp`` and ``value`` columns. + cutoffs : list of Cutoff or None + Cutoffs to shade. Defaults to :data:`DEFAULT_CUTOFFS`. + horizons : int + Number of months each forecast window spans. + dark : bool + Render for a dark surface. + + Returns + ------- + go.Figure + Interactive chart with one shaded band per cutoff. + """ + theme = _theme(dark) + picks = cutoffs if cutoffs is not None else DEFAULT_CUTOFFS + lo = min(c.timestamp for c in picks) - pd.offsets.MonthBegin(6) + hi = max(c.timestamp for c in picks) + pd.offsets.MonthBegin(horizons + 6) + df = prices[(prices["timestamp"] >= lo) & (prices["timestamp"] <= hi)] + + fig = go.Figure() + for cut in picks: + end = cut.timestamp + pd.offsets.MonthBegin(horizons) + fig.add_vrect( + x0=cut.timestamp, + x1=end, + fillcolor=theme.get(cut.kind, theme["quiet"]), + line_width=0, + layer="below", + annotation_text=f"{cut.date[:7]}
{cut.kind}", + annotation_position="top left", + annotation_font_size=9, + ) + + fig.add_trace( + go.Scatter( + x=df["timestamp"], + y=df["value"], + mode="lines", + name="Palm oil", + line={"color": theme["series_1"], "width": 2}, + hovertemplate="%{x|%b %Y}
$%{y:.0f}/tonne", + ) + ) + + return _style( + fig, + theme, + title=f"Candidate cutoffs and their {horizons}-month forecast windows", + ylabel="USD / metric ton", + height=520, + ) + + +def plot_information_gap( + service: object, + series_id: str, + *, + cutoffs: list[Cutoff] | None = None, + horizons: int = _HORIZONS, + dark: bool = False, +) -> go.Figure: + """Contrast what a forecaster could see at each cutoff with what happened. + + Draws the full realised price as a faint reference, then overlays -- per + cutoff -- the truncated history that was actually published by that date. + The visible gap between the end of each overlay and its cutoff line is the + publication lag, made concrete. + + Parameters + ---------- + service : object + A ``DataService`` exposing ``get_series(series_id, as_of=...)``. + series_id : str + Registered series id to query. + cutoffs : list of Cutoff or None + Cutoffs to draw. Defaults to :data:`DEFAULT_CUTOFFS`. + horizons : int + Months of forecast window to show past each cutoff. + dark : bool + Render for a dark surface. + + Returns + ------- + go.Figure + Interactive chart; click legend entries to isolate one cutoff. + """ + theme = _theme(dark) + picks = cutoffs if cutoffs is not None else DEFAULT_CUTOFFS + truth = service.get_series(series_id, as_of=datetime.now()) # type: ignore[attr-defined] + + lo = min(c.timestamp for c in picks) - pd.offsets.MonthBegin(12) + hi = max(c.timestamp for c in picks) + pd.offsets.MonthBegin(horizons + 3) + shown = truth[(truth["timestamp"] >= lo) & (truth["timestamp"] <= hi)] + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=shown["timestamp"], + y=shown["value"], + mode="lines", + name="What actually happened", + line={"color": theme["muted"], "width": 1.5, "dash": "dot"}, + hovertemplate="%{x|%b %Y}
actual $%{y:.0f}", + ) + ) + + slots = ["series_1", "series_2", "series_3", "series_4"] + for i, cut in enumerate(picks): + seen = service.get_series(series_id, as_of=cut.timestamp.to_pydatetime()) # type: ignore[attr-defined] + seen = seen[seen["timestamp"] >= lo] + if seen.empty: + continue + colour = theme[slots[i % len(slots)]] + last = seen.iloc[-1] + fig.add_trace( + go.Scatter( + x=seen["timestamp"], + y=seen["value"], + mode="lines", + name=f"{cut.date[:7]} ({cut.kind})", + line={"color": colour, "width": 2}, + hovertemplate=f"as of {cut.date[:7]}
%{{x|%b %Y}} $%{{y:.0f}}", + ) + ) + # Direct label at the data edge — identity never rests on colour alone. + fig.add_trace( + go.Scatter( + x=[last["timestamp"]], + y=[last["value"]], + mode="markers+text", + text=[f" {cut.date[:7]}"], + textposition="middle right", + textfont={"size": 10, "color": theme["text"]}, + marker={"size": 9, "color": colour, "line": {"color": theme["surface"], "width": 2}}, + showlegend=False, + hovertemplate=f"newest price available at {cut.date[:7]}
$%{{y:.0f}}", + ) + ) + + return _style( + fig, + theme, + title="What the model can see at each cutoff, vs what really happened", + ylabel="USD / metric ton", + height=560, + ) + + +def plot_monthly_changes(prices: pd.DataFrame, *, start: str = "2020-01-01", dark: bool = False) -> go.Figure: + """Plot month-over-month percentage change, coloured by direction. + + Parameters + ---------- + prices : pd.DataFrame + Frame with ``timestamp`` and ``value`` columns. + start : str + Clip the chart to this start date. + dark : bool + Render for a dark surface. + + Returns + ------- + go.Figure + Interactive diverging bar chart with blackout periods shaded. + """ + theme = _theme(dark) + df = prices.copy() + df["pct"] = df["value"].pct_change() * 100 + df = df[df["timestamp"] >= start].dropna(subset=["pct"]) + + fig = go.Figure() + fig.add_trace( + go.Bar( + x=df["timestamp"], + y=df["pct"], + marker={"color": [theme["up"] if v >= 0 else theme["down"] for v in df["pct"]]}, + name="Monthly change", + hovertemplate="%{x|%b %Y}
%{y:+.1f}%", + showlegend=False, + ) + ) + _add_blackouts(fig, theme) + fig.add_hline(y=0, line={"color": theme["muted"], "width": 1}) + + return _style(fig, theme, title="Palm oil, month-over-month change", ylabel="% change", height=420) + + +def plot_oil_complex(frames: dict[str, pd.DataFrame], *, start: str = "2015-01-01", dark: bool = False) -> go.Figure: + """Plot palm oil against the other IMF edible oils on one axis. + + All four series share units (USD/tonne), so a single axis is correct -- never + a second y-axis. Each line carries a direct end label, which also satisfies + the contrast relief rule for the aqua and yellow slots. + + Parameters + ---------- + frames : dict + Mapping of display name to frame with ``timestamp`` and ``value``. + Insertion order drives colour-slot assignment, so pass palm oil first. + start : str + Clip the chart to this start date. + dark : bool + Render for a dark surface. + + Returns + ------- + go.Figure + Interactive chart; click a legend entry to hide that oil. + """ + theme = _theme(dark) + slots = ["series_1", "series_2", "series_3", "series_4"] + + fig = go.Figure() + for i, (name, frame) in enumerate(frames.items()): + df = frame[frame["timestamp"] >= start] + if df.empty: + continue + colour = theme[slots[i % len(slots)]] + fig.add_trace( + go.Scatter( + x=df["timestamp"], + y=df["value"], + mode="lines", + name=name, + line={"color": colour, "width": 2}, + hovertemplate=f"{name}
%{{x|%b %Y}} $%{{y:.0f}}", + ) + ) + last = df.iloc[-1] + fig.add_trace( + go.Scatter( + x=[last["timestamp"]], + y=[last["value"]], + mode="markers+text", + text=[f" {name}"], + textposition="middle right", + textfont={"size": 10, "color": theme["text"]}, + marker={"size": 9, "color": colour, "line": {"color": theme["surface"], "width": 2}}, + showlegend=False, + hoverinfo="skip", + ) + ) + + return _style(fig, theme, title="The IMF edible-oil complex", ylabel="USD / metric ton", height=520) + + +__all__ = [ + "BLACKOUT_PERIODS", + "DARK_THEME", + "DEFAULT_CUTOFFS", + "LIGHT_THEME", + "Cutoff", + "plot_cutoff_windows", + "plot_information_gap", + "plot_monthly_changes", + "plot_oil_complex", + "plot_price_history", +] diff --git a/scripts/explore_fred_oils.py b/scripts/explore_fred_oils.py new file mode 100644 index 00000000..3da2644a --- /dev/null +++ b/scripts/explore_fred_oils.py @@ -0,0 +1,443 @@ +"""Survey FRED for candidate palm / edible-oil price series and their publication lags. + +This is a *selection* tool, not a fetch script. It answers the two questions we +need settled before committing to a forecasting target: + +1. **What does FRED actually carry?** Searches the FRED series catalogue for + palm and edible-oil price series and reports id, title, frequency, units, + history span, and last-update date for each unique hit. Frequency is the + decisive column — a monthly target and a weekly target imply very different + experiment designs. + +2. **When was each observation really published?** FRED timestamps an + observation with the *start* of its reference period (a June monthly average + is stamped ``2026-06-01``) but does not publish it until weeks later. The + repo's :class:`~aieng.forecasting.data.adapters.FREDAdapter` approximates + ``released_at = timestamp``, which would let a predictor see a value up to + ~6 weeks before it existed. ``--lag`` measures the true lag from FRED's + real-time archive so we can populate an honest ``released_at`` column and let + :class:`~aieng.forecasting.data.cutoff.CutoffEnforcer` do its job. + +Publication lag is measured with ``output_type=4`` (initial releases only), where +each observation's ``realtime_start`` *is* the date that value first became +public. + +.. warning:: + FRED's real-time archive does not extend to the beginning of most series -- + for ``PPOILUSDM`` it starts 2015-11-06. Observations first published before + that date are **omitted entirely** from the ``output_type=4`` response (they + are not stamped with a floor date), so they need a fallback rule. Keep + forecast origins inside the vintage-covered window and the recorded release + dates are exact where it matters. + +.. warning:: + Some recorded "initial releases" are FRED **batch backfills**, not real + publications -- ``PPOILUSDM`` shows 2017-07 through 2017-12 all first + appearing on 2019-06-18, a ~2-year apparent lag that reflects an archive + reconstruction rather than the IMF publishing late. ``--lag`` detects these + batches and excludes them from the typical-lag statistics, since including + them would inflate any percentile-based rule. They are still safe to use as + ``released_at`` values -- a late recorded release is conservative, never leaky. + +**Prerequisite:** ``FRED_API_KEY`` in the repo-root ``.env`` (or the +environment). Free key: https://fred.stlouisfed.org/docs/api/api_key.html + +Usage +----- +:: + + # Survey the default search terms + uv run python scripts/explore_fred_oils.py + + # Widen or narrow the search + uv run python scripts/explore_fred_oils.py --search "palm oil" "coconut oil" + uv run python scripts/explore_fred_oils.py --all-frequencies + + # Measure the true publication lag for chosen candidates + uv run python scripts/explore_fred_oils.py --lag PPOILUSDM + uv run python scripts/explore_fred_oils.py --lag PPOILUSDM PSOILUSDM +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from dotenv import load_dotenv + + +load_dotenv(REPO_ROOT / ".env", override=False) + +import pandas as pd + + +FRED_API_BASE = "https://api.stlouisfed.org/fred" + +#: Search terms covering the palm complex plus the substitutes it trades against. +#: Edible oils are close substitutes, so a liquid neighbour can serve as a +#: covariate even when it is not the target. +DEFAULT_SEARCH_TERMS: list[str] = [ + "palm oil", + "palm kernel oil", + "vegetable oil", + "edible oil", + "soybean oil", + "coconut oil", + "sunflower oil", + "rapeseed oil", + "fats and oils", +] + +#: Sort order for the frequency column — finer resolution first, since that is +#: the constraint that decides whether weekly news aggregation is even possible. +_FREQUENCY_RANK: dict[str, int] = { + "D": 0, + "W": 1, + "BW": 2, + "M": 3, + "Q": 4, + "SA": 5, + "A": 6, +} + +#: Courtesy delay between API calls. FRED allows 120 requests/minute. +_REQUEST_DELAY_SECONDS = 0.3 + +#: Widest real-time window FRED accepts, used to span a series' entire vintage archive. +_FRED_MIN_REALTIME = "1776-07-04" +_FRED_MAX_REALTIME = "9999-12-31" + + +def get_api_key() -> str: + """Return the FRED API key, or exit with an actionable message. + + Returns + ------- + str + The API key from the ``FRED_API_KEY`` environment variable. + """ + key = os.environ.get("FRED_API_KEY") + if not key or key == "your_fred_api_key": + sys.exit( + "FRED_API_KEY is not set.\n" + " 1. Request a free key: https://fred.stlouisfed.org/docs/api/api_key.html\n" + " 2. Add it to the repo-root .env (which is gitignored -- never .env.example):\n" + " printf 'FRED_API_KEY=%s\\n' 'YOUR_KEY_HERE' > .env" + ) + return key + + +def fred_get(endpoint: str, api_key: str, **params: Any) -> dict[str, Any]: + """Call a FRED API endpoint and return the decoded JSON payload. + + Parameters + ---------- + endpoint : str + Path below the API base, e.g. ``"series/search"``. + api_key : str + FRED API key. + **params : Any + Additional query parameters. + + Returns + ------- + dict + Decoded JSON response. + + Raises + ------ + SystemExit + If FRED rejects the request (most commonly an invalid key). + """ + query = urllib.parse.urlencode({**params, "api_key": api_key, "file_type": "json"}) + url = f"{FRED_API_BASE}/{endpoint}?{query}" + try: + with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 + payload: dict[str, Any] = json.load(response) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:300] + sys.exit(f"FRED API error {exc.code} on {endpoint}: {detail}") + time.sleep(_REQUEST_DELAY_SECONDS) + return payload + + +def verify_key(api_key: str) -> None: + """Print a one-line confirmation that the key works.""" + payload = fred_get("series", api_key, series_id="PPOILUSDM") + series = payload["seriess"][0] + print( + f"FRED API key OK — reference series {series['id']} ({series['frequency']}), updated {series['last_updated']}\n" + ) + + +def search_series(api_key: str, terms: list[str], limit_per_term: int) -> pd.DataFrame: + """Search FRED for each term and return the deduplicated union of hits. + + Parameters + ---------- + api_key : str + FRED API key. + terms : list[str] + Free-text search terms. + limit_per_term : int + Maximum hits to request per term. + + Returns + ------- + pd.DataFrame + One row per unique series id, with a ``matched_terms`` column recording + which search terms surfaced it. + """ + hits: dict[str, dict[str, Any]] = {} + for term in terms: + payload = fred_get( + "series/search", + api_key, + search_text=term, + limit=limit_per_term, + order_by="popularity", + sort_order="desc", + ) + found = payload.get("seriess", []) + print(f" {term:<20} {len(found):>3} hits") + for series in found: + existing = hits.setdefault(series["id"], {**series, "matched_terms": []}) + existing["matched_terms"].append(term) + + if not hits: + return pd.DataFrame() + + frame = pd.DataFrame(hits.values()) + frame["matched_terms"] = frame["matched_terms"].apply(", ".join) + frame["freq_rank"] = frame["frequency_short"].map(_FREQUENCY_RANK).fillna(99) + return frame.sort_values(["freq_rank", "popularity"], ascending=[True, False]).reset_index(drop=True) + + +def print_catalogue(frame: pd.DataFrame, *, all_frequencies: bool) -> None: + """Print the search results as a readable table, finest frequency first.""" + if frame.empty: + print("\nNo series found.") + return + + shown = frame if all_frequencies else frame[frame["freq_rank"] <= _FREQUENCY_RANK["M"]] + dropped = len(frame) - len(shown) + + print(f"\n{'=' * 118}\nCANDIDATE SERIES ({len(shown)} shown, sorted by frequency then popularity)\n{'=' * 118}") + header = f"{'SERIES_ID':<18} {'FREQ':<6} {'START':<11} {'END':<11} {'POP':>4} TITLE / UNITS" + print(header) + print("-" * 118) + for _, row in shown.iterrows(): + print( + f"{row['id']:<18} {str(row['frequency_short']):<6} " + f"{row['observation_start']:<11} {row['observation_end']:<11} " + f"{int(row['popularity']):>4} {row['title'][:70]}" + ) + print(f"{'':<18} {'':<6} {'':<11} {'':<11} {'':>4} units: {row['units_short']}") + + if dropped: + print(f"\n({dropped} quarterly/annual/unranked series hidden — pass --all-frequencies to see them)") + + _print_frequency_summary(shown) + + +def _print_frequency_summary(frame: pd.DataFrame) -> None: + """Print a frequency histogram and call out any sub-monthly series.""" + print(f"\n{'-' * 118}\nFREQUENCY BREAKDOWN") + for freq, count in frame["frequency_short"].value_counts().items(): + print(f" {freq:<6} {count:>3} series") + + sub_monthly = frame[frame["freq_rank"] < _FREQUENCY_RANK["M"]] + if sub_monthly.empty: + print( + "\n >> No daily or weekly series in these results. If a sub-monthly target is\n" + " required, FRED is not the source for it and the experiment design needs\n" + " to assume a monthly target." + ) + else: + print(f"\n >> {len(sub_monthly)} sub-monthly series found: {', '.join(sub_monthly['id'])}") + + +def measure_publication_lag(api_key: str, series_id: str) -> None: + """Report the true publication lag for a series from FRED's real-time archive. + + Uses ``output_type=4`` (initial release only), where each observation's + ``realtime_start`` is the date that value first became public. Observations + predating the series' earliest vintage carry that floor date instead of a + true release date and are excluded from the statistics. + + Parameters + ---------- + api_key : str + FRED API key. + series_id : str + FRED series identifier, e.g. ``"PPOILUSDM"``. + """ + meta = fred_get("series", api_key, series_id=series_id)["seriess"][0] + vintages = fred_get("series/vintagedates", api_key, series_id=series_id).get("vintage_dates", []) + # output_type=4 returns initial releases only, but the real-time window must span the + # whole archive — FRED defaults it to today, which holds no vintage and 400s. + initial = fred_get( + "series/observations", + api_key, + series_id=series_id, + output_type=4, + realtime_start=_FRED_MIN_REALTIME, + realtime_end=_FRED_MAX_REALTIME, + ).get("observations", []) + current = fred_get("series/observations", api_key, series_id=series_id).get("observations", []) + + print(f"\n{'=' * 118}\nPUBLICATION LAG — {series_id}: {meta['title']}\n{'=' * 118}") + print(f" frequency : {meta['frequency']} ({meta['frequency_short']})") + print(f" units : {meta['units']}") + print(f" observation span : {meta['observation_start']} -> {meta['observation_end']}") + + if not vintages: + print(" vintages : none recorded — publication lag cannot be measured.") + return + + print(f" vintages : {len(vintages)} recorded, {vintages[0]} -> {vintages[-1]}") + + frame = _build_lag_frame(initial, meta["frequency_short"]) + n_total = len([o for o in current if o.get("value") not in (None, ".")]) + n_missing = n_total - len(frame) + + print("\n Initial-release coverage:") + print(f" observations with a value : {n_total}") + print( + f" with a true release date : {len(frame)} ({frame['timestamp'].min().date()} -> " + f"{frame['timestamp'].max().date()})" + ) + print(f" older than the archive : {n_missing} (omitted by FRED — need the fallback rule)") + + batches = _detect_backfill_batches(frame) + clean = frame[~frame["released_at"].isin(batches.index)] + + if not batches.empty: + print(f"\n Batch backfills excluded from the statistics ({len(batches)} dates, archive artifacts):") + for release_date, count in batches.items(): + print(f" {release_date.date()} published {count} periods at once") + + lag = clean["lag_days"] + print(f"\n Typical lag after period end, measured on {len(clean)} genuine releases:") + print(f" median : {lag.median():.0f} days") + print(f" mean / min / max: {lag.mean():.1f} / {lag.min():.0f} / {lag.max():.0f} days") + print(f" 90th percentile : {lag.quantile(0.9):.0f} days") + + print("\n Most recent releases:") + for _, row in frame.tail(6).iterrows(): + print( + f" period {row['timestamp'].date()} (ends {row['period_end'].date()})" + f" -> published {row['released_at'].date()} (+{row['lag_days']:.0f}d)" + ) + + _print_lag_recommendation(lag, frame["timestamp"].min(), n_missing) + + +def _build_lag_frame(observations: list[dict[str, Any]], frequency_short: str) -> pd.DataFrame: + """Return a frame of timestamp, period end, release date, and lag in days.""" + rows = [ + {"timestamp": pd.Timestamp(obs["date"]), "released_at": pd.Timestamp(obs["realtime_start"])} + for obs in observations + if obs.get("value") not in (None, ".") + ] + frame = pd.DataFrame(rows) + period_offsets = {"M": pd.offsets.MonthEnd(0), "Q": pd.offsets.QuarterEnd(0), "A": pd.offsets.YearEnd(0)} + offset = period_offsets.get(frequency_short) + frame["period_end"] = frame["timestamp"] + offset if offset is not None else frame["timestamp"] + frame["lag_days"] = (frame["released_at"] - frame["period_end"]).dt.days + return frame + + +def _detect_backfill_batches(frame: pd.DataFrame, min_periods: int = 4) -> pd.Series: + """Return release dates that published many periods at once, with their counts. + + A genuine monthly release publishes one new period. A release date carrying + several periods is an archive backfill, and the resulting multi-hundred-day + "lags" would distort any percentile-based rule. + + Parameters + ---------- + frame : pd.DataFrame + Lag frame from :func:`_build_lag_frame`. + min_periods : int + Number of periods on one release date above which it counts as a batch. + + Returns + ------- + pd.Series + Release date -> period count, for batch dates only. + """ + counts = frame["released_at"].value_counts().sort_index() + return counts[counts >= min_periods] + + +def _print_lag_recommendation(lag: pd.Series, archive_start: pd.Timestamp, n_missing: int) -> None: + """Print the concrete released_at rule implied by the measured lag.""" + fallback = int(lag.quantile(0.9)) + 1 + print( + f"\n >> RECOMMENDED released_at RULE\n" + f" - periods from {archive_start.date()} onward: use the true realtime_start from\n" + f" output_type=4 verbatim, batch backfills included. A recorded release later than\n" + f" the real one is conservative — it hides data, never leaks it.\n" + f" - the {n_missing} periods before {archive_start.date()}: period_end + {fallback} days\n" + f" (90th-percentile genuine lag, rounded up). These are warmup history only —\n" + f" keep every forecast origin after {archive_start.date()} and this rule never binds.\n" + f" - register the corrected frame via StaticFrameAdapter so CutoffEnforcer sees released_at" + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Survey FRED for palm/edible-oil price series and measure their publication lags.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--search", nargs="+", metavar="TERM", default=None, help="Search terms (default: oil complex)." + ) + parser.add_argument("--limit", type=int, default=25, help="Max hits per search term (default: 25).") + parser.add_argument("--all-frequencies", action="store_true", help="Include quarterly/annual series in the table.") + parser.add_argument("--lag", nargs="+", metavar="SERIES_ID", default=None, help="Measure publication lag instead.") + parser.add_argument("--csv", type=Path, default=None, help="Write the catalogue table to this CSV path.") + return parser.parse_args() + + +def main() -> None: + """Run the survey or the lag measurement, depending on the flags.""" + args = parse_args() + api_key = get_api_key() + verify_key(api_key) + + if args.lag: + for series_id in args.lag: + measure_publication_lag(api_key, series_id) + return + + terms = args.search or DEFAULT_SEARCH_TERMS + print(f"Searching FRED for {len(terms)} terms:") + frame = search_series(api_key, terms, args.limit) + print_catalogue(frame, all_frequencies=args.all_frequencies) + + if args.csv is not None and not frame.empty: + args.csv.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(args.csv, index=False) + print(f"\nWrote {len(frame)} rows to {args.csv}") + + print("\nNext: measure the publication lag for your shortlist, e.g.") + print(" uv run python scripts/explore_fred_oils.py --lag PPOILUSDM") + + +if __name__ == "__main__": + main()