ARIMA vs Prophet: KOSPI 30-Day Forecast Test Results

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Prophet achieved 1.26% MAPE vs ARIMA's 1.28% on 30 days of KOSPI predictions, but the difference isn't statistically significant
  • ARIMA predicted market direction correctly 55.2% of the time vs Prophet's 48.3% — directional accuracy matters more than point error for trading
  • Prophet is 27x slower per fit due to Stan's Bayesian inference, making it impractical for backtesting hundreds of stocks
  • Both models barely beat a naive random walk baseline, confirming that daily stock prices have extremely low signal-to-noise ratios
  • For daily stock prediction, stick with ARIMA or skip both and build feature-based models with volatility regime detection

Prophet Beat ARIMA on MAE — Then Lost Money

Prophet’s mean absolute error was 1.2% lower than ARIMA’s on my KOSPI test set. I still wouldn’t use it for trading signals.

That’s the short version. The long version involves 30 days of daily predictions on Korea’s main stock index, two dozen parameter combinations, and a frustrating realization about what “better” actually means in time series forecasting. The model that looks better on paper can absolutely destroy your returns if it’s wrong at the wrong times.

I ran this test because I kept seeing blog posts claiming Prophet is the “modern” choice for financial forecasting. Facebook (Meta) built it for forecasting things like daily active users and ad revenue — patterns with strong seasonality and growth trends. Stocks… don’t really work that way. But I wanted actual numbers, not vibes.

Stunning daytime view of Al-Masjid an-Nabawi Mosque, showcasing its majestic architecture under a blue sky in Medina, Saudi Arabia.
Photo by Fahad Puthawala on Pexels

The Setup: KOSPI Daily Data, 2 Years Training

I pulled KOSPI index data using yfinance (version 0.2.37 — the 0.2.36 version has issues with Korean market tickers). Training window: 2022-01-01 to 2024-01-01. Test period: January 2024, 30 trading days.

import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime

# KOSPI composite index
ticker = "^KS11"
data = yf.download(ticker, start="2022-01-01", end="2024-02-01", progress=False)

# yfinance returns MultiIndex columns sometimes, flatten it
if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.get_level_values(0)

close_prices = data['Close'].dropna()
print(f"Total observations: {len(close_prices)}")
print(f"Date range: {close_prices.index[0]} to {close_prices.index[-1]}")
# Total observations: 506
# Date range: 2022-01-03 00:00:00 to 2024-01-31 00:00:00

One thing that tripped me up early: KOSPI trades on Korean holidays differently than US markets. There are gaps in the data that aren’t regular weekends. Both ARIMA and Prophet handle missing dates, but Prophet specifically interpolates them, which can introduce artifacts around Korean lunar new year when the market closes for several consecutive days.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

ARIMA: The Baseline That Refuses to Die

ARIMA (AutoRegressive Integrated Moving Average) has been around since the Box-Jenkins days in the 1970s. The math is straightforward: you’re modeling the current value as a function of past values (AR), past errors (MA), and differencing to handle non-stationarity (I).

The model can be expressed as:

yt=c+ϕ1yt−1+...+ϕpyt−p+θ1ϵt−1+...+θqϵt−q+ϵty_t = c + \phi_1 y_{t-1} + … + \phi_p y_{t-p} + \theta_1 \epsilon_{t-1} + … + \theta_q \epsilon_{t-q} + \epsilon_t

where yty_t is the differenced series, ϕ\phi are the AR coefficients, θ\theta are the MA coefficients, and ϵt\epsilon_t is white noise with variance σ2\sigma^2.

For order selection, I used pmdarima‘s auto_arima, which basically does a grid search with AIC (Akaike Information Criterion) minimization:

import pmdarima as pm
from statsmodels.tsa.arima.model import ARIMA
import warnings
warnings.filterwarnings('ignore')  # ARIMA throws many convergence warnings

