What You Will Build
By the end of this tutorial, you will have a Python script that pulls data from three sources -- the FRED API (economic indicators), Yahoo Finance via yfinance (commodity prices and shipping indices), and the GDELT DOC API (geopolitical event sentiment) -- merges them into a single time-aligned dataset, computes rolling correlations between shipping-related metrics and consumer price indicators, and generates a set of charts that visualize these relationships.
This is not a production application. It is a learning tool that demonstrates the data pipeline underlying shipping-price analysis: ingestion, alignment, correlation, and visualization. Once you understand the pipeline, you can extend it with additional data sources, automate the refresh cycle, or port the logic to a web application.
Prerequisites
You need Python 3.9 or later, a free FRED API key (register at fred.stlouisfed.org), and the following packages:
Install Dependencies
pip install fredapi yfinance pandas matplotlib requests fredapi wraps the FRED REST API and returns pandas DataFrames. yfinance pulls Yahoo Finance data. matplotlib handles charting. requests is for GDELT API calls.
No paid subscriptions are required. The FRED API key is free. Yahoo Finance data is free. The GDELT API requires no authentication at all.
Step 1: Pull Economic Data from FRED
Start by pulling the economic indicators that represent the price side of the shipping-price equation. The FRED API returns pandas Series objects that you can immediately join by date.
fred_data.py
from fredapi import Fred
import pandas as pd
FRED_API_KEY = "YOUR_KEY_HERE" # Replace with your free API key
fred = Fred(api_key=FRED_API_KEY)
# Define the series we care about
series_ids = {
"cpi_all": "CPIAUCSL", # CPI All Items (monthly)
"cpi_energy": "CPIENGSL", # CPI Energy (monthly)
"cpi_food": "CPIFABSL", # CPI Food at Home (monthly)
"ppi_all": "PPIACO", # PPI All Commodities (monthly)
"import_price": "IR", # Import Price Index (monthly)
"brent": "DCOILBRENTEU",# Brent Crude (daily)
"wti": "DCOILWTICO", # WTI Crude (daily)
"gas_price": "GASREGW", # Retail Gas Price (weekly)
}
START_DATE = "2020-01-01"
def pull_fred_data():
"""Pull all FRED series into a single DataFrame."""
frames = {}
for name, sid in series_ids.items():
print(f" Fetching {name} ({sid})...")
s = fred.get_series(sid, observation_start=START_DATE)
s.name = name
frames[name] = s
# Resample everything to daily, forward-fill monthly/weekly
df = pd.DataFrame(frames)
df.index = pd.to_datetime(df.index)
df = df.resample("D").last().ffill()
# Compute year-over-year percent changes for price indices
for col in ["cpi_all", "cpi_energy", "cpi_food", "ppi_all", "import_price"]:
df[f"{col}_yoy"] = df[col].pct_change(periods=365) * 100
return df
if __name__ == "__main__":
df = pull_fred_data()
print(f"FRED data: {len(df)} rows, {df.columns.tolist()}")
print(df.tail()) A few things to note about this code. Monthly series like CPI and PPI are published once per month but need to be aligned with daily commodity prices. Resampling to daily frequency and forward-filling carries the most recent monthly value forward until the next release -- this is the standard approach for mixed-frequency financial datasets. The year-over-year percent change calculation uses a 365-day lookback on the daily-resampled data, which is equivalent to comparing each month's value to the same month a year earlier.
Step 2: Pull Shipping and Commodity Data from Yahoo Finance
Yahoo Finance provides free access to commodity futures, shipping company stocks, and -- when available -- indices like the Baltic Dry Index. The yfinance package wraps Yahoo's API and returns pandas DataFrames.
yfinance_data.py
import yfinance as yf
import pandas as pd
# Tickers relevant to shipping-price analysis
tickers = {
"bdi": "^BDI", # Baltic Dry Index (availability varies)
"crude": "CL=F", # WTI Crude Futures
"nat_gas":"NG=F", # Natural Gas Futures
"wheat": "ZW=F", # CBOT Wheat Futures
"corn": "ZC=F", # CBOT Corn Futures
"usd": "DX-Y.NYB", # US Dollar Index
"zim": "ZIM", # ZIM Integrated Shipping
"danaos": "DAC", # Danaos Corporation
"maersk": "AMKBY", # Maersk (OTC ADR)
}
START_DATE = "2020-01-01"
def pull_yfinance_data():
"""Pull closing prices for shipping-relevant tickers."""
frames = {}
for name, ticker in tickers.items():
print(f" Fetching {name} ({ticker})...")
try:
data = yf.download(ticker, start=START_DATE, progress=False)
if len(data) > 0:
frames[name] = data["Close"]
except Exception as e:
print(f" Warning: {name} failed: {e}")
df = pd.DataFrame(frames)
df.index = pd.to_datetime(df.index)
# Compute 30-day rolling returns
for col in df.columns:
df[f"{col}_30d_ret"] = df[col].pct_change(periods=30) * 100
return df
if __name__ == "__main__":
df = pull_yfinance_data()
print(f"Yahoo Finance data: {len(df)} rows, {df.columns.tolist()}")
print(df.tail()) A note on ticker availability: the Baltic Dry Index ticker (^BDI) on Yahoo Finance has inconsistent data coverage. Some periods show gaps or stale values. For production use, the Baltic Exchange is the authoritative source, but it requires a paid subscription. For a learning dashboard, Yahoo Finance is sufficient to demonstrate the concept, and if BDI data is unavailable, the shipping stocks (ZIM, Danaos, Maersk) serve as proxy indicators of freight market conditions.
Step 3: Pull Geopolitical Sentiment from GDELT
The GDELT DOC API returns article-level data with tone scores. For dashboard purposes, you want to aggregate this into a daily time series of average sentiment for shipping-relevant topics.
gdelt_data.py
import requests
import pandas as pd
from datetime import datetime, timedelta
GDELT_DOC_URL = "https://api.gdeltproject.org/api/v2/doc/doc"
# Chokepoint keywords for monitoring
QUERIES = {
"hormuz": '"strait of hormuz" OR "hormuz tanker"',
"suez": '"suez canal" OR "suez blockage"',
"red_sea": '"red sea shipping" OR "houthi ship"',
"panama": '"panama canal" OR "panama drought"',
"malacca": '"malacca strait" OR "malacca shipping"',
}
def fetch_gdelt_tone(query, timespan="90d", max_records=250):
"""Fetch articles and return daily average tone."""
params = {
"query": query,
"mode": "TimelineTone",
"timespan": timespan,
"format": "json",
}
try:
resp = requests.get(GDELT_DOC_URL, params=params, timeout=30)
data = resp.json()
if "timeline" not in data:
return pd.Series(dtype=float)
# Parse the timeline response
rows = []
for series in data["timeline"]:
for point in series.get("data", []):
date = datetime.strptime(point["date"], "%Y%m%dT%H%M%SZ")
rows.append({"date": date.date(), "tone": point["value"]})
if not rows:
return pd.Series(dtype=float)
df = pd.DataFrame(rows).groupby("date")["tone"].mean()
df.index = pd.to_datetime(df.index)
return df
except Exception as e:
print(f" GDELT error for query: {e}")
return pd.Series(dtype=float)
def pull_gdelt_data():
"""Pull tone timelines for all chokepoint queries."""
frames = {}
for name, query in QUERIES.items():
print(f" Fetching GDELT tone: {name}...")
tone = fetch_gdelt_tone(query)
if len(tone) > 0:
frames[f"tone_{name}"] = tone
df = pd.DataFrame(frames)
df.index = pd.to_datetime(df.index)
return df
if __name__ == "__main__":
df = pull_gdelt_data()
print(f"GDELT data: {len(df)} rows, {df.columns.tolist()}")
print(df.tail()) The GDELT DOC API's TimelineTone mode returns pre-aggregated daily tone averages, which saves you from downloading individual articles and computing the average yourself. The 90-day timespan is a practical limit for the free API -- for longer historical analysis, use the BigQuery interface described in Module 25.
Step 4: Merge and Align the Data
The three data sources operate on different frequencies (daily, weekly, monthly) and different calendar conventions (FRED uses business days, Yahoo Finance uses trading days, GDELT uses calendar days). The merge step aligns everything to a common daily index.
merge_data.py
import pandas as pd
from fred_data import pull_fred_data
from yfinance_data import pull_yfinance_data
from gdelt_data import pull_gdelt_data
def build_merged_dataset():
"""Merge all three data sources on a daily index."""
print("Pulling FRED data...")
fred_df = pull_fred_data()
print("Pulling Yahoo Finance data...")
yf_df = pull_yfinance_data()
print("Pulling GDELT data...")
gdelt_df = pull_gdelt_data()
# Merge on date index
merged = fred_df.join(yf_df, how="outer").join(gdelt_df, how="outer")
# Forward-fill gaps (weekends, holidays)
merged = merged.ffill()
# Drop rows where key columns are all NaN
key_cols = ["cpi_all", "brent", "crude"]
merged = merged.dropna(subset=key_cols, how="all")
print(f"Merged dataset: {len(merged)} rows, {len(merged.columns)} columns")
return merged
if __name__ == "__main__":
df = build_merged_dataset()
df.to_csv("shipping_price_data.csv")
print("Saved to shipping_price_data.csv") Forward-filling after the outer join ensures that weekends and holidays carry the most recent available value forward. This is standard practice in financial data analysis -- the CPI does not change on weekends, and the last known oil price remains the best estimate until the next trading session opens. If you prefer to avoid imputation, use an inner join instead, which retains only dates where all three sources have actual observations. The tradeoff is a much smaller dataset.
Step 5: Compute Correlations
The core analytical question is: how strongly do shipping-related metrics (crude oil, freight indices, geopolitical sentiment) correlate with consumer price indicators (CPI, PPI, import prices), and does that correlation change over time?
correlations.py
import pandas as pd
import matplotlib.pyplot as plt
from merge_data import build_merged_dataset
def compute_correlations(df, window=90):
"""Compute rolling correlations between shipping and price metrics."""
pairs = [
("brent", "cpi_energy_yoy", "Brent Crude vs CPI Energy (YoY)"),
("crude", "ppi_all_yoy", "WTI Futures vs PPI All (YoY)"),
("crude", "cpi_all_yoy", "WTI Futures vs CPI All (YoY)"),
("nat_gas", "cpi_energy_yoy", "Natural Gas vs CPI Energy (YoY)"),
("gas_price", "cpi_all_yoy", "Retail Gas vs CPI All (YoY)"),
]
results = {}
for col_a, col_b, label in pairs:
if col_a in df.columns and col_b in df.columns:
corr = df[col_a].rolling(window).corr(df[col_b])
results[label] = corr
return pd.DataFrame(results)
def plot_rolling_correlations(corr_df, window=90):
"""Plot rolling correlations over time."""
fig, ax = plt.subplots(figsize=(12, 6))
for col in corr_df.columns:
ax.plot(corr_df.index, corr_df[col], label=col, linewidth=1.2)
ax.set_title(f"Rolling {window}-Day Correlations: Shipping vs. Price Indicators")
ax.set_ylabel("Pearson Correlation")
ax.set_xlabel("Date")
ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
ax.legend(loc="lower left", fontsize=8)
ax.set_ylim(-1, 1)
plt.tight_layout()
plt.savefig("rolling_correlations.png", dpi=150)
plt.show()
print("Saved rolling_correlations.png")
if __name__ == "__main__":
df = build_merged_dataset()
corr_df = compute_correlations(df, window=90)
plot_rolling_correlations(corr_df, window=90) The 90-day rolling window is a starting point. Shorter windows (30 days) reveal faster-moving relationships but are noisier. Longer windows (180 days) are smoother but miss short-lived disruption signals. Experiment with different window sizes to see how the correlations change character. During acute disruptions -- the 2022 energy shock, the late-2023 Red Sea crisis -- correlations between crude oil and CPI energy tighten dramatically, approaching 0.8 or higher. During calm periods, the correlation drops because other factors (monetary policy, seasonal demand, inventory cycles) dominate consumer price movements.
Step 6: Build the Dashboard Charts
A useful shipping-price dashboard shows four things: the current state of key indicators, how they relate to each other, how those relationships have changed over time, and whether any geopolitical signal is active. The following script generates a four-panel chart that covers these bases.
dashboard.py
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from merge_data import build_merged_dataset
from correlations import compute_correlations
def build_dashboard():
df = build_merged_dataset()
corr_df = compute_correlations(df, window=90)
# Use last 365 days for display
cutoff = df.index.max() - pd.Timedelta(days=365)
df = df[df.index >= cutoff]
corr_df = corr_df[corr_df.index >= cutoff]
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Shipping-Price Dashboard", fontsize=16, fontweight="bold")
# Panel 1: Crude Oil vs CPI Energy
ax1 = axes[0, 0]
ax1.set_title("Brent Crude vs CPI Energy (YoY%)", fontsize=11)
ax1.plot(df.index, df["brent"], color="#3b82f6", label="Brent ($/bbl)", linewidth=1)
ax1_r = ax1.twinx()
ax1_r.plot(df.index, df["cpi_energy_yoy"], color="#ef4444", label="CPI Energy YoY%", linewidth=1)
ax1.set_ylabel("Brent ($/bbl)", color="#3b82f6")
ax1_r.set_ylabel("CPI Energy YoY%", color="#ef4444")
ax1.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
# Panel 2: Rolling Correlations
ax2 = axes[0, 1]
ax2.set_title("90-Day Rolling Correlations", fontsize=11)
for col in list(corr_df.columns)[:3]:
ax2.plot(corr_df.index, corr_df[col], label=col, linewidth=1)
ax2.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
ax2.set_ylim(-1, 1)
ax2.legend(fontsize=7, loc="lower left")
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
# Panel 3: Shipping Stocks as Freight Proxies
ax3 = axes[1, 0]
ax3.set_title("Shipping Stock 30-Day Returns (%)", fontsize=11)
for col in ["zim_30d_ret", "danaos_30d_ret"]:
if col in df.columns:
ax3.plot(df.index, df[col], label=col.replace("_30d_ret",""), linewidth=1)
ax3.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
ax3.set_ylabel("30-Day Return (%)")
ax3.legend(fontsize=8)
ax3.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
# Panel 4: GDELT Chokepoint Tone
ax4 = axes[1, 1]
ax4.set_title("GDELT Chokepoint Tone (7-Day Avg)", fontsize=11)
for col in df.columns:
if col.startswith("tone_"):
label = col.replace("tone_", "").replace("_", " ").title()
rolling = df[col].rolling(7).mean()
ax4.plot(df.index, rolling, label=label, linewidth=1)
ax4.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
ax4.set_ylabel("Average Tone")
ax4.legend(fontsize=7, loc="lower left")
ax4.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
plt.tight_layout()
plt.savefig("shipping_dashboard.png", dpi=150, bbox_inches="tight")
plt.show()
print("Dashboard saved to shipping_dashboard.png")
if __name__ == "__main__":
build_dashboard() The four panels serve distinct analytical purposes. Panel 1 (Brent vs. CPI Energy) shows the direct price transmission with a visible lag -- oil moves first, consumer energy prices follow weeks later. Panel 2 (rolling correlations) shows whether the transmission is currently tight or loose. Panel 3 (shipping stocks) provides a market-priced view of freight conditions, since ZIM and Danaos stock prices respond to freight rate movements within trading hours. Panel 4 (GDELT tone) provides the geopolitical early warning layer -- falling tone in a chokepoint region often precedes the shipping disruption that drives the other three panels.
Interpreting the Output
What you are looking for in the finished dashboard is convergence across panels. When a geopolitical signal appears in Panel 4 (falling tone in the Red Sea or Hormuz), watch for Panel 3 (shipping stocks rallying, because higher freight rates benefit carriers), Panel 1 (crude oil rising as supply route risk is priced in), and eventually Panel 2 (correlations tightening as the disruption transmits into consumer prices). The time lag between Panel 4 moving and Panel 2 tightening is typically two to six weeks -- the propagation delay of the shipping-price transmission mechanism.
When the panels are not converging -- tone is falling but shipping stocks are flat, or oil is rising but correlations are loosening -- it usually means the market has already priced the risk, or the media signal is noise rather than a genuine operational disruption. Disagreement between panels is analytically valuable because it forces you to investigate which signal is wrong.
Extending the Dashboard
This tutorial builds a static chart generator. To turn it into a live dashboard, you have several options.
Automated refresh. Schedule the script to run daily via cron (Linux/Mac) or Task Scheduler (Windows). Save the output PNG to a web-accessible directory, or post it to a Slack channel via webhook.
Interactive web version. Replace matplotlib with Plotly or Dash to build a browser-based dashboard with hover tooltips, zoom, and interactive date range selection. Plotly generates standalone HTML files that require no server.
Additional data sources. Add the Freightos Baltic Index (FBX) for container rates if you have access, or scrape the Shanghai Containerized Freight Index (SCFI) from public press releases. Add weather data for the Panama Canal watershed to build a drought-monitoring panel.
Alerting. Add a threshold check after the correlation computation. If any rolling correlation exceeds 0.7, or if GDELT tone drops below -5 for any chokepoint, send an email or push notification. A simple SMTP send in Python is about 10 lines of code.
Lag analysis. Shift one series forward or backward in time before computing the correlation to find the optimal lag. If Brent crude at a 45-day lead produces a higher correlation with CPI energy than the zero-lag version, that 45-day number is your estimate of the transmission delay -- and it becomes a forecasting input.
Common Pitfalls
Mixed frequencies create phantom correlations. Forward-filling a monthly series to daily frequency means the same CPI value appears 30 times in your dataset. Computing a daily rolling correlation against daily oil prices will overweight the relationship within each month and underweight the month-to-month changes. For rigorous analysis, compute correlations at the native frequency of the slower series (monthly for CPI, weekly for gas prices).
Year-over-year changes can lag the narrative. Because YoY calculations compare to 12 months ago, a price spike today will not affect the YoY number until a full year has passed. If oil spiked dramatically in March 2025, the March 2026 YoY comparison is against that elevated base, which can make current prices look flat even if they remain historically high. This is the "base effect" problem, and it has fooled more than one analyst into declaring inflation defeated prematurely.
Survivorship bias in shipping stocks. Companies like ZIM trade at valuations heavily influenced by dividend policy, fleet composition, and charter contract timing. A stock price decline does not necessarily mean freight rates are falling -- it might mean the company issued guidance below expectations for reasons unrelated to the broader market. Use multiple shipping stocks and cross-reference against freight index data when possible.
GDELT's 90-day DOC API limit. The free DOC API only returns data for the past 90 days. For historical backtesting, you need to use the BigQuery interface (free for 1 TB of queries per month) or download the raw GDELT files and process them locally.
Key Takeaways
- 1. Three free data sources cover the full pipeline. FRED provides the economic indicators (CPI, PPI, import prices). Yahoo Finance provides commodity prices and shipping stock proxies. GDELT provides geopolitical sentiment. Together, they cover the information chain from geopolitical event to consumer price impact.
- 2. Mixed-frequency alignment is the hardest technical problem. Monthly CPI, weekly gas prices, daily oil prices, and 15-minute GDELT updates all need to land on a common time axis. Forward-filling is the standard approach but introduces artifacts. Be aware of the limitations.
- 3. Rolling correlations reveal regime changes. The relationship between shipping metrics and consumer prices is not static. It tightens during disruptions and loosens during calm periods. A 90-day rolling window captures these shifts without excess noise.
- 4. Multi-panel convergence is the signal. No single indicator is reliable in isolation. When GDELT tone drops, shipping stocks rally, oil rises, and correlations tighten simultaneously, the signal is strong. When panels disagree, investigate why.
- 5. This is a learning tool, not a trading system. The dashboard demonstrates data pipeline mechanics. Production applications require cleaner data sources, proper backtesting, and risk management that goes far beyond a matplotlib chart.