KDB knowledge

19. Analysis functions

What you will learn

All examples are in examples/19-analysis-functions/. The output under the cookbook examples is real. The prices are made up.

Chapter 5 explains SAMPLE BY, GROUP BY and the first window functions (sma, ema, rsi, atr, lag). Read it first.

How to read this chapter

Every function has a short section:

Three kinds of functions are in this chapter:

KindUsed whereExample
Scalaranywhere: SELECT, WHERE, ORDER BYround_tick(close)
Aggregatewith GROUP BY or SAMPLE BY, alone, or with OVER (...)corr(a, b)
Window / seriesin the SELECT list; without OVER they run in time ordermacd(close) OVER (PARTITION BY symbol ORDER BY time)

Rules for window and series functions (the same as in chapter 5):

The sample candles

The cookbook uses daily candles for the five symbols of the sample data (chapter 1). The sample table ochl_day is short, so this chapter makes a longer, made-up price history with a view. The view itself uses generate_series from this chapter:

01-candles.sql:

CREATE VIEW candles AS
SELECT time, symbol, sector,
       round(close * (1 + 0.006 * sin(n * 1.3 + k)), 1) AS open,
       round(greatest(close, close * (1 + 0.006 * sin(n * 1.3 + k))) * (1.004 + 0.003 * abs(sin(n * 0.7 + k))), 1) AS high,
       round(least(close, close * (1 + 0.006 * sin(n * 1.3 + k))) * (0.996 - 0.003 * abs(cos(n * 0.9 + k))), 1) AS low,
       close, volume
FROM (
  SELECT time, symbol, sector, n, k,
         round(base * (1 + 0.08 * sin(n / 11.0 + k) + 0.04 * sin(n / 3.0 + 2 * k) + 0.0004 * n * (k - 2)), 1) AS close,
         round(50000 + 30000 * abs(sin(n * 0.37 + k))) AS volume
  FROM (
    SELECT d AS time, s.symbol AS symbol, s.sector AS sector, datediff('d', '2026-01-01', d) AS n,
           CASE s.symbol WHEN 'CITY' THEN 0 WHEN 'COAST' THEN 1 WHEN 'VOLT' THEN 2 WHEN 'TELCO' THEN 3 ELSE 4 END AS k,
           CASE s.symbol WHEN 'CITY' THEN 500 WHEN 'COAST' THEN 380 WHEN 'VOLT' THEN 180 WHEN 'TELCO' THEN 900 ELSE 250 END AS base
    FROM generate_series('2026-01-01'::TIMESTAMP, '2026-09-10'::TIMESTAMP, '1 day') AS g(d)
    CROSS JOIN securities s
    WHERE is_trading_day(d)
  ) a
) b;

Each symbol has 181 trading days:

SELECT symbol, count(*) AS days, min(time) AS first_day, max(time) AS last_day, min(close) AS low, max(close) AS high
FROM candles GROUP BY symbol ORDER BY symbol;
view 'candles' created

symbol | days | first_day                | last_day                 | low   | high  
-------+------+--------------------------+--------------------------+-------+-------
ACME   |  181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z |   223 |  327.1
CITY   |  181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 371.8 |  542.9
COAST  |  181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 299.1 |  419.4
TELCO  |  181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 805.8 | 1076.2
VOLT   |  181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 158.8 |  201.2
(5 rows, 5.3 ms)

Part 1: Aggregates

Aggregates look at many rows and give one value. Use them alone, with GROUP BY, with SAMPLE BY, or with OVER (...) for a moving window (for example corr(a, b) OVER (ORDER BY time ROWS 19 PRECEDING)).

The general NULL rule: rows with NULL in a used argument are skipped. For two-value aggregates (like corr(y, x)) the whole pair is skipped. With no rows left, the result is NULL (regr_count, count_if and approx_count_distinct give 0).

Correlation and regression

NameSyntaxMeaningNULLs / too few rows
corrcorr(y, x)Pearson correlation, -1 to 1NULL when y or x does not change
covar_popcovar_pop(y, x)population covariance (divide by n)NULL with no pairs
covar_sampcovar_samp(y, x)sample covariance (divide by n - 1)NULL with fewer than 2 pairs
regr_sloperegr_slope(y, x)slope of the best straight line y = a + b·xNULL when x does not change
regr_interceptregr_intercept(y, x)a of that lineNULL when x does not change
regr_r2regr_r2(y, x)how well the line fits, 0 to 1NULL when x does not change; 1 when only y does not change
regr_countregr_count(y, x)number of pairs without NULL0 with no pairs
regr_avgx, regr_avgyregr_avgx(y, x)average of x (of y) over the pairsNULL with no pairs
regr_sxx, regr_syy, regr_sxyregr_sxx(y, x)sum of squared differences from the averageNULL with no pairs
betabeta(asset_returns, market_returns)how much the asset moves with the marketNULL with fewer than 2 pairs
alphaalpha(asset_returns, market_returns [, risk_free])return not explained by beta, per periodNULL with fewer than 2 pairs

The argument order is y first, then x, as in PostgreSQL.

corr

covar_pop

covar_samp

regr_slope

regr_intercept

regr_r2

regr_count

regr_avgx

regr_sxx

beta

alpha

Shape of the data

NameSyntaxMeaningNULLs / too few rows
skewness (skew)skewness(x)sample skewness (like Excel SKEW, pandas skew)NULL with fewer than 3 values or no change
skewness_popskewness_pop(x)population skewnessNULL with no change
kurtosis (kurt)kurtosis(x)sample excess kurtosis (like Excel KURT, pandas kurt)NULL with fewer than 4 values or no change
kurtosis_popkurtosis_pop(x)population excess kurtosisNULL with no change
geomean (geometric_mean)geomean(x)geometric meanNULL if a value is 0 or less
harmonic_meanharmonic_mean(x)harmonic meanNULL if a value is 0 or less
modemode(x) or mode() WITHIN GROUP (ORDER BY x)most frequent valuea tie gives the smallest value
percentile_cont (quantile_cont)percentile_cont(x, p) or percentile_cont(p) WITHIN GROUP (ORDER BY x)value at fraction p, between valuesp must be 0 to 1
percentile_disc (quantile_disc)percentile_disc(x, p) or ... WITHIN GROUP (ORDER BY x)first real value at or above fraction pany type that can be sorted
median_abs_deviation (mad)mad(x)median of |x - median|NULL with no values
iqriqr(x)75th minus 25th percentileNULL with no values
rangerange(x)max - min (for times: milliseconds, for DATE: days)NULL with no values
entropyentropy(x)Shannon entropy of the values, in bitsNULL with no values

skewness

skewness_pop

kurtosis

kurtosis_pop

geomean

harmonic_mean

mode

percentile_cont

percentile_disc

median_abs_deviation

iqr

range

entropy

Counting, conditions and picking rows

