Session 7 of 26 Part III: Forecasting — Chapter 7 of 18
Part III: Forecasting Chapter 7 of 18 Session 7 Python

Revenue & Cost Forecasting using Python

Apply time series decomposition, ARIMA, Prophet, and regression models to forecast the top-line and cost structure — the most critical step in any DCF valuation.

Learning Objectives

7.1 Why Forecasting Is the Heart of Valuation

In a DCF valuation, the explicit forecast period — typically 5 years — is where the analyst's judgment has the greatest impact. The terminal value, which we will study in Chapter 13, often contributes 60–80% of the total DCF result. But those first 5 years matter enormously: they establish the base from which terminal growth is projected, and they are the years where company-specific insight (rather than generic perpetuity assumptions) carries the most weight.

Forecasting is not about predicting the future with precision — that is impossible. It is about building reasonable, internally consistent, assumption-transparent projections that can be stress-tested. A good forecast:

🌎
Real World — The Consensus Forecasting Trap: Equity research analysts tend to cluster their forecasts near the consensus. A 2023 study of Nifty 50 analyst estimates found that 80% of 1-year-forward EPS forecasts fell within ±15% of the consensus — yet actual EPS deviated from consensus by more than 30% in 40% of cases. The lesson: do not anchor your forecast to what others are saying. Build it from the bottom up, based on your own analysis of the company's value drivers.

7.2 Preparing the Forecasting Environment

Install the forecasting libraries we need:

pip install yfinance pandas numpy matplotlib seaborn statsmodels prophet scikit-learn
LibraryRole in Forecasting
statsmodelsStatistical models — ARIMA, exponential smoothing, time series decomposition, diagnostic tests
prophetFacebook Prophet — automated time series forecasting with seasonality and changepoint detection
scikit-learnMachine learning — linear regression, cross-validation, train/test splits, metrics

Let us start by fetching the historical data we will use throughout this chapter:

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

sns.set_style("darkgrid")
plt.rcParams['figure.figsize'] = (14, 5)

# Fetch historical financials for a sample company
ticker = yf.Ticker("ASIANPAINT.NS")
info = ticker.info

# Extract annual financials
is_df = ticker.financials       # Income Statement
bs_df = ticker.balance_sheet    # Balance Sheet
cf_df = ticker.cashflow         # Cash Flow

# Sort chronologically (oldest first) for time series work
is_df = is_df.sort_index(axis=1)
bs_df = bs_df.sort_index(axis=1)
cf_df = cf_df.sort_index(axis=1)

# Extract key series
def safe_get(df, candidates):
    for c in ([candidates] if isinstance(candidates, str) else candidates):
        if c in df.index:
            return df.loc[c]
    return None

revenue = safe_get(is_df, ['Total Revenue', 'Revenue'])
cogs = safe_get(is_df, ['Cost Of Goods Sold', 'Cost of Revenue'])
ebit = safe_get(is_df, ['Operating Income', 'EBIT'])
net_income = safe_get(is_df, ['Net Income', 'Net Income Common Stockholders'])

# Convert to crores and create a clean DataFrame
years = [d.year for d in revenue.index]  # Extract just the year

df = pd.DataFrame({
    'Year': years,
    'Revenue': revenue.values / 1e7,
    'COGS': cogs.values / 1e7 if cogs is not None else None,
    'EBIT': ebit.values / 1e7 if ebit is not None else None,
    'Net_Income': net_income.values / 1e7 if net_income is not None else None,
})

df = df.set_index('Year').sort_index()
df['Gross_Margin'] = ((df['Revenue'] - df['COGS']) / df['Revenue']) * 100
df['Operating_Margin'] = (df['EBIT'] / df['Revenue']) * 100

print(f"Company: {info.get('longName')}")
print(f"Data range: {df.index.min()} – {df.index.max()}")
print(f"\n{df.round(2)}")
# Quick visualization of the historical revenue trend
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

axes[0].plot(df.index, df['Revenue'], marker='o', linewidth=2, color='#6c8cff', markersize=8)
axes[0].set_title('Revenue (Rs. Crore)', fontweight='bold')
axes[0].set_ylabel('Rs. Crore')
axes[0].grid(True, alpha=0.3)

axes[1].bar(df.index, df['Revenue'].pct_change() * 100, color='#00c9a7', edgecolor='white')
axes[1].axhline(y=0, color='red', linestyle='--', alpha=0.5)
axes[1].set_title('Revenue Growth Rate (%)', fontweight='bold')

axes[2].plot(df.index, df['Gross_Margin'], marker='s', linewidth=2, color='#f0a040', label='Gross Margin')
axes[2].plot(df.index, df['Operating_Margin'], marker='^', linewidth=2, color='#6c8cff', label='Operating Margin')
axes[2].set_title('Margin Trends (%)', fontweight='bold')
axes[2].legend()

