"BusinessMath Quarterly Series"
12 min read
Part 11 of 12-Week BusinessMath Series
CFOs and business leaders need revenue forecasts for planning: How much revenue will we generate next quarter? Next year? What’s the range of likely outcomes?
Building accurate forecasts requires:
Doing this properly in spreadsheets is tedious and error-prone. You need a systematic, reproducible forecasting workflow.
Let’s build a production-ready revenue forecast using BusinessMath, combining growth modeling, seasonality extraction, and trend fitting.
Start with 2 years of quarterly revenue:
import BusinessMath
// Define periods (8 quarters: 2023-2024)
let periods = [
Period.quarter(year: 2023, quarter: 1),
Period.quarter(year: 2023, quarter: 2),
Period.quarter(year: 2023, quarter: 3),
Period.quarter(year: 2023, quarter: 4),
Period.quarter(year: 2024, quarter: 1),
Period.quarter(year: 2024, quarter: 2),
Period.quarter(year: 2024, quarter: 3),
Period.quarter(year: 2024, quarter: 4)
]
// Historical revenue (showing both growth and Q4 spike)
let revenue: [Double] = [
800_000, // Q1 2023
850_000, // Q2 2023
820_000, // Q3 2023
1_100_000, // Q4 2023 (holiday spike)
900_000, // Q1 2024
950_000, // Q2 2024
920_000, // Q3 2024
1_250_000 // Q4 2024 (holiday spike + growth)
]
let historical = TimeSeries(periods: periods, values: revenue)
print("Loaded \(historical.count) quarters of historical data")
print("Total historical revenue: \(historical.reduce(0, +).currency())")
Output:
Loaded 8 quarters of historical data
Total historical revenue: $7,590,000
Before modeling, understand the data:
// Calculate quarter-over-quarter growth
let qoqGrowth = historical.growthRate(lag: 1)
print("\nQuarter-over-Quarter Growth:")
for (i, growth) in qoqGrowth.enumerated() {
let period = periods[i + 1]
print("\(period.label): \(growth.percent(1))")
}
// Calculate year-over-year growth
let yoyGrowth = historical.growthRate(lag: 4) // 4 quarters = 1 year
print("\nYear-over-Year Growth:")
for (i, growth) in yoyGrowth.valuesArray.enumerated() {
let period = periods[i + 4]
print("\(period.label): \(growth.percent(1))")
}
// Calculate overall CAGR
let totalYears = 2.0
let cagrValue = cagr(
beginningValue: revenue[0],
endingValue: revenue[revenue.count - 1],
years: totalYears
)
print("\nOverall CAGR: \(cagrValue.percent(1))")
Output:
Quarter-over-Quarter Growth:
2023-Q2: +6.3%
2023-Q3: -3.5%
2023-Q4: +34.1% ← Holiday spike
2024-Q1: -18.2% ← Post-holiday drop
2024-Q2: +5.6%
2024-Q3: -3.2%
2024-Q4: +35.9% ← Holiday spike again
Year-over-Year Growth:
2024-Q1: +12.5%
2024-Q2: +11.8%
2024-Q3: +12.2%
2024-Q4: +13.6%
Overall CAGR: 25.0%
The insight: Q-o-Q growth is volatile (swings from -18% to +36%), but Y-o-Y growth is steady (~12%). This suggests strong seasonality with underlying growth.
Identify the recurring pattern:
// Calculate seasonal indices (4 quarters per year)
let seasonality = try seasonalIndices(timeSeries: historical, periodsPerYear: 4)
print("\nSeasonal Indices:")
let quarters = ["Q1", "Q2", "Q3", "Q4"]
for (i, index) in seasonality.enumerated() {
let pct = (index - 1.0)
let direction = pct > 0 ? "above" : "below"
print("\(quarters[i]): \(index.number(3)) (\(abs(pct).percent(1)) \(direction) average)")
}
Output:
Seasonal Indices:
Q1: 0.942 (5.8% below average)
Q2: 0.968 (3.2% below average)
Q3: 0.908 (9.2% below average)
Q4: 1.183 (18.3% above average) ← Holiday seasonality confirmed!
The pattern: Q4 is 18% above average (holiday shopping), Q1-Q3 are all below average, with Q3 the lowest (summer slowdown).
Remove seasonal effects to see the underlying trend:
let deseasonalized = try seasonallyAdjust(timeSeries: historical, indices: seasonality)
print("\nDeseasonalized Revenue:")
print("Original → Deseasonalized")
for i in 0...(historical.count - 1) {
let original = historical.valuesArray[i]
let adjusted = deseasonalized.valuesArray[i]
let period = periods[i]
print("\(period.label): \(original.currency(0)) → \(adjusted.currency(0))")
}
Output:
Deseasonalized Revenue:
Original → Deseasonalized
2023-Q1: $800,000 → $849,566
2023-Q2: $850,000 → $878,143
2023-Q3: $820,000 → $903,399
2023-Q4: $1,100,000 → $930,069
2024-Q1: $900,000 → $955,762
2024-Q2: $950,000 → $981,454
2024-Q3: $920,000 → $1,013,570
2024-Q4: $1,250,000 → $1,056,897
The insight: After removing seasonality, the revenue trend is smooth and steadily increasing: $850k → $878k → $903k → … → $1,060k.
Fit a linear trend to the deseasonalized data:
var linearModel = LinearTrend()
try linearModel.fit(to: deseasonalized)
print("\nLinear Trend Model Fitted")
print("Indicates steady absolute growth per quarter")
Project forward and reapply seasonality:
let forecastPeriods = 4 // Forecast next 4 quarters (2025)
// Step 6a: Project trend forward
let trendForecast = try linearModel.project(periods: forecastPeriods)
print("\nTrend Forecast (deseasonalized):")
for (period, value) in zip(trendForecast.periods, trendForecast.valuesArray) {
print("\(period.label): \(value.currency(0))")
}
// Step 6b: Reapply seasonal pattern
let finalForecast = try applySeasonal(timeSeries: trendForecast, indices: seasonality)
print("\nFinal Forecast (with seasonality):")
var forecastTotal = 0.0
for (period, value) in zip(finalForecast.periods, finalForecast.valuesArray) {
forecastTotal += value
print("\(period.label): \(value.currency(0))")
}
print("\nForecast Summary:")
print("Total 2025 revenue: \(forecastTotal.currency(0))")
print("Average quarterly revenue: \((forecastTotal / 4).currency(0))")
// Compare to 2024
let revenue2024 = revenue[4...7].reduce(0.0, +)
let forecastGrowth = (forecastTotal - revenue2024) / revenue2024
print("Growth vs 2024: \(forecastGrowth.percent(1))")
Output:
Trend Forecast (deseasonalized):
2025-Q1: $1,074,052
2025-Q2: $1,102,485
2025-Q3: $1,130,917
2025-Q4: $1,159,349
Final Forecast (with seasonality):
2025-Q1: $1,011,389 ← Deseasonalized × Q1 index (0.942)
2025-Q2: $1,067,152 ← Deseasonalized × Q2 index (0.968)
2025-Q3: $1,026,514 ← Deseasonalized × Q3 index (0.908)
2025-Q4: $1,371,171 ← Deseasonalized × Q4 index (1.183)
Forecast Summary:
Total 2025 revenue: $4,476,226
Average quarterly revenue: $1,119,057
Growth vs 2024: 11.3%
The insight: The forecast shows continued steady growth (~11%) with the expected Q4 spike.
Create conservative and optimistic scenarios by adjusting the growth rate:
print("\nScenario Analysis for 2025:")
// Base case parameters (from the fitted linear model)
let baseSlope = linearModel.slopeValue!
let baseIntercept = linearModel.interceptValue!
// Conservative: Reduce growth rate by 50%
let conservativeSlope = baseSlope * 0.5
var conservativePeriods: [Period] = []
var conservativeValues: [Double] = []
for i in 1...forecastPeriods {
let index = Double(deseasonalized.count + i - 1)
let trendValue = baseIntercept + conservativeSlope * index
conservativePeriods.append(Period.quarter(year: 2025, quarter: i))
conservativeValues.append(trendValue)
}
let conservativeForecast = TimeSeries(
periods: conservativePeriods,
values: conservativeValues
)
let conservativeSeasonalForecast = try applySeasonal(
timeSeries: conservativeForecast,
indices: seasonality
)
// Optimistic: Increase growth rate by 50%
let optimisticSlope = baseSlope * 1.5
var optimisticPeriods: [Period] = []
var optimisticValues: [Double] = []
for i in 1...forecastPeriods {
let index = Double(deseasonalized.count + i - 1)
let trendValue = baseIntercept + optimisticSlope * index
optimisticPeriods.append(Period.quarter(year: 2025, quarter: i))
optimisticValues.append(trendValue)
}
let optimisticForecast = TimeSeries(
periods: optimisticPeriods,
values: optimisticValues
)
let optimisticSeasonalForecast = try applySeasonal(
timeSeries: optimisticForecast,
indices: seasonality
)
let conservativeTotal = conservativeSeasonalForecast.reduce(0, +)
let optimisticTotal = optimisticSeasonalForecast.reduce(0, +)
print("Conservative: \(conservativeTotal.currency(0)) (growth dampened 50%)")
print("Base Case: \(forecastTotal.currency(0))")
print("Optimistic: \(optimisticTotal.currency(0)) (growth amplified 50%)")
Output:
Scenario Analysis for 2025:
Conservative: $3,931,302 (growth dampened 50%)
Base Case: $4,476,226
Optimistic: $5,021,150 (growth amplified 50%)
Note: The exact values depend on your fitted model’s slope parameter. Run the playground to see actual results with your data. The key insight is that dampening the growth rate by 50% produces noticeably lower forecasts, while amplifying by 50% produces higher forecasts.
Here’s the end-to-end forecast in one place:
import BusinessMath
func buildRevenueModel() throws {
// 1. Prepare data
let periods = (1...8).map { i in
let year = 2023 + (i - 1) / 4
let quarter = ((i - 1) % 4) + 1
return Period.quarter(year: year, quarter: quarter)
}
let revenue: [Double] = [
800_000, 850_000, 820_000, 1_100_000,
900_000, 950_000, 920_000, 1_250_000
]
let historical = TimeSeries(periods: periods, values: revenue)
// 2. Extract seasonality
let seasonalIndices = try seasonalIndices(timeSeries: historical, periodsPerYear: 4)
// 3. Deseasonalize
let deseasonalized = try seasonallyAdjust(timeSeries: historical, indices: seasonalIndices)
// 4. Fit trend
var model = LinearTrend()
try model.fit(to: deseasonalized)
// 5. Generate forecast
let trendForecast = try model.project(periods: 4)
let finalForecast = try applySeasonal(timeSeries: trendForecast, indices: seasonalIndices)
// 6. Present results
print("Revenue Forecast:")
for (period, value) in zip(finalForecast.periods, finalForecast.valuesArray) {
print("\(period.label): \(value.currency(0))")
}
let total = finalForecast.reduce(0, +)
print("Total 2025 forecast: \(total.currency(0))")
}
try buildRevenueModel()
import BusinessMath
// Define periods (8 quarters: 2023-2024)
let periods = [
Period.quarter(year: 2023, quarter: 1),
Period.quarter(year: 2023, quarter: 2),
Period.quarter(year: 2023, quarter: 3),
Period.quarter(year: 2023, quarter: 4),
Period.quarter(year: 2024, quarter: 1),
Period.quarter(year: 2024, quarter: 2),
Period.quarter(year: 2024, quarter: 3),
Period.quarter(year: 2024, quarter: 4)
]
// Historical revenue (showing both growth and Q4 spike)
let revenue: [Double] = [
800_000, // Q1 2023
850_000, // Q2 2023
820_000, // Q3 2023
1_100_000, // Q4 2023 (holiday spike)
900_000, // Q1 2024
950_000, // Q2 2024
920_000, // Q3 2024
1_250_000 // Q4 2024 (holiday spike + growth)
]
let historical = TimeSeries(periods: periods, values: revenue)
print("Loaded \(historical.count) quarters of historical data")
print("Total historical revenue: \(historical.reduce(0, +).currency())")
// Calculate quarter-over-quarter growth
let qoqGrowth = historical.growthRate(lag: 1)
print("\nQuarter-over-Quarter Growth:")
for (i, growth) in qoqGrowth.enumerated() {
let period = periods[i + 1]
print("\(period.label): \(growth.percent(1))")
}
// Calculate year-over-year growth
let yoyGrowth = historical.growthRate(lag: 4) // 4 quarters = 1 year
print("\nYear-over-Year Growth:")
for (i, growth) in yoyGrowth.valuesArray.enumerated() {
let period = periods[i + 4]
print("\(period.label): \(growth.percent(1))")
}
// Calculate overall CAGR
let totalYears = 2.0
let cagrValue = cagr(
beginningValue: revenue[0],
endingValue: revenue[revenue.count - 1],
years: totalYears
)
print("\nOverall CAGR: \(cagrValue.percent(1))")
// Calculate seasonal indices (4 quarters per year)
let seasonality = try seasonalIndices(timeSeries: historical, periodsPerYear: 4)
print("\nSeasonal Indices:")
let quarters = ["Q1", "Q2", "Q3", "Q4"]
for (i, index) in seasonality.enumerated() {
let pct = (index - 1.0)
let direction = pct > 0 ? "above" : "below"
print("\(quarters[i]): \(index.number(3)) (\(abs(pct).percent(1)) \(direction) average)")
}
let deseasonalized = try seasonallyAdjust(timeSeries: historical, indices: seasonality)
print("\nDeseasonalized Revenue:")
print("Original → Deseasonalized")
for i in 0..()
try linearModel.fit(to: deseasonalized)
print("\nLinear Trend Model Fitted")
print("Indicates steady absolute growth per quarter")
let forecastPeriods = 4 // Forecast next 4 quarters (2025)
// Step 6a: Project trend forward
let trendForecast = try linearModel.project(periods: forecastPeriods)
print("\nTrend Forecast (deseasonalized):")
for (period, value) in zip(trendForecast.periods, trendForecast.valuesArray) {
print("\(period.label): \(value.currency(0))")
}
// Step 6b: Reapply seasonal pattern
let finalForecast = try applySeasonal(timeSeries: trendForecast, indices: seasonality)
print("\nFinal Forecast (with seasonality):")
var forecastTotal = 0.0
for (period, value) in zip(finalForecast.periods, finalForecast.valuesArray) {
forecastTotal += value
print("\(period.label): \(value.currency(0))")
}
print("\nForecast Summary:")
print("Total 2025 revenue: \(forecastTotal.currency(0))")
print("Average quarterly revenue: \((forecastTotal / 4).currency(0))")
// Compare to 2024
let revenue2024 = revenue[4...7].reduce(0.0, +)
let forecastGrowth = (forecastTotal - revenue2024) / revenue2024
print("Growth vs 2024: \(forecastGrowth.percent(1))")
print("\nScenario Analysis for 2025:")
// Base case parameters (from the fitted linear model)
let baseSlope = linearModel.slopeValue!
let baseIntercept = linearModel.interceptValue!
// Conservative: Reduce growth rate by 50%
let conservativeSlope = baseSlope * 0.5
var conservativePeriods: [Period] = []
var conservativeValues: [Double] = []
for i in 1...forecastPeriods {
let index = Double(deseasonalized.count + i - 1)
let trendValue = baseIntercept + conservativeSlope * index
conservativePeriods.append(Period.quarter(year: 2025, quarter: i))
conservativeValues.append(trendValue)
}
let conservativeForecast = TimeSeries(
periods: conservativePeriods,
values: conservativeValues
)
let conservativeSeasonalForecast = try applySeasonal(
timeSeries: conservativeForecast,
indices: seasonality
)
// Optimistic: Increase growth rate by 50%
let optimisticSlope = baseSlope * 1.5
var optimisticPeriods: [Period] = []
var optimisticValues: [Double] = []
for i in 1...forecastPeriods {
let index = Double(deseasonalized.count + i - 1)
let trendValue = baseIntercept + optimisticSlope * index
optimisticPeriods.append(Period.quarter(year: 2025, quarter: i))
optimisticValues.append(trendValue)
}
let optimisticForecast = TimeSeries(
periods: optimisticPeriods,
values: optimisticValues
)
let optimisticSeasonalForecast = try applySeasonal(
timeSeries: optimisticForecast,
indices: seasonality
)
let conservativeTotal = conservativeSeasonalForecast.reduce(0, +)
let optimisticTotal = optimisticSeasonalForecast.reduce(0, +)
print("Conservative: \(conservativeTotal.currency(0)) (growth dampened 50%)")
print("Base Case: \(forecastTotal.currency(0))")
print("Optimistic: \(optimisticTotal.currency(0)) (growth amplified 50%)")
func buildRevenueModel() throws {
// 1. Prepare data
let periods = (1...8).map { i in
let year = 2023 + (i - 1) / 4
let quarter = ((i - 1) % 4) + 1
return Period.quarter(year: year, quarter: quarter)
}
let revenue: [Double] = [
800_000, 850_000, 820_000, 1_100_000,
900_000, 950_000, 920_000, 1_250_000
]
let historical = TimeSeries(periods: periods, values: revenue)
// 2. Extract seasonality
let seasonalIndices = try seasonalIndices(timeSeries: historical, periodsPerYear: 4)
// 3. Deseasonalize
let deseasonalized = try seasonallyAdjust(timeSeries: historical, indices: seasonalIndices)
// 4. Fit trend
var model = LinearTrend()
try model.fit(to: deseasonalized)
// 5. Generate forecast
let trendForecast = try model.project(periods: 4)
let finalForecast = try applySeasonal(timeSeries: trendForecast, indices: seasonalIndices)
// 6. Present results
print("Revenue Forecast:")
for (period, value) in zip(finalForecast.periods, finalForecast.valuesArray) {
print("\(period.label): \(value.currency(0))")
}
let total = finalForecast.reduce(0, +)
print("Total 2025 forecast: \(total.currency(0))")
}
try buildRevenueModel()
→ Full API Reference: BusinessMath Docs – 3.3 Revenue Forecasting
Modifications to try:
Think about using this for annual planning:
Rather than saying “we’re growing 10% per month, so we’ll hit $30mm,” it’s far more credible to say: “Our base case projects $24M ARR, with 80% confidence interval of $22M-$26M.”
★ Insight ─────────────────────────────────────
Why Forecast with Scenarios?
Point forecasts are always wrong. The question is: how wrong?
Scenarios communicate uncertainty:
Present all three with probabilities (e.g., 20% / 60% / 20%).
This is a much more nuanced and thoughful approach, that sets realistic expectations and prepares stakeholders for variance.
─────────────────────────────────────────────────
The hardest part of implementing revenue forecasting wasn’t the math—it was deciding how opinionated to be about the workflow.
Option 1: Provide primitive functions (seasonalIndices, fit, project) and let users compose them.
Option 2: Provide a high-level forecast(historical:periods:) function that does everything automatically.
We chose Option 1 because forecasting requires judgment:
A fully automated forecast hides these choices, producing results users don’t understand.
The lesson: For workflows requiring judgment, provide composable primitives rather than black-box automation.
Related Methodology: Documentation as Design (Week 2) - Designing learnable APIs
Coming up next: Capital Equipment Decision (Friday) - Case study combining depreciation + TVM + financial ratios.
Week 4: We’ll explore investment analysis and portfolio optimization.
Series Progress:
Tagged with: businessmath, swift, revenue, forecasting, time-series, seasonality