NameSyntaxMeaningNULLs
count_ifcount_if(condition)rows where the condition is true0 with no rows
sum_ifsum_if(x, condition)sum of x where the condition is trueNULL when no row matches
avg_ifavg_if(x, condition)average of x where the condition is trueNULL when no row matches
bool_and (every)bool_and(b)true if every value is trueNULL values skipped
bool_orbool_or(b)true if any value is trueNULL values skipped
arg_min (argmin, min_by)arg_min(x, y)x of the row with the smallest yrows with NULL y skipped
arg_max (argmax, max_by)arg_max(x, y)x of the row with the biggest yrows with NULL y skipped
approx_count_distinctapprox_count_distinct(x)number of different values (exact in KDB)0 with no rows
sum_of_squaressum_of_squares(x)Σx²NULL with no values
productproduct(x)x1 × x2 × ...NULL with no values, or when too big
weighted_avg (wavg)weighted_avg(x, weight)Σ(x·weight) / ΣweightNULL when the weights add up to 0
twaptwap(price, time)time-weighted average priceNULL with no rows

count_if

sum_if

avg_if

bool_and

bool_or

arg_min

arg_max

approx_count_distinct

sum_of_squares

product

weighted_avg

twap

Text and JSON lists

NameSyntaxMeaningNULLs
string_aggstring_agg(x, separator)values joined as text, in time orderNULL values skipped
listagglistagg(x [, separator])the same; separator default ,NULL values skipped
array_agg (json_agg)array_agg(x)a JSON array of the values, in time orderNULL values become null

string_agg

listagg

array_agg

Risk aggregates

These work on a column of returns (for example pct_return(close), Part 3), in time order. Use a subquery to make the returns first. periods is the number of periods in one year: about 240 or 252 trading days (it depends on the exchange), 52 for weeks, 12 for months.

NameSyntaxMeaningNULLs / too few rows
sharpe_ratiosharpe_ratio(r [, risk_free [, periods]])(average r - risk_free) / standard deviation × √periodsNULL with fewer than 2 returns or no change
sortino_ratiosortino_ratio(r [, target [, periods]])like Sharpe, but only falls below target count as riskNULL when no return is below target
calmar_ratiocalmar_ratio(r, periods)yearly growth / largest drawdownNULL when there is no drawdown
information_ratioinformation_ratio(r, benchmark_r [, periods])average extra return / its standard deviation × √periodsNULL with fewer than 2 pairs
max_drawdownmax_drawdown(price)largest fall from a peak, as a negative fractionNULL if a price is 0 or less
value_at_riskvalue_at_risk(r, confidence)loss that is not passed with this confidence (historical)NULL with no returns
expected_shortfallexpected_shortfall(r, confidence)average loss in the worst cases beyond value at riskNULL with no returns

sharpe_ratio

sortino_ratio

calmar_ratio

information_ratio

max_drawdown

value_at_risk

expected_shortfall

Money over time

NameSyntaxMeaningNULLs
xnpvxnpv(rate, amount, date)net present value of cash flows on dates (Excel XNPV)rows with NULL skipped
xirrxirr(amount, date [, guess])yearly rate that makes XNPV 0 (Excel XIRR)NULL without both signs

xnpv

xirr


Part 2: Window functions

OVER and frames

A window function gets OVER (...):

fn(...) OVER ([PARTITION BY expr, ...] [ORDER BY expr [ASC|DESC], ...] [frame])

The frame says which rows of the partition each row looks at. Aggregates (sum, avg, corr, ...), first_value, last_value, nth_value, zscore and minmax_scale use the frame. Ranks, lag, lead and the indicators ignore it.

FrameRows for each row
no frame, no ORDER BYthe whole partition
no frame, with ORDER BYfrom the first row of the partition to this row (running)
ROWS n PRECEDINGthis row and n rows before it
ROWS BETWEEN n PRECEDING AND m FOLLOWINGn rows before to m rows after
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWINGthis row to the last row
ROWS BETWEEN 5 PRECEDING AND 1 PRECEDINGthe 5 rows before, without this row
RANGE BETWEEN '5 days' PRECEDING AND CURRENT ROWrows whose time is at most 5 days before this row's time
RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWINGrows whose ORDER BY value is within 10 of this row's value
RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWINGthis row, rows with the same ORDER BY value, and all after

Rules:

Example: the average close of the week around each day, and the volume of the last 7 calendar days (there are no candles on Fridays and Saturdays, so 7 days are 5 rows):

SELECT time, close,
       round(avg(close) OVER (ORDER BY time ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING), 2) AS centered_avg,
       sum(volume) OVER (ORDER BY time RANGE BETWEEN '6 days' PRECEDING AND CURRENT ROW) AS volume_7d
FROM candles WHERE symbol = 'CITY' AND time >= '2026-08-25';

02-frames.sql:

time                     | close | centered_avg | volume_7d
-------------------------+-------+--------------+----------
2026-08-25T00:00:00.000Z | 423.5 |       413.83 |     68026
2026-08-26T00:00:00.000Z | 413.6 |       407.23 |    126161
2026-08-27T00:00:00.000Z | 404.4 |       402.96 |    179019
2026-08-30T00:00:00.000Z | 387.4 |        395.5 |    257039
2026-08-31T00:00:00.000Z | 385.9 |       390.38 |    337039
2026-09-01T00:00:00.000Z | 386.2 |       387.62 |    346933
2026-09-02T00:00:00.000Z |   388 |       389.42 |    360859
2026-09-03T00:00:00.000Z | 390.6 |       391.38 |    371217
2026-09-06T00:00:00.000Z | 396.4 |       392.76 |    361444
2026-09-07T00:00:00.000Z | 395.7 |       392.86 |    357067
2026-09-08T00:00:00.000Z | 393.1 |       391.16 |    358678
2026-09-09T00:00:00.000Z | 388.5 |       389.85 |    366060
2026-09-10T00:00:00.000Z | 382.1 |        387.9 |    378213
(13 rows, 4.9 ms)

Ranking and position

NameSyntaxMeaningNULLs
row_number, rank, dense_rankrank() OVER (...)see chapter 5NULL ORDER BY values sort last
percent_rankpercent_rank() OVER (...)(rank - 1) / (rows - 1), 0 to 1one row gives 0
cume_distcume_dist() OVER (...)share of rows at or before this row's value, 0 to 1
ntilentile(n) OVER (...)bucket number 1..n of about equal size
first_valuefirst_value(x) OVER (...)x of the first row of the framemay be NULL
last_valuelast_value(x) OVER (...)x of the last row of the framemay be NULL
nth_valuenth_value(x, n) OVER (...)x of the n-th row of the frameNULL when the frame is shorter
lag, leadlag(x [, k [, default]])x of k rows before (after)default when there is no such row

percent_rank

cume_dist

ntile

first_value

last_value

nth_value

lag

Cumulative values

NameSyntaxMeaningNULLs
cumsumcumsum(x)running total (chapter 5)NULL values skipped
cumprodcumprod(x)running productNULL values skipped; NULL before the first value
cummaxcummax(x)highest value so farNULL values skipped
cummincummin(x)lowest value so farNULL values skipped

