#!/usr/bin/env python3 """ lagged_oil_unrate_chart_styled.py --------------------------------- Offline version: reads UNRATE & WTI series from `data/fred.db` (populated by `scripts/refresh_data.py`), forward‐fills any NaNs in the UNRATE series, drops remaining NaNs in both series to maintain purity, validates that both series contain data for the requested period (overlap only), and plots them with a configurable lag. """ from __future__ import annotations import argparse from datetime import datetime from pathlib import Path import matplotlib.dates as mdates import matplotlib.pyplot as plt import matplotlib.ticker as mticker import pandas as pd from dateutil.relativedelta import relativedelta # IMPORTANT: import common directly (no "scripts." prefix) from common import ( fetch_series_db, save_figure, validate_dataframe, validate_overlap, ) # ────────────────────────────────────────────────────────────────────────── # Data helpers # ────────────────────────────────────────────────────────────────────────── def fetch_series( start: datetime, end: datetime, db_path: str | Path = Path("data/fred.db"), ) -> tuple[pd.DataFrame, pd.DataFrame]: """ Load UNRATE and DCOILWTICO from a local SQLite DB, then: * convert UNRATE to month-end timestamps * average WTI daily prices to month-end """ # Read both series from SQLite df = fetch_series_db(["UNRATE", "DCOILWTICO"], start, end, db_path) # :contentReference[oaicite:0]{index=0} # Extract UNRATE and rename to 'value' unrate = df[["UNRATE"]].rename(columns={"UNRATE": "value"}) # Extract DCOILWTICO (oil price) and rename to 'value' oil = df[["DCOILWTICO"]].rename(columns={"DCOILWTICO": "value"}) # Resample WTI to month-end average oil_monthly = oil.resample("M").mean() # :contentReference[oaicite:1]{index=1} # Ensure UNRATE is at month-end, too unrate.index = unrate.index.to_period("M").to_timestamp("M") # :contentReference[oaicite:2]{index=2} return unrate, oil_monthly def validate_series(unrate: pd.DataFrame, oil: pd.DataFrame) -> None: """Run basic sanity checks on the two series.""" validate_dataframe(unrate, "UNRATE") # Raises ValueError if UNRATE is empty, NaNs, or bad index validate_dataframe(oil, "DCOILWTICO") # Raises ValueError if oil is empty, NaNs, or bad index validate_overlap(unrate, oil) # Raises ValueError if no date overlap # ────────────────────────────────────────────────────────────────────────── # Plotting # ────────────────────────────────────────────────────────────────────────── def plot_lagged( unrate: pd.DataFrame, oil: pd.DataFrame, offset_months: int, _start_date: datetime, _end_date: datetime, extend_years: int, ) -> plt.Figure: """ Render the dual-axis chart with fixed scales and log ticks on oil. Trims both series to their overlapping date range before shifting. Returns the created :class:`matplotlib.figure.Figure` so callers can decide how to display or save it. """ # Find overlapping date range common_start = max(unrate.index.min(), oil.index.min()) common_end = min(unrate.index.max(), oil.index.max()) print(f"⚠️ Using overlapping range: {common_start.date()} to {common_end.date()}") print(f" - UNRATE range: {unrate.index.min().date()} to {unrate.index.max().date()}") print(f" - OIL range: {oil.index.min().date()} to {oil.index.max().date()}") # Trim series to common range unrate_common = unrate.loc[common_start:common_end] oil_common = oil.loc[common_start:common_end] # Apply lag/lead oil_shifted = oil_common.shift(offset_months) # Shift by given months fig, ax1 = plt.subplots(figsize=(14, 7)) plt.title("Unemployment Rate and US Oil Price", fontsize=20, weight="bold") plt.suptitle( "Civilian Unemployment Rate and WTI Crude Oil Price", fontsize=14, y=0.93, ) # Left axis – UNRATE ax1.plot( unrate_common.index, unrate_common["value"], label="UNRATE (LHS)", linewidth=2.5, color="#1f77b4", ) ax1.set_ylabel("Unemployment rate (%)", fontsize=12) ax1.set_ylim(3, 15) ax1.yaxis.set_major_locator(mticker.MultipleLocator(2)) ax1.yaxis.set_major_formatter("{x:.0f}%") ax1.grid(axis="y", linestyle="--", alpha=0.4) # Right axis – shifted oil (log scale) ax2 = ax1.twinx() ax2.plot( oil_shifted.index, oil_shifted["value"], label="USOIL (RHS)", linewidth=2, linestyle="-", alpha=0.8, color="#FF9900", ) ax2.set_ylabel("Oil price (USD)", fontsize=12) # Build doubling ticks from $3 upward min_tick = 3 max_val = oil_shifted["value"].max() * 1.1 ticks = [] i = 0 while min_tick * 2**i < max_val: ticks.append(min_tick * 2**i) i += 1 ticks.append(min_tick * 2**i) ax2.set_yscale("log") ax2.set_ylim(min_tick, ticks[-1]) ax2.set_yticks(ticks) ax2.yaxis.set_major_formatter("${x:.0f}") # X-axis formatting using common range ax1.set_xlim(common_start, common_end + relativedelta(years=extend_years)) ax1.xaxis.set_major_locator(mdates.YearLocator(base=5)) ax1.xaxis.set_minor_locator(mdates.YearLocator(1)) ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) plt.setp(ax1.get_xticklabels(), rotation=0, fontsize=11) # Legends lines, labels = ax1.get_legend_handles_labels() l2, l2l = ax2.get_legend_handles_labels() ax1.legend(lines + l2, labels + l2l, loc="upper left", frameon=False, fontsize=12) # Footnote with actual ranges foot = ( f"UNRATE: {unrate.index.min().strftime('%b %Y')}–{unrate.index.max().strftime('%b %Y')} | " f"OIL: {oil.index.min().strftime('%b %Y')}–{oil.index.max().strftime('%b %Y')}\n" "Source: Local FRED snapshot (offline)." ) plt.annotate( foot, (0, 0), (0, -40), xycoords="axes fraction", textcoords="offset points", va="top", fontsize=10, color="gray", ) fig.tight_layout(rect=[0, 0.03, 1, 0.95]) return fig # ────────────────────────────────────────────────────────────────────────── # CLI # ────────────────────────────────────────────────────────────────────────── def main() -> plt.Figure: p = argparse.ArgumentParser() p.add_argument( "-o", "--offset", type=int, default=18, help="lag in months (positive=lag, negative=lead)", ) p.add_argument( "--start", type=str, default="1973-01-01", help="series start date (YYYY-MM-DD)", ) p.add_argument( "--end", type=str, default=datetime.today().strftime("%Y-%m-%d"), help="series end date (YYYY-MM-DD)", ) p.add_argument( "--extend-years", type=int, default=3, help="how many years beyond end_date to show on the x-axis", ) p.add_argument( "--db", type=str, default="data/fred.db", help="path to local FRED SQLite file", ) p.add_argument( "--output", type=str, default=None, help="optional path to save the figure (PNG or PDF)", ) args = p.parse_args() start_dt = datetime.fromisoformat(args.start) end_dt = datetime.fromisoformat(args.end) # ─────────────────────────────────────────────────────────────────────── # Fetch both UNRATE and oil; UNRATE may contain NaNs if the last # month’s data has not yet been published. We forward‐fill UNRATE # (option B), then drop any remaining NaNs in both series. # ─────────────────────────────────────────────────────────────────────── unrate, oil = fetch_series(start_dt, end_dt, args.db) # Forward‐fill UNRATE to propagate the last known value into any NaN gaps unrate["value"] = unrate["value"].ffill() # :contentReference[oaicite:3]{index=3} # Drop any rows where UNRATE is still NaN (e.g., if the entire series started after 'start') unrate = unrate.dropna(subset=["value"]) # :contentReference[oaicite:4]{index=4} # Drop any rows in 'oil' where the value is NaN (ensures no Nulls remain) oil = oil.dropna(subset=["value"]) # :contentReference[oaicite:5]{index=5} # Align oil to only dates present in UNRATE after cleaning oil = oil.loc[oil.index.isin(unrate.index)] validate_series(unrate, oil) fig = plot_lagged(unrate, oil, args.offset, start_dt, end_dt, args.extend_years) save_figure(fig, args.output, __file__) fig.show() return fig if __name__ == "__main__": main()