03 / SQL & DATA ANALYSIS
SQL Market Analytics
Most analytics that people reach for pandas to do can be done in the database — often more clearly. This project loads ~72,000 rows of daily prices into a two-table relational schema and answers five real market questions in pure SQL, leaning on window functions: LAG, RANK, ROW_NUMBER, and sliding-window aggregates. Every chart below is drawn directly from a query's result set; the SQL shown is the SQL that ran.
securities(ticker PK, sector)
prices(date, ticker → securities, close, volume)
0163-day momentum leaderboard
Rolling 63-trading-day (one quarter) total return per ticker, computed with LAG over each ticker's price series, then ranked with RANK() 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;02Sector monthly return matrix
Month-end closes are isolated with ROW_NUMBER() partitioned by ticker and calendar month, monthly returns computed with LAG, then averaged by sector — the site renders this as a 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
21-day rolling standard deviation of SPY daily returns, annualized, using AVG() OVER a sliding window of the squared deviations — a pure-SQL rolling volatility estimator.
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;04Maximum drawdown per ticker
Running peak via MAX() OVER an expanding window, drawdown as distance from peak, then the deepest drawdown per name 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;05Golden-cross scanner
50-day and 200-day moving averages per ticker via AVG() OVER sliding windows; a LAG comparison flags the dates where the 50-day crosses the 200-day. Returns each name's most recent crossover and its current trend state.
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) |