cumprod

cummax

cummin


Part 3: Returns and risk (series)

These run over the rows in time order, like sma. Use OVER (PARTITION BY symbol ORDER BY time) for many symbols.

NameSyntaxMeaningFirst value / NULLs
pct_return (returns, percent_return)pct_return(price [, k])price / price k rows before - 1row k+1; NULL when a price is NULL or the old one is 0
log_returnlog_return(price [, k])ln(price / price k rows before)row k+1; NULL when the ratio is 0 or less
pct_changepct_change(price)the same as pct_return × 100 (chapter 5)row 2
cum_returncum_return(price)price / first price - 1row 1
drawdowndrawdown(price)price / highest price so far - 1row 1
volatilityvolatility(price, n [, periods])standard deviation of the last n log returns × √periodsrow n+1
zscorezscore(x [, n])(x - average) / standard deviationwith n: row n
minmax_scaleminmax_scale(x [, n])(x - min) / (max - min), 0 to 1with n: row n
max_drawdownmax_drawdown(price) OVER (...)the aggregate of Part 1 as a running valuerow 1

pct_return

log_return

cum_return

drawdown

volatility

zscore

minmax_scale


Part 4: Technical indicators

All indicators are series functions over candles in time order. Most follow TA-Lib: the same formulas, and the first value on the same row as TA-Lib (the "warm-up" rows before it are NULL). Where TA-Lib versions differ for flat prices (a window where high = low, or no price change), KDB follows TA-Lib 0.6. The table says whose definition each one uses.

NULL rules for indicators:

Defaults: arguments in [ ] can be left out; the default is shown after =.

Moving averages

NameSyntaxDefinitionFirst value (row)
smasma(x, n)chapter 5n
emaema(x, n)chapter 5 (TA-Lib)n
wmawma(x, n)TA-Lib WMA: weights 1..n, newest heaviestn
demadema(x, n)TA-Lib DEMA: 2·EMA - EMA(EMA)2n - 1
tematema(x, n)TA-Lib TEMA: 3·EMA - 3·EMA(EMA) + EMA(EMA(EMA))3n - 2
trimatrima(x, n)TA-Lib TRIMA: triangular weightsn
kamakama(x, n)TA-Lib KAMA (fast 2, slow 30)n + 1
hmahma(x, n)Hull: WMA(2·WMA(x, n/2) - WMA(x, n), √n)n + ⌊√n⌋ - 1
vwmavwma(price, volume, n)Σ(price·volume) / Σvolume of n rowsn

wma

dema

tema

trima

kama

hma

vwma

VWAP for a trading session or a rolling window needs no new function: vwap(close, volume) OVER (PARTITION BY symbol, date_trunc('day', time) ORDER BY time) (session, on minute candles) and vwap(close, volume) OVER (PARTITION BY symbol ORDER BY time ROWS 19 PRECEDING) (rolling).

MACD

NameSyntaxDefinitionFirst value (row)
macdmacd(x [, fast = 12, slow = 26, signal = 9])TA-Lib MACD lineslow + signal - 1 (34)
macd_signalmacd_signal(x [, 12, 26, 9])EMA of the MACD lineslow + signal - 1
macd_histmacd_hist(x [, 12, 26, 9])line - signalslow + signal - 1

macd

macd_signal

macd_hist

Oscillators

NameSyntaxDefinitionFirst value (row)
rsirsi(x, n)chapter 5 (Wilder, TA-Lib start)n + 1
stoch_kstoch_k(high, low, close [, k = 14, k_smooth = 3, d = 3])TA-Lib STOCH slow %Kk + k_smooth + d - 2
stoch_dstoch_d(high, low, close [, 14, 3, 3])TA-Lib STOCH slow %Dk + k_smooth + d - 2
stochf_kstochf_k(high, low, close [, n = 14])raw (fast) %Kn
stochrsi_kstochrsi_k(x [, n = 14, k = 5, d = 3])TA-Lib STOCHRSI fast %Kn + k + d - 1
stochrsi_dstochrsi_d(x [, 14, 5, 3])TA-Lib STOCHRSI fast %Dn + k + d - 1
willr (williams_r)willr(high, low, close [, n = 14])TA-Lib WILLR, -100 to 0n
ccicci(high, low, close [, n = 20])TA-Lib CCIn
rocroc(x [, n = 10])TA-Lib ROC: (x / x n rows before - 1) × 100n + 1
rocprocp(x [, n = 10])(x - old) / oldn + 1
rocrrocr(x [, n = 10])x / oldn + 1
mom (momentum)mom(x [, n = 10])x - x n rows beforen + 1
trixtrix(x [, n = 30])TA-Lib TRIX: 1-row ROC of a triple EMA3n - 1

stoch_k

stoch_d

stochf_k

stochrsi_k

stochrsi_d

willr

cci

roc

rocp

rocr

mom

trix

Volatility and bands

NameSyntaxDefinitionFirst value (row)
atratr(high, low, close, n)chapter 5 (TA-Lib)n + 1
natrnatr(high, low, close [, n = 14])TA-Lib NATR: ATR / close × 100n + 1
trange (true_range)trange(high, low, close)TA-Lib TRANGE2
bb_upper, bb_middle, bb_lowerbb_upper(x [, n = 20, k = 2])Bollinger: SMA ± k × population std. dev.n
bb_percent_bbb_percent_b(x [, 20, 2])(x - lower) / (upper - lower)n
bb_widthbb_width(x [, 20, 2])(upper - lower) / middlen
kc_upper, kc_middle, kc_lowerkc_upper(high, low, close [, n = 20, mult = 2, atr_n = 10])Keltner: EMA(close, n) ± mult × ATR(atr_n)max(n, atr_n + 1)
dc_upper, dc_middle, dc_lowerdc_upper(high, low [, n = 20])Donchian: highest high / middle / lowest lown

natr

trange

bb_upper

bb_percent_b

bb_width

kc_upper

dc_upper

Trend strength and direction

NameSyntaxDefinitionFirst value (row)
plus_di, minus_diplus_di(high, low, close [, n = 14])TA-Lib PLUS_DI / MINUS_DIn + 1
dxdx(high, low, close [, n = 14])TA-Lib DXn + 1
adxadx(high, low, close [, n = 14])TA-Lib ADX2n
adxradxr(high, low, close [, n = 14])TA-Lib ADXR3n - 1
aroon_uparoon_up(high [, n = 14])TA-Lib AROON upn + 1
aroon_downaroon_down(low [, n = 14])TA-Lib AROON downn + 1
aroon_oscaroon_osc(high, low [, n = 14])TA-Lib AROONOSCn + 1
sar (parabolic_sar)sar(high, low [, acceleration = 0.02, maximum = 0.2])TA-Lib SAR2
supertrendsupertrend(high, low, close [, n = 10, multiplier = 3])TradingView ta.supertrendn
supertrend_directionsupertrend_direction(high, low, close [, 10, 3])1 up, -1 downn
linearreg (linreg)linearreg(x [, n = 14])TA-Lib LINEARREGn
linearreg_slopelinearreg_slope(x [, 14])TA-Lib LINEARREG_SLOPEn
linearreg_interceptlinearreg_intercept(x [, 14])TA-Lib LINEARREG_INTERCEPTn
linearreg_anglelinearreg_angle(x [, 14])TA-Lib LINEARREG_ANGLE (degrees)n
tsftsf(x [, 14])TA-Lib TSF: the line's value one row aheadn

