03 / SQL & DATA ANALYSIS
SQL Market Analytics
A lot of what I used to reach for pandas to do turns out to be a window function. So this project has one rule: load ~72,000 rows of daily prices (25 large caps plus SPY, back to 2015) into a two-table SQLite schema, then answer five market questions without leaving SQL. The workhorses are LAG, RANK, ROW_NUMBER and sliding-window aggregates. Every chart is drawn straight from a query's result set, and the SQL shown is exactly what ran.
securities(ticker PK, sector)
prices(date, ticker → securities, close, volume)
0163-day momentum leaderboard
Quarterly (63 trading-day) total return for each name, straight off one LAG(close, 63) per ticker, ranked on the latest date.
WITH returns AS (
SELECT
date,
ticker,
close / LAG(close, 63) OVER (
PARTITION BY ticker ORDER BY date
) - 1 AS ret_63d
FROM prices
)
SELECT
r.ticker,
s.sector,
ROUND(r.ret_63d * 100, 2) AS return_pct,
RANK() OVER (ORDER BY r.ret_63d DESC) AS rank
FROM returns r
JOIN securities s USING (ticker)
WHERE r.date = (SELECT MAX(date) FROM prices)
AND r.ret_63d IS NOT NULL
AND s.sector != 'Benchmark'
ORDER BY rank;AMD at +146% for the quarter is the AI-hardware trade in one number. I didn't expect Netflix at the bottom of the table.
02Sector monthly return matrix
Two passes: ROW_NUMBER() isolates each month's final close, LAG turns those into monthly returns, and an AVG by sector fills the heatmap. Last 24 months.
WITH month_end AS (
SELECT date, ticker, close,
ROW_NUMBER() OVER (
PARTITION BY ticker, strftime('%Y-%m', date)
ORDER BY date DESC
) AS rn
FROM prices
),
monthly_ret AS (
SELECT
strftime('%Y-%m', date) AS month,
ticker,
close / LAG(close) OVER (
PARTITION BY ticker ORDER BY date
) - 1 AS ret
FROM month_end
WHERE rn = 1
)
SELECT
m.month,
s.sector,
ROUND(AVG(m.ret) * 100, 2) AS avg_return_pct
FROM monthly_ret m
JOIN securities s USING (ticker)
WHERE m.ret IS NOT NULL
AND s.sector != 'Benchmark'
AND m.month >= strftime(
'%Y-%m',
date((SELECT MAX(date) FROM prices), '-24 months')
)
GROUP BY m.month, s.sector
ORDER BY m.month, s.sector;| Jul 24 | Aug 24 | Sep 24 | Oct 24 | Nov 24 | Dec 24 | Jan 25 | Feb 25 | Mar 25 | Apr 25 | May 25 | Jun 25 | Jul 25 | Aug 25 | Sep 25 | Oct 25 | Nov 25 | Dec 25 | Jan 26 | Feb 26 | Mar 26 | Apr 26 | May 26 | Jun 26 | Jul 26 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Communication Services | -5.9 | 3.3 | 4.8 | 2.2 | 9.8 | 2.5 | 9.2 | -4.6 | -10.2 | 2.9 | 14.3 | 9.5 | -0.9 | 2.5 | 2.4 | -1.1 | 0.7 | -0.9 | 1.2 | -2.0 | -7.1 | 11.4 | -1.9 | -9.7 | 4.1 |
| Consumer Discretionary | 6.3 | -0.7 | 10.7 | -2.8 | 15.1 | 2.9 | 3.5 | -8.8 | -6.8 | 1.6 | 8.6 | -1.9 | 1.7 | 5.3 | 6.7 | 1.4 | -2.9 | -0.2 | 2.8 | -2.2 | -7.5 | 6.1 | 2.0 | -1.5 | 0.2 |
| Consumer Staples | -0.3 | 9.8 | 2.8 | -1.3 | 10.7 | -4.3 | 4.1 | 2.6 | -6.4 | 3.4 | 3.1 | -3.6 | -2.4 | 1.8 | 2.1 | -1.6 | 3.9 | -1.1 | 6.8 | 8.8 | -8.1 | 4.4 | -7.3 | -0.0 | 1.0 |
| Energy | 2.8 | -3.3 | -0.5 | 0.3 | 5.9 | -9.7 | 1.2 | 6.3 | 6.2 | -14.9 | -0.3 | 5.1 | 4.7 | 5.2 | -2.3 | 1.5 | -0.4 | 2.3 | 16.8 | 7.6 | 11.0 | -7.8 | -5.0 | -7.5 | 1.2 |
| Financials | 5.2 | 3.0 | -2.9 | 5.3 | 13.1 | -4.0 | 9.4 | 0.6 | -8.0 | -1.3 | 8.7 | 8.2 | 0.5 | 3.7 | 2.7 | 0.4 | 1.0 | 4.4 | -2.4 | -4.2 | -2.6 | 8.8 | 0.6 | 6.1 | 2.9 |
| Health Care | 10.6 | 1.1 | -1.0 | -2.3 | -0.0 | -7.4 | 4.7 | -1.2 | 2.4 | -10.3 | -9.5 | 1.9 | -4.8 | 12.9 | 6.6 | -0.8 | 4.4 | -0.8 | 1.6 | 5.6 | -2.4 | 8.7 | 0.3 | 4.9 | 2.3 |
| Information Technology | -2.9 | 1.2 | 7.7 | -1.1 | 5.4 | -3.0 | -2.9 | -4.4 | -8.3 | -0.4 | 10.9 | 15.1 | 9.4 | -2.4 | 5.9 | 12.7 | -10.6 | 1.9 | -6.3 | -8.2 | -2.1 | 18.4 | 20.9 | -11.7 | -0.1 |
03Rolling volatility regimes
SQLite has no STDDEV, so the estimator is built by hand: mean and mean-of-squares over a 21-day window, subtract, square root, annualize. Downsampled to weekly for the chart.
WITH daily AS (
SELECT date,
close / LAG(close) OVER (ORDER BY date) - 1 AS ret
FROM prices
WHERE ticker = 'SPY'
),
stats AS (
SELECT date, ret,
AVG(ret) OVER w AS mean_21,
AVG(ret * ret) OVER w AS meansq_21,
COUNT(ret) OVER w AS n
FROM daily
WINDOW w AS (ORDER BY date ROWS BETWEEN 20 PRECEDING AND CURRENT ROW)
)
SELECT
date,
ROUND(SQRT((meansq_21 - mean_21 * mean_21) * 252.0) * 100, 2)
AS annualized_vol_pct
FROM stats
WHERE n = 21
ORDER BY date;Covid dwarfs everything: March 2020 peaked at 88% annualized, while the calm stretches of 2017 sat under 5%.
04Maximum drawdown per ticker
A running MAX() tracks each name's all-time peak; max drawdown is the deepest distance below it over the full sample.
WITH with_peak AS (
SELECT
ticker,
date,
close,
MAX(close) OVER (
PARTITION BY ticker
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_peak
FROM prices
)
SELECT
w.ticker,
s.sector,
ROUND(MIN(w.close / w.running_peak - 1) * 100, 2) AS max_drawdown_pct
FROM with_peak w
JOIN securities s USING (ticker)
WHERE s.sector != 'Benchmark'
GROUP BY w.ticker, s.sector
ORDER BY max_drawdown_pct;META's −77% is the 2022 collapse. Even the mildest case in the universe (PG) lost almost a quarter of its value at some point in the decade.
05Golden-cross scanner
50- and 200-day moving averages from two sliding windows, with a LAG comparison to catch the dates where they cross. Most recent crossover per name, newest first.
WITH ma AS (
SELECT date, ticker, close,
AVG(close) OVER (PARTITION BY ticker ORDER BY date
ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS ma50,
AVG(close) OVER (PARTITION BY ticker ORDER BY date
ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS ma200,
COUNT(*) OVER (PARTITION BY ticker ORDER BY date
ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS n
FROM prices
),
crosses AS (
SELECT date, ticker,
CASE WHEN ma50 > ma200 THEN 'golden' ELSE 'death' END AS state,
LAG(CASE WHEN ma50 > ma200 THEN 'golden' ELSE 'death' END)
OVER (PARTITION BY ticker ORDER BY date) AS prev_state
FROM ma
WHERE n = 200
)
SELECT
ticker,
MAX(date) AS last_cross_date,
state AS signal
FROM crosses
WHERE state != prev_state
AND ticker != 'SPY'
GROUP BY ticker
ORDER BY last_cross_date DESC;| Ticker | Last crossover | Current signal |
|---|---|---|
| JPM | 2026-06-05 | Golden cross (50d above 200d) |
| BAC | 2026-06-05 | Golden cross (50d above 200d) |
| UNH | 2026-05-14 | Golden cross (50d above 200d) |
| MCD | 2026-05-12 | Death cross (50d below 200d) |
| AMZN | 2026-05-06 | Golden cross (50d above 200d) |
| PG | 2026-05-04 | Death cross (50d below 200d) |
| TSLA | 2026-04-09 | Death cross (50d below 200d) |
| MSFT | 2026-01-22 | Death cross (50d below 200d) |
| ORCL | 2026-01-08 | Death cross (50d below 200d) |
| DIS | 2025-12-11 | Death cross (50d below 200d) |
| META | 2025-12-10 | Death cross (50d below 200d) |
| HD | 2025-12-09 | Death cross (50d below 200d) |
| NFLX | 2025-12-05 | Death cross (50d below 200d) |
| V | 2025-10-28 | Death cross (50d below 200d) |
| AAPL | 2025-09-15 | Golden cross (50d above 200d) |
| PFE | 2025-08-15 | Golden cross (50d above 200d) |
| XOM | 2025-08-11 | Golden cross (50d above 200d) |
| CVX | 2025-08-11 | Golden cross (50d above 200d) |
| GOOGL | 2025-07-23 | Golden cross (50d above 200d) |
| AMD | 2025-07-16 | Golden cross (50d above 200d) |
| JNJ | 2025-07-09 | Golden cross (50d above 200d) |
| NVDA | 2025-06-27 | Golden cross (50d above 200d) |
| GS | 2025-06-10 | Golden cross (50d above 200d) |
| CRM | 2025-04-14 | Death cross (50d below 200d) |
| WMT | 2022-11-15 | Golden cross (50d above 200d) |
11 of the 25 names currently sit below their 200-day trend. JPM and BAC flipped golden on the same June day, which reads more like a sector move than a coincidence.