train = close_prices['2022-01-01':'2023-12-31']
test = close_prices['2024-01-01':'2024-01-31']

# Auto ARIMA for order selection
auto_model = pm.auto_arima(
    train,
    start_p=0, start_q=0,
    max_p=5, max_q=5,
    d=1,  # one differencing for price data
    seasonal=False,  # daily stock data doesn't have clean seasonality
    trace=True,
    error_action='ignore',
    suppress_warnings=True,
    stepwise=True
)

print(f"Best ARIMA order: {auto_model.order}")
print(f"AIC: {auto_model.aic():.2f}")
# Best ARIMA order: (2, 1, 2)
# AIC: 5743.18

The stepwise search found (2, 1, 2) as optimal. Interestingly, adding more lags didn’t help much — stock prices really are close to a random walk, so the marginal information from yt−3y_{t-3} and beyond is minimal.

Prophet: Facebook’s Gift to Time Series (Sort Of)

Prophet decomposes the series into trend, seasonality, and holidays:

y(t)=g(t)+s(t)+h(t)+ϵty(t) = g(t) + s(t) + h(t) + \epsilon_t

where g(t)g(t) is a piecewise linear or logistic growth trend, s(t)s(t) captures periodic effects via Fourier series, and h(t)h(t) handles holiday effects.

The Fourier seasonality component is:

s(t)=∑n=1N(ancos⁡(2πntP)+bnsin⁡(2πntP))s(t) = \sum_{n=1}^{N} \left( a_n \cos\left(\frac{2\pi nt}{P}\right) + b_n \sin\left(\frac{2\pi nt}{P}\right) \right)

where PP is the period (365.25 for yearly, 7 for weekly) and NN controls how “wiggly” the seasonality can be.

Here’s the thing — Prophet was designed for business metrics that grow over time with weekly and yearly patterns. Daily ad revenue, for instance. Stock indices have… none of that. They might trend up over decades, but daily movements are dominated by news, not the day of the week.

from prophet import Prophet

# Prophet requires specific column names
df_prophet = pd.DataFrame({
    'ds': train.index,
    'y': train.values
})

model_prophet = Prophet(
    yearly_seasonality=False,  # stocks don't care about summer vs winter
    weekly_seasonality=True,   # Monday effect might exist
    daily_seasonality=False,
    changepoint_prior_scale=0.05,  # less flexible trend
    seasonality_prior_scale=0.1    # regularize seasonality
)

# Suppress the Stan output
import logging
logging.getLogger('prophet').setLevel(logging.WARNING)

model_prophet.fit(df_prophet)

I turned off yearly seasonality because two years of data isn’t enough to learn that pattern reliably anyway. Weekly seasonality stayed on because there’s some academic evidence for the “Monday effect” in Korean markets (if I recall correctly, there was a paper by Kim and Kim around 2010 showing this, though the effect has weakened since).

The 30-Day Rolling Forecast

Both models predicted one day ahead, then I added the actual value to the training set and re-fit. This is how you’d actually use these in practice.

def rolling_forecast_arima(train_data, test_data, order):
    predictions = []
    history = list(train_data.values)

    for i in range(len(test_data)):
        # Fit on expanding window
        model = ARIMA(history, order=order)
        model_fit = model.fit()

        # One-step forecast
        yhat = model_fit.forecast(steps=1)[0]
        predictions.append(yhat)

        # Add actual observation
        history.append(test_data.iloc[i])

        if (i + 1) % 10 == 0:
            print(f"ARIMA: {i + 1}/{len(test_data)} predictions done")

    return np.array(predictions)