plus_di

dx

adx

adxr

aroon_up

aroon_osc

sar

supertrend

supertrend_direction

linearreg

linearreg_slope

linearreg_intercept

linearreg_angle

tsf

Ichimoku

NameSyntaxMeaningFirst value (row)
ichimoku_tenkanichimoku_tenkan(high, low [, n = 9])(highest high + lowest low) / 2 of n rowsn
ichimoku_kijunichimoku_kijun(high, low [, n = 26])the same over 26 rowsn
ichimoku_senkou_aichimoku_senkou_a(high, low [, tenkan = 9, kijun = 26, shift = 26])(tenkan + kijun) / 2 from shift rows beforekijun + shift
ichimoku_senkou_bichimoku_senkou_b(high, low [, n = 52, shift = 26])52-row middle from shift rows beforen + shift
ichimoku_chikouichimoku_chikou(close [, shift = 26])the close shift rows after this rowNULL on the last shift rows

ichimoku_tenkan

ichimoku_kijun

ichimoku_senkou_a

ichimoku_senkou_b

ichimoku_chikou

Volume

NameSyntaxDefinitionFirst value (row)
obvobv(close, volume)TA-Lib OBV (starts with the first volume)1
ad (ad_line)ad(high, low, close, volume)TA-Lib AD (accumulation/distribution)1
cmfcmf(high, low, close, volume [, n = 20])Chaikin money flown
adosc (chaikin_osc)adosc(high, low, close, volume [, fast = 3, slow = 10])TA-Lib ADOSCslow
mfimfi(high, low, close, volume [, n = 14])TA-Lib MFIn + 1
efi (force_index)efi(close, volume [, n = 13])Elder's force index, EMA of (change × volume)n + 1
eom (ease_of_movement)eom(high, low, volume [, n = 14, divisor = 100000000])ease of movement (pandas-ta)n + 1

obv

cmf

adosc

mfi

efi

eom

Heikin-Ashi

NameSyntaxMeaning
ha_openha_open(open, high, low, close)first row (open + close) / 2, then (previous ha_open + previous ha_close) / 2
ha_closeha_close(open, high, low, close)(open + high + low + close) / 4
ha_highha_high(open, high, low, close)biggest of high, ha_open, ha_close
ha_lowha_low(open, high, low, close)smallest of low, ha_open, ha_close

ha_open

ha_close

ha_high

ha_low


Part 5: Scalar functions

A NULL argument gives NULL for every function in this part (except random() and setseed(), which take none or one).

Math

NameSyntaxMeaningExampleResult
trunc (truncate)trunc(x [, d])cut toward zero to d decimalstrunc(-2.7)-2
cbrtcbrt(x)cube rootcbrt(27)3
loglog(x), log(base, x)natural log; log with a baselog(2, 8)3
log2log2(x)log base 2log2(8)3
gcd, lcmgcd(a, b)greatest common divisor, least common multiplelcm(4, 6)12
factorialfactorial(n)n! (LONG up to 20, DOUBLE above)factorial(5)120
pipi()3.14159...
degrees, radiansdegrees(x)radians to degrees and backdegrees(pi())180
sin cos tan cotsin(x)trigonometry (radians)cos(0)1
asin acos atan atan2atan2(y, x)inverse trigonometryatan2(1, 1)0.785...
sinh cosh tanh asinh acosh atanhtanh(x)hyperbolic functionstanh(0)0
width_bucketwidth_bucket(x, low, high, count)bucket number for a histogramwidth_bucket(5.35, 0.024, 10.06, 5)3
clampclamp(x, low, high)x kept between low and highclamp(15, 0, 10)10
safe_dividesafe_divide(a, b)a / b, NULL when b is 0safe_divide(1, 0)NULL
div0div0(a, b)a / b, 0 when b is 0div0(1, 0)0
percentpercent(a, b)a / b × 100, NULL when b is 0percent(1, 4)25
is_nan, is_inf, is_finiteis_finite(x)tests for special DOUBLE valuesis_finite(2)true
random (rand)random()random DOUBLE, 0 or more and less than 1
setseedsetseed(s)fixes the random numbers, s from -1 to 1NULL
normal_cdfnormal_cdf(x [, mean, sd])normal distribution: P(X ≤ x)normal_cdf(1.96)0.975...
normal_pdfnormal_pdf(x [, mean, sd])normal density
normal_invnormal_inv(p [, mean, sd])x where P(X ≤ x) = pnormal_inv(0.975)1.9599...

trunc

cbrt

log

log2

gcd

factorial

pi

degrees

sin

width_bucket

clamp

safe_divide

div0

percent

is_nan

random

setseed

normal_cdf

normal_inv

Prices

NameSyntaxMeaningExampleResult
round_tickround_tick(price [, tick = 0.1])nearest price on the tick gridround_tick(512.35)512.4
floor_tickfloor_tick(price [, tick = 0.1])tick price at or belowfloor_tick(512.37)512.3
ceil_tickceil_tick(price [, tick = 0.1])tick price at or aboveceil_tick(512.31)512.4
pivot_pointpivot_point(high, low, close [, method])pivot point Ppivot_point(110, 90, 100)100
pivot_levelpivot_level(high, low, close, level [, method])support and resistance levelspivot_level(110, 90, 100, 'r1')110

round_tick

pivot_point

pivot_level

MethodR1 / S1R2 / S2R3 / S3R4 / S4
classic (also 'standard')2P - L / 2P - HP ± RH + 2(P - L) / L - 2(H - P)-
woodieas classic, with Woodie's PP ± Ras classic-
fibonacciP ± 0.382·RP ± 0.618·RP ± R-
camarillaC ± R·1.1/12C ± R·1.1/6C ± R·1.1/4C ± R·1.1/2
SELECT time, symbol, pivot_level(ph, pl, pc, 's1') AS s1, pivot_point(ph, pl, pc) AS p, pivot_level(ph, pl, pc, 'r1') AS r1
FROM (SELECT time, symbol,
             lag(high) OVER (PARTITION BY symbol ORDER BY time) AS ph,
             lag(low) OVER (PARTITION BY symbol ORDER BY time) AS pl,
             lag(close) OVER (PARTITION BY symbol ORDER BY time) AS pc
      FROM candles) x
WHERE time = '2026-09-10';

Finance

