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.

Price rows
71,968
daily closes since 2015
Securities
26
25 large caps + SPY
Analytical queries
5
all window functions
Engine
SQLite 3.41.2
same SQL runs on Postgres
-- schema
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.

SQL · SQLite window functions
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;
Communication ServicesConsumer DiscretionaryConsumer StaplesEnergyFinancialsHealth CareInformation Technology
AMD+146.3%UNH+56.1%V+21.6%GOOGL+21.1%AAPL+20.8%BAC+19.8%GS+19.2%AMZN+15.2%JPM+13.8%NVDA+11.0%HD+9.4%JNJ+8.4%MSFT+6.0%PG+5.9%DIS+3.8%TSLA+3.2%META+0.7%ORCL-3.1%MCD-8.1%WMT-10.2%CRM-10.4%PFE-13.4%CVX-13.5%XOM-14.2%NFLX-18.7%

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.

SQL · SQLite window functions
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;

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.

SQL · SQLite window functions
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;
0%25%50%75%2015-072018-042021-012023-102026-07

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.

SQL · SQLite window functions
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;
Communication ServicesConsumer DiscretionaryConsumer StaplesEnergyFinancialsHealth CareInformation Technology
META-77%NFLX-76%TSLA-74%NVDA-66%AMD-65%UNH-61%XOM-61%DIS-61%PFE-59%CRM-59%ORCL-58%AMZN-56%CVX-56%BAC-49%GS-49%GOOGL-44%JPM-44%AAPL-39%HD-38%MSFT-37%MCD-37%V-36%JNJ-27%WMT-26%PG-24%

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.

SQL · SQLite window functions
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;
TickerLast crossoverCurrent signal
JPM2026-06-05Golden cross (50d above 200d)
BAC2026-06-05Golden cross (50d above 200d)
UNH2026-05-14Golden cross (50d above 200d)
MCD2026-05-12Death cross (50d below 200d)
AMZN2026-05-06Golden cross (50d above 200d)
PG2026-05-04Death cross (50d below 200d)
TSLA2026-04-09Death cross (50d below 200d)
MSFT2026-01-22Death cross (50d below 200d)
ORCL2026-01-08Death cross (50d below 200d)
DIS2025-12-11Death cross (50d below 200d)
META2025-12-10Death cross (50d below 200d)
HD2025-12-09Death cross (50d below 200d)
NFLX2025-12-05Death cross (50d below 200d)
V2025-10-28Death cross (50d below 200d)
AAPL2025-09-15Golden cross (50d above 200d)
PFE2025-08-15Golden cross (50d above 200d)
XOM2025-08-11Golden cross (50d above 200d)
CVX2025-08-11Golden cross (50d above 200d)
GOOGL2025-07-23Golden cross (50d above 200d)
AMD2025-07-16Golden cross (50d above 200d)
JNJ2025-07-09Golden cross (50d above 200d)
NVDA2025-06-27Golden cross (50d above 200d)
GS2025-06-10Golden cross (50d above 200d)
CRM2025-04-14Death cross (50d below 200d)
WMT2022-11-15Golden cross (50d above 200d)