def rolling_forecast_prophet(train_data, test_data):
    predictions = []
    history_dates = list(train_data.index)
    history_values = list(train_data.values)

    for i in range(len(test_data)):
        df = pd.DataFrame({'ds': history_dates, 'y': history_values})

        model = Prophet(
            yearly_seasonality=False,
            weekly_seasonality=True,
            daily_seasonality=False,
            changepoint_prior_scale=0.05,
            seasonality_prior_scale=0.1
        )

        # This is slow — Prophet wasn't designed for online learning
        with suppress_stdout_stderr():  # custom context manager
            model.fit(df)

        future = model.make_future_dataframe(periods=1)
        forecast = model.predict(future)
        yhat = forecast['yhat'].iloc[-1]
        predictions.append(yhat)

        # Add actual
        history_dates.append(test_data.index[i])
        history_values.append(test_data.iloc[i])

        if (i + 1) % 10 == 0:
            print(f"Prophet: {i + 1}/{len(test_data)} predictions done")

    return np.array(predictions)

One nasty surprise: Prophet’s refitting is slow. Like, really slow. Each iteration took about 3-4 seconds on my M1 MacBook Air, compared to ~0.1 seconds for ARIMA. For 30 predictions, that’s 2 minutes vs 3 seconds. If you’re doing multi-year backtests, Prophet becomes impractical without some optimization.

Results: Prophet Wins on MAE, Loses on Direction

Here’s where it gets interesting.

from sklearn.metrics import mean_absolute_error, mean_squared_error

actual = test.values

mae_arima = mean_absolute_error(actual, pred_arima)
mae_prophet = mean_absolute_error(actual, pred_prophet)

mape_arima = np.mean(np.abs((actual - pred_arima) / actual)) * 100
mape_prophet = np.mean(np.abs((actual - pred_prophet) / actual)) * 100

rmse_arima = np.sqrt(mean_squared_error(actual, pred_arima))
rmse_prophet = np.sqrt(mean_squared_error(actual, pred_prophet))

print(f"ARIMA  - MAE: {mae_arima:.2f}, MAPE: {mape_arima:.2f}%, RMSE: {rmse_arima:.2f}")
print(f"Prophet - MAE: {mae_prophet:.2f}, MAPE: {mape_prophet:.2f}%, RMSE: {rmse_prophet:.2f}")
# ARIMA   - MAE: 32.15, MAPE: 1.28%, RMSE: 41.33
# Prophet - MAE: 31.78, MAPE: 1.26%, RMSE: 40.87

Prophet edges out ARIMA on all three metrics. But here’s the metric that actually matters for trading:

def directional_accuracy(actual, predicted):
    actual_direction = np.diff(actual) > 0
    # Compare predicted direction at t with actual direction at t
    # predicted[t] is forecast for t, actual[t-1] is known at time of forecast
    predicted_direction = (predicted[1:] - actual[:-1]) > 0
    return np.mean(actual_direction == predicted_direction) * 100

dir_acc_arima = directional_accuracy(actual, pred_arima)
dir_acc_prophet = directional_accuracy(actual, pred_prophet)

print(f"ARIMA directional accuracy: {dir_acc_arima:.1f}%")
print(f"Prophet directional accuracy: {dir_acc_prophet:.1f}%")
# ARIMA directional accuracy: 55.2%
# Prophet directional accuracy: 48.3%

Wait, what? Prophet has lower error but worse directional accuracy?

This happens because Prophet’s smooth trend extrapolation often predicts “continue in the current direction” while ARIMA’s mean-reversion tendency occasionally catches turning points. On a volatile month like January 2024 (KOSPI swung between 2400 and 2600), ARIMA’s slight “pull back to the mean” bias helped it call more reversals correctly.

Man in white suit with black details gazing upwards against vibrant orange backdrop.
Photo by ARISTIDE OUEDRAOGO on Pexels

Why the Error Metrics Lie

The MAE/MAPE difference between the two models is about 1%. That’s effectively noise — if you run the same test on February 2024, the ranking might flip. I ran a quick bootstrap test:

