This assignment uses SQL window functions to calculate two averages of daily stock prices:
Year-to-date average: the average closing price from the start of the year up to each day.
Six-day moving average: the average of each day’s closing price and the five trading days before it.
Both are calculated separately for each stock. A window function is what makes this possible: it calculates a value for every row from a “window” of related rows, without collapsing the rows the way GROUP BY does.
2 The Data
The data is the daily closing price of three stocks, Apple (AAPL), Microsoft (MSFT) and Amazon (AMZN), from January 3, 2022 (the first trading day of 2022) to September 18, 2026. It was downloaded from Yahoo Finance and saved as data/stock_prices.csv in my GitHub repository, so the results do not change when new prices come in. The code below reads the file directly from GitHub. Prices are adjusted for stock splits, such as Amazon’s 20-for-1 split in 2022.
The prices go into a SQLite table with one row per stock per day. The primary key (ticker, date) makes sure no day is loaded twice for the same stock.
-- 01_create_table.sql-- One row per stock per trading day.DROPTABLEIFEXISTS stock_prices;CREATETABLE stock_prices (date TEXT NOTNULL, -- YYYY-MM-DD ticker TEXT NOTNULL,closeREALNOTNULL, -- closing price in US dollarsPRIMARYKEY (ticker, date));
# Run a .sql file one statement at a timerun_sql_file <-function(con, path) { statements <-readLines(path) |>str_remove("--.*$") |>paste(collapse ="\n") |>str_split_1(";") |>str_trim()walk(statements[statements !=""], \(s) dbExecute(con, s))}con <-dbConnect(SQLite(), ":memory:")run_sql_file(con, "sql/01_create_table.sql")dbAppendTable(con, "stock_prices", prices |>mutate(date =as.character(date)))
[1] 3546
dbGetQuery(con, "SELECT ticker, COUNT(*) AS rows FROM stock_prices GROUP BY ticker")
-- 02_window_functions.sql-- Year-to-date average and six-day moving average of the closing price, for each stock.SELECTdate, ticker,close,-- Year-to-date average: every day from January 1 of the same year up to this day.-- PARTITION BY ticker and year makes the average restart each January for each stock.AVG(close) OVER (PARTITIONBY ticker, strftime('%Y', date)ORDERBYdateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW ) AS ytd_avg,-- Six-day moving average: this trading day and the five before it.-- The first five days of each stock have fewer than six days of history, so they are NULL.CASEWHENCOUNT(close) OVER six_days =6THENAVG(close) OVER six_daysENDAS moving_avg_6dFROM stock_pricesWINDOW six_days AS (PARTITIONBY tickerORDERBYdateROWSBETWEEN5PRECEDINGANDCURRENTROW)ORDERBY ticker, date;
How each part works:
Part
What it does
PARTITION BY ticker
Calculates each stock separately
strftime('%Y', date)
Also splits by year, so the year-to-date average restarts every January
ORDER BY date
Puts each stock’s days in order
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Year-to-date: every day so far this year
ROWS BETWEEN 5 PRECEDING AND CURRENT ROW
Six-day: today and the five trading days before it
Two details:
The moving average counts trading days, not calendar days. Weekends and holidays have no price, so six trading days usually span eight calendar days.
The first five days of each stock do not have six days of history yet. COUNT(close) OVER six_days counts the days in the window, and the moving average is left empty until there are six.
The first row’s year-to-date average equals its closing price, because it is the only day so far. The moving average starts on the sixth row.
The year-to-date average restarts on the first trading day of each year:
results |>filter(ticker =="AAPL", date >="2022-12-28", date <="2023-01-05")
date
ticker
close
ytd_avg
moving_avg_6d
2022-12-28
AAPL
126.04
155.0364
131.3183
2022-12-29
AAPL
129.61
154.9347
130.8700
2022-12-30
AAPL
129.93
154.8351
129.9500
2023-01-03
AAPL
125.07
125.0700
128.7567
2023-01-04
AAPL
126.36
125.7150
127.8400
2023-01-05
AAPL
125.02
125.4833
127.0050
On January 3, 2023, the year-to-date average drops back to that day’s price. The moving average does not restart, because the five days before it are still the most recent trading days.
5 Checking the Results
To make sure the SQL is correct, I calculate the same averages a second way, with dplyr in R, and compare them.
Figure 1: Daily closing price in 2026 with the six-day moving average and the year-to-date average.
The chart shows what each average is good for:
The six-day moving average follows the price closely but smooths out day-to-day noise.
The year-to-date average moves slowly and becomes more stable as the year goes on, because each new day is a smaller share of the total. A price above the year-to-date average means the stock is trading higher than it has on average this year.
7 Conclusion
One SQL query with two window functions calculates both averages for all three stocks and all years at once, without any loops.
PARTITION BY keeps the stocks separate, and adding the year to the partition makes the year-to-date average restart each January.
The ROWS BETWEEN frame controls how many days go into each average.
The results match the same calculation done with dplyr in R.
The same query would work for any number of stocks, or for any other daily time series, by changing only the table.