NameSyntaxMeaning (Excel's function of the same name)
cagrcagr(start_value, end_value, years)compound yearly growth rate
compound_interestcompound_interest(principal, rate, years [, times_per_year = 1])interest earned
fvfv(rate, nper, pmt [, pv = 0, type = 0])future value
pvpv(rate, nper, pmt [, fv = 0, type = 0])present value
pmtpmt(rate, nper, pv [, fv = 0, type = 0])payment per period
npernper(rate, pmt, pv [, fv = 0, type = 0])number of periods
npvnpv(rate, value1, value2, ...)net present value, first value one period away
irrirr(value0, value1, ...)internal rate of return per period

Excel's sign rule: money you pay is negative, money you get is positive. type 0 = payments at the end of each period, 1 = at the start. rate is per period (0.12 / 12 for 12 % a year paid monthly).

cagr

compound_interest

fv

pv

pmt

nper

npv

irr

Dates for reports

All in UTC (for a local clock add its offset, for example 20700000 ms for Nepal, +05:45; chapter 4).

NameSyntaxMeaningExampleResult
extract, date_partextract(field FROM t), date_part('field', t)a part of a timeextract(dow FROM '2026-09-11')5
date_truncdate_trunc('quarter', t)new unit quarter (chapter 11 has the others)date_trunc('quarter', '2026-08-15')2026-07-01T00:00:00.000Z
datediffdatediff('month', a, b)new units month and year (whole calendar months)datediff('month', '2026-01-31', '2026-02-28')1
ageage(a [, b])the time from b to a as textage('2026-09-11', '2020-01-05')6 years 8 mons 6 days
quarterquarter(t)1 to 4quarter('2026-09-11')3
day_of_yearday_of_year(t)1 to 366day_of_year('2026-02-01')32
week_of_yearweek_of_year(t)ISO week, 1 to 53week_of_year('2027-01-01')53
last_daylast_day(t)last day of the month (DATE)last_day('2024-02-10')2024-02-29
make_datemake_date(year, month, day)a DATEmake_date(2026, 9, 11)2026-09-11
make_timestampmake_timestamp(y, m, d, h, mi, s)a TIMESTAMP (s may have decimals)
fiscal_yearfiscal_year(t [, start_month = 7, start_day = 17])the year the fiscal year starts infiscal_year('2026-07-16')2025
fiscal_quarterfiscal_quarter(t [, 7, 17])quarter of the fiscal yearfiscal_quarter('2026-10-17')2
nepal_fiscal_yearnepal_fiscal_year(t)Nepal's fiscal year in Bikram Sambat yearsnepal_fiscal_year('2026-09-11')2083/84

extract

age

quarter

day_of_year

week_of_year

last_day

make_date

make_timestamp

fiscal_year

fiscal_quarter

nepal_fiscal_year


Part 6: Tables of numbers and pivots

generate_series and long_sequence

generate_series is a table function: write it in FROM like a table.

SELECT ... FROM generate_series(start, stop [, step]) [[AS] alias [(column)]]
SELECT ... FROM long_sequence(count)
FormRowsColumn type
generate_series(1, 10)1, 2, ..., 10LONG
generate_series(10, 1, -3)10, 7, 4, 1LONG
generate_series(0, 1, 0.25)0, 0.25, 0.5, 0.75, 1DOUBLE
generate_series('2026-01-01'::TIMESTAMP, '2026-01-31'::TIMESTAMP, '1 day')every dayTIMESTAMP
generate_series('2026-01-31'::TIMESTAMP, '2026-12-31'::TIMESTAMP, '1 month')month ends (Jan 31, Feb 28, ...)TIMESTAMP
long_sequence(5)x = 1, ..., 5 (QuestDB)LONG

Rules:

generate_series

SELECT d, count(c.close) AS candles
FROM generate_series('2026-09-01'::TIMESTAMP, '2026-09-10'::TIMESTAMP, '1 day') AS g(d)
LEFT JOIN candles c ON c.time = g.d AND c.symbol = 'CITY'
WHERE is_trading_day(d)
GROUP BY d ORDER BY d;

long_sequence

PIVOT

SELECT ... FROM source PIVOT (aggregate(x) [AS name], ... FOR column IN (value [AS name], ...)) [[AS] alias]

PIVOT turns values of one column into columns. Every column of the source that PIVOT does not use (not in the aggregates, not the FOR column) becomes a group key: one row per key. source is a table, a view, a subquery or a table function.

PartRule
aggregatesany aggregate: sum, count(*), avg, max, last, corr, ...
FOR columna column or expression of the source
IN valuesconstants. A value without AS gives a column named after the value
column namesone aggregate: the value's name; several: value_name, like jan_total
missing combinationsNULL (count gives 0)

It is the same as a GROUP BY with CASE: sum(CASE WHEN month = 1 THEN turnover END) AS jan, .... PIVOT writes that for you. Put the columns you do not need out of the source with a subquery, or they become group keys.

PIVOT


Part 7: Cookbook

All recipes use the candles view from the start of this chapter.

Top gainers per day

The change from the day before is a window over each symbol. The rank inside each day is a second window over the result, so it needs one more subquery level (PARTITION BY time).

03-cook-top-gainers.sql:

-- The two best symbols of each day, by the change from the day before.
SELECT time, symbol, chg_pct, place
FROM (
  SELECT time, symbol, chg_pct, rank() OVER (PARTITION BY time ORDER BY chg_pct DESC) AS place
  FROM (
    SELECT time, symbol, round(pct_return(close) OVER (PARTITION BY symbol ORDER BY time) * 100, 2) AS chg_pct
    FROM candles
  ) d
  WHERE chg_pct IS NOT NULL
) r
WHERE place <= 2 AND time >= '2026-09-06'
ORDER BY time, place;
time                     | symbol | chg_pct | place
-------------------------+--------+---------+------
2026-09-06T00:00:00.000Z | TELCO  |    5.44 |     1
2026-09-06T00:00:00.000Z | CITY   |    1.48 |     2
2026-09-07T00:00:00.000Z | TELCO  |    1.33 |     1
2026-09-07T00:00:00.000Z | VOLT   |    1.27 |     2
2026-09-08T00:00:00.000Z | VOLT   |    1.73 |     1
2026-09-08T00:00:00.000Z | TELCO  |    0.98 |     2
2026-09-09T00:00:00.000Z | VOLT   |    1.94 |     1
2026-09-09T00:00:00.000Z | TELCO  |    0.56 |     2
2026-09-10T00:00:00.000Z | VOLT   |    2.13 |     1
2026-09-10T00:00:00.000Z | TELCO  |    0.17 |     2
(10 rows, 5.8 ms)

20-day volatility

volatility(close, 20, 240) is the standard deviation of the last 20 daily log returns, scaled to a year of 240 trading days. The inner query computes it for every day; the outer one keeps the last day.

04-cook-volatility.sql:

-- Yearly volatility from the last 20 daily log returns (240 trading days a year).
SELECT symbol, round(close, 1) AS close, round(vol_20d * 100, 1) AS vol_pct
FROM (
  SELECT time, symbol, close, volatility(close, 20, 240) OVER (PARTITION BY symbol ORDER BY time) AS vol_20d
  FROM candles
) v
WHERE time = '2026-09-10'
ORDER BY vol_pct DESC;
symbol | close  | vol_pct
-------+--------+--------
CITY   |  382.1 |    29.6
VOLT   |  177.1 |    27.9
COAST  |  299.1 |    26.8
TELCO  | 1073.4 |    21.8
ACME   |  312.2 |    21.2
(5 rows, 5.1 ms)

Drawdown

drawdown(close) is the fall from the highest close so far. max_drawdown is the worst of it, and arg_min(time, dd) finds the day of the lowest point. Compute the drawdown on all days first, then filter the days to show.

05-cook-drawdown.sql:

-- The largest fall from a peak for every symbol, and the day of the lowest point.
SELECT symbol, round(max_drawdown(close) * 100, 1) AS max_dd_pct, arg_min(time, dd) AS worst_day
FROM (SELECT time, symbol, close, drawdown(close) OVER (PARTITION BY symbol ORDER BY time) AS dd FROM candles) x
GROUP BY symbol
ORDER BY max_dd_pct;

-- The drawdown of COAST on the last days.
SELECT time, close, round(drawdown(close) * 100, 2) AS dd_pct
FROM (SELECT time, close FROM candles WHERE symbol = 'COAST') n
WHERE time >= '2026-09-01';
symbol | max_dd_pct | worst_day               
-------+------------+-------------------------
CITY   |      -31.5 | 2026-07-05T00:00:00.000Z
COAST  |      -28.7 | 2026-09-10T00:00:00.000Z
VOLT   |      -20.9 | 2026-04-08T00:00:00.000Z
TELCO  |      -18.6 | 2026-08-12T00:00:00.000Z
ACME   |      -16.8 | 2026-05-21T00:00:00.000Z
(5 rows, 5.2 ms)

time                     | close | dd_pct
-------------------------+-------+-------
2026-09-01T00:00:00.000Z | 330.7 |      0
2026-09-02T00:00:00.000Z | 329.8 |  -0.27
2026-09-03T00:00:00.000Z | 327.5 |  -0.97
2026-09-06T00:00:00.000Z | 314.3 |  -4.96
2026-09-07T00:00:00.000Z | 309.2 |   -6.5
2026-09-08T00:00:00.000Z | 304.6 |  -7.89
2026-09-09T00:00:00.000Z | 301.1 |  -8.95
2026-09-10T00:00:00.000Z | 299.1 |  -9.56
(8 rows, 4.4 ms)

MACD crossover signals

The MACD histogram (line - signal) changes sign when the lines cross. lag(hist) in one more level gives yesterday's histogram. A cross from below is a buy signal, a cross from above a sell signal.

06-cook-macd-crossover.sql:

-- Days when the MACD line crosses its signal line.
SELECT time, symbol, round(line, 3) AS macd, round(sig, 3) AS signal,
       CASE WHEN hist > 0 THEN 'BUY' ELSE 'SELL' END AS action
FROM (
  SELECT time, symbol, line, sig, hist, lag(hist) OVER (PARTITION BY symbol ORDER BY time) AS prev_hist
  FROM (
    SELECT time, symbol,
           macd(close) OVER (PARTITION BY symbol ORDER BY time) AS line,
           macd_signal(close) OVER (PARTITION BY symbol ORDER BY time) AS sig,
           macd_hist(close) OVER (PARTITION BY symbol ORDER BY time) AS hist
    FROM candles
  ) m
) x
WHERE (hist > 0 AND prev_hist <= 0) OR (hist < 0 AND prev_hist >= 0)
ORDER BY time DESC, symbol
LIMIT 8;
time                     | symbol | macd    | signal  | action
-------------------------+--------+---------+---------+-------
2026-09-07T00:00:00.000Z | VOLT   |  -3.953 |  -4.085 | BUY   
2026-09-02T00:00:00.000Z | VOLT   |  -4.098 |  -4.059 | SELL  
2026-08-27T00:00:00.000Z | VOLT   |  -4.054 |  -4.171 | BUY   
2026-08-24T00:00:00.000Z | CITY   |   8.463 |   9.102 | SELL  
2026-08-19T00:00:00.000Z | TELCO  | -19.481 | -20.742 | BUY   
2026-08-11T00:00:00.000Z | ACME   |  -4.911 |  -5.082 | BUY   
2026-08-05T00:00:00.000Z | COAST  |    6.99 |   7.709 | SELL  
2026-07-28T00:00:00.000Z | VOLT   |   4.393 |   4.919 | SELL  
(8 rows, 5.9 ms)

Bollinger squeeze filter

A squeeze is a quiet market: the Bollinger band width is close to its lowest value of the last 120 days. Often a big move follows. min(width) OVER (... ROWS 119 PRECEDING) is the lowest width of 120 rows.

07-cook-bollinger-squeeze.sql:

-- A squeeze: the Bollinger band width is near its lowest of the last 120 days.
SELECT time, symbol, round(width * 100, 2) AS width_pct, round(lowest * 100, 2) AS lowest_120d_pct
FROM (
  SELECT time, symbol, width, min(width) OVER (PARTITION BY symbol ORDER BY time ROWS 119 PRECEDING) AS lowest
  FROM (SELECT time, symbol, bb_width(close, 20, 2) OVER (PARTITION BY symbol ORDER BY time) AS width FROM candles) b
) s
WHERE width <= lowest * 1.05 AND time >= '2026-08-20'
ORDER BY time, symbol;
time                     | symbol | width_pct | lowest_120d_pct
-------------------------+--------+-----------+----------------
2026-08-24T00:00:00.000Z | CITY   |     10.77 |           10.77
2026-08-25T00:00:00.000Z | CITY   |     10.77 |           10.77
(2 rows, 5.6 ms)

Correlation matrix of sectors

First the daily return of every sector (the average return of its symbols), then corr for every pair of sectors with a self-join, then PIVOT to turn the pairs into a matrix. 1 on the diagonal; values near 0 mean the sectors move independently.

08-cook-correlation-matrix.sql:

-- Daily return of each sector (the average of its symbols), then the correlation of every pair of sectors.
SELECT *
FROM (
  SELECT a.sector AS sector, b.sector AS other, round(corr(a.r, b.r), 2) AS c
  FROM (SELECT time, sector, avg(r) AS r
        FROM (SELECT time, sector, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
        GROUP BY time, sector) a
  JOIN (SELECT time, sector, avg(r) AS r
        FROM (SELECT time, sector, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
        GROUP BY time, sector) b ON a.time = b.time
  GROUP BY a.sector, b.sector
) pairs
PIVOT (max(c) FOR other IN ('Banking' AS banking, 'Energy' AS energy, 'Investment' AS investment, 'Telecom' AS telecom))
ORDER BY sector;
sector     | banking | energy | investment | telecom
-----------+---------+--------+------------+--------
Banking    |       1 |  -0.63 |       0.15 |   -0.06
Energy     |   -0.63 |      1 |      -0.59 |   -0.18
Investment |    0.15 |  -0.59 |          1 |   -0.19
Telecom    |   -0.06 |  -0.18 |      -0.19 |       1
(4 rows, 12.4 ms)

Monthly turnover by sector

Sectors as rows, months as columns. The subquery keeps only the three columns PIVOT needs; every other column would become a group key. The outer query rounds to millions.

09-cook-pivot-turnover.sql:

-- Turnover (close x volume) in millions: sectors as rows, months as columns.
SELECT sector, round(jun / 1000000, 1) AS jun, round(jul / 1000000, 1) AS jul, round(aug / 1000000, 1) AS aug, round(sep / 1000000, 1) AS sep
FROM (SELECT sector, month(time) AS m, close * volume AS turnover FROM candles WHERE time >= '2026-06-01') t
PIVOT (sum(turnover) FOR m IN (6 AS jun, 7 AS jul, 8 AS aug, 9 AS sep))
ORDER BY sector;
sector     | jun    | jul    | aug    | sep  
-----------+--------+--------+--------+------
Banking    | 1213.6 | 1131.2 | 1198.2 | 392.5
Energy     |  260.5 |  288.6 |  264.9 |  92.1
Investment |  431.3 |  434.7 |    445 |   168
Telecom    | 1412.1 | 1563.6 | 1382.9 | 611.1
(4 rows, 5.3 ms)

Histograms

width_bucket(x, -4, 4, 8) puts each daily return into a 1 % bucket from -4 % to +4 %. Bucket 0 means below -4 %, bucket 9 means +4 % or more. Items that are not aggregates are the group keys.

10-cook-histogram.sql:

-- How often did CITY move by how much? Buckets of 1 % from -4 % to +4 %.
SELECT bucket, -5 + bucket AS from_pct, count(*) AS days
FROM (SELECT width_bucket(pct_return(close) * 100, -4, 4, 8) AS bucket FROM candles WHERE symbol = 'CITY') h
WHERE bucket IS NOT NULL
ORDER BY bucket;
bucket | from_pct | days
-------+----------+-----
     0 |       -5 |    5
     1 |       -4 |    5
     2 |       -3 |   16
     3 |       -2 |   24
     4 |       -1 |   46
     5 |        0 |   42
     6 |        1 |   25
     7 |        2 |    8
     8 |        3 |    4
     9 |        4 |    5
(10 rows, 5.1 ms)

Beta against a benchmark

The sample data has no market index, so the "market" here is the average daily return of the five symbols. Join each symbol's returns to the market returns by day, then use beta, alpha, information_ratio and corr with GROUP BY. With a real index table, join to its returns instead.

11-cook-beta.sql:

-- Beta, alpha and information ratio of each symbol against a "market" made of all five symbols.
SELECT s.symbol, round(beta(s.r, m.r), 2) AS beta, round(alpha(s.r, m.r) * 240 * 100, 2) AS alpha_pct_year,
       round(information_ratio(s.r, m.r, 240), 2) AS info_ratio, round(corr(s.r, m.r), 2) AS corr
FROM (SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) s
JOIN (SELECT time, avg(r) AS r
      FROM (SELECT time, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
      GROUP BY time) m ON s.time = m.time
GROUP BY s.symbol
ORDER BY beta DESC;
symbol | beta  | alpha_pct_year | info_ratio | corr 
-------+-------+----------------+------------+------
CITY   |  2.12 |         -25.23 |      -1.06 |  0.45
COAST  |   1.9 |         -35.58 |      -1.51 |  0.43
TELCO  |  1.87 |          32.33 |       1.27 |  0.45
ACME   |   1.2 |          39.01 |       1.65 |  0.31
VOLT   | -2.08 |         -10.52 |      -0.04 | -0.49
(5 rows, 12.0 ms)

Risk report

All risk aggregates work on daily returns: make them in a subquery, then group by symbol.

12-cook-risk.sql:

-- Risk report per symbol, from daily returns.
SELECT symbol, count(r) AS days,
       round(sharpe_ratio(r, 0, 240), 2) AS sharpe,
       round(sortino_ratio(r, 0, 240), 2) AS sortino,
       round(calmar_ratio(r, 240), 2) AS calmar,
       round(value_at_risk(r, 0.95) * 100, 2) AS var95_pct,
       round(expected_shortfall(r, 0.95) * 100, 2) AS es95_pct,
       round(skewness(r), 2) AS skew
FROM (SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
GROUP BY symbol
ORDER BY sharpe DESC;
symbol | days | sharpe | sortino | calmar | var95_pct | es95_pct | skew 
-------+------+--------+---------+--------+-----------+----------+------
ACME   |  180 |   1.45 |    2.31 |   2.28 |      1.91 |     3.34 |  0.31
TELCO  |  180 |   1.05 |    1.59 |   1.42 |      1.88 |      3.7 |  0.16
VOLT   |  180 |  -0.16 |   -0.23 |  -0.35 |      1.98 |      4.1 | -0.01
CITY   |  180 |  -1.08 |   -1.46 |  -0.96 |      3.25 |     4.44 |  0.05
COAST  |  180 |   -1.5 |   -1.98 |  -1.26 |      2.67 |     4.43 |  0.01
(5 rows, 6.3 ms)

Many indicators at once

Indicators need the history before the days you show: compute them on all rows inside, filter the days outside (trend is supertrend_direction: 1 up, -1 down).

13-cook-indicators.sql:

-- Many indicators on the last days of one symbol.
SELECT time, close, rsi, round(stoch_k, 1) AS stoch_k, round(adx, 1) AS adx, round(sar, 1) AS sar, trend, round(bb_pb, 2) AS bb_pb
FROM (
  SELECT time, close,
         round(rsi(close, 14), 1) AS rsi,
         stoch_k(high, low, close) AS stoch_k,
         adx(high, low, close, 14) AS adx,
         sar(high, low) AS sar,
         supertrend_direction(high, low, close, 10, 3) AS trend,
         bb_percent_b(close, 20, 2) AS bb_pb
  FROM candles WHERE symbol = 'VOLT'
) i
WHERE time >= '2026-09-01';
time                     | close | rsi  | stoch_k | adx  | sar   | trend | bb_pb
-------------------------+-------+------+---------+------+-------+-------+------
2026-09-01T00:00:00.000Z | 166.3 | 35.3 |    40.9 | 25.2 | 174.6 |    -1 |  0.31
2026-09-02T00:00:00.000Z | 164.8 | 33.5 |    37.3 | 25.7 | 174.4 |    -1 |  0.29
2026-09-03T00:00:00.000Z | 163.7 | 32.2 |    31.1 | 26.2 |   174 |    -1 |  0.26
2026-09-06T00:00:00.000Z | 165.1 | 35.7 |    28.6 | 26.4 | 173.3 |    -1 |  0.33
2026-09-07T00:00:00.000Z | 167.2 | 40.6 |    33.4 | 25.8 | 172.6 |    -1 |  0.44
2026-09-08T00:00:00.000Z | 170.1 | 46.6 |      47 | 24.2 |   172 |    -1 |  0.63
2026-09-09T00:00:00.000Z | 173.4 | 52.5 |    63.6 | 23.2 | 162.6 |    -1 |  0.87
2026-09-10T00:00:00.000Z | 177.1 | 58.1 |    79.3 | 22.7 | 162.9 |     1 |  1.06
(8 rows, 5.0 ms)

Pivot levels on the tick grid

Today's pivot levels come from yesterday's high, low and close (lag). round_tick puts them on a 0.1 price grid (10 cents).

14-cook-pivots-ticks.sql:

-- Today's pivot levels from yesterday's candle, on the price tick grid.
SELECT symbol,
       round_tick(pivot_level(ph, pl, pc, 's1')) AS s1,
       round_tick(pivot_point(ph, pl, pc)) AS p,
       round_tick(pivot_level(ph, pl, pc, 'r1')) AS r1,
       round_tick(pivot_level(ph, pl, pc, 'r1', 'camarilla')) AS cam_r1
FROM (SELECT time, symbol,
             lag(high) OVER (PARTITION BY symbol ORDER BY time) AS ph,
             lag(low) OVER (PARTITION BY symbol ORDER BY time) AS pl,
             lag(close) OVER (PARTITION BY symbol ORDER BY time) AS pc
      FROM candles) x
WHERE time = '2026-09-10'
ORDER BY symbol;
symbol | s1     | p      | r1     | cam_r1
-------+--------+--------+--------+-------
ACME   |  312.3 |  314.5 |  316.9 |  315.2
CITY   |  385.5 |  387.9 |  390.9 |    389
COAST  |  298.9 |  301.4 |  303.6 |  301.5
TELCO  | 1063.8 | 1072.2 | 1079.9 | 1073.1
VOLT   |  172.1 |  173.9 |  175.2 |  173.7
(5 rows, 5.4 ms)

Nepal fiscal year

nepal_fiscal_year and fiscal_quarter group rows by a fiscal year that starts in mid-July, as in Nepal (see the note in fiscal_year).

15-cook-fiscal-year.sql:

-- Volume per Nepal fiscal year and fiscal quarter.
SELECT nepal_fiscal_year(time) AS fy, fiscal_quarter(time) AS q, count(*) AS rows_, sum(volume) AS volume
FROM candles
ORDER BY fy, q;

SELECT extract(year FROM '2026-09-11') AS y, extract(quarter FROM '2026-09-11') AS q, week_of_year('2026-09-11') AS week,
       age('2026-09-11', '2020-01-05') AS age, datediff('month', '2026-01-31', '2026-02-28') AS months;
fy      | q | rows_ | volume  
--------+---+-------+---------
2082/83 | 2 |    55 |  3827816
2082/83 | 3 |   325 | 22483660
2082/83 | 4 |   325 | 22400713
2083/84 | 1 |   200 | 13842460
(4 rows, 8.3 ms)

y    | q | week | age                   | months
-----+---+------+-----------------------+-------
2026 | 3 |   37 | 6 years 8 mons 6 days |      1
(1 row, 0.1 ms)

Calendars with generate_series

generate_series makes rows of numbers or days. A LEFT JOIN to it shows the days without data.

16-cook-generate-series.sql:

SELECT count(*) AS n, sum(generate_series) AS total FROM generate_series(1, 100);

SELECT m, day(m) AS day FROM generate_series('2026-01-31'::TIMESTAMP, '2026-05-31'::TIMESTAMP, '1 month') AS s(m);

-- Days without a candle (Fridays and Saturdays) in the first week of September.
SELECT d, day_of_week(d) AS dow, count(c.close) AS candles
FROM generate_series('2026-09-01'::TIMESTAMP, '2026-09-07'::TIMESTAMP, '1 day') AS g(d)
LEFT JOIN candles c ON c.time = g.d AND c.symbol = 'CITY'
GROUP BY d
ORDER BY d;
n   | total
----+------
100 |  5050
(1 row, 0.2 ms)

m                        | day
-------------------------+----
2026-01-31T00:00:00.000Z |  31
2026-02-28T00:00:00.000Z |  28
2026-03-31T00:00:00.000Z |  31
2026-04-30T00:00:00.000Z |  30
2026-05-31T00:00:00.000Z |  31
(5 rows, 0.1 ms)

d                        | dow | candles
-------------------------+-----+--------
2026-09-01T00:00:00.000Z |   2 |       1
2026-09-02T00:00:00.000Z |   3 |       1
2026-09-03T00:00:00.000Z |   4 |       1
2026-09-04T00:00:00.000Z |   5 |       0
2026-09-05T00:00:00.000Z |   6 |       0
2026-09-06T00:00:00.000Z |   0 |       1
2026-09-07T00:00:00.000Z |   1 |       1
(7 rows, 4.9 ms)

Loans and savings

A 5-year loan of $500,000 at 10 % a year paid monthly (EMI), a monthly saving of $5000 for 10 years at 8 %, the yearly growth of a share from $400 to $510 in 3 years, and the IRR of a bond-like investment.

17-cook-finance.sql:

SELECT round(pmt(0.10 / 12, 60, 500000), 2) AS emi_5y_loan,
       round(fv(0.08 / 12, 120, -5000), 2) AS sip_10y,
       round(cagr(400, 510, 3) * 100, 2) AS cagr_pct,
       round(irr(-100000, 8000, 8000, 8000, 120000) * 100, 2) AS irr_pct;
emi_5y_loan | sip_10y   | cagr_pct | irr_pct
------------+-----------+----------+--------
  -10623.52 | 914730.18 |     8.44 |   10.56
(1 row, 0.3 ms)

Speed

Try it yourself

  1. Which symbol had the biggest drawdown in the sample candles? Use max_drawdown.
  2. Write the 10-day correlation between CITY's and COAST's daily returns for every day.
  3. How many trading days does each month of 2026 have? Use generate_series and is_trading_day.
Answers
  1. SELECT symbol, max_drawdown(close) AS dd FROM candles GROUP BY symbol ORDER BY dd LIMIT 1
  2. SELECT time, corr(a, b) OVER (ORDER BY time ROWS 9 PRECEDING) FROM (SELECT n.time AS time, n.r AS a, i.r AS b FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'CITY') n JOIN (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'COAST') i ON n.time = i.time) x
  3. SELECT month(d) AS m, count_if(is_trading_day(d)) AS days FROM generate_series('2026-01-01'::TIMESTAMP, '2026-12-31'::TIMESTAMP, '1 day') AS g(d) ORDER BY m

Next: 20. Charts and dashboards