# Correlation and regression > Do two series move together? corr, covariance, a straight trend line (regr_slope, regr_intercept, regr_r2), and the beta and alpha of a share against the market. - Category: Functions — statistics - Keywords: correlation, correlation coefficient, Pearson, pearson, correl, covariance, covar, covariance\_samp, covariance\_pop, regression, linear regression, least squares, trend line, line of best fit, slope, intercept, r squared, R2, rsq, coefficient of determination, beta, market beta, systematic risk, alpha, Jensen's alpha, excess return, market index, diversification, pairs, y and x - See also: [Statistics functions](functions-statistics.md), [Returns and risk](functions-returns-risk.md), [Trend indicators (linearreg)](functions-indicators-trend.md#linearreg), [Window and series functions (aggregates)](functions-window.md#aggregates), [Joins](joins.md) These aggregates look at **pairs** of numbers: one `y` and one `x` on each row, for example the return of CITY and the return of a market index on the same day. They answer two questions: - **Do the two move together?** The **correlation** is a number from `-1` to `1`. `1`: when one goes up, the other always goes up too. `-1`: when one goes up, the other goes down. `0`: no straight-line link. Traders use it to spread risk: two shares with a low correlation do not fall on the same days so often (this is **diversification**). - **What straight line fits best?** A **linear regression** finds the line `y = slope × x + intercept` that is closest to all points (the "least squares" line). The slope tells how much `y` changes when `x` changes by 1. They follow these rules: - **Argument order:** `y` first, `x` second, like PostgreSQL: `regr_slope(close, day_no)` is the slope of close against the day number. For `corr` and `covar_*` the order does not change the result. - **NULL:** a row where `y` or `x` is NULL is **skipped** (the whole pair). With no pairs left the result is `NULL`, except `regr_count`, which gives `0`. - They take numbers. Text gives an error `needs numbers`. - They work with `GROUP BY`, `SAMPLE BY`, `HAVING` and `OVER (...)` (for example a moving 5-day correlation), like the other [aggregates](functions-aggregate.md). - They give a `DOUBLE`, except `regr_count` (`LONG`). **Use returns, not prices.** Two share prices that both trend up for months look strongly correlated even when their daily moves have nothing to do with each other. Compare the **daily changes** (`pct_change(close)`, or [pct_return](functions-returns-risk.md#pct_return)). To put two series side by side, make each one in a subquery and join them on `time`. **Example: Which shares move together? Correlation of the daily % changes of every pair** (sample data: sample) ```sql SELECT a.symbol AS share_a, b.symbol AS share_b, count(*) AS days, round(corr(a.r, b.r), 3) AS correlation FROM (SELECT time, symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM ochl_day WHERE time >= '2026-08-23') a JOIN (SELECT time, symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM ochl_day WHERE time >= '2026-08-23') b ON a.time = b.time AND a.symbol < b.symbol GROUP BY a.symbol, b.symbol ORDER BY correlation DESC; ``` Result: | share_a (SYMBOL) | share_b (SYMBOL) | days (LONG) | correlation (DOUBLE) | |---|---|---|---| | COAST | VOLT | 15 | 0.366 | | CITY | VOLT | 15 | 0.217 | | CITY | COAST | 15 | 0.032 | (3 rows) ## corr Syntax: ```sql corr(y, x) corr(y, x) OVER (...) ``` The **Pearson correlation** of `y` and `x`: a number from `-1` to `1` that tells how well the points lie on one straight line. Above about `0.7` is a strong link, below about `0.3` a weak one. | Argument | Type | Meaning | |---|---|---| | `y` | number | the first value of each pair | | `x` | number | the second value of each pair | - **Returns:** `DOUBLE` from `-1` to `1`. - **NULL:** pairs with a NULL are skipped. `NULL` with fewer than 2 pairs, and when all `x` or all `y` values are the same (a flat line has no correlation). - **Notes:** correlation is not cause: two shares can move together because the whole market moves. `corr(y, x)` is the same as `corr(x, y)`. **Example: CITY and COAST: daily returns joined on the day** (sample data: sample) ```sql SELECT count(*) AS days, regr_count(a.r, b.r) AS pairs, round(corr(a.r, b.r), 3) AS correlation FROM (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-08-23') a JOIN (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'COAST' AND time >= '2026-08-23') b ON a.time = b.time; ``` Result: | days (LONG) | pairs (LONG) | correlation (DOUBLE) | |---|---|---| | 15 | 14 | 0.032 | (1 row) **Example: Moving 5-day correlation (with OVER)** (sample data: sample) ```sql SELECT time, round(corr(city_r, coast_r) OVER (ORDER BY time ROWS 4 PRECEDING), 3) AS corr_5d FROM (SELECT a.time AS time, a.r AS city_r, b.r AS coast_r FROM (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-08-23') a JOIN (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'COAST' AND time >= '2026-08-23') b ON a.time = b.time) t ORDER BY time; ``` Result: | time (TIMESTAMP) | corr_5d (DOUBLE) | |---|---| | 2026-08-23T00:00:00.000Z | NULL | | 2026-08-24T00:00:00.000Z | NULL | | 2026-08-25T00:00:00.000Z | 1 | | 2026-08-26T00:00:00.000Z | 0.939 | | 2026-08-27T00:00:00.000Z | 0.326 | | 2026-08-30T00:00:00.000Z | -0.208 | | 2026-08-31T00:00:00.000Z | -0.149 | | 2026-09-01T00:00:00.000Z | -0.091 | | 2026-09-02T00:00:00.000Z | -0.063 | | 2026-09-03T00:00:00.000Z | -0.331 | | 2026-09-06T00:00:00.000Z | -0.174 | | 2026-09-07T00:00:00.000Z | 0.584 | | 2026-09-08T00:00:00.000Z | 0.293 | | 2026-09-09T00:00:00.000Z | 0.386 | | 2026-09-10T00:00:00.000Z | 0.424 | (15 rows) **Example: Why returns: today's price and yesterday's price look strongly linked** (sample data: sample) ```sql SELECT round(corr(close, prev_close), 3) AS price_corr FROM (SELECT close, lag(close) AS prev_close FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-08-23') t; ``` Result: | price_corr (DOUBLE) | |---| | 0.78 | (1 row) ## covar_pop Syntax: ```sql covar_pop(y, x) ``` The **population covariance**: the average of `(y - average y) × (x - average x)`. It is positive when `y` and `x` are often above their averages at the same time, negative when one is above while the other is below. It divides by `n`, the number of pairs. | Argument | Type | Meaning | |---|---|---| | `y` | number | the first value of each pair | | `x` | number | the second value of each pair | - **Returns:** `DOUBLE`. Its unit is the unit of `y` times the unit of `x`, so the size is hard to read; `corr` is the same idea scaled to `-1 ... 1`. - **NULL:** pairs with a NULL are skipped. One pair gives `0`; no pairs give `NULL`. **Example: Pairs with a NULL are skipped: only 3 pairs count** (sample data: sample) ```sql SELECT regr_count(y, x) AS pairs, covar_pop(y, x) AS covar_pop, covar_samp(y, x) AS covar_samp FROM (SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 4 UNION ALL SELECT 3, 5 UNION ALL SELECT 4, NULL UNION ALL SELECT NULL, 9) t; ``` Result: | pairs (LONG) | covar_pop (DOUBLE) | covar_samp (DOUBLE) | |---|---|---| | 3 | 1 | 1.5 | (1 row) ## covar_samp Syntax: ```sql covar_samp(y, x) ``` The **sample covariance**: like `covar_pop`, but it divides by `n - 1`. Use it when your rows are only a part of a longer history, as for returns (see "sample or population" in [statistics](functions-statistics.md)). | Argument | Type | Meaning | |---|---|---| | `y` | number | the first value of each pair | | `x` | number | the second value of each pair | - **Returns:** `DOUBLE`. - **NULL:** pairs with a NULL are skipped. `NULL` with fewer than 2 pairs. - **Notes:** `covar_samp(y, x) / (stddev(y) × stddev(x))` is `corr(y, x)` (when no pair has a NULL). **Example: Covariance of price and volume, minute by minute** (sample data: sample) ```sql SELECT round(covar_samp(close, volume), 1) AS covar_samp, round(covar_pop(close, volume), 1) AS covar_pop, round(corr(close, volume), 3) AS corr FROM ochl_minutes WHERE symbol = 'CITY' AND time IN '2026-09-10'; ``` Result: | covar_samp (DOUBLE) | covar_pop (DOUBLE) | corr (DOUBLE) | |---|---|---| | 0.8 | 0.8 | 0.001 | (1 row) **Example: One pair: covar_pop is 0, covar_samp and corr are NULL** (sample data: sample) ```sql SELECT covar_pop(y, x) AS covar_pop, covar_samp(y, x) AS covar_samp, corr(y, x) AS corr FROM (SELECT 1 AS x, 5 AS y) t; ``` Result: | covar_pop (DOUBLE) | covar_samp (STRING) | corr (STRING) | |---|---|---| | 0 | NULL | NULL | (1 row) ## regr_slope Syntax: ```sql regr_slope(y, x) ``` The **slope** of the best straight line through the points: how much `y` goes up when `x` goes up by 1. With `x` = the day number and `y` = the close, it is the trend in dollars per day. | Argument | Type | Meaning | |---|---|---| | `y` | number | the value to explain, for example `close` | | `x` | number | the value that explains it, for example the day or minute number | - **Returns:** `DOUBLE`. Positive: an up trend; negative: a down trend. - **NULL:** pairs with a NULL are skipped. `NULL` when all `x` values are the same (also with only one pair). - **Notes:** the same as `regr_sxy(y, x) / regr_sxx(y, x)`. Number the rows with `row_number()` in a subquery. For a slope for every candle over the last `n` candles, use [linearreg_slope](functions-indicators-trend.md#linearreg_slope). **Example: Trend line of VOLT since 23 August: dollars per trading day** (sample data: sample) ```sql SELECT round(regr_slope(close, day_no), 3) AS dollars_per_day, round(regr_intercept(close, day_no), 2) AS line_at_day_0, round(regr_r2(close, day_no), 3) AS r2 FROM (SELECT row_number() AS day_no, close FROM ochl_day WHERE symbol = 'VOLT' AND time >= '2026-08-23') t; ``` Result: | dollars_per_day (DOUBLE) | line_at_day_0 (DOUBLE) | r2 (DOUBLE) | |---|---|---| | 0.673 | 172.21 | 0.247 | (1 row) **Example: Intraday trend of each share on 10 September: dollars per minute** (sample data: sample) ```sql SELECT symbol, round(regr_slope(close, minute_no), 4) AS dollars_per_minute, round(regr_r2(close, minute_no), 3) AS r2 FROM (SELECT symbol, close, row_number() OVER (PARTITION BY symbol ORDER BY time) AS minute_no FROM ochl_minutes WHERE time IN '2026-09-10') t GROUP BY symbol ORDER BY symbol; ``` Result: | symbol (SYMBOL) | dollars_per_minute (DOUBLE) | r2 (DOUBLE) | |---|---|---| | CITY | -0.0031 | 0.021 | | COAST | -0.028 | 0.848 | (2 rows) ## regr_intercept Syntax: ```sql regr_intercept(y, x) ``` The **intercept** of the best straight line: the value of the line where `x` is `0`. Together with the slope it gives the line: `y = regr_slope × x + regr_intercept`. | Argument | Type | Meaning | |---|---|---| | `y` | number | the value to explain | | `x` | number | the value that explains it | - **Returns:** `DOUBLE`. - **NULL:** pairs with a NULL are skipped. `NULL` when all `x` values are the same. - **Notes:** the same as `regr_avgy - regr_slope × regr_avgx`. To draw the line or guess the next value, compute `regr_intercept(y, x) + regr_slope(y, x) × next_x`. **Example: Where would the VOLT trend line be on day 16?** (sample data: sample) ```sql SELECT round(regr_intercept(close, day_no) + regr_slope(close, day_no) * 16, 2) AS line_day_16, max(day_no) AS last_day FROM (SELECT row_number() AS day_no, close FROM ochl_day WHERE symbol = 'VOLT' AND time >= '2026-08-23') t; ``` Result: | line_day_16 (DOUBLE) | last_day (LONG) | |---|---| | 182.97 | 15 | (1 row) ## regr_r2 Syntax: ```sql regr_r2(y, x) ``` The **R squared** (coefficient of determination), from `0` to `1`: how much of the ups and downs of `y` the straight line explains. `1`: all points are on the line. `0`: the line explains nothing. For a trend line, a high R squared means a clean, steady trend. | Argument | Type | Meaning | |---|---|---| | `y` | number | the value to explain | | `x` | number | the value that explains it | - **Returns:** `DOUBLE` from `0` to `1`. - **NULL:** pairs with a NULL are skipped. `NULL` when all `x` values are the same. When `x` changes but all `y` values are the same, the result is `1` (like PostgreSQL). - **Notes:** when no pair has a NULL, it is `corr(y, x)` squared. **Example: A flat y: R squared 1, slope 0, corr NULL** (sample data: sample) ```sql SELECT regr_r2(y, x) AS r2, regr_slope(y, x) AS slope, corr(y, x) AS corr FROM (SELECT 1 AS x, 5 AS y UNION ALL SELECT 2, 5) t; ``` Result: | r2 (DOUBLE) | slope (DOUBLE) | corr (STRING) | |---|---|---| | 1 | 0 | NULL | (1 row) ## regr_count Syntax: ```sql regr_count(y, x) ``` The number of pairs where **both** `y` and `x` are not NULL: the rows the other `regr_` functions really use. | Argument | Type | Meaning | |---|---|---| | `y` | any number | the first value of each pair | | `x` | any number | the second value of each pair | - **Returns:** `LONG`. - **NULL:** never `NULL`: no pairs give `0`. - **Notes:** check it next to a correlation or a beta: a number made from 3 pairs means little. **Example: The first day of a return series has no return: 15 days, 14 pairs** (sample data: sample) ```sql SELECT count(*) AS days, regr_count(a.r, b.r) AS pairs FROM (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-08-23') a JOIN (SELECT time, pct_change(close) AS r FROM ochl_day WHERE symbol = 'VOLT' AND time >= '2026-08-23') b ON a.time = b.time; ``` Result: | days (LONG) | pairs (LONG) | |---|---| | 15 | 14 | (1 row) **Example: No rows: regr_count is 0, corr is NULL** (sample data: sample) ```sql SELECT regr_count(close, volume) AS pairs, corr(close, volume) AS corr FROM ochl_day WHERE symbol = 'NONE'; ``` Result: | pairs (LONG) | corr (STRING) | |---|---| | 0 | NULL | (1 row) ## regr_avgx, regr_avgy Syntax: ```sql regr_avgx(y, x) regr_avgy(y, x) ``` The average of `x` (`regr_avgx`) or of `y` (`regr_avgy`), taken **only over the pairs** where both values are not NULL. So it can be different from `avg(x)`, which also counts rows where `y` is NULL. | Argument | Type | Meaning | |---|---|---| | `y` | number | the first value of each pair | | `x` | number | the second value of each pair | - **Returns:** `DOUBLE`. - **NULL:** pairs with a NULL are skipped. No pairs give `NULL`. **Example: avg counts every value, regr_avgx only the full pairs** (sample data: sample) ```sql SELECT avg(x) AS avg_x, regr_avgx(y, x) AS regr_avgx, avg(y) AS avg_y, round(regr_avgy(y, x), 4) AS regr_avgy FROM (SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 4 UNION ALL SELECT 3, 5 UNION ALL SELECT 4, NULL UNION ALL SELECT NULL, 9) t; ``` Result: | avg_x (DOUBLE) | regr_avgx (DOUBLE) | avg_y (DOUBLE) | regr_avgy (DOUBLE) | |---|---|---|---| | 2.5 | 2 | 5 | 3.6667 | (1 row) ## regr_sxx, regr_syy, regr_sxy Syntax: ```sql regr_sxx(y, x) regr_syy(y, x) regr_sxy(y, x) ``` The sums that the regression is made from, over the full pairs: | Function | Sum | |---|---| | `regr_sxx(y, x)` | `sum((x - avg x)²)` | | `regr_syy(y, x)` | `sum((y - avg y)²)` | | `regr_sxy(y, x)` | `sum((x - avg x) × (y - avg y))` | | Argument | Type | Meaning | |---|---|---| | `y` | number | the first value of each pair | | `x` | number | the second value of each pair | - **Returns:** `DOUBLE`. - **NULL:** pairs with a NULL are skipped. One pair gives `0`; no pairs give `NULL`. - **Notes:** `regr_slope = regr_sxy / regr_sxx`, and `corr = regr_sxy / sqrt(regr_sxx × regr_syy)`. You rarely need them yourself. **Example: Build the slope from the sums** (sample data: sample) ```sql SELECT regr_sxx(y, x) AS sxx, round(regr_syy(y, x), 4) AS syy, regr_sxy(y, x) AS sxy, regr_sxy(y, x) / regr_sxx(y, x) AS slope_by_hand, regr_slope(y, x) AS slope FROM (SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 4 UNION ALL SELECT 3, 5) t; ``` Result: | sxx (DOUBLE) | syy (DOUBLE) | sxy (DOUBLE) | slope_by_hand (DOUBLE) | slope (DOUBLE) | |---|---|---|---|---| | 2 | 4.6667 | 3 | 1.5 | 1.5 | (1 row) ## beta Syntax: ```sql beta(stock_return, market_return) beta(stock_return, market_return) OVER (...) ``` The **beta** of a share: how strongly it follows the market. It is the regression slope of the share's returns (`y`) against the market's returns (`x`), for example a stock market index. - beta `1`: the share moves like the market. - beta above `1` (like `1.5`): the share moves more; when the market rises 1%, it rises about 1.5% on average, and falls more too. It is riskier. - beta below `1`: a calmer share. Near `0`: no link with the market. Negative: it tends to move the other way. | Argument | Type | Meaning | |---|---|---| | `stock_return` | number | the return of the share on each day (`y`) | | `market_return` | number | the return of the market on the same day (`x`) | - **Returns:** `DOUBLE`. - **NULL:** pairs with a NULL are skipped. `NULL` with fewer than 2 pairs, and when the market return is the same on every row. - **Notes:** the same number as `regr_slope(stock_return, market_return)`. Both returns must be in the same unit (both % or both fractions) and for the same days: join the two series on `time`. Beta from a few weeks of data changes a lot; use months or years of history for real decisions. **Example: Beta of each share against the market index** (sample data: sample, analysis-market) ```sql SELECT s.symbol, regr_count(s.r, m.r) AS days, round(beta(s.r, m.r), 3) AS beta, round(corr(s.r, m.r), 3) AS corr, round(regr_r2(s.r, m.r), 3) AS r2 FROM (SELECT time, symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM ochl_day WHERE time >= '2026-08-23') s JOIN (SELECT time, pct_change(close) AS r FROM market_index WHERE time >= '2026-08-23') m ON s.time = m.time GROUP BY s.symbol ORDER BY beta DESC; ``` Result: | symbol (SYMBOL) | days (LONG) | beta (DOUBLE) | corr (DOUBLE) | r2 (DOUBLE) | |---|---|---|---|---| | VOLT | 14 | 3.322 | 0.705 | 0.497 | | COAST | 14 | 1.962 | 0.686 | 0.47 | | CITY | 14 | 0.862 | 0.462 | 0.214 | (3 rows) ## alpha Syntax: ```sql alpha(stock_return, market_return) alpha(stock_return, market_return, risk_free) ``` The **alpha** (Jensen's alpha) of a share: the part of its average return that the market does **not** explain. After taking away what its beta would give, a positive alpha means the share did better than the market risk predicts; a negative alpha means worse. Fund managers try to "make alpha". It is `(avg(stock) - risk_free) - beta × (avg(market) - risk_free)`. | Argument | Type | Meaning | |---|---|---| | `stock_return` | number | the return of the share on each day (`y`) | | `market_return` | number | the return of the market on the same day (`x`) | | `risk_free` | number | the return without risk for **one period** (for example a fixed deposit rate per day), in the same unit as the returns; default `0` | - **Returns:** `DOUBLE`, in the unit of the returns **per period**: with daily returns in %, `0.05` means +0.05% a day. - **NULL:** pairs with a NULL are skipped. `NULL` with fewer than 2 pairs, when the market return is the same on every row, and when `risk_free` is NULL. - **Notes:** `risk_free` must be a constant; it is read from the first row. A yearly rate must first be changed to the period: 6% a year is about `6 / 250 = 0.024`% a trading day. **Example: Alpha and beta of each share (daily returns in %)** (sample data: sample, analysis-market) ```sql SELECT s.symbol, round(beta(s.r, m.r), 3) AS beta, round(alpha(s.r, m.r), 4) AS alpha_pct_per_day, round(avg(s.r), 4) AS avg_return_pct FROM (SELECT time, symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM ochl_day WHERE time >= '2026-08-23') s JOIN (SELECT time, pct_change(close) AS r FROM market_index WHERE time >= '2026-08-23') m ON s.time = m.time GROUP BY s.symbol ORDER BY alpha_pct_per_day DESC; ``` Result: | symbol (SYMBOL) | beta (DOUBLE) | alpha_pct_per_day (DOUBLE) | avg_return_pct (DOUBLE) | |---|---|---|---| | VOLT | 3.322 | 0.4136 | 0.4088 | | CITY | 0.862 | -0.0385 | -0.0397 | | COAST | 1.962 | -0.2682 | -0.271 | (3 rows) **Example: With a risk-free return of 0.024% a day** (sample data: sample) ```sql SELECT round(alpha(y, x), 5) AS alpha, round(alpha(y, x, 0.024), 5) AS alpha_with_risk_free, round(beta(y, x), 3) AS beta FROM (SELECT 1.0 AS x, 2.0 AS y UNION ALL SELECT 2.0, 3.0 UNION ALL SELECT -1.0, -1.0) t; ``` Result: | alpha (DOUBLE) | alpha_with_risk_free (DOUBLE) | beta (DOUBLE) | |---|---|---| | 0.42857 | 0.43714 | 1.357 | (1 row) **Example: Returns must be numbers** (sample data: sample; shows an error) ```sql SELECT beta(symbol, close) FROM ochl_day; ``` Result: Error: `line 1, column 8: beta: needs numbers, got 'CITY'`