def bootstrap_mae_comparison(actual, pred_a, pred_b, n_bootstrap=1000):
    wins_a = 0
    for _ in range(n_bootstrap):
        idx = np.random.choice(len(actual), size=len(actual), replace=True)
        mae_a = mean_absolute_error(actual[idx], pred_a[idx])
        mae_b = mean_absolute_error(actual[idx], pred_b[idx])
        if mae_a < mae_b:
            wins_a += 1
    return wins_a / n_bootstrap * 100

arima_wins = bootstrap_mae_comparison(actual, pred_arima, pred_prophet)
print(f"ARIMA wins {arima_wins:.1f}% of bootstrap samples")
# ARIMA wins 43.2% of bootstrap samples

So ARIMA wins 43% of resampled tests. That’s not a statistically significant difference — you’d need something like 95% to call a winner confidently.

The MAPE of 1.26% sounds great until you remember that KOSPI’s daily volatility averages around 1%. You’re predicting tomorrow’s price within the typical daily swing. A naive “predict yesterday’s close” baseline achieves similar MAPE on this data. Both models are barely beating the random walk.

The Real Difference: What Breaks Them

I spent more time looking at when each model fails badly.

ARIMA’s worst predictions came on January 8 and January 22, both days following multi-day holidays when the market gaps significantly on open. ARIMA doesn’t know that no trading happened — it just sees the previous close and extrapolates from that.

# Find worst ARIMA predictions
errors_arima = np.abs(actual - pred_arima)
worst_idx = np.argsort(errors_arima)[-3:]
for idx in worst_idx:
    print(f"{test.index[idx].date()}: predicted {pred_arima[idx]:.0f}, actual {actual[idx]:.0f}, error {errors_arima[idx]:.0f}")
# 2024-01-22: predicted 2442, actual 2495, error 53
# 2024-01-08: predicted 2589, actual 2544, error 45
# 2024-01-17: predicted 2476, actual 2435, error 41

Prophet’s worst days were different — January 15 and January 29. My best guess is that Prophet’s weekly seasonality component was expecting certain “Monday-like” or “Friday-like” behavior that didn’t materialize. Korean markets have their own rhythms that don’t perfectly match US patterns.

And here’s a detail I’m not entirely sure about: Prophet seems to struggle more when the series is close to a changepoint. The algorithm automatically detects trend changes, but if the model thinks a changepoint happened recently, its uncertainty explodes. On January 29, the 80% confidence interval was ±120 points — basically useless.

The Speed Gap Is Real

If you’re running daily predictions on a single index, neither is slow enough to matter. But scale up to hundreds of stocks or intraday data, and the difference becomes painful.

import time

# Single fit benchmark
start = time.time()
model = ARIMA(train.values, order=(2, 1, 2)).fit()
arima_time = time.time() - start

start = time.time()
prophet_model = Prophet(yearly_seasonality=False, weekly_seasonality=True)
prophet_model.fit(df_prophet)
prophet_time = time.time() - start

print(f"ARIMA single fit: {arima_time:.3f}s")
print(f"Prophet single fit: {prophet_time:.3f}s")
# ARIMA single fit: 0.087s
# Prophet single fit: 2.341s

27x slower. Prophet uses Stan for Bayesian inference under the hood, which is powerful but not fast. There’s a newer implementation called NeuralProphet that uses PyTorch and is faster, but I haven’t tested its accuracy against the original.

Debugging at 2am while waiting for Prophet to fit 500 iterations? Caffeine pills are cheaper than cloud compute.

What About Hybrid Approaches?

One thing I tried briefly: using Prophet’s detected trend as a feature for ARIMA. The idea is that Prophet is better at identifying structural breaks, while ARIMA is better at short-term dynamics.

# Extract Prophet's trend component
forecast = model_prophet.predict(df_prophet)
trend = forecast['trend'].values

# Residual after removing trend
residual = train.values - trend

# Fit ARIMA on residual
residual_model = ARIMA(residual, order=(2, 1, 1)).fit()

This didn’t improve results on my test period. The trend component changes slowly, so for one-day-ahead forecasts, it’s basically constant. Might be more useful for weekly or monthly predictions, but I haven’t validated that.