plt.suptitle(f"{info.get('longName', 'Company')} — Historical Financial Trends",
             fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()

7.3 Time Series Decomposition: Understanding What Drives the Data

Before you forecast, you must understand the structure of your data. Every time series can be decomposed into three components:

ComponentWhat It IsExample in Revenue Data
Trend (T)The long-term direction — up, down, or flatAsian Paints' revenue has trended upward at ~12–15% annually for two decades
Seasonality (S)Regular, repeating patterns within fixed periodsPaint sales spike before Diwali (festive season) and dip during monsoon (construction slows)
Residual (R)Everything else — random noise, one-time eventsCOVID-19 lockdown quarter (Apr–Jun 2020) — revenue collapsed, then rebounded sharply

The decomposition can be additive (Y = T + S + R) or multiplicative (Y = T × S × R). Use additive when the seasonal swings are roughly constant in absolute terms. Use multiplicative when seasonal swings grow proportionally with the trend — revenue data is typically multiplicative.

7.3.1 Decomposing Revenue with statsmodels

from statsmodels.tsa.seasonal import seasonal_decompose

# For decomposition, we need quarterly data for meaningful seasonality.
# Let's fetch quarterly data instead of annual.
ticker_q = yf.Ticker("ASIANPAINT.NS")

# Get quarterly revenue (use quarterly financials)
quarterly_is = ticker_q.quarterly_financials.sort_index(axis=1)
q_revenue = safe_get(quarterly_is, ['Total Revenue', 'Revenue'])

# Create a proper time series
q_rev_series = pd.Series(
    q_revenue.values / 1e7,
    index=pd.to_datetime(q_revenue.index),
    name='Quarterly Revenue'
).sort_index()

# Run decomposition (multiplicative for financial data)
decomp = seasonal_decompose(q_rev_series, model='multiplicative', period=4)

# Plot
fig, axes = plt.subplots(4, 1, figsize=(14, 10))

axes[0].plot(q_rev_series.index, q_rev_series.values, color='#6c8cff', linewidth=2)
axes[0].set_title('Observed — Quarterly Revenue (Rs. Crore)', fontweight='bold')

axes[1].plot(decomp.trend.index, decomp.trend.values, color='#00c9a7', linewidth=2)
axes[1].set_title('Trend — The Long-Term Direction', fontweight='bold')

axes[2].plot(decomp.seasonal.index, decomp.seasonal.values, color='#f0a040', linewidth=1.5)
axes[2].set_title('Seasonality — Repeating Quarterly Pattern', fontweight='bold')
axes[2].axhline(y=1.0, color='gray', linestyle='--', alpha=0.5)

axes[3].bar(decomp.resid.index, decomp.resid.values, color='#e0556a' if decomp.resid.mean() < 1 else '#00c9a7', alpha=0.7)
axes[3].set_title('Residual — Unexplained / Random Component', fontweight='bold')
axes[3].axhline(y=1.0, color='gray', linestyle='--', alpha=0.5)

plt.suptitle('Time Series Decomposition — Asian Paints Quarterly Revenue',
             fontsize=14, fontweight='bold', y=1.01)
plt.tight_layout()
plt.show()
💡
Pro Tip: The residual component is where you find anomalies — quarters where something unusual happened. Investigate large residuals. A positive residual in a particular quarter might be a one-time export order. A negative residual might be a strike or supply disruption. Identifying these helps you decide whether to exclude them from your forecast baseline or model them as recurring risks.

7.4 Simple Forecasting Methods: Baselines You Must Beat

Before deploying sophisticated models, establish a baseline. If your complex ARIMA or Prophet model cannot beat a simple moving average, the complex model is not adding value. Every forecasting project should start with these three baselines:

7.4.1 Naive Forecast

The simplest possible model: next year's revenue = this year's revenue. No change.

naive_forecast = df['Revenue'].iloc[-1]  # Last known value
print(f"Naive Forecast (Next Year Revenue): Rs. {naive_forecast:.0f} Cr")
print(f"Implied Growth: 0%")

7.4.2 Moving Average

Forecast = average of the last N years. Smooths out year-to-year noise.

def moving_average_forecast(series, window=3, periods=1):
    """
    Forecast using a trailing moving average.

    Parameters
    ----------
    series : pd.Series — historical values (chronological)
    window : int — number of years to average
    periods : int — number of years to forecast forward
    """
    forecasts = []
    last_value = series.iloc[-window:].mean()
    # For financial forecasting, we typically use the MA of recent growth rates
    # rather than the absolute values, then apply that growth to the last value.
    growth_rates = series.pct_change().dropna()
    avg_growth = growth_rates.iloc[-window:].mean()

    for i in range(1, periods + 1):
        forecasts.append(series.iloc[-1] * (1 + avg_growth) ** i)

    return forecasts

ma_forecast = moving_average_forecast(df['Revenue'], window=3, periods=3)
print("3-Year MA (Growth Rate) Forecast:")
for i, f in enumerate(ma_forecast, 1):
    print(f"  Year +{i}: Rs. {f:.0f} Cr (Growth: {((f/df['Revenue'].iloc[-1])**(1/i)-1)*100:.1f}% CAGR)")

7.4.3 Exponential Smoothing

Exponential smoothing gives more weight to recent observations and less to distant ones. The alpha parameter (0 to 1) controls the decay: alpha near 1 gives almost all weight to the most recent observation; alpha near 0 spreads weight evenly.

from statsmodels.tsa.holtwinters import SimpleExpSmoothing

def exp_smoothing_forecast(series, alpha=0.6, periods=3):
    """Forecast using Simple Exponential Smoothing."""
    model = SimpleExpSmoothing(series).fit(smoothing_level=alpha, optimized=False)
    forecast = model.forecast(periods)
    return forecast

es_forecast = exp_smoothing_forecast(df['Revenue'], alpha=0.6, periods=3)
print("Exponential Smoothing Forecast (alpha=0.6):")
for i, f in enumerate(es_forecast, 1):
    print(f"  Year +{i}: Rs. {f:.0f} Cr")

# Compare baselines
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(df.index, df['Revenue'], 'o-', color='#6c8cff', linewidth=3, markersize=8, label='Historical')
# Extend x-axis for forecasts
forecast_years = [df.index[-1] + i for i in range(1, 4)]
ax.plot(forecast_years, ma_forecast, 's--', color='#00c9a7', linewidth=2, label='Moving Average (3Y Growth)')
ax.plot(forecast_years, es_forecast, '^--', color='#f0a040', linewidth=2, label='Exponential Smoothing')
ax.set_title('Baseline Forecast Comparison', fontweight='bold')
ax.set_ylabel('Revenue (Rs. Crore)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
Baseline Test: If your sophisticated ARIMA or Prophet model produces forecasts that are wildly different from the simple baselines, that is not necessarily bad — but it demands justification. Why does the complex model diverge? Is it detecting a genuine signal the baselines miss, or is it overfitting noise?

7.5 ARIMA Models: The Workhorse of Statistical Forecasting

ARIMA (AutoRegressive Integrated Moving Average) is the most widely used statistical forecasting framework. It models a time series as a function of its own past values (autoregression) and past forecast errors (moving average), after differencing to make the series stationary.

7.5.1 The Three Parameters: p, d, q

ParameterNameWhat It DoesHow to Choose
pAutoRegressive orderNumber of lagged values of the series used as predictors. p=1 means "this year's value depends on last year's value."Look at PACF plot — significant lags suggest p.
dDifferencing orderNumber of times the series is differenced to make it stationary. Most financial series need d=1 or d=2.Run an ADF test. If p-value > 0.05, difference the series and test again.
qMoving Average orderNumber of lagged forecast errors used as predictors. Captures "shock" persistence.Look at ACF plot — significant lags suggest q.

7.5.2 Stationarity and the ADF Test

ARIMA requires the time series to be stationary — its statistical properties (mean, variance) must be constant over time. Most financial series are non-stationary (they trend upward), so we difference them.

from statsmodels.tsa.stattools import adfuller

def test_stationarity(series, name="Series"):
    """Augmented Dickey-Fuller test for stationarity."""
    result = adfuller(series.dropna(), autolag='AIC')
    print(f"\n{name} — ADF Test:")
    print(f"  ADF Statistic: {result[0]:.4f}")
    print(f"  p-value:       {result[1]:.4f}")
    print(f"  Critical Values: 1%: {result[4]['1%']:.3f}, 5%: {result[4]['5%']:.3f}")

    if result[1] <= 0.05:
        print(f"  ✓ Series IS stationary (reject H0 at 95% confidence)")
    else:
        print(f"  ✗ Series is NON-stationary — differencing required")

# Test original revenue series
test_stationarity(df['Revenue'], "Revenue (Levels)")

# Test differenced revenue
revenue_diff = df['Revenue'].diff().dropna()
test_stationarity(revenue_diff, "Revenue (First Difference)")

7.5.3 Auto-ARIMA: Let the Algorithm Choose

Manually selecting (p, d, q) by inspecting ACF/PACF plots is educational but time-consuming. The pmdarima library automates this:

pip install pmdarima
from pmdarima import auto_arima

# Fit auto-ARIMA on revenue data
auto_model = auto_arima(
    df['Revenue'],
    start_p=0, start_q=0, max_p=3, max_q=3,
    seasonal=False,          # Annual data — no seasonality
    trace=False,             # Set True to see the search
    error_action='ignore',
    suppress_warnings=True,
    stepwise=True,
    n_fits=50
)

print(f"Best ARIMA model: {auto_model}")
print(f"AIC: {auto_model.aic():.1f}")

# Forecast
arima_forecast, arima_ci = auto_model.predict(n_periods=5, return_conf_int=True)

print("\nARIMA 5-Year Forecast:")
for i, (f, (lo, hi)) in enumerate(zip(arima_forecast, arima_ci), 1):
    growth = ((f / df['Revenue'].iloc[-1]) ** (1/i) - 1) * 100
    print(f"  Year +{i}: Rs. {f:.0f} Cr (CAGR: {growth:.1f}%)  [95% CI: {lo:.0f} – {hi:.0f}]")

# Plot
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(df.index, df['Revenue'], 'o-', color='#6c8cff', linewidth=3, markersize=8, label='Historical')
forecast_years = [df.index[-1] + i for i in range(1, 6)]
ax.plot(forecast_years, arima_forecast, 's-', color='#00c9a7', linewidth=2, label='ARIMA Forecast')
ax.fill_between(forecast_years, arima_ci[:, 0], arima_ci[:, 1],
                alpha=0.2, color='#00c9a7', label='95% Confidence Interval')
ax.set_title(f'ARIMA({auto_model.order[0]},{auto_model.order[1]},{auto_model.order[2]}) Revenue Forecast',
             fontweight='bold')
ax.set_ylabel('Revenue (Rs. Crore)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
ARIMA Limitation with Annual Data: ARIMA works best with 30+ observations. Most Indian companies have only 5–10 years of reliable annual financials in yfinance. With so few data points, ARIMA results can be unstable. The confidence intervals will be wide — and they should be. A 5-year forecast from 7 data points carries enormous uncertainty. This is why we use multiple methods and treat forecasts as scenarios, not predictions.

7.6 Facebook Prophet: Automated Forecasting with Business Intuition

Prophet (developed by Meta/Facebook) is designed for business forecasting. Unlike ARIMA, which is purely statistical, Prophet is a decomposable additive model with three built-in components that align with how business analysts think about data:

y(t) = g(t) + s(t) + h(t) + εt
g(t) = trend (linear or logistic growth with changepoints)
s(t) = seasonality (weekly, monthly, yearly)
h(t) = holiday effects
εt = error term

Prophet's key advantage is that it handles the realities of business data: trend changes, missing data, outliers, and seasonal patterns — all with minimal parameter tuning.

7.6.1 Forecasting Revenue with Prophet

from prophet import Prophet

# Prophet requires columns: 'ds' (date) and 'y' (value)
# For annual data, we use the fiscal year-end date
prophet_df = pd.DataFrame({
    'ds': pd.to_datetime([f'{y}-03-31' for y in df.index]),  # March 31 fiscal year-end
    'y': df['Revenue'].values
})

# Initialize and fit
prophet_model = Prophet(
    growth='linear',            # 'logistic' if you want to cap growth
    yearly_seasonality=False,   # Annual data has no within-year seasonality
    changepoint_prior_scale=0.05,  # Flexibility of trend changes (lower = less flexible)
    n_changepoints=len(prophet_df) // 2,  # Potential trend changepoints
    interval_width=0.80         # 80% prediction interval
)

prophet_model.fit(prophet_df)

# Create future DataFrame: 5 years forward
future = prophet_model.make_future_dataframe(periods=5, freq='Y')  # 'Y' = year-end
forecast = prophet_model.predict(future)

# Extract forecasts
prophet_forecast = forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail(5)

print("Prophet 5-Year Revenue Forecast:")
for _, row in prophet_forecast.iterrows():
    year = row['ds'].year
    growth = ((row['yhat'] / df['Revenue'].iloc[-1]) ** (1/5) - 1) * 100  # Approx
    print(f"  FY{year}: Rs. {row['yhat']:.0f} Cr  [80% CI: {row['yhat_lower']:.0f} – {row['yhat_upper']:.0f}]")

# Plot Prophet's components
fig = prophet_model.plot(forecast, figsize=(14, 6))
ax = fig.gca()
ax.set_title('Prophet Revenue Forecast — Asian Paints', fontweight='bold')
ax.set_ylabel('Revenue (Rs. Crore)')
plt.show()

# Plot changepoints (where Prophet detected trend shifts)
fig2 = prophet_model.plot_components(forecast, figsize=(14, 5))
plt.show()

7.6.2 Tuning Prophet with Changepoints

The changepoint_prior_scale is Prophet's most important tuning parameter. It controls how flexible the trend is:

# Compare different changepoint sensitivities
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

for ax, cps, label in zip(axes,
                           [0.01, 0.05, 0.5],
                           ['Rigid (cps=0.01)', 'Balanced (cps=0.05)', 'Flexible (cps=0.5)']):
    m = Prophet(growth='linear', changepoint_prior_scale=cps, yearly_seasonality=False)
    m.fit(prophet_df)
    f = m.predict(m.make_future_dataframe(periods=5, freq='Y'))
    ax.plot(df.index, df['Revenue'], 'o-', color='#6c8cff', linewidth=2, markersize=6, label='Historical')
    ax.plot([r.year for _, r in f.tail(5).iterrows()], f.tail(5)['yhat'], 's--', color='#00c9a7', linewidth=2)
    ax.fill_between([r.year for _, r in f.tail(5).iterrows()],
                    f.tail(5)['yhat_lower'], f.tail(5)['yhat_upper'],
                    alpha=0.2, color='#00c9a7')
    ax.set_title(label, fontweight='bold')
    ax.set_ylabel('Revenue (Rs. Cr)')
    ax.grid(True, alpha=0.3)

plt.suptitle('Prophet Sensitivity: Effect of changepoint_prior_scale on Forecast',
             fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
🌎
Real World: When forecasting Asian Paints' revenue, Prophet typically detects a changepoint around FY2021 — the COVID recovery year when pent-up demand and home improvement spending drove a sharp revenue bounce. The model will treat this as either a genuine trend acceleration (if cps is high) or a temporary deviation from trend (if cps is low). Your judgment as the analyst determines which interpretation is correct for valuation purposes. Was COVID a permanent accelerant for paint demand, or a one-time shift that will normalize?

7.7 Regression-Based Forecasting: Linking Revenue to Economic Drivers

Time series models (ARIMA, Prophet) forecast revenue using only its own history. But revenue does not exist in a vacuum — it is driven by macroeconomic factors and company-specific investments. Regression forecasting models revenue as a function of these drivers.

7.7.1 Simple Linear Regression: Revenue vs Time

from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error

# Revenue as a function of time (year index)
X = np.arange(len(df)).reshape(-1, 1)  # Year indices: 0, 1, 2, ...
y = df['Revenue'].values

model_lr = LinearRegression()
model_lr.fit(X, y)

# Forecast next 5 years
X_future = np.arange(len(df), len(df) + 5).reshape(-1, 1)
lr_forecast = model_lr.predict(X_future)

print(f"Linear Regression: Revenue = {model_lr.intercept_:.0f} + {model_lr.coef_[0]:.0f} × YearIndex")
print(f"R² = {model_lr.score(X, y):.3f}")
print("\n5-Year Forecast:")
for i, f in enumerate(lr_forecast, 1):
    growth = ((f / y[-1]) ** (1/i) - 1) * 100
    print(f"  Year +{i}: Rs. {f:.0f} Cr (CAGR: {growth:.1f}%)")

7.7.2 Multiple Regression: Revenue vs Multiple Drivers

For a richer model, we could regress revenue against:

# Example: Revenue as a function of Time + Lagged Capex
# (Simplified — real GDP data would come from RBI/World Bank APIs)

# Fetch capex data
capex = safe_get(cf_df, ['Capital Expenditure', 'Capital Expenditure Reported'])
if capex is not None:
    capex_values = capex.values / 1e7  # Convert to crores
    # Lag capex by 1 year (this year's capex enables next year's revenue)
    capex_lagged = np.roll(capex_values, 1)
    capex_lagged[0] = capex_values[0]  # Fill the first year with unlagged value

    # Build feature matrix
    X_multi = np.column_stack([
        np.arange(len(df)),    # Time trend
        capex_lagged            # Lagged capex
    ])

    model_multi = LinearRegression()
    model_multi.fit(X_multi, y)

    print(f"Multiple Regression R²: {model_multi.score(X_multi, y):.3f}")
    print(f"Coefficients: Time={model_multi.coef_[0]:.0f}, LaggedCapex={model_multi.coef_[1]:.1f}")

    # To forecast, you need future capex assumptions — this links revenue to your investment forecast
💡
Pro Tip: Regression is most valuable when you have a clear economic driver for revenue. For a cement company, regress revenue against GDP growth and infrastructure spending. For a retailer, regress against store count and same-store sales growth. For an IT services company, regress against global IT spending. The regression quantifies the relationship, and your macroeconomic forecast becomes a revenue forecast. This is how professional analysts build their models.

7.8 Forecasting Costs and Margins

Revenue is only half the equation. To get to free cash flow, you must forecast costs. The approach depends on the cost category:

7.8.1 Cost of Goods Sold (COGS): Margin-Based Forecasting

The most common approach is to forecast gross margin rather than COGS directly. Margins are more stable than absolute costs and reflect the company's competitive position.

# Forecast gross margin using its historical average and trend
historical_gm = df['Gross_Margin'].dropna()
avg_gm = historical_gm.mean()
trend_gm = np.polyfit(range(len(historical_gm)), historical_gm.values, 1)[0]

print(f"Historical Gross Margin: Mean = {avg_gm:.1f}%, Trend = {trend_gm:.1f}pp/year")

# Three margin scenarios for forecasting
# 1. Constant: keep margin at the historical average
# 2. Trend: continue the historical trend (positive or negative)
# 3. Mean-reverting: margins revert to industry average over time

def forecast_cogs_from_margin(revenue_forecast, gross_margin):
    """Given a revenue forecast and a gross margin, compute COGS."""
    cogs = revenue_forecast * (1 - gross_margin / 100)
    return cogs

# Example: Use Prophet revenue forecast with three margin scenarios
rev_forecast_values = prophet_forecast['yhat'].values[:5]

scenarios = {
    'Constant Margin': avg_gm,
    'Trend Margin': avg_gm + trend_gm * len(df),  # Extrapolate trend
    'Industry Avg Margin': 38.0  # Hypothetical industry average
}

for scenario, margin in scenarios.items():
    cogs = forecast_cogs_from_margin(rev_forecast_values, margin)
    print(f"\n{scenario} ({margin:.1f}%):")
    for i, (r, c) in enumerate(zip(rev_forecast_values, cogs), 1):
        print(f"  Year +{i}: Revenue Rs.{r:.0f}Cr, COGS Rs.{c:.0f}Cr, "
              f"Gross Profit Rs.{r-c:.0f}Cr")

7.8.2 Operating Expenses: Fixed vs Variable Split

Operating expenses are a mix of fixed costs (rent, base salaries, depreciation) and variable costs (commissions, freight, marketing). The split determines how margins evolve as revenue grows:

def forecast_opex(revenue_forecast, fixed_opex_pct=0.40, variable_opex_pct=0.15):
    """
    Forecast operating expenses assuming a fixed/variable split.

    Parameters
    ----------
    revenue_forecast : array of future revenue values
    fixed_opex_pct : float — fixed opex as % of CURRENT (last year) revenue
    variable_opex_pct : float — variable opex as % of revenue
    """
    last_revenue = df['Revenue'].iloc[-1]
    fixed_opex = last_revenue * fixed_opex_pct

    opex_forecast = []
    for rev in revenue_forecast:
        total_opex = fixed_opex + (rev * variable_opex_pct)
        opex_forecast.append(total_opex)

    return np.array(opex_forecast)

# Example
rev_future = prophet_forecast['yhat'].values[:5]
opex = forecast_opex(rev_future, fixed_opex_pct=0.12, variable_opex_pct=0.10)

last_op_margin = df['Operating_Margin'].iloc[-1]
future_op_margin = ((rev_future - opex) / rev_future) * 100

print("Operating Margin Evolution (with Operating Leverage):")
for i, (r, o, m) in enumerate(zip(rev_future, opex, future_op_margin), 1):
    print(f"  Year +{i}: Revenue Rs.{r:.0f}Cr, Opex Rs.{o:.0f}Cr, "
          f"Op Margin {m:.1f}% (Δ from last: {m-last_op_margin:+.1f}pp)")

7.8.3 Depreciation: A Function of Capex History

Depreciation is forecast based on the existing asset base plus future capex. A simple approach: depreciation = (historical depreciation rate) × (net PPE + new capex).

def forecast_depreciation(balance_sheet, income_stmt, future_capex_assumptions, depr_rate=None):
    """
    Forecast depreciation based on asset base and future capex.

    future_capex_assumptions : list — projected capex for each forecast year
    depr_rate : float — depreciation as % of net PPE (if None, uses historical avg)
    """
    net_ppe = safe_get(balance_sheet, ['Net PPE', 'Property Plant Equipment Net'])
    d_a = safe_get(income_stmt, ['Depreciation And Amortization',
                                  'Depreciation Amortization Depletion'])

    if net_ppe is None or d_a is None:
        print("Cannot forecast depreciation: missing data")
        return None

    if depr_rate is None:
        depr_rate = (d_a / net_ppe).mean()

    depr_forecast = []
    current_ppe = net_ppe.iloc[-1]

    for capex in future_capex_assumptions:
        # Simplified: D&A = rate × (beginning PPE + capex)
        depr = depr_rate * (current_ppe + capex)
        depr_forecast.append(depr)
        current_ppe = current_ppe + capex - depr  # Net PPE evolves

    print(f"Historical Depreciation Rate: {depr_rate*100:.1f}% of Net PPE")
    print("\nDepreciation Forecast:")
    for i, (c, d) in enumerate(zip(future_capex_assumptions, depr_forecast), 1):
        print(f"  Year +{i}: Capex Rs.{c:.0f}Cr → Depreciation Rs.{d:.0f}Cr")

    return np.array(depr_forecast)

# Example
depr_forecast = forecast_depreciation(
    bs_df, is_df,
    future_capex_assumptions=[1200, 1300, 1400, 1500, 1600]  # 5 years of assumed capex
)

7.9 Evaluating Forecast Accuracy: Choosing the Best Model

How do you know which forecasting model to trust? You test them against data they have not seen. The standard approach is backtesting: hold out the last 2–3 years of data, train models on the earlier years, forecast the held-out period, and measure the error.

7.9.1 Error Metrics

MetricFormulaWhat It Tells You
MAE (Mean Absolute Error)(1/n) ∑ |Actual − Forecast|Average absolute deviation in rupees. Easy to interpret.
RMSE (Root Mean Squared Error)√( (1/n) ∑ (Actual − Forecast)2 )Penalizes large errors more than small ones. Lower = better.
MAPE (Mean Absolute % Error)(1/n) ∑ |(Actual − Forecast) / Actual| × 100Error as a percentage. Enables comparison across companies of different sizes.

7.9.2 Backtesting Framework

from sklearn.metrics import mean_absolute_error, mean_squared_error

def backtest_models(series, test_years=3):
    """
    Backtest multiple forecasting models against held-out data.

    Parameters
    ----------
    series : pd.Series — historical values (chronological)
    test_years : int — number of years to hold out for testing

    Returns
    -------
    DataFrame of error metrics for each model.
    """
    train = series.iloc[:-test_years]
    test = series.iloc[-test_years:]

    results = {}

    # 1. Naive (last value repeated)
    naive_preds = np.repeat(train.iloc[-1], test_years)
    results['Naive'] = {
        'MAE': mean_absolute_error(test, naive_preds),
        'RMSE': np.sqrt(mean_squared_error(test, naive_preds)),
        'MAPE': np.mean(np.abs((test.values - naive_preds) / test.values)) * 100
    }

    # 2. Moving Average (3-year growth rate)
    growth = train.pct_change().dropna().iloc[-3:].mean()
    ma_preds = np.array([train.iloc[-1] * (1 + growth) ** i for i in range(1, test_years+1)])
    results['Moving Avg (3Y)'] = {
        'MAE': mean_absolute_error(test, ma_preds),
        'RMSE': np.sqrt(mean_squared_error(test, ma_preds)),
        'MAPE': np.mean(np.abs((test.values - ma_preds) / test.values)) * 100
    }

    # 3. Exponential Smoothing
    try:
        es_model = SimpleExpSmoothing(train).fit(smoothing_level=0.6, optimized=False)
        es_preds = es_model.forecast(test_years)
        results['Exp Smoothing'] = {
            'MAE': mean_absolute_error(test, es_preds),
            'RMSE': np.sqrt(mean_squared_error(test, es_preds)),
            'MAPE': np.mean(np.abs((test.values - es_preds) / test.values)) * 100
        }
    except:
        pass

    # 4. Linear Regression
    X_train = np.arange(len(train)).reshape(-1, 1)
    X_test = np.arange(len(train), len(train) + test_years).reshape(-1, 1)
    lr = LinearRegression().fit(X_train, train.values)
    lr_preds = lr.predict(X_test)
    results['Linear Regression'] = {
        'MAE': mean_absolute_error(test, lr_preds),
        'RMSE': np.sqrt(mean_squared_error(test, lr_preds)),
        'MAPE': np.mean(np.abs((test.values - lr_preds) / test.values)) * 100
    }

    # 5. ARIMA (if enough data)
    if len(train) >= 6:
        try:
            arima_m = auto_arima(train, seasonal=False, suppress_warnings=True,
                                 error_action='ignore', stepwise=True)
            arima_preds = arima_m.predict(n_periods=test_years)
            results['ARIMA'] = {
                'MAE': mean_absolute_error(test, arima_preds),
                'RMSE': np.sqrt(mean_squared_error(test, arima_preds)),
                'MAPE': np.mean(np.abs((test.values - arima_preds) / test.values)) * 100
            }
        except:
            pass

    # 6. Prophet
    try:
        prophet_train = pd.DataFrame({
            'ds': pd.to_datetime([f'{y}-03-31' for y in train.index]),
            'y': train.values
        })
        p_model = Prophet(yearly_seasonality=False)
        p_model.fit(prophet_train)
        p_future = p_model.make_future_dataframe(periods=test_years, freq='Y')
        p_forecast = p_model.predict(p_future)
        prophet_preds = p_forecast.tail(test_years)['yhat'].values
        results['Prophet'] = {
            'MAE': mean_absolute_error(test, prophet_preds),
            'RMSE': np.sqrt(mean_squared_error(test, prophet_preds)),
            'MAPE': np.mean(np.abs((test.values - prophet_preds) / test.values)) * 100
        }
    except:
        pass

    return pd.DataFrame(results).T.sort_values('RMSE')

# Run backtest
backtest_results = backtest_models(df['Revenue'], test_years=3)
print("=== Backtest Results: Revenue Forecasting Models ===")
print(f"(Lower RMSE = better. Models ranked by RMSE.)\n")
print(backtest_results.round(2))

# Highlight the winner
best_model = backtest_results.index[0]
print(f"\nBest model: {best_model} (RMSE = {backtest_results.loc[best_model, 'RMSE']:.1f})")
💡
Pro Tip: The model with the lowest backtest error is not automatically the best model for your DCF. Business judgment overrides statistical metrics. If the lowest-error model projects 30% annual revenue growth forever, while you know the industry grows at 8%, reject it. Statistical fit + Economic plausibility = A defensible forecast.

7.10 The Complete Forecasting Pipeline

Now we combine everything into a single, reusable forecasting pipeline that takes a ticker symbol and produces a 5-year revenue and margin forecast:

from prophet import Prophet
from pmdarima import auto_arima
from sklearn.linear_model import LinearRegression

def complete_forecast_pipeline(ticker_symbol, forecast_years=5):
    """
    End-to-end revenue and margin forecasting pipeline.

    Returns a dict with:
      - historical: DataFrame of historical revenue and margins
      - prophet_forecast: Prophet model and forecast for revenue
      - margin_scenarios: 3 margin scenarios (constant, trend, industry)
      - recommended: recommended forecast for DCF use
    """
    ticker = yf.Ticker(ticker_symbol)
    info = ticker.info
    is_df = ticker.financials.sort_index(axis=1)
    bs_df = ticker.balance_sheet.sort_index(axis=1)

    # --- Extract Historical Data ---
    def safe_get(df, candidates):
        for c in ([candidates] if isinstance(candidates, str) else candidates):
            if c in df.index:
                return df.loc[c]
        return None

    rev = safe_get(is_df, ['Total Revenue', 'Revenue']) / 1e7
    cogs = safe_get(is_df, ['Cost Of Goods Sold', 'Cost of Revenue'])
    cogs = cogs / 1e7 if cogs is not None else None
    ebit = safe_get(is_df, ['Operating Income', 'EBIT'])
    ebit = ebit / 1e7 if ebit is not None else None
    ni = safe_get(is_df, ['Net Income', 'Net Income Common Stockholders'])
    ni = ni / 1e7 if ni is not None else None

    years = [d.year for d in rev.index]

    historical = pd.DataFrame({
        'Year': years,
        'Revenue': rev.values,
    })
    if cogs is not None:
        historical['COGS'] = cogs.values
        historical['Gross_Margin'] = (historical['Revenue'] - historical['COGS']) / historical['Revenue'] * 100
    if ebit is not None:
        historical['EBIT'] = ebit.values
        historical['Operating_Margin'] = historical['EBIT'] / historical['Revenue'] * 100
    if ni is not None:
        historical['Net_Income'] = ni.values

    historical = historical.set_index('Year').sort_index()

    # --- Forecast Revenue with Prophet ---
    prophet_df = pd.DataFrame({
        'ds': pd.to_datetime([f'{y}-03-31' for y in historical.index]),
        'y': historical['Revenue'].values
    })

    p_model = Prophet(growth='linear', changepoint_prior_scale=0.05,
                      yearly_seasonality=False, interval_width=0.80)
    p_model.fit(prophet_df)
    p_future = p_model.make_future_dataframe(periods=forecast_years, freq='Y')
    p_forecast = p_model.predict(p_future)

    rev_forecast = p_forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail(forecast_years)
    rev_forecast['Year'] = rev_forecast['ds'].dt.year
    rev_forecast = rev_forecast.set_index('Year')

    # --- Margin Scenarios ---
    margin_scenarios = {}
    if 'Gross_Margin' in historical.columns:
        gm_mean = historical['Gross_Margin'].mean()
        gm_trend = np.polyfit(range(len(historical)), historical['Gross_Margin'].values, 1)[0]
        margin_scenarios['Gross Margin'] = {
            'Historical Avg': gm_mean,
            'Recent Trend': historical['Gross_Margin'].iloc[-1],
            'Trend Extrapolation': historical['Gross_Margin'].iloc[-1] + gm_trend * forecast_years
        }

    if 'Operating_Margin' in historical.columns:
        om_mean = historical['Operating_Margin'].mean()
        om_trend = np.polyfit(range(len(historical)), historical['Operating_Margin'].values, 1)[0]
        margin_scenarios['Operating Margin'] = {
            'Historical Avg': om_mean,
            'Recent Trend': historical['Operating_Margin'].iloc[-1],
            'Trend Extrapolation': historical['Operating_Margin'].iloc[-1] + om_trend * forecast_years
        }

    # --- Print Summary ---
    print(f"\n{'='*60}")
    print(f"  FORECAST SUMMARY: {info.get('longName', ticker_symbol)}")
    print(f"{'='*60}")
    print(f"\n  Revenue Forecast (Prophet):")
    for year, row in rev_forecast.iterrows():
        growth = ((row['yhat'] / historical['Revenue'].iloc[-1]) ** (1/(year-historical.index[-1])) - 1) * 100
        print(f"    FY{year}: Rs. {row['yhat']:.0f} Cr [80% CI: {row['yhat_lower']:.0f} – {row['yhat_upper']:.0f}] "
              f"→ CAGR: {growth:.1f}%")

    print(f"\n  Margin Scenarios:")
    for margin_type, scenarios in margin_scenarios.items():
        print(f"    {margin_type}:")
        for scenario_name, value in scenarios.items():
            print(f"      {scenario_name}: {value:.1f}%")

    return {
        'historical': historical,
        'prophet_model': p_model,
        'prophet_forecast_full': p_forecast,
        'revenue_forecast': rev_forecast,
        'margin_scenarios': margin_scenarios,
        'company_info': info,
        'recommendation': {
            'use_revenue_model': 'Prophet',
            'use_margin': 'Historical Average (conservative) unless trend is strongly supported by competitive analysis'
        }
    }


# --- Run the complete pipeline ---
result = complete_forecast_pipeline("ASIANPAINT.NS")

# Access components for further analysis
historical = result['historical']
rev_forecast = result['revenue_forecast']
margins = result['margin_scenarios']

Hands-On Project: 5-Year Forecast for Your Capstone Company

Build a complete 5-year revenue and margin forecast for the company you plan to use for your capstone valuation. Apply all four forecasting methods, backtest them, and produce a final forecast with explicit assumptions and scenario ranges. This forecast will feed directly into your DCF model in Chapters 12–14.

Steps

  1. Choose your capstone company — a listed Indian company with at least 5 years of financial data in yfinance.
  2. Run time series decomposition (Section 7.3) on quarterly revenue. Identify the trend, any seasonal patterns, and unusual residuals.
  3. Build all 5 forecasting models: Naive baseline, Moving Average, Exponential Smoothing, ARIMA, and Prophet. Run the backtesting framework (Section 7.9) to evaluate them.
  4. Check economic plausibility: Does the best statistical model make economic sense? If it projects revenue growth far above the industry average, explain why this company can outgrow its market.
  5. Forecast costs: Use the margin-based approach (Section 7.8) to project COGS and operating expenses. Build at least 3 margin scenarios (constant, trend, mean-reverting).
  6. Produce a final forecast table with columns for Year, Revenue, Revenue Growth %, Gross Margin %, Operating Margin %, EBIT, and Net Income — for the next 5 years. Include your key assumptions as footnotes.
  7. Write a 300-word forecast narrative explaining: What revenue growth rate did you choose and why? Which margin scenario did you select and what supports it? What is the biggest risk to your forecast?
View Solution / Walkthrough

Illustrative Example: Asian Paints 5-Year Forecast

# ============================================================
# COMPLETE CAPSTONE FORECAST — Asian Paints
# ============================================================

# Run the pipeline
result = complete_forecast_pipeline("ASIANPAINT.NS", forecast_years=5)

# --- Build the final forecast table ---
rev_fc = result['revenue_forecast']
hist = result['historical']

# Choose margin assumptions (from our analysis)
# Gross Margin: stable ~43% (brand pricing power, raw material pass-through)
# Operating Margin: ~20% (historical average; conservative — no margin expansion assumed)

GROSS_MARGIN = 43.0
OPERATING_MARGIN = 20.0
TAX_RATE = 0.25

forecast_table = []
for idx, (year, row) in enumerate(rev_fc.iterrows()):
    revenue = row['yhat']
    gross_profit = revenue * GROSS_MARGIN / 100
    ebit = revenue * OPERATING_MARGIN / 100
    ni = ebit * (1 - TAX_RATE)
    growth = ((revenue / hist['Revenue'].iloc[-1]) ** (1/(year-hist.index[-1])) - 1) * 100

    forecast_table.append({
        'Year': f'FY{year}',
        'Revenue (Cr)': round(revenue, 0),
        'Revenue Growth (%)': round(growth, 1) if idx == 4 else round(
            ((revenue / rev_fc.iloc[idx-1]['yhat']) - 1) * 100, 1) if idx > 0 else round(
            ((revenue / hist['Revenue'].iloc[-1]) - 1) * 100, 1),
        'Gross Profit (Cr)': round(gross_profit, 0),
        'Gross Margin (%)': GROSS_MARGIN,
        'EBIT (Cr)': round(ebit, 0),
        'Operating Margin (%)': OPERATING_MARGIN,
        'Net Income (Cr)': round(ni, 0),
    })

forecast_df = pd.DataFrame(forecast_table)

print("=== ASIAN PAINTS — 5-YEAR FORECAST ===")
print(f"\nKey Assumptions:")
print(f"  Revenue Model: Prophet (changepoint_prior_scale=0.05)")
print(f"  Gross Margin:  {GROSS_MARGIN}% (historical average — stable, brand-driven)")
print(f"  Operating Margin: {OPERATING_MARGIN}% (historical average — conservative)")
print(f"  Tax Rate: {TAX_RATE*100:.0f}%")
print(f"\n{forecast_df.to_string(index=False)}")

# --- Sensitivity: Revenue Growth vs Operating Margin ---
print(f"\n=== SENSITIVITY: 5th Year EBIT (Cr) ===")
growth_rates = [8, 10, 12, 15, 18]
op_margins_range = [17, 19, 20, 22, 24]

sensitivity = np.zeros((len(growth_rates), len(op_margins_range)))
last_rev = hist['Revenue'].iloc[-1]

for i, g in enumerate(growth_rates):
    for j, m in enumerate(op_margins_range):
        future_rev = last_rev * (1 + g/100) ** 5
        sensitivity[i, j] = future_rev * m / 100

sensitivity_df = pd.DataFrame(
    sensitivity,
    index=[f'{g}% Growth' for g in growth_rates],
    columns=[f'{m}% Margin' for m in op_margins_range]
)
print(sensitivity_df.round(0))

# Heatmap
fig, ax = plt.subplots(figsize=(10, 6))
sns.heatmap(sensitivity_df, annot=True, fmt='.0f', cmap='RdYlGn', ax=ax,
            linewidths=0.5, cbar_kws={'label': 'Year 5 EBIT (Rs. Cr)'})
ax.set_title('EBIT Sensitivity: Revenue Growth vs Operating Margin',
             fontweight='bold', fontsize=13)
plt.tight_layout()
plt.show()

Forecast Narrative (300 words):

Asian Paints' revenue is forecast using Prophet with a balanced changepoint sensitivity (cps=0.05), which captures the structural growth trajectory without overfitting COVID-era volatility. The model projects a 5-year revenue CAGR of ~12%, consistent with the company's 10-year historical average of 13% but moderated to reflect the maturing paint market and increasing competition from new entrants (JSW Paints, Grasim). This growth rate exceeds the ~8% Indian paint industry growth forecast, justified by Asian Paints' market leadership (40%+ share in organized decorative paints), distribution depth (70,000+ dealers), and expanding home improvement portfolio.

Gross margin is held constant at 43% — the 10-year historical average. Asian Paints has demonstrated remarkable gross margin stability (range: 41–45%) despite crude oil price cycles (a key raw material input), reflecting its pricing power and backward integration. Operating margin is set at 20% (historical average), a conservative choice that assumes no operating leverage benefit despite revenue growth. The actual margin could expand to 22–23% if the company leverages its fixed distribution infrastructure, but we prefer to model expansion as upside rather than base case.

The primary risk to this forecast is a structural shift in the paint industry. Grasim's entry (Rs. 10,000 crore investment) and JSW Paints' aggressive expansion could fragment the market and pressure both volume growth and pricing. A secondary risk is sustained high crude oil prices compressing gross margins — our constant 43% assumption would prove optimistic if crude stays above $90/barrel for multiple years. These risks are addressed through scenario analysis in the next chapter.

Key Takeaways

1

Forecasting is the bridge between history and valuation. It is the step where your understanding of the business — its competitive position, industry dynamics, and growth drivers — is quantified into numbers.

2

Always start with simple baselines (naive, moving average). If a complex model cannot beat a simple one in backtesting, it adds no value. Statistical fit must be combined with economic plausibility.

3

Prophet is the recommended primary model for revenue forecasting in this course. It handles trend changes, provides confidence intervals, and aligns with business intuition about growth patterns.

4

Margins are easier to forecast than absolute costs. Use historical margin averages, trend analysis, and industry benchmarks. Build at least three margin scenarios — constant, trend, and mean-reverting.

5

Every forecast must survive the "economic plausibility" test. If your model projects 25% revenue growth forever while the industry grows at 6%, your DCF will produce a staggeringly high valuation — and no investment committee will take it seriously.

Test Your Understanding

1. What are the three components into which a time series can be decomposed?

2. In ARIMA forecasting, what do the three parameters (p, d, q) stand for?

3. Why is Prophet often preferred over ARIMA for financial forecasting with limited annual data?

4. A company has 60% fixed operating costs and 15% variable costs. If revenue grows 20%, what happens to the operating margin?

5. Your backtest shows that the Naive model (assuming zero growth) has the lowest RMSE for a company's revenue. What is the most appropriate conclusion?