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.

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
in-memory, no server
-- schema
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.

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%

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.

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

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.

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

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.

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%

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.

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)

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.