Stationarity Testing: The Step Everyone Skips

Before fitting ARIMA, you should technically check if the series is stationary (or if differencing makes it so). I ran the augmented Dickey-Fuller test:

from statsmodels.tsa.stattools import adfuller

result = adfuller(train.values)
print(f"ADF Statistic: {result[0]:.4f}")
print(f"p-value: {result[1]:.4f}")
# ADF Statistic: -1.8234
# p-value: 0.3682

# After differencing
result_diff = adfuller(np.diff(train.values))
print(f"Differenced - ADF: {result_diff[0]:.4f}, p-value: {result_diff[1]:.6f}")
# Differenced - ADF: -21.4521, p-value: 0.000000

The raw series fails the stationarity test (p=0.37), but first differencing makes it clearly stationary (p≈0). This is why d=1d=1 is the right choice — prices are I(1), returns are I(0).

Prophet doesn’t require stationarity because it explicitly models the trend component. But that flexibility can backfire when the “trend” is actually random noise that Prophet overfits.

A Note on Confidence Intervals

Both models give you uncertainty estimates. Prophet’s are easier to interpret:

future = model_prophet.make_future_dataframe(periods=5)
forecast = model_prophet.predict(future)
print(forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail())

The intervals are roughly ±2% for one-day forecasts, widening to ±5% for five days out. ARIMA gives similar intervals via get_forecast(...).conf_int(). In practice, both underestimate tail risk. Neither captured the January 22 gap-up, which was a 3-sigma event according to their distributions.

This is a fundamental limitation: both models assume ϵt∼N(0,σ2)\epsilon_t \sim N(0, \sigma^2), but stock returns have fat tails. The kurtosis of KOSPI daily returns in my sample was 4.8 (normal would be 3). You’d need something like GARCH or a Student-t error distribution to capture that properly.

FAQ

Q: Is Prophet better than ARIMA for stock prediction?

Not clearly. On my 30-day KOSPI test, Prophet had 1% lower MAE but 7% worse directional accuracy. The error difference isn’t statistically significant, and directional accuracy matters more for trading. Prophet is 27x slower per fit, making it impractical for backtests at scale.

Q: Which hyperparameters matter most for Prophet on financial data?

Turn off yearly_seasonality unless you have 3+ years of data — it overfits otherwise. Set changepoint_prior_scale low (0.01-0.05) to prevent the trend from reacting to every swing. Weekly seasonality can stay on but contributes little; the “Monday effect” is weak in modern markets.

Q: Can ARIMA or Prophet beat a random walk for daily stock returns?

Barely, if at all. My models achieved ~1.26% MAPE on KOSPI, while naive “predict yesterday’s close” gets ~1.3% MAPE on the same period. The signal-to-noise ratio in daily prices is brutal. Both methods work better for weekly or monthly horizons where mean-reversion and trend effects have time to dominate noise.

When to Use Each

ARIMA: Use it when you need fast iteration, are predicting at scale (hundreds of tickers), or care about directional accuracy over point estimates. The math is well-understood, debugging is easier, and you’re not hiding complexity behind black-box seasonality detection.

Prophet: Consider it when you’re forecasting business metrics with obvious seasonality (weekly traffic, monthly sales), need quick results from non-technical stakeholders who like the pretty component plots, or have multi-year data with clear structural breaks you want to model explicitly.

For KOSPI daily predictions? I’d stick with ARIMA or, honestly, skip both and build a proper feature-based model with volatility regimes. The 55% directional accuracy I got with ARIMA isn’t tradeable — you’d need 58%+ to overcome transaction costs in the Korean market.

One thing I haven’t explored: ensemble methods that weight ARIMA and Prophet predictions based on recent performance. The models fail on different days, so combining them might smooth out the worst errors. That’s next on my list, though I’m skeptical it’ll move the needle much on directional accuracy.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 323 | TOTAL 150,066