Forecast GPU Spot Prices: ARIMA vs SARIMA
Build a GPU spot price forecasting system using ARIMA and SARIMA models.
Introduction
30 Second Summary
Cloud GPU instances can cost anywhere from pennies to dollars per hour depending on when you spin them up. Timing your compute jobs right could save you hundreds of dollars a month.
In this project, you will build a GPU spot price intelligence system that analyzes 120 days of hourly pricing data, uncovers hidden temporal patterns, and produces forecasts that tell you exactly when to launch your cloud workloads for the cheapest price.
What You'll Build
When you run your finished script, it produces a full market intelligence report: price regime classifications, hourly heatmaps revealing the cheapest launch windows, and a head-to-head ARIMA vs SARIMA forecast comparison that visually proves why seasonality-aware models win on cloud pricing data.
By the end of this project, you'll have:
- A market intelligence overview that classifies the current pricing regime as cheap, normal, or expensive, and quantifies exactly how much you could save by timing your GPU launches.
- Hourly and day-of-week pricing heatmaps that reveal concrete cost-saving windows backed by 120 days of real spot price data.
- A head-to-head ARIMA vs SARIMA forecast comparison where you watch ARIMA's predictions flatten out while SARIMA tracks the daily price oscillations, proving quantitatively which model belongs in production.
- Secret Mission: Build an Optimal Launch Window Recommender that identifies the cheapest N-hour window in the next 48 hours to spin up your GPU instances.
Are there any prerequisites?
You should be comfortable with basic Python (loops, functions, f-strings) and have some familiarity with pandas DataFrames and matplotlib plotting. Conceptual understanding of ARIMA/SARIMA is helpful but not required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Understanding the GPU spot price forecasting problem upfront will make every modeling choice feel intentional.
Set Up the Project Environment
Before you can analyze GPU spot pricing data, you need a reproducible Python environment with the right statistical libraries installed.
In this step, you'll create a uv project, install the time series analysis stack, and load the dataset to confirm everything works together.
In this step, get ready to:
- Initialize a uv project with time series analysis libraries.
- Create the analysis script with data-loading code.
- Verify the environment runs and loads the dataset correctly.
Initialize the project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key) or selecting Terminal then New Terminal from the top menu.
- Move to your Desktop and create the project by running these commands:
cd ~/Desktop
uv init gpu-spot-forecast
cd gpu-spot-forecast
What do these commands do?
- cd ~/Desktop moves you to the Desktop so your project folder is easy to find.
- uv init gpu-spot-forecast creates a new Python project directory with a pyproject.toml, main.py, and other scaffolding files.
- cd gpu-spot-forecast moves into that new directory so all future commands run inside it.
- Confirm the project was created by running:
ls
You should see pyproject.toml and main.py listed in the output.
- Install all the time series analysis libraries by running:
uv add pandas statsmodels matplotlib seaborn numpy scipy
What is uv installing?
This installs six packages into your project's virtual environment.
- pandas for data manipulation and time series indexing.
- statsmodels for ARIMA, SARIMA, and statistical tests.
- matplotlib and seaborn for plotting.
- numpy and scipy for numerical computation.
This command may take 30-60 seconds as uv resolves and downloads all dependencies.
- Verify the libraries installed correctly by running:
uv run python -c "import statsmodels; import pandas; import matplotlib; print('All imports OK - statsmodels', statsmodels.__version__)"
You should see All imports OK - statsmodels 0.14.6 printed.
Seeing an import error?
- Make sure you ran uv add (not pip install). The uv tool manages its own virtual environment.
- Confirm you are inside the gpu-spot-forecast directory. Run pwd to check your current location.
help me troubleshoot why my uv add packages aren't importing correctly
- Download the spot_prices.csv dataset provided with this project and place it inside your gpu-spot-forecast folder on the Desktop.
- Confirm the file is in place by running:
ls spot_prices.csv
You should see spot_prices.csv printed without any "No such file" error.
Create the analysis script
With the environment ready, you need a single Python script that loads and prepares the dataset for analysis.
- In the VS Code Explorer sidebar (left panel), right-click inside the gpu-spot-forecast folder and select New File.
- Name the file analysis.py and press Enter.
- Paste the following code into analysis.py:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.stattools import adfuller
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from scipy import stats
# Load and prepare data
df = pd.read_csv("spot_prices.csv", parse_dates=["Timestamp"])
df = df[["Timestamp", "SpotPrice"]].copy()
df = df.set_index("Timestamp")
df = df.sort_index()
df = df.asfreq("h")
print("=" * 60)
print("GPU SPOT PRICE INTELLIGENCE REPORT")
print("=" * 60)
print(f"\nDataset: {len(df)} hourly observations")
print(f"Period: {df.index[0]} to {df.index[-1]}")
print(f"Instance: g4dn.xlarge (ap-south-1a)")
What does this code do?
- The first 10 lines import all the libraries you'll use throughout the project: pandas for data handling, statsmodels for forecasting models, and matplotlib/seaborn for visualization.
- pd.read_csv("spot_prices.csv", parse_dates=["Timestamp"]) reads the CSV and converts the Timestamp column into proper datetime objects.
- set_index("Timestamp") and asfreq("h") turn the DataFrame into a time-indexed series with explicit hourly frequency, which statsmodels requires for forecasting.
- The print statements output a quick summary header confirming the dataset loaded correctly.
- Save analysis.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Verify the environment loads data correctly
Everything is in place. Before you run the script, think about what you expect to see based on the code you just wrote.
- Run the analysis script by executing:
uv run python analysis.py
You should see output like this:
============================================================
GPU SPOT PRICE INTELLIGENCE REPORT
============================================================
Dataset: 2880 hourly observations
Period: 2025-02-20 00:00:00 to 2025-06-19 23:00:00
Instance: g4dn.xlarge (ap-south-1a)
The exact dates and observation count depend on your dataset, but you should see approximately 2880 rows covering a 120-day period.
Seeing a FileNotFoundError?
- This means spot_prices.csv is not in the same folder as analysis.py. Run ls to check that both files appear in the listing.
- If you placed the CSV in a subfolder, move it up to the gpu-spot-forecast root directory.
help me fix a FileNotFoundError when loading spot_prices.csv
✔️ Awesome, I've got everything!
Great. Double check you've saved analysis.py and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete analysis.py at this point:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.stattools import adfuller
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from scipy import stats
# Load and prepare data
df = pd.read_csv("spot_prices.csv", parse_dates=["Timestamp"])
df = df[["Timestamp", "SpotPrice"]].copy()
df = df.set_index("Timestamp")
df = df.sort_index()
df = df.asfreq("h")
print("=" * 60)
print("GPU SPOT PRICE INTELLIGENCE REPORT")
print("=" * 60)
print(f"\nDataset: {len(df)} hourly observations")
print(f"Period: {df.index[0]} to {df.index[-1]}")
print(f"Instance: g4dn.xlarge (ap-south-1a)")
Your environment is ready and the data is loaded. Next up, you'll compute market intelligence statistics and build your first visualizations of the pricing landscape.
Build the Market Intelligence Overview
Your environment is set up, your data is loaded, and your script already prints the basic dataset shape. Now it is time to turn that raw data into insight.
Before you build any forecasting model, you need to understand the pricing landscape. Raw spot prices hide patterns that only emerge through statistical summaries. In this step, you will produce a "market report" that immediately tells you whether current prices are historically cheap or expensive.
In this step, get ready to:
- Compute market summary statistics and classify the current price regime.
- Calculate daily cost metrics that quantify the savings from optimal timing.
- Visualize the full 120-day price time series with rolling statistics and a distribution histogram.
Compute market summary statistics
A handful of numbers can tell you more about a market than staring at a price chart. You will compute descriptive statistics and use them to classify whether the market is currently "cheap" or "expensive" relative to its own history.
- In analysis.py, add a section header above the # Load and prepare data comment by pasting these three lines:
# =============================================================
# STEP 2: Market Intelligence Overview
# =============================================================
- Now, below the last print statement (the one printing the instance type), add the following code to compute summary statistics and classify the price regime:
# Market summary statistics
mean_price = df["SpotPrice"].mean()
median_price = df["SpotPrice"].median()
std_price = df["SpotPrice"].std()
min_price = df["SpotPrice"].min()
max_price = df["SpotPrice"].max()
cv = std_price / mean_price
iqr = df["SpotPrice"].quantile(0.75) - df["SpotPrice"].quantile(0.25)
# Price regime classification
rolling_7d = df["SpotPrice"].rolling(window=168).mean()
latest_rolling = rolling_7d.iloc[-1]
if latest_rolling > median_price + 0.5 * std_price:
regime = "EXPENSIVE"
elif latest_rolling < median_price - 0.5 * std_price:
regime = "CHEAP"
else:
regime = "NORMAL"
print(f"\n--- Market Summary ---")
print(f"Mean Price: ${mean_price:.4f}/hr")
print(f"Median Price: ${median_price:.4f}/hr")
print(f"Std Dev: ${std_price:.6f}")
print(f"Min: ${min_price:.4f}/hr")
print(f"Max: ${max_price:.4f}/hr")
print(f"Coeff of Var: {cv:.4f}")
print(f"IQR: ${iqr:.6f}")
print(f"\nCurrent Regime: {regime}")
What does this code do?
The first block computes standard descriptive statistics on the SpotPrice column. The coefficient of variation (cv) normalizes volatility by the mean, so you can compare variability across different price levels.
The regime classifier computes a 7-day (168-hour) rolling mean and compares the latest value to the overall median. If the recent average is more than half a standard deviation above the median, the market is classified as "EXPENSIVE." If it is more than half a standard deviation below, it is "CHEAP." Otherwise it is "NORMAL."
- Save analysis.py.
- Run the script to verify the new output by running:
uv run python analysis.py
You should see the dataset header you already had, followed by a new "Market Summary" section showing the mean, median, std, min, max, coefficient of variation, IQR, and a "Current Regime" classification.
Seeing a KeyError or empty output?
- Make sure the new code is placed below the last existing print statement, not inside or above the data-loading block.
- Check that the column name in your CSV matches SpotPrice exactly (case-sensitive).
- If you see NaN for the regime, confirm the rolling_7d window of 168 is smaller than your dataset length.
Still stuck? Help me debug my market summary statistics code.
Compute daily cost metrics
Knowing the mean price is useful, but translating it into dollar-per-day cost makes the savings from timing your launches concrete and tangible.
- Below the regime print statement you just added, add the following code to calculate daily costs:
# Daily cost metrics
daily_cost_mean = mean_price * 24
daily_cost_p10 = df["SpotPrice"].quantile(0.10) * 24
savings_per_day = daily_cost_mean - daily_cost_p10
print(f"\n--- Daily Cost Analysis ---")
print(f"Cost at mean price: ${daily_cost_mean:.2f}/day")
print(f"Cost at 10th percentile: ${daily_cost_p10:.2f}/day")
print(f"Potential daily savings: ${savings_per_day:.2f}/day (from optimal timing)")
What does this code do?
It calculates the cost of running one g4dn.xlarge instance for 24 hours at the average price, then at the 10th percentile price (the cheapest 10% of hours). The difference tells you how much money you could save per day by launching during off-peak windows instead of at random.
- Save analysis.py and run it again:
uv run python analysis.py
You should now see a "Daily Cost Analysis" section printed below the market summary, showing the mean daily cost, the 10th-percentile daily cost, and the potential savings.
Don't see the daily cost section?
- Check that the daily cost code is below the regime print statement, not accidentally inside the if/elif/else block (indentation matters).
- Confirm there are no syntax errors by checking the terminal output for a traceback.
Need help? My daily cost metrics aren't printing correctly.
Visualize the full price time series with context
Numbers tell part of the story. A time series plot with rolling statistics layered on top reveals trends and volatility that summary stats alone cannot convey.
- Below the daily cost print statements, add the following code to create the top panel of your market overview figure:
# Market overview plot
fig, axes = plt.subplots(2, 1, figsize=(14, 8))
axes[0].plot(df.index, df["SpotPrice"], linewidth=0.5, alpha=0.7, label="Spot Price")
rolling_24h = df["SpotPrice"].rolling(window=24).mean()
rolling_24h_std = df["SpotPrice"].rolling(window=24).std()
axes[0].plot(df.index, rolling_24h, color="red", linewidth=1.5, label="24h Rolling Mean")
axes[0].fill_between(
df.index,
rolling_24h - rolling_24h_std,
rolling_24h + rolling_24h_std,
alpha=0.2,
color="red",
label="+/- 1 Std Band",
)
axes[0].axhline(y=median_price, color="green", linestyle="--", linewidth=1, label=f"Median (${median_price:.4f})")
axes[0].set_title("g4dn.xlarge Spot Price - 120 Day Overview")
axes[0].set_ylabel("Price ($/hr)")
axes[0].legend(loc="upper right")
axes[0].grid(True, alpha=0.3)
What does this code do?
This creates a 2-row figure and fills the top panel. The raw price series is plotted as a thin semi-transparent line so the smoothed 24-hour rolling mean (red) stands out. The shaded band shows +/- 1 standard deviation around that rolling mean, making volatility visible at a glance.
The green dashed horizontal line marks the overall median price as a fixed reference point. You can immediately see whether prices are trending above or below normal.
The plot is not saved yet. The next chunk adds the bottom panel and writes the file to disk.
- Directly below the top panel code, add the bottom panel (price distribution histogram) and save the figure:
axes[1].hist(df["SpotPrice"], bins=50, density=True, alpha=0.7, color="steelblue", edgecolor="black", linewidth=0.5)
kde_x = np.linspace(df["SpotPrice"].min(), df["SpotPrice"].max(), 200)
kde = stats.gaussian_kde(df["SpotPrice"].dropna())
axes[1].plot(kde_x, kde(kde_x), color="red", linewidth=2, label="KDE")
axes[1].axvline(x=mean_price, color="orange", linestyle="--", label=f"Mean (${mean_price:.4f})")
axes[1].axvline(x=median_price, color="green", linestyle="--", label=f"Median (${median_price:.4f})")
axes[1].set_title("Price Distribution")
axes[1].set_xlabel("Price ($/hr)")
axes[1].set_ylabel("Density")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("market_overview.png", dpi=150, bbox_inches="tight")
plt.close()
print("\nSaved: market_overview.png")
What does this code do?
The bottom panel shows a histogram of all spot prices overlaid with a KDE (Kernel Density Estimate) curve in red. This reveals the price distribution shape. Vertical lines mark the mean and median so you can see whether the distribution is skewed.
Finally, plt.savefig writes the 2-panel figure to market_overview.png and plt.close() frees memory so subsequent plots start fresh.
- Save analysis.py.
Before you run the script, what do you expect to see printed at the very end of the output?
- Run the full script:
uv run python analysis.py
You should see all the previous console output (dataset info, market summary, daily cost analysis) followed by a final line: Saved: market_overview.png.
- Open market_overview.png from the file explorer in VS Code (or double-click it in your system file browser).
You should see a 2-panel figure. The top panel shows the 120-day price time series with a red rolling mean and a shaded volatility band. The bottom panel shows a histogram with a smooth KDE curve and vertical mean/median markers.
Script runs but no PNG file appears?
- Check that plt.savefig("market_overview.png", dpi=150, bbox_inches="tight") is outside any if block and at the top level of indentation.
- If the file exists but looks blank or corrupted, confirm plt.close() comes after plt.savefig(), not before it.
- Look in the terminal for any matplotlib warnings about missing fonts or backends. These are usually harmless but can indicate a rendering issue.
Still not working? Help me debug my matplotlib market overview plot.
✔️ Awesome, I've got everything!
Great. Double check you have saved analysis.py and that market_overview.png is visible in your project folder.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm each of these sections is present in order in analysis.py:
- Imports (10 lines starting with import pandas as pd).
- Section header comment (# STEP 2: Market Intelligence Overview).
- Data loading block (pd.read_csv through the dataset info prints).
- Market summary statistics (mean_price through iqr).
- Price regime classification (rolling_7d through the if/elif/else block).
- Market summary print statements (10 lines of formatted output).
- Daily cost metrics (daily_cost_mean, daily_cost_p10, savings_per_day + 4 print lines).
- Market overview plot (figure creation, top panel with rolling stats, bottom panel with histogram/KDE, savefig, and close).
You now have a market intelligence dashboard that answers the first question any cost-conscious team asks: "Is now a good time to launch GPU instances?" Next, you will mine the temporal patterns hidden in this data to find exactly which hours and days offer the best prices.
Mine Temporal Patterns and Decompose the Series
Your market overview confirmed that spot prices fluctuate, and timing your launches could save real money. But the overview only told you that prices vary. It didn't tell you when they're cheapest.
Cloud GPU demand follows predictable cycles because human work patterns drive compute usage. In this step, you'll mine those temporal patterns to find the specific hours and days where prices dip. You'll also decompose the time series into trend, seasonal, and residual components to mathematically confirm that a 24-hour cycle exists before you try to model it.
In this step, get ready to:
- Build an hourly and day-of-week pricing heatmap that reveals concrete cost-saving windows.
- Decompose the time series into trend, seasonal, and residual components to confirm daily periodicity.
- Detect price spikes and quantify when they cluster.
Build the hourly pricing heatmap
A pivot table grouped by hour-of-day and day-of-week turns 2,880 raw price points into a compact grid that immediately shows when GPU time is cheapest.
- In analysis.py, add the following code below the print("\nSaved: market_overview.png") line to create the pivot table and render a heatmap:
# =============================================================
# STEP 3: Temporal Pattern Mining and Decomposition
# =============================================================
print("\n" + "=" * 60)
print("TEMPORAL PATTERN ANALYSIS")
print("=" * 60)
# Extract hour and day features for grouping
df["hour"] = df.index.hour
df["day_of_week"] = df.index.day_name()
# Build pivot table: mean price by hour and day
day_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
pivot = df.pivot_table(values="SpotPrice", index="hour", columns="day_of_week", aggfunc="mean")
pivot = pivot[day_order]
# Render heatmap visualization
fig, ax = plt.subplots(figsize=(12, 8))
sns.heatmap(pivot, annot=True, fmt=".4f", cmap="RdYlGn_r", ax=ax, linewidths=0.5)
ax.set_title("Average Spot Price by Hour and Day of Week")
ax.set_ylabel("Hour of Day (UTC)")
ax.set_xlabel("Day of Week")
plt.tight_layout()
plt.savefig("hourly_heatmap.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved: hourly_heatmap.png")
What does this code do?
It adds temporary hour and day_of_week columns to the DataFrame so you can group prices by time-of-day and weekday.
The pivot table computes the mean SpotPrice for each (hour, day) pair. The seaborn heatmap renders it with a red-to-green color scale so expensive cells stand out immediately.
Now you need to extract the actual cheapest and most expensive (hour, day) combinations from the pivot and quantify the peak-vs-off-peak spread.
- Add this code directly below the print("Saved: hourly_heatmap.png") line:
# Identify cheapest and most expensive (hour, day) pairs
cheapest_hour = pivot.min().idxmin()
cheapest_day_hour = pivot[cheapest_hour].idxmin()
cheapest_price = pivot.loc[cheapest_day_hour, cheapest_hour]
expensive_hour = pivot.max().idxmax()
expensive_day_hour = pivot[expensive_hour].idxmax()
expensive_price = pivot.loc[expensive_day_hour, expensive_hour]
print(f"\nCheapest: Hour {cheapest_day_hour}:00 UTC on {cheapest_hour} (${cheapest_price:.4f}/hr)")
print(f"Expensive: Hour {expensive_day_hour}:00 UTC on {expensive_hour} (${expensive_price:.4f}/hr)")
# Compute peak vs off-peak percentage difference
peak_avg = pivot.values.max()
offpeak_avg = pivot.values.min()
pct_diff = ((peak_avg - offpeak_avg) / offpeak_avg) * 100
print(f"Peak vs Off-Peak difference: {pct_diff:.2f}%")
What does this code do?
It scans the pivot table in two directions. pivot.min().idxmin() finds the column (day) with the lowest minimum, then pivot[cheapest_hour].idxmin() finds the row (hour) within that column where the minimum lives.
The peak-vs-off-peak percentage tells you how much more expensive the worst timeslot is compared to the best. This is the concrete cost-saving window you can exploit.
- Save analysis.py.
- Run the script by executing:
uv run python analysis.py
- At the bottom of the output you should see the "TEMPORAL PATTERN ANALYSIS" header, a "Saved: hourly_heatmap.png" confirmation, and the cheapest/expensive hour-day combinations with a peak-vs-off-peak percentage.
Seeing a KeyError on day_of_week?
Make sure the day_order list matches the exact capitalized English day names (Monday, Tuesday, etc.) that day_name() produces.
help me debug a KeyError in my pivot table code
Perform seasonal decomposition
The heatmap shows that prices vary by hour. Seasonal decomposition mathematically separates the time series into three components: trend (long-term direction), seasonal (repeating 24-hour cycle), and residual (noise). This confirms whether a true periodic signal exists that a model can exploit.
- Add this code below the print(f"Peak vs Off-Peak difference: {pct_diff:.2f}%") line to decompose the series and plot the result:
# Decompose into trend, seasonal (24h period), and residual
decomposition = seasonal_decompose(df["SpotPrice"].dropna(), model="additive", period=24)
# Plot the four decomposition panels
fig, axes = plt.subplots(4, 1, figsize=(14, 10))
decomposition.observed.plot(ax=axes[0], linewidth=0.5)
axes[0].set_title("Observed")
axes[0].set_ylabel("Price")
decomposition.trend.plot(ax=axes[1], linewidth=1)
axes[1].set_title("Trend")
axes[1].set_ylabel("Price")
decomposition.seasonal.plot(ax=axes[2], linewidth=0.5)
axes[2].set_title("Seasonal (period=24h)")
axes[2].set_ylabel("Price")
decomposition.resid.plot(ax=axes[3], linewidth=0.5)
axes[3].set_title("Residual")
axes[3].set_ylabel("Price")
for ax in axes:
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("decomposition.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved: decomposition.png")
# Quantify the daily swing from the seasonal component
seasonal_amplitude = decomposition.seasonal.max() - decomposition.seasonal.min()
print(f"\nSeasonal swing amplitude: ${seasonal_amplitude:.6f}/hr")
print(f"Seasonal component max: +${decomposition.seasonal.max():.6f}")
print(f"Seasonal component min: -${abs(decomposition.seasonal.min()):.6f}")
What does this code do?
seasonal_decompose with period=24 splits the price signal into a slowly moving trend, a repeating 24-hour wave (the seasonal component), and leftover noise (residual).
The seasonal amplitude tells you how much the price swings purely from the daily cycle. If this number is non-trivial relative to the mean price, a seasonal model (SARIMA) should outperform a plain ARIMA.
- Save analysis.py and run the script again:
uv run python analysis.py
- You should see "Saved: decomposition.png" followed by the seasonal swing amplitude and min/max values printed to the console.
- Open decomposition.png in your file explorer. The third panel (Seasonal) should display a clear repeating wave with a 24-hour period.
Decomposition plot looks flat or empty?
Check that df["SpotPrice"].dropna() still has data. If your DatetimeIndex has gaps, asfreq("h") may have introduced NaN rows. Confirm your data loading section uses df = df.asfreq("h") after sorting.
help me debug my seasonal decomposition
Compute rolling volatility and detect price spikes
Knowing the average daily pattern is useful, but you also need to know how reliable those patterns are. High volatility means the pattern is noisy and forecasts will have wider confidence intervals. Spike detection reveals extreme price events that could blow a budget.
- Add this final code block below the seasonal amplitude prints to compute volatility, detect spikes, and clean up the temporary columns:
# 24-hour rolling volatility as a stability proxy
rolling_vol = df["SpotPrice"].rolling(window=24).std()
spike_threshold = mean_price + 2 * std_price
spikes = df[df["SpotPrice"] > spike_threshold]
print(f"\n--- Volatility & Spikes ---")
print(f"Mean 24h volatility: ${rolling_vol.mean():.6f}")
print(f"Spike threshold (mean + 2*std): ${spike_threshold:.4f}")
print(f"Number of spikes: {len(spikes)}")
if len(spikes) > 0:
print(f"Average spike magnitude: ${spikes['SpotPrice'].mean():.4f}")
spike_hours = spikes["hour"].value_counts().head(3)
print(f"Top spike hours: {spike_hours.to_dict()}")
# Remove temporary columns before modeling steps
df = df.drop(columns=["hour", "day_of_week"])
What does this code do?
The 24-hour rolling standard deviation measures how much prices jitter within any given day. Higher values mean less predictable pricing.
A "spike" is any observation more than two standard deviations above the mean. The code counts these outliers and reports which hours they cluster in, so you know when the market is most dangerous.
The final df.drop(columns=["hour", "day_of_week"]) removes the temporary columns so the DataFrame is clean for the modeling steps that follow.
- Save analysis.py.
Before you run the script, think about what you expect to see. Will there be many spikes or few? Based on the distribution histogram from Step 2, do you think prices are tightly clustered or spread out?
- Run the full script:
uv run python analysis.py
- At the bottom of the output, you should see the "Volatility & Spikes" section with the mean 24h volatility, spike threshold, spike count, and the top hours where spikes cluster.
- Confirm that hourly_heatmap.png and decomposition.png both exist in your project folder by running:
ls *.png
- You should see decomposition.png, hourly_heatmap.png, and market_overview.png listed.
Seeing a KeyError on 'hour' in the spike detection?
The spike detection line spikes["hour"].value_counts() requires the hour column to still exist on df. Make sure the df = df.drop(columns=["hour", "day_of_week"]) line comes after the spike analysis, not before it.
help me fix a KeyError in my spike detection code
✔️ Awesome, I've got everything!
Double check you've saved analysis.py and all three PNG files exist in your project directory.
ⓧ I'd like to double check the full code
Your analysis.py should contain these sections in order. Scroll through your file and confirm each one is present:
- All imports at the top (pandas, numpy, matplotlib, seaborn, statsmodels, scipy).
- STEP 2: Market Intelligence Overview section (data loading, market stats, regime classification, daily costs, market overview plot).
- STEP 3: Temporal Pattern Mining section header with the "TEMPORAL PATTERN ANALYSIS" print.
- Hour/day_of_week column creation, pivot table, and heatmap plot saved as hourly_heatmap.png.
- Cheapest/expensive hour identification and peak-vs-off-peak percentage print.
- Seasonal decomposition with period=24, four-panel plot saved as decomposition.png, and seasonal amplitude prints.
- Rolling volatility, spike threshold, spike detection with hour clustering, and finally df.drop(columns=["hour", "day_of_week"]) at the very end.
You now have hard evidence that a 24-hour seasonal pattern exists in the data. The decomposition confirmed it mathematically, and the heatmap pinpointed exactly when prices are cheapest. Next, you'll fit an ARIMA model as a baseline forecast and watch it struggle to capture the very pattern you just proved exists.
Fit the ARIMA Baseline Forecast
In the previous step you confirmed that a 24-hour seasonal cycle exists in the spot pricing data. Now it is time to build your first forecast model and see how well it predicts future prices.
ARIMA is the standard workhorse for time series forecasting. You will fit it as a baseline, but because ARIMA has no concept of recurring daily patterns, its predictions will flatten out and miss the intraday oscillations you discovered earlier. Seeing this failure visually is the motivation for the seasonal model in the next step.
In this step, get ready to:
- Test the training data for stationarity using the Augmented Dickey-Fuller test.
- Analyze ACF and PACF plots to understand lag structure.
- Fit an ARIMA(2,1,2) model and visually compare its forecast against actual prices.
Split the data into train and test sets
Before you can train a forecasting model, you need to hold back a portion of the data to evaluate how well the model predicts unseen prices. An 80/20 split gives roughly 96 days for training and 24 days for testing.
- In analysis.py, scroll to the bottom of the file (after the line df = df.drop(columns=["hour", "day_of_week"])) and add the following code:
# =============================================================
# STEP 4: ARIMA Baseline Forecast
# =============================================================
print("\n" + "=" * 60)
print("ARIMA BASELINE MODEL")
print("=" * 60)
# Train/test split
train_size = int(len(df) * 0.8)
train = df["SpotPrice"].iloc[:train_size]
test = df["SpotPrice"].iloc[train_size:]
print(f"\nTrain set: {len(train)} observations ({train.index[0]} to {train.index[-1]})")
print(f"Test set: {len(test)} observations ({test.index[0]} to {test.index[-1]})")
What does this code do?
- train_size = int(len(df) * 0.8) takes 80% of the total rows as training data. With ~2880 hourly observations, that is roughly 2304 hours (96 days).
- The remaining 20% (~576 hours, 24 days) becomes the test set. This covers multiple full weekly cycles, giving a fair evaluation of the model.
- You split on time order (first 80%, last 20%) because shuffling would leak future information into training. Time series splits must respect chronological order.
- Save analysis.py.
- Run the script to confirm the split sizes:
uv run python analysis.py
At the bottom of the output you should see the train set size (~2304 observations) and test set size (~576 observations) with their date ranges printed.
Not seeing the train/test output?
- Make sure you added the new code after the last line of Step 3 code, not inside it.
- Check that the file is saved before running.
Help me debug my train/test split output.
Test for stationarity with the ADF test
ARIMA requires the input series to be stationary, meaning its statistical properties (mean, variance) do not change over time. The Augmented Dickey-Fuller test checks this formally. If the p-value is above 0.05, the series is non-stationary and needs differencing.
- Add this code below the train/test print statements in analysis.py:
# ADF test
adf_result = adfuller(train.dropna(), autolag="AIC")
print(f"\n--- Augmented Dickey-Fuller Test (Raw Series) ---")
print(f"ADF Statistic: {adf_result[0]:.4f}")
print(f"p-value: {adf_result[1]:.6f}")
print(f"Lags Used: {adf_result[2]}")
if adf_result[1] > 0.05:
print("Result: Non-stationary (p > 0.05). Differencing required.")
train_diff = train.diff().dropna()
adf_diff = adfuller(train_diff, autolag="AIC")
print(f"\n--- ADF Test (After First Differencing) ---")
print(f"ADF Statistic: {adf_diff[0]:.4f}")
print(f"p-value: {adf_diff[1]:.6f}")
print("Result: Stationary" if adf_diff[1] <= 0.05 else "Result: Still non-stationary")
d = 1
else:
print("Result: Stationary (p <= 0.05). No differencing needed.")
d = 0
What does the ADF test tell you?
The ADF test returns a test statistic and a p-value. A low p-value (below 0.05) means the series is stationary and ready for ARIMA. A high p-value means the mean or variance drifts over time.
If non-stationary, the code applies first-order differencing (subtracting each value from the previous one) and re-tests. The variable d records how many times differencing was needed. This becomes the "d" in ARIMA(p,d,q).
- Save analysis.py and run the script again:
uv run python analysis.py
You should see the ADF statistic, p-value, and a conclusion about whether differencing was required.
Seeing an error from adfuller?
- If you see NameError: name 'adfuller' is not defined, confirm that from statsmodels.tsa.stattools import adfuller is at the top of your file.
- If you see ValueError related to NaN values, double check that you have .dropna() on the train series passed to adfuller.
Help me fix my ADF test error.
Plot ACF and PACF to examine lag structure
ACF (autocorrelation function) and PACF (partial autocorrelation function) plots show how strongly each past time step correlates with the present. They guide your choice of AR and MA terms for the ARIMA model.
- Add this code below the ADF test block in analysis.py:
# ACF/PACF plots
fig, axes = plt.subplots(2, 1, figsize=(12, 6))
series_for_acf = train.diff().dropna() if d == 1 else train
plot_acf(series_for_acf, lags=48, ax=axes[0], title="ACF (Differenced Series)" if d == 1 else "ACF")
plot_pacf(series_for_acf, lags=48, ax=axes[1], title="PACF (Differenced Series)" if d == 1 else "PACF")
plt.tight_layout()
plt.savefig("acf_pacf.png", dpi=150, bbox_inches="tight")
plt.close()
print("\nSaved: acf_pacf.png")
How do you read ACF and PACF plots?
Each bar shows the correlation at that lag. Bars that extend past the shaded confidence band are statistically significant.
In the PACF, significant spikes at lags 1-3 suggest AR terms (p=2 or 3). In the ACF, a sharp cutoff suggests MA terms. The code uses 48 lags so you can spot the 24-hour seasonal pattern as a spike at lag 24.
- Save analysis.py and run the script:
uv run python analysis.py
- Open acf_pacf.png from your project folder.
You should see two panels. Look for significant spikes at short lags (1-3) and notice whether there is a notable spike near lag 24. This lag-24 spike is evidence of the daily cycle that ARIMA alone cannot model.
Fit the ARIMA model
Based on the ACF/PACF analysis, the code uses order (2,1,2). This means 2 AR terms, 1 differencing step, and 2 MA terms.
- Add this code below the ACF/PACF section in analysis.py:
# Fit ARIMA
print("\nFitting ARIMA(2,1,2)...")
arima_model = ARIMA(train, order=(2, 1, 2))
arima_result = arima_model.fit()
print(arima_result.summary())
arima_aic = arima_result.aic
print(f"\nARIMA AIC: {arima_aic:.2f}")
What is AIC?
AIC (Akaike Information Criterion) measures model quality by balancing goodness-of-fit against complexity. Lower AIC means a better model. You will compare this value against SARIMA in the next step to quantify the improvement.
- Save analysis.py and run the script:
uv run python analysis.py
At the bottom of the output you should see the full ARIMA model summary table with coefficients, standard errors, and the AIC value printed on its own line.
Model fitting takes a very long time?
- ARIMA(2,1,2) on ~2300 observations should fit in a few seconds. If it seems stuck, check that you passed train (not the full df) to ARIMA().
- If you see a convergence warning, the model still produces results. Note the warning and continue.
Help me troubleshoot my ARIMA model fitting.
Forecast on the test set and evaluate
Now you will use the fitted model to predict prices across the entire test period. You will calculate RMSE, MAE, and MAPE to measure accuracy, then plot the forecast against actual values to see how well ARIMA tracks reality.
- Add this code below the AIC print line in analysis.py:
# Forecast on test set
arima_forecast = arima_result.get_forecast(steps=len(test))
arima_pred = arima_forecast.predicted_mean
arima_ci = arima_forecast.conf_int(alpha=0.05)
# Calculate metrics
arima_rmse = np.sqrt(np.mean((test.values - arima_pred.values) ** 2))
arima_mae = np.mean(np.abs(test.values - arima_pred.values))
arima_mape = np.mean(np.abs((test.values - arima_pred.values) / test.values)) * 100
print(f"\n--- ARIMA Test Set Performance ---")
print(f"RMSE: ${arima_rmse:.6f}")
print(f"MAE: ${arima_mae:.6f}")
print(f"MAPE: {arima_mape:.2f}%")
# Plot ARIMA forecast
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(test.index, test.values, linewidth=0.8, label="Actual", color="black")
ax.plot(test.index, arima_pred.values, linewidth=1.2, label="ARIMA Forecast", color="blue")
ax.fill_between(test.index, arima_ci.iloc[:, 0].values, arima_ci.iloc[:, 1].values, alpha=0.2, color="blue", label="95% CI")
ax.set_title("ARIMA(2,1,2) Forecast vs Actual - Test Period")
ax.set_xlabel("Time")
ax.set_ylabel("Spot Price ($/hr)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("arima_forecast.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved: arima_forecast.png")
What do these metrics measure?
- RMSE (Root Mean Squared Error) penalizes large errors heavily. Useful when big misses are costly.
- MAE (Mean Absolute Error) gives the average size of prediction errors in dollar terms.
- MAPE (Mean Absolute Percentage Error) expresses accuracy as a percentage, making it easy to compare across different price scales.
- Save analysis.py.
Before you run the script, think about this: do you expect ARIMA's forecast line to track the daily ups and downs you saw in the heatmap, or do you think it will flatten out?
- Run the script:
uv run python analysis.py
- Open arima_forecast.png from your project folder.
Look at the blue forecast line compared to the black actual prices. The ARIMA prediction converges to a flat line that hovers around the mean price. It completely misses the daily oscillations (the regular peaks and valleys in the actual data). This is the fundamental limitation you will fix in the next step with a seasonal model.
Why does ARIMA flatten out?
ARIMA only models short-term dependencies between consecutive observations. It has no mechanism to "remember" that prices spike every 24 hours during business hours and drop at night. After a few time steps, its forecast regresses toward the long-run mean.
This is not a failure of your parameters. Any ARIMA order will produce the same flat long-horizon forecast on seasonal data. You need SARIMA's explicit seasonal terms to capture the 24-hour cycle.
Forecast plot looks completely wrong?
- If the blue line is wildly off-scale or the plot shows no data, confirm you passed steps=len(test) to get_forecast(). Using a different number of steps will misalign the forecast with actual values.
- If you see NaN values in the metrics, check that test.values and arima_pred.values have the same length by printing their shapes.
Help me fix my ARIMA forecast plot.
✔️ Awesome, I've got everything!
Great. Double check you have saved analysis.py and confirm both acf_pacf.png and arima_forecast.png are in your project folder.
ⓧ I'd like to double check the full code
Your Step 4 code should contain these sections in order. Scroll through analysis.py and confirm each is present below the Step 3 code:
- The section header comment # STEP 4: ARIMA Baseline Forecast with the banner print lines.
- The train/test split block (train_size, train, test variables and their print statements).
- The ADF test block (adf_result, the if/else that sets d).
- The ACF/PACF plot block (creates and saves acf_pacf.png).
- The ARIMA fit block (arima_model, arima_result, arima_aic).
- The forecast + metrics + plot block (arima_forecast, arima_pred, arima_ci, RMSE/MAE/MAPE, saves arima_forecast.png).
Your ARIMA baseline is complete. You have quantified its performance and seen firsthand how it misses the daily price cycle. Next up, you will fit a SARIMA model that explicitly captures that 24-hour seasonality and produces forecasts with actionable intraday detail.
Fit SARIMA and Generate the Production Recommendation
Your ARIMA baseline exposed a clear shortcoming: the forecast flattens out and ignores the daily ups and downs your heatmap already proved exist. SARIMA fixes this by adding seasonal terms that explicitly model the 24-hour demand cycle.
In this step you fit the seasonal model on the same training data, produce the same forecast horizon, and deliver a head-to-head comparison that quantifies exactly how much better SARIMA performs. The final output is a production recommendation with dollar-value savings.
In this step, get ready to:
- Fit a SARIMA model with a 24-hour seasonal period on the training data.
- Compare ARIMA and SARIMA forecasts visually and numerically on the test set.
- Produce a production recommendation report with predicted cheap and expensive hours.
Fit SARIMA with seasonal order (P,D,Q,s=24)
SARIMAX extends ARIMA with a second set of parameters (P, D, Q, s) that capture the recurring seasonal pattern at period s. Because your data is hourly and the demand cycle repeats every 24 hours, you set s=24.
- In analysis.py, scroll to the very bottom of the file (after the ARIMA forecast plot code) and add the SARIMA section header and model fit by pasting the following code:
# =============================================================
# STEP 5: SARIMA Forecast and Production Recommendation
# =============================================================
print("\n" + "=" * 60)
print("SARIMA MODEL (Seasonal)")
print("=" * 60)
# Fit SARIMA
print("\nFitting SARIMAX(2,1,2)(1,1,1,24)... (this may take a minute)")
sarima_model = SARIMAX(
train,
order=(2, 1, 2),
seasonal_order=(1, 1, 1, 24),
enforce_stationarity=False,
enforce_invertibility=False,
)
sarima_result = sarima_model.fit(disp=False)
print(sarima_result.summary())
sarima_aic = sarima_result.aic
print(f"\nSARIMA AIC: {sarima_aic:.2f}")
What does this code do?
SARIMAX takes the same order=(2,1,2) as your ARIMA baseline, then adds seasonal_order=(1,1,1,24). This means one seasonal AR term, one seasonal difference, one seasonal MA term, all at a 24-hour period.
The enforce_stationarity=False and enforce_invertibility=False flags prevent convergence issues when the state space is large (s=24 creates a big state vector). The disp=False argument suppresses verbose convergence output so only the final summary prints.
- Save analysis.py and run the script:
uv run python analysis.py
Before you look at the output, predict: will SARIMA's AIC be higher or lower than ARIMA's?
The fit takes 30 to 90 seconds because the seasonal state space with s=24 is large. Once it completes, you see the full SARIMA model summary and a SARIMA AIC value that is lower than the ARIMA AIC you saw earlier. Lower AIC means better model fit.
Fit taking more than 2 minutes?
On older hardware the seasonal model can be slow. Let it finish. If it crashes with a memory error, close other applications and try again.
help me debug a slow SARIMA fit
Forecast and compare head-to-head
With the model fitted, you can produce a forecast over the same test period and directly compare SARIMA's predictions against ARIMA's.
- Add the SARIMA forecast and metrics code directly below the AIC print line:
# Forecast on test set
sarima_forecast = sarima_result.get_forecast(steps=len(test))
sarima_pred = sarima_forecast.predicted_mean
sarima_ci = sarima_forecast.conf_int(alpha=0.05)
# Calculate metrics
sarima_rmse = np.sqrt(np.mean((test.values - sarima_pred.values) ** 2))
sarima_mae = np.mean(np.abs(test.values - sarima_pred.values))
sarima_mape = np.mean(np.abs((test.values - sarima_pred.values) / test.values)) * 100
print(f"\n--- SARIMA Test Set Performance ---")
print(f"RMSE: ${sarima_rmse:.6f}")
print(f"MAE: ${sarima_mae:.6f}")
print(f"MAPE: {sarima_mape:.2f}%")
What does this code do?
get_forecast(steps=len(test)) produces predictions for every hour in the test set, plus 95% confidence intervals via conf_int(alpha=0.05).
RMSE, MAE, and MAPE are the same metrics you computed for ARIMA. Using identical metrics on the identical test set makes the comparison fair.
- Save analysis.py and run the script again.
- Look at the SARIMA Test Set Performance section in the output. All three metrics (RMSE, MAE, MAPE) should be lower than ARIMA's values printed above them.
SARIMA metrics look worse than ARIMA?
Double-check that seasonal_order=(1, 1, 1, 24) is set correctly. A typo like (1, 1, 1, 12) models a 12-hour cycle instead of 24-hour, which misaligns with the actual daily pattern.
my SARIMA metrics are worse than ARIMA
Now create the visual comparison plot. This code creates a two-panel figure, so you will add it in two parts.
- Add the first panel (full test period overlay) directly below the MAPE print line:
# Head-to-head comparison plot
fig, axes = plt.subplots(2, 1, figsize=(14, 10))
# Full test period
axes[0].plot(test.index, test.values, linewidth=0.8, label="Actual", color="black")
axes[0].plot(test.index, arima_pred.values, linewidth=1, label="ARIMA", color="blue", alpha=0.8)
axes[0].plot(test.index, sarima_pred.values, linewidth=1, label="SARIMA", color="red", alpha=0.8)
axes[0].set_title("Model Comparison - Full Test Period")
axes[0].set_ylabel("Spot Price ($/hr)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
- Add the zoomed 72-hour panel and save logic directly below:
# Zoomed 72-hour window
zoom_hours = 72
axes[1].plot(test.index[:zoom_hours], test.values[:zoom_hours], linewidth=1.2, label="Actual", color="black")
axes[1].plot(test.index[:zoom_hours], arima_pred.values[:zoom_hours], linewidth=1.5, label="ARIMA", color="blue", linestyle="--")
axes[1].plot(test.index[:zoom_hours], sarima_pred.values[:zoom_hours], linewidth=1.5, label="SARIMA", color="red")
axes[1].fill_between(
test.index[:zoom_hours],
sarima_ci.iloc[:zoom_hours, 0].values,
sarima_ci.iloc[:zoom_hours, 1].values,
alpha=0.15,
color="red",
label="SARIMA 95% CI",
)
axes[1].set_title("Zoomed: First 72 Hours - SARIMA Tracks Daily Patterns")
axes[1].set_xlabel("Time")
axes[1].set_ylabel("Spot Price ($/hr)")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("model_comparison.png", dpi=150, bbox_inches="tight")
plt.close()
print("Saved: model_comparison.png")
Why two panels?
The top panel shows the full test period so you can see overall fit. The bottom panel zooms into the first 72 hours where the difference is most visible: ARIMA's forecast stays flat while SARIMA oscillates in sync with the actual daily price swings.
The fill_between shading on the zoomed panel shows SARIMA's 95% confidence interval, giving you a sense of forecast uncertainty.
- Save analysis.py and run the script.
- Open model_comparison.png from your project folder. In the zoomed panel, you should see the red SARIMA line tracking the daily oscillations while the blue ARIMA dashed line stays nearly flat.
Plot looks empty or has errors?
Make sure you added both code blocks in sequence with no blank lines missing between the first and second panel code. The fig, axes line must come before both panel configurations.
my model comparison plot is not rendering correctly
Produce the production recommendation report
The final piece turns your metrics into a decision. You print a formatted comparison table, compute improvement percentages, and forecast the next 24 hours to identify the predicted cheapest and most expensive launch windows.
- Add the comparison table code below the print("Saved: model_comparison.png") line:
# Production recommendation report
print("\n" + "=" * 60)
print("PRODUCTION RECOMMENDATION")
print("=" * 60)
print(f"\n{'Metric':<12} {'ARIMA':<14} {'SARIMA':<14} {'Improvement':<14}")
print("-" * 54)
rmse_imp = ((arima_rmse - sarima_rmse) / arima_rmse) * 100
mae_imp = ((arima_mae - sarima_mae) / arima_mae) * 100
mape_imp = ((arima_mape - sarima_mape) / arima_mape) * 100
aic_imp = ((arima_aic - sarima_aic) / abs(arima_aic)) * 100
print(f"{'AIC':<12} {arima_aic:<14.2f} {sarima_aic:<14.2f} {aic_imp:>+.1f}%")
print(f"{'RMSE':<12} ${arima_rmse:<13.6f} ${sarima_rmse:<13.6f} {rmse_imp:>+.1f}%")
print(f"{'MAE':<12} ${arima_mae:<13.6f} ${sarima_mae:<13.6f} {mae_imp:>+.1f}%")
print(f"{'MAPE':<12} {arima_mape:<13.2f}% {sarima_mape:<13.2f}% {mape_imp:>+.1f}%")
What does this code do?
Each improvement percentage computes how much better SARIMA is relative to ARIMA on that metric. A positive percentage means SARIMA improved (lower error or lower AIC). This gives you a single number to cite when recommending the model.
- Save analysis.py and run the script.
- Scroll to the PRODUCTION RECOMMENDATION section in your terminal output. You should see a formatted table with all four metrics showing positive improvement percentages for SARIMA.
Seeing negative improvement percentages?
A negative improvement means ARIMA actually beat SARIMA on that metric. Verify your seasonal_order is (1, 1, 1, 24) and that the SARIMA model fitted on the same train series as ARIMA.
help me understand why my SARIMA improvement percentages are negative
Now add the 24-hour ahead forecast and final recommendation. This is the actionable output: specific hours when prices will be cheapest and most expensive.
- Add this code directly below the MAPE comparison print line:
# 24-hour ahead forecast from SARIMA
future_forecast = sarima_result.get_forecast(steps=len(test) + 24)
next_24h = future_forecast.predicted_mean.iloc[-24:]
cheapest_hour_forecast = next_24h.idxmin()
expensive_hour_forecast = next_24h.idxmax()
print(f"\n--- Next 24h Forecast (SARIMA) ---")
print(f"Predicted cheapest hour: {cheapest_hour_forecast} (${next_24h.min():.4f}/hr)")
print(f"Predicted most expensive: {expensive_hour_forecast} (${next_24h.max():.4f}/hr)")
print(f"Predicted price range: ${next_24h.max() - next_24h.min():.6f}/hr")
optimal_daily_savings = (next_24h.max() - next_24h.min()) * 24
print(f"\n*** RECOMMENDATION ***")
print(f"Deploy SARIMA(2,1,2)(1,1,1,24) for production spot price forecasting.")
print(f"Estimated cost advantage from optimal timing: ${optimal_daily_savings:.2f}/day per instance.")
How does the 24-hour forecast work?
get_forecast(steps=len(test) + 24) projects 24 hours beyond the end of the test set (which is also the end of the dataset). The iloc[-24:] slice grabs only those final 24 predicted hours.
The optimal daily savings estimate multiplies the predicted price range by 24 hours. This represents the maximum daily cost difference between always launching at the cheapest hour versus always launching at the most expensive hour.
- Save analysis.py.
Before you run this final version, predict: will the cheapest predicted hour fall during business hours or off-peak hours?
- Run the script:
uv run python analysis.py
Scroll to the bottom of the output. You see the Next 24h Forecast section listing specific timestamps for the cheapest and most expensive predicted hours, a price range, and the final RECOMMENDATION line with a dollar-per-day savings estimate.
What should I see?
The cheapest predicted hour typically falls in the early morning (UTC) when compute demand is lowest. The most expensive hour aligns with peak business hours. Your exact timestamps depend on the dataset's end date, but the pattern should match the heatmap from Step 3.
Seeing a KeyError or index error?
Make sure steps=len(test) + 24 uses len(test) not a hardcoded number. The forecast must extend past the test set to produce future predictions.
my 24-hour forecast is throwing an error
✔️ Awesome, I've got everything!
Great. Double check you have saved analysis.py and that model_comparison.png exists in your project folder.
ⓧ I'd like to double check the full code
Your analysis.py is now long. Scroll through and confirm these sections appear in order:
- Imports (pandas, numpy, matplotlib, seaborn, statsmodels ARIMA/SARIMAX/seasonal_decompose/adfuller/plot_acf/plot_pacf, scipy stats).
- STEP 2: Market Intelligence Overview (data loading, summary stats, regime, daily cost, market_overview.png).
- STEP 3: Temporal Pattern Mining (heatmap, decomposition, volatility/spikes, column cleanup).
- STEP 4: ARIMA Baseline (train/test split, ADF test, ACF/PACF, ARIMA fit, forecast, metrics, arima_forecast.png).
- STEP 5: SARIMA section header and SARIMAX fit with seasonal_order=(1, 1, 1, 24).
- SARIMA forecast on test set and metrics (RMSE, MAE, MAPE).
- Head-to-head comparison plot (two panels: full period + zoomed 72h) saved to model_comparison.png.
- Production recommendation report (comparison table, improvement percentages).
- 24-hour ahead forecast with cheapest/most expensive hour and final RECOMMENDATION print.
Secret mission
Build the Optimal Launch Window Recommender
A forecast is only useful if it drives action. Your SARIMA model can predict prices, but when exactly should you launch a 4-hour GPU training job to pay the least? Build a sliding window recommender that answers that question with dollar values and confidence ratings.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future experimentation, or delete them entirely.
Resources you used:
- The gpu-spot-forecast project directory on your Desktop (contains analysis.py, spot_prices.csv, generated .png plots, and the .venv virtual environment).
Keep everything running
No action needed. Choose this if you want to keep experimenting with different ARIMA/SARIMA parameters, try new forecast horizons, or adapt the pipeline to other pricing datasets.
- Your project directory stays on your Desktop with all generated plots and the dataset intact.
- You can re-run the analysis anytime with uv run python analysis.py from inside the gpu-spot-forecast folder.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, there is nothing to pause. Your files sit on disk without consuming any resources until you run the script again.
- No action required. The project is already paused by default.
- When you return, open a terminal, navigate to the gpu-spot-forecast directory, and run uv run python analysis.py to regenerate your forecasts.
Delete - I don't want to use this again
Remove the entire project directory. This deletes the analysis script, dataset, generated plots, and the virtual environment with all installed packages.
- Open your terminal and navigate to your Desktop by running:
cd ~/Desktop
- Delete the project folder by running:
macOS
rm -rf gpu-spot-forecast
Windows
rmdir /s /q gpu-spot-forecast
- Confirm the folder is gone by running ls. You should no longer see gpu-spot-forecast in the output.
That's a Wrap!
That's a Wrap!
Nice work. You just built a GPU spot price intelligence system that transforms 120 days of raw pricing data into actionable cost-saving forecasts using time series analysis.
You've learned how to:
- Decomposed time series data into trend, seasonal, and residual components, mining temporal patterns that reveal the cheapest hours and days to launch GPU instances.
- Fit an ARIMA baseline forecast and observed firsthand how it fails to capture the 24-hour demand cycle in its predictions.
- Built a SARIMA model that explicitly captures daily seasonality, proved its superiority with a head-to-head quantitative comparison (RMSE, MAE, MAPE, AIC), and produced a production recommendation with dollar-value cost savings.
- Secret Mission: Built an Optimal Launch Window Recommender that turns raw SARIMA forecasts into actionable GPU scheduling decisions with confidence qualifiers.
Ready to quiz yourself?