KDB knowledge

Returns and risk

Returns, drawdown, volatility, z-score and the risk numbers of an investment: Sharpe, Sortino, Calmar, information ratio, value at risk and expected shortfall.

A return is how much an investment grew: a price that goes from 500 to 510 has a return of 0.02 (2%). Risk is how much the returns jump up and down, and how deep the losses can be. The functions on this page measure both.

There are two kinds:

More rules:

FunctionFirst value on rowNULL rows at the start
pct_return(x), log_return(x)21
pct_return(x, k), log_return(x, k)k + 1k
cum_return(x), drawdown(x)10
volatility(price, n)n + 1n
zscore(x, n), minmax_scale(x, n)nn - 1

Example: VOLT: daily return, total return and drawdown (sample data: sample, analysis-market)

SELECT time, close,
       round(pct_return(close), 4) AS daily_return,
       round(cum_return(close), 4) AS total_return,
       round(drawdown(close), 4) AS drawdown
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)daily_return (DOUBLE)total_return (DOUBLE)drawdown (DOUBLE)
2026-08-23T00:00:00.000Z181NULL00
2026-08-24T00:00:00.000Z178.5-0.0138-0.0138-0.0138
2026-08-25T00:00:00.000Z176.8-0.0095-0.0232-0.0232
2026-08-26T00:00:00.000Z174.2-0.0147-0.0376-0.0376
2026-08-27T00:00:00.000Z173.3-0.0052-0.0425-0.0425
2026-08-30T00:00:00.000Z173-0.0017-0.0442-0.0442
2026-08-31T00:00:00.000Z172.6-0.0023-0.0464-0.0464
2026-09-01T00:00:00.000Z169-0.0209-0.0663-0.0663
2026-09-02T00:00:00.000Z171.50.0148-0.0525-0.0525
2026-09-03T00:00:00.000Z1760.0262-0.0276-0.0276
2026-09-06T00:00:00.000Z180.10.0233-0.005-0.005
2026-09-07T00:00:00.000Z176.9-0.0178-0.0227-0.0227
2026-09-08T00:00:00.000Z182.40.03110.00770
2026-09-09T00:00:00.000Z187.30.02690.03480
2026-09-10T00:00:00.000Z191.20.02080.05640

(15 rows)

Example: Risk report of two funds: monthly returns inside, the risk numbers outside (sample data: sample, analysis-market)

SELECT fund,
       count(r) AS months,
       round(avg(r) * 100, 2) AS avg_month_pct,
       round(sharpe_ratio(r, 0, 12), 2) AS sharpe,
       round(sortino_ratio(r, 0, 12), 2) AS sortino,
       round(calmar_ratio(r, 12), 2) AS calmar,
       round(max_drawdown(nav) * 100, 2) AS max_drawdown_pct,
       round(value_at_risk(r, 0.95) * 100, 2) AS var95_pct
FROM (SELECT time, fund, nav,
             pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)months (LONG)avg_month_pct (DOUBLE)sharpe (DOUBLE)sortino (DOUBLE)calmar (DOUBLE)max_drawdown_pct (DOUBLE)var95_pct (DOUBLE)
GROWTH230.891.352.331.26-8.613.09
INCOME230.332.856.764.5-0.890.39

(2 rows)

pct_return, percent_return, returns

Syntax:

pct_return(x)
pct_return(x, k)
pct_return(x, k) OVER ([PARTITION BY ...] [ORDER BY ...])
percent_return(x [, k])
returns(x [, k])

The simple return: the change from k rows before, as a fraction: x / previous x - 1. On daily closes it is the daily return; 0.012 means the price rose 1.2%. percent_return and returns are the same function.

ArgumentTypeMeaning
xnumberthe price, usually close or a fund's NAV
kwhole number, 1 or morehow many rows back; default 1. 5 on daily candles is the return over 5 trading days (about one week)

Example: Daily return of each symbol: a fraction, and pct_change in percent (sample data: sample, analysis-market)

SELECT time, symbol, close,
       round(pct_return(close) OVER (PARTITION BY symbol ORDER BY time), 5) AS daily_return,
       round(pct_change(close) OVER (PARTITION BY symbol ORDER BY time), 3) AS change_pct
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)daily_return (DOUBLE)change_pct (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5NULLNULL
2026-09-09T00:00:00.000ZCITY504.60.014271.427
2026-09-10T00:00:00.000ZCITY504.90.000590.059
2026-09-08T00:00:00.000ZCOAST377.2NULLNULL
2026-09-09T00:00:00.000ZCOAST380.10.007690.769
2026-09-10T00:00:00.000ZCOAST376.1-0.01052-1.052
2026-09-08T00:00:00.000ZVOLT182.4NULLNULL
2026-09-09T00:00:00.000ZVOLT187.30.026862.686
2026-09-10T00:00:00.000ZVOLT191.20.020822.082

(9 rows)

Example: Return over the last 5 trading days (sample data: sample, analysis-market)

SELECT time, close, lag(close, 5) AS close_5_days_ago,
       round(pct_return(close, 5) * 100, 2) AS return_5d_pct
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)close_5_days_ago (DOUBLE)return_5d_pct (DOUBLE)
2026-08-23T00:00:00.000Z391NULLNULL
2026-08-24T00:00:00.000Z384.9NULLNULL
2026-08-25T00:00:00.000Z379.8NULLNULL
2026-08-26T00:00:00.000Z373NULLNULL
2026-08-27T00:00:00.000Z367.1NULLNULL
2026-08-30T00:00:00.000Z372.4391-4.76
2026-08-31T00:00:00.000Z368.2384.9-4.34
2026-09-01T00:00:00.000Z367.4379.8-3.26
2026-09-02T00:00:00.000Z369.2373-1.02
2026-09-03T00:00:00.000Z373.2367.11.66
2026-09-06T00:00:00.000Z375.4372.40.81
2026-09-07T00:00:00.000Z379.2368.22.99
2026-09-08T00:00:00.000Z377.2367.42.67
2026-09-09T00:00:00.000Z380.1369.22.95
2026-09-10T00:00:00.000Z376.1373.20.78

(15 rows)

Example: NULL and zero prices (sample data: sample, analysis-market)

Setup:

CREATE TABLE prices (time TIMESTAMP, close DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO prices VALUES ('2026-09-01', 100), ('2026-09-02', 110), ('2026-09-03', NULL),
  ('2026-09-04', 99), ('2026-09-05', 121), ('2026-09-06', 0), ('2026-09-07', 50);
SELECT time, close,
       round(pct_return(close), 4) AS pct_return,
       round(log_return(close), 4) AS log_return,
       round(cum_return(close), 4) AS cum_return,
       round(drawdown(close), 4) AS drawdown
FROM prices
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)pct_return (DOUBLE)log_return (DOUBLE)cum_return (DOUBLE)drawdown (DOUBLE)
2026-09-01T00:00:00.000Z100NULLNULL00
2026-09-02T00:00:00.000Z1100.10.09530.10
2026-09-03T00:00:00.000ZNULLNULLNULLNULLNULL
2026-09-04T00:00:00.000Z99NULLNULL-0.01-0.1
2026-09-05T00:00:00.000Z1210.22220.20070.210
2026-09-06T00:00:00.000Z0-1NULL-1-1
2026-09-07T00:00:00.000Z50NULLNULL-0.5-0.5868

(7 rows)

log_return

Syntax:

log_return(x)
log_return(x, k)
log_return(x, k) OVER ([PARTITION BY ...] [ORDER BY ...])

The log return: ln(x / previous x). For small moves it is almost the same as the simple return: a rise from 507.9 to 510.3 is 0.00473 as a simple return and 0.00471 as a log return. Traders like it because log returns add up: the sum of the daily log returns is the log return of the whole period.

ArgumentTypeMeaning
xnumberthe price
kwhole number, 1 or morehow many rows back; default 1

Example: Log returns add up to the log return of the whole period (sample data: sample, analysis-market)

SELECT symbol, first(close) AS first_close, last(close) AS last_close,
       round(sum(lr), 6) AS sum_of_log_returns,
       round(ln(last(close) / first(close)), 6) AS log_of_whole_period,
       round(exp(sum(lr)) - 1, 6) AS total_return
FROM (SELECT time, symbol, close,
             log_return(close) OVER (PARTITION BY symbol ORDER BY time) AS lr
      FROM ochl_day
      WHERE time >= '2026-08-23') t
GROUP BY symbol
ORDER BY symbol;

Result:

symbol (SYMBOL)first_close (DOUBLE)last_close (DOUBLE)sum_of_log_returns (DOUBLE)log_of_whole_period (DOUBLE)total_return (DOUBLE)
CITY507.9504.9-0.005924-0.005924-0.005907
COAST391376.1-0.038852-0.038852-0.038107
VOLT181191.20.0548230.0548230.056354

(3 rows)

Example: Simple and log return of CITY's last 4 minutes (sample data: sample, analysis-market)

SELECT time, close, simple_return, log_return
FROM (SELECT time, close,
             round(pct_return(close), 6) AS simple_return,
             round(log_return(close), 6) AS log_return
      FROM ochl_minutes
      WHERE symbol = 'CITY') t
ORDER BY time DESC
LIMIT 4;

Result:

time (TIMESTAMP)close (DOUBLE)simple_return (DOUBLE)log_return (DOUBLE)
2026-09-10T09:14:00.000Z504.9-0.000396-0.000396
2026-09-10T09:13:00.000Z505.10.0005940.000594
2026-09-10T09:12:00.000Z504.80.0005950.000594
2026-09-10T09:11:00.000Z504.5-0.000198-0.000198

(4 rows)

cum_return

Syntax:

cum_return(x)
cum_return(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The cumulative (total) return: the change from the first value of the series to this row, x / first x - 1. It answers "how much did I make since I bought on the first day?".

ArgumentTypeMeaning
xnumberthe price or NAV

Example: What became of 100 dollars in each fund (the last 4 months) (sample data: sample, analysis-market)

SELECT time, fund, nav, growth_of_100
FROM (SELECT time, fund, nav,
             round(100 * (1 + cum_return(nav) OVER (PARTITION BY fund ORDER BY time)), 2) AS growth_of_100
      FROM fund_nav) t
WHERE time >= '2026-05-01'
ORDER BY fund, time;

Result:

time (TIMESTAMP)fund (SYMBOL)nav (DOUBLE)growth_of_100 (DOUBLE)
2026-05-01T00:00:00.000ZGROWTH11.31113.1
2026-06-01T00:00:00.000ZGROWTH11.66116.6
2026-07-01T00:00:00.000ZGROWTH11.94119.4
2026-08-01T00:00:00.000ZGROWTH12.18121.8
2026-05-01T00:00:00.000ZINCOME10.58105.8
2026-06-01T00:00:00.000ZINCOME10.65106.5
2026-07-01T00:00:00.000ZINCOME10.71107.1
2026-08-01T00:00:00.000ZINCOME10.78107.8

(8 rows)

Example: Total return of every symbol since 23 August (sample data: sample, analysis-market)

SELECT symbol, close, total_return_pct
FROM (SELECT time, symbol, close,
             round(cum_return(close) OVER (PARTITION BY symbol ORDER BY time) * 100, 2) AS total_return_pct
      FROM ochl_day
      WHERE time >= '2026-08-23') t
WHERE time IN '2026-09-10'
ORDER BY total_return_pct DESC;

Result:

symbol (SYMBOL)close (DOUBLE)total_return_pct (DOUBLE)
VOLT191.25.64
CITY504.9-0.59
COAST376.1-3.81

(3 rows)

drawdown

Syntax:

drawdown(x)
drawdown(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The drawdown: how far the price is below its highest value so far (the peak), as a fraction: x / peak - 1. -0.05 means 5% below the peak. It is 0 on a new high. Traders use it to see how painful a fall was, and when the price got back to its old high.

ArgumentTypeMeaning
xnumberthe price or NAV

Example: VOLT: the peak so far and the drawdown from it (sample data: sample, analysis-market)

SELECT time, close, cummax(close) AS peak, round(drawdown(close) * 100, 2) AS drawdown_pct
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)peak (DOUBLE)drawdown_pct (DOUBLE)
2026-08-23T00:00:00.000Z1811810
2026-08-24T00:00:00.000Z178.5181-1.38
2026-08-25T00:00:00.000Z176.8181-2.32
2026-08-26T00:00:00.000Z174.2181-3.76
2026-08-27T00:00:00.000Z173.3181-4.25
2026-08-30T00:00:00.000Z173181-4.42
2026-08-31T00:00:00.000Z172.6181-4.64
2026-09-01T00:00:00.000Z169181-6.63
2026-09-02T00:00:00.000Z171.5181-5.25
2026-09-03T00:00:00.000Z176181-2.76
2026-09-06T00:00:00.000Z180.1181-0.5
2026-09-07T00:00:00.000Z176.9181-2.27
2026-09-08T00:00:00.000Z182.4182.40
2026-09-09T00:00:00.000Z187.3187.30
2026-09-10T00:00:00.000Z191.2191.20

(15 rows)

Example: Days when a share was more than 3% below its peak (sample data: sample, analysis-market)

SELECT time, symbol, close, drawdown_pct
FROM (SELECT time, symbol, close,
             round(drawdown(close) OVER (PARTITION BY symbol ORDER BY time) * 100, 2) AS drawdown_pct
      FROM ochl_day
      WHERE time >= '2026-08-23') t
WHERE drawdown_pct < -3
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)drawdown_pct (DOUBLE)
2026-09-02T00:00:00.000ZCITY496.4-3.29
2026-09-08T00:00:00.000ZCITY497.5-3.08
2026-08-26T00:00:00.000ZCOAST373-4.6
2026-08-27T00:00:00.000ZCOAST367.1-6.11
2026-08-30T00:00:00.000ZCOAST372.4-4.76
2026-08-31T00:00:00.000ZCOAST368.2-5.83
2026-09-01T00:00:00.000ZCOAST367.4-6.04
2026-09-02T00:00:00.000ZCOAST369.2-5.58
2026-09-03T00:00:00.000ZCOAST373.2-4.55
2026-09-06T00:00:00.000ZCOAST375.4-3.99
2026-09-07T00:00:00.000ZCOAST379.2-3.02
2026-09-08T00:00:00.000ZCOAST377.2-3.53
2026-09-10T00:00:00.000ZCOAST376.1-3.81
2026-08-26T00:00:00.000ZVOLT174.2-3.76
2026-08-27T00:00:00.000ZVOLT173.3-4.25
2026-08-30T00:00:00.000ZVOLT173-4.42
2026-08-31T00:00:00.000ZVOLT172.6-4.64
2026-09-01T00:00:00.000ZVOLT169-6.63
2026-09-02T00:00:00.000ZVOLT171.5-5.25

(19 rows)

volatility

Syntax:

volatility(price, n)
volatility(price, n, periods)
volatility(price, n, periods) OVER ([PARTITION BY ...] [ORDER BY ...])

The historical volatility: how much the price jumps, as a fraction. It is the sample standard deviation of the last n log returns. With periods it is made yearly: × sqrt(periods). A yearly volatility of 0.25 means the price usually moves about 25% up or down in a year. High volatility means high risk.

ArgumentTypeMeaning
pricenumberthe price, usually close
nwhole number, 2 or morehow many returns to use (20 is about one month of trading days)
periodsnumber, more than 0returns per year, to make it yearly: 240 for daily candles; default 1 (per period, not yearly)

Example: CITY: 5-day volatility per day, and made yearly with 240 trading days (sample data: sample, analysis-market)

SELECT time, close,
       round(volatility(close, 5), 5) AS vol_per_day,
       round(volatility(close, 5, 240) * 100, 1) AS vol_per_year_pct
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)vol_per_day (DOUBLE)vol_per_year_pct (DOUBLE)
2026-08-23T00:00:00.000Z507.9NULLNULL
2026-08-24T00:00:00.000Z510.3NULLNULL
2026-08-25T00:00:00.000Z513.3NULLNULL
2026-08-26T00:00:00.000Z513.2NULLNULL
2026-08-27T00:00:00.000Z505.9NULLNULL
2026-08-30T00:00:00.000Z503.10.0082912.8
2026-08-31T00:00:00.000Z505.70.0083813
2026-09-01T00:00:00.000Z502.40.007311.3
2026-09-02T00:00:00.000Z496.40.0075511.7
2026-09-03T00:00:00.000Z497.90.0071211
2026-09-06T00:00:00.000Z4990.0073211.3
2026-09-07T00:00:00.000Z498.70.006389.9
2026-09-08T00:00:00.000Z497.50.006039.3
2026-09-09T00:00:00.000Z504.60.0064710
2026-09-10T00:00:00.000Z504.90.0065810.2

(15 rows)

Example: Which share is most volatile? 10-day yearly volatility on 10 September (sample data: sample, analysis-market)

SELECT symbol, close, vol_year_pct
FROM (SELECT time, symbol, close,
             round(volatility(close, 10, 240) OVER (PARTITION BY symbol ORDER BY time) * 100, 1) AS vol_year_pct
      FROM ochl_day
      WHERE time >= '2026-08-23') t
WHERE time IN '2026-09-10'
ORDER BY vol_year_pct DESC;

Result:

symbol (SYMBOL)close (DOUBLE)vol_year_pct (DOUBLE)
VOLT191.229.6
COAST376.114.2
CITY504.911.1

(3 rows)

Example: n must be 2 or more (sample data: sample, analysis-market; shows an error)

SELECT volatility(close, 1) FROM ochl_day;

Result:

Error: line 1, column 8: volatility: the period must be a whole number of rows (2 or more)

zscore

Syntax:

zscore(x)
zscore(x) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS ...])
zscore(x, n)
zscore(x, n) OVER ([PARTITION BY ...] [ORDER BY ...])

The z-score (standard score): how many standard deviations x is above (+) or below (-) the average: (x - average) / stddev. 0 is a normal value; above 2 or below -2 is unusual. Traders use it to find strange volume, or a price far from its average ("mean reversion").

ArgumentTypeMeaning
xnumberthe value
nwhole number, 2 or moreoptional: compare with the last n rows (this row and the n - 1 before it)

Which values it compares with:

CallAverage and stddev of
zscore(x) or zscore(x) OVER (PARTITION BY ...)all rows of the series (or of the part)
zscore(x) OVER (... ORDER BY time)the first row to this row (running)
zscore(x) OVER (... ORDER BY time ROWS 4 PRECEDING)the frame: this row and the 4 before it
zscore(x, n)the last n rows; NULL until there are n rows

Example: How unusual is the volume? z-score of each day's volume per symbol (sample data: sample, analysis-market)

SELECT time, symbol, volume, volume_z
FROM (SELECT time, symbol, volume,
             round(zscore(volume) OVER (PARTITION BY symbol), 2) AS volume_z
      FROM ochl_day
      WHERE time >= '2026-08-23') t
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)volume (DOUBLE)volume_z (DOUBLE)
2026-09-08T00:00:00.000ZCITY34400-2
2026-09-09T00:00:00.000ZCITY1823901.65
2026-09-10T00:00:00.000ZCITY1830401.67
2026-09-08T00:00:00.000ZCOAST60980-0.77
2026-09-09T00:00:00.000ZCOAST1863301.85
2026-09-10T00:00:00.000ZCOAST1862001.85
2026-09-08T00:00:00.000ZVOLT1367701.19
2026-09-09T00:00:00.000ZVOLT944100.05
2026-09-10T00:00:00.000ZVOLT41470-1.38

(9 rows)

Example: Rolling 5-day z-score against the running z-score (sample data: sample, analysis-market)

SELECT time, close,
       round(zscore(close, 5), 3) AS z_last_5_days,
       round(zscore(close) OVER (ORDER BY time), 3) AS z_since_start
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)z_last_5_days (DOUBLE)z_since_start (DOUBLE)
2026-08-23T00:00:00.000Z507.9NULLNULL
2026-08-24T00:00:00.000Z510.3NULL0.707
2026-08-25T00:00:00.000Z513.3NULL1.035
2026-08-26T00:00:00.000Z513.2NULL0.782
2026-08-27T00:00:00.000Z505.9-1.297-1.297
2026-08-30T00:00:00.000Z503.1-1.338-1.432
2026-08-31T00:00:00.000Z505.7-0.54-0.71
2026-09-01T00:00:00.000Z502.4-0.855-1.261
2026-09-02T00:00:00.000Z496.4-1.638-1.842
2026-09-03T00:00:00.000Z497.9-0.832-1.325
2026-09-06T00:00:00.000Z499-0.341-1.024
2026-09-07T00:00:00.000Z498.7-0.081-0.982
2026-09-08T00:00:00.000Z497.5-0.388-1.082
2026-09-09T00:00:00.000Z504.61.750.106
2026-09-10T00:00:00.000Z504.91.1240.153

(15 rows)

minmax_scale

Syntax:

minmax_scale(x)
minmax_scale(x) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS ...])
minmax_scale(x, n)
minmax_scale(x, n) OVER ([PARTITION BY ...] [ORDER BY ...])

Puts x on a scale from 0 to 1: (x - lowest) / (highest - lowest). 0 is the lowest value, 1 the highest, 0.5 the middle. Use it to draw shares with very different prices on one chart, or to see where today's close sits in its recent range.

ArgumentTypeMeaning
xnumberthe value
nwhole number, 1 or moreoptional: the lowest and highest of the last n rows

The values it compares with are chosen like zscore: all rows, the window frame, or the last n rows.

Example: Three shares with different prices on one 0-1 scale (sample data: sample, analysis-market)

SELECT time, symbol, close, scaled
FROM (SELECT time, symbol, close,
             round(minmax_scale(close) OVER (PARTITION BY symbol), 3) AS scaled
      FROM ochl_day
      WHERE time >= '2026-08-23') t
WHERE time >= '2026-09-08'
ORDER BY time, symbol;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)scaled (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.50.065
2026-09-08T00:00:00.000ZCOAST377.20.423
2026-09-08T00:00:00.000ZVOLT182.40.604
2026-09-09T00:00:00.000ZCITY504.60.485
2026-09-09T00:00:00.000ZCOAST380.10.544
2026-09-09T00:00:00.000ZVOLT187.30.824
2026-09-10T00:00:00.000ZCITY504.90.503
2026-09-10T00:00:00.000ZCOAST376.10.377
2026-09-10T00:00:00.000ZVOLT191.21

(9 rows)

Example: Where is the close in its 5-day range? (0 = at the low, 1 = at the high) (sample data: sample, analysis-market)

SELECT time, close, round(minmax_scale(close, 5), 3) AS place_in_5_day_range
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)place_in_5_day_range (DOUBLE)
2026-08-23T00:00:00.000Z391NULL
2026-08-24T00:00:00.000Z384.9NULL
2026-08-25T00:00:00.000Z379.8NULL
2026-08-26T00:00:00.000Z373NULL
2026-08-27T00:00:00.000Z367.10
2026-08-30T00:00:00.000Z372.40.298
2026-08-31T00:00:00.000Z368.20.087
2026-09-01T00:00:00.000Z367.40.051
2026-09-02T00:00:00.000Z369.20.396
2026-09-03T00:00:00.000Z373.21
2026-09-06T00:00:00.000Z375.41
2026-09-07T00:00:00.000Z379.21
2026-09-08T00:00:00.000Z377.20.8
2026-09-09T00:00:00.000Z380.11
2026-09-10T00:00:00.000Z376.10.149

(15 rows)

max_drawdown

Syntax:

max_drawdown(price)
max_drawdown(price) OVER (...)

The maximum drawdown: the biggest fall from a peak to a later low, as a fraction. -0.086 means that at the worst moment, an investor who bought at the top had lost 8.6%. It is the deepest value of drawdown. Fund reports show it next to the return.

ArgumentTypeMeaning
pricenumberthe price or NAV (not returns)

Example: Worst fall of each fund (sample data: sample, analysis-market)

SELECT fund, first(nav) AS start_nav, last(nav) AS end_nav,
       round(max_drawdown(nav) * 100, 2) AS max_drawdown_pct
FROM fund_nav
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)start_nav (DOUBLE)end_nav (DOUBLE)max_drawdown_pct (DOUBLE)
GROWTH1012.18-8.61
INCOME1010.78-0.89

(2 rows)

Example: Drawdown of the day and the worst drawdown so far (sample data: sample, analysis-market)

SELECT time, close,
       round(drawdown(close) * 100, 2) AS drawdown_pct,
       round(max_drawdown(close) OVER (ORDER BY time) * 100, 2) AS worst_so_far_pct
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)drawdown_pct (DOUBLE)worst_so_far_pct (DOUBLE)
2026-08-23T00:00:00.000Z18100
2026-08-24T00:00:00.000Z178.5-1.38-1.38
2026-08-25T00:00:00.000Z176.8-2.32-2.32
2026-08-26T00:00:00.000Z174.2-3.76-3.76
2026-08-27T00:00:00.000Z173.3-4.25-4.25
2026-08-30T00:00:00.000Z173-4.42-4.42
2026-08-31T00:00:00.000Z172.6-4.64-4.64
2026-09-01T00:00:00.000Z169-6.63-6.63
2026-09-02T00:00:00.000Z171.5-5.25-6.63
2026-09-03T00:00:00.000Z176-2.76-6.63
2026-09-06T00:00:00.000Z180.1-0.5-6.63
2026-09-07T00:00:00.000Z176.9-2.27-6.63
2026-09-08T00:00:00.000Z182.40-6.63
2026-09-09T00:00:00.000Z187.30-6.63
2026-09-10T00:00:00.000Z191.20-6.63

(15 rows)

sharpe_ratio

Syntax:

sharpe_ratio(returns)
sharpe_ratio(returns, risk_free)
sharpe_ratio(returns, risk_free, periods)

The Sharpe ratio: how much return you get for each unit of risk. It is the average return above the risk-free rate, divided by the standard deviation of the returns: (avg(returns) - risk_free) / stddev(returns) × sqrt(periods). Bigger is better: above 1 (yearly) is good, above 2 very good. The risk-free rate is what a safe investment pays, like a bank fixed deposit.

ArgumentTypeMeaning
returnsnumberone return per row, as a fraction (from pct_return)
risk_freenumberthe safe return per period, as a fraction; default 0. A fixed deposit of 6% a year is 0.06 / 12 = 0.005 per month
periodsnumberreturns per year, to make the ratio yearly (12 for monthly, 240 for daily stock returns); default 1

Example: Sharpe ratio of each fund: per month, per year, and above a 6% fixed deposit (sample data: sample, analysis-market)

SELECT fund,
       round(sharpe_ratio(r), 3) AS per_month,
       round(sharpe_ratio(r, 0, 12), 3) AS per_year,
       round(sharpe_ratio(r, 0.06 / 12, 12), 3) AS per_year_above_fd
FROM (SELECT time, fund, pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)per_month (DOUBLE)per_year (DOUBLE)per_year_above_fd (DOUBLE)
GROWTH0.391.3510.588
INCOME0.8222.847-1.495

(2 rows)

Example: Yearly Sharpe ratio of the shares from daily returns (only 14 returns: just a demo) (sample data: sample, analysis-market)

SELECT symbol, count(r) AS returns, round(avg(r) * 100, 3) AS avg_day_pct,
       round(sharpe_ratio(r, 0, 240), 2) AS sharpe_year
FROM (SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r
      FROM ochl_day
      WHERE time >= '2026-08-23') t
GROUP BY symbol
ORDER BY sharpe_year DESC;

Result:

symbol (SYMBOL)returns (LONG)avg_day_pct (DOUBLE)sharpe_year (DOUBLE)
VOLT140.4093.36
CITY14-0.04-0.82
COAST14-0.271-3.67

(3 rows)

sortino_ratio

Syntax:

sortino_ratio(returns)
sortino_ratio(returns, target)
sortino_ratio(returns, target, periods)

The Sortino ratio: like the Sharpe ratio, but it only counts the bad jumps as risk. Rises above the target do not hurt an investor, so only returns below target go into the risk (the downside deviation). (avg(returns) - target) / downside deviation × sqrt(periods). Bigger is better.

ArgumentTypeMeaning
returnsnumberone return per row, as a fraction
targetnumberthe smallest return you are happy with, per period; default 0
periodsnumberreturns per year, to make it yearly; default 1

Example: Sortino against Sharpe for both funds (sample data: sample, analysis-market)

SELECT fund,
       round(sharpe_ratio(r, 0, 12), 2) AS sharpe,
       round(sortino_ratio(r, 0, 12), 2) AS sortino,
       round(sortino_ratio(r, 0.005, 12), 2) AS sortino_target_half_pct
FROM (SELECT time, fund, pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)sharpe (DOUBLE)sortino (DOUBLE)sortino_target_half_pct (DOUBLE)
GROWTH1.352.330.84
INCOME2.856.76-1.43

(2 rows)

calmar_ratio

Syntax:

calmar_ratio(returns, periods)

The Calmar ratio: the yearly return divided by the maximum drawdown. It answers "how much did I earn per year for each 1% of the worst fall?". Bigger is better; above 1 is good. Fund reports usually use the last 36 monthly returns.

ArgumentTypeMeaning
returnsnumberone return per row, as a fraction, in time order
periodsnumberreturns per year: 12 for monthly, 240 for daily stock returns (needed)

Example: Calmar ratio of each fund, with its drawdown (sample data: sample, analysis-market)

SELECT fund, count(r) AS months,
       round(max_drawdown(nav) * 100, 2) AS max_drawdown_pct,
       round(calmar_ratio(r, 12), 2) AS calmar
FROM (SELECT time, fund, nav, pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)months (LONG)max_drawdown_pct (DOUBLE)calmar (DOUBLE)
GROWTH23-8.611.26
INCOME23-0.894.5

(2 rows)

Example: The number of periods per year is needed (sample data: sample, analysis-market; shows an error)

SELECT calmar_ratio(nav) FROM fund_nav;

Result:

Error: line 1, column 8: function 'calmar_ratio' takes 2 argument(s), got 1

information_ratio

Syntax:

information_ratio(returns, benchmark)
information_ratio(returns, benchmark, periods)

The information ratio: how well an investment beats a benchmark (a market index), for the risk of being different from it. It is the average of returns - benchmark, divided by the standard deviation of that difference (the tracking error), × sqrt(periods). Above 0 the share did better than the index; above 0.5 (yearly) is good.

ArgumentTypeMeaning
returnsnumberthe returns of the share or fund
benchmarknumberthe returns of the index on the same days
periodsnumberreturns per year, to make it yearly; default 1

Example: Did the shares beat the market index? (daily returns since 23 August) (sample data: sample, analysis-market)

SELECT symbol, count(r) AS days,
       round(avg(r - m) * 100, 3) AS avg_excess_day_pct,
       round(information_ratio(r, m), 3) AS per_day,
       round(information_ratio(r, m, 240), 2) AS per_year
FROM (SELECT d.time, d.symbol,
             pct_return(d.close) OVER (PARTITION BY d.symbol ORDER BY d.time) AS r,
             pct_return(i.close) OVER (PARTITION BY d.symbol ORDER BY d.time) AS m
      FROM ochl_day d
      JOIN market_index i ON i.time = d.time
      WHERE d.time >= '2026-08-23') t
GROUP BY symbol
ORDER BY per_year DESC;

Result:

symbol (SYMBOL)days (LONG)avg_excess_day_pct (DOUBLE)per_day (DOUBLE)per_year (DOUBLE)
VOLT140.410.2523.9
CITY14-0.038-0.058-0.89
COAST14-0.27-0.294-4.55

(3 rows)

value_at_risk

Syntax:

value_at_risk(returns, confidence)

The value at risk (VaR): a loss that is not passed in most periods. With confidence 0.95, a VaR of 0.031 means: in 95 months of 100 the loss is at most 3.1%; in about 5 of 100 it is bigger. Banks and brokers use it to set limits: "the 1-day 95% VaR of this position is $12,000".

ArgumentTypeMeaning
returnsnumberone return per row, as a fraction
confidencenumber, more than 0 and less than 1how sure: 0.95 or 0.99

Example: 95% monthly VaR of each fund, and in dollars for 500,000 invested (sample data: sample, analysis-market)

SELECT fund,
       round(value_at_risk(r, 0.95) * 100, 2) AS var95_pct,
       round(value_at_risk(r, 0.95) * 500000) AS var95_dollars
FROM (SELECT time, fund, pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)var95_pct (DOUBLE)var95_dollars (DOUBLE)
GROWTH3.0915433
INCOME0.391927

(2 rows)

Example: Confidence is a fraction, not a percent (sample data: sample, analysis-market; shows an error)

SELECT value_at_risk(close, 95) FROM ochl_day;

Result:

Error: line 1, column 8: value_at_risk: the confidence level must be more than 0 and less than 1, like 0.95, got 95

expected_shortfall

Syntax:

expected_shortfall(returns, confidence)

The expected shortfall (also called CVaR, conditional value at risk): the average loss in the bad periods that value_at_risk leaves out. VaR says "in the worst 5% of months you lose at least this much"; expected shortfall says "and in those months you lose this much on average". It is always the same as VaR or bigger.

ArgumentTypeMeaning
returnsnumberone return per row, as a fraction
confidencenumber, more than 0 and less than 1how sure: 0.95 or 0.99

Example: Both funds: VaR and expected shortfall of monthly returns, at 90% and 95% (sample data: sample, analysis-market)

SELECT fund,
       round(value_at_risk(r, 0.90) * 100, 2) AS var90_pct,
       round(expected_shortfall(r, 0.90) * 100, 2) AS es90_pct,
       round(value_at_risk(r, 0.95) * 100, 2) AS var95_pct,
       round(expected_shortfall(r, 0.95) * 100, 2) AS es95_pct
FROM (SELECT time, fund, pct_return(nav) OVER (PARTITION BY fund ORDER BY time) AS r
      FROM fund_nav) t
GROUP BY fund
ORDER BY fund;

Result:

fund (SYMBOL)var90_pct (DOUBLE)es90_pct (DOUBLE)var95_pct (DOUBLE)es95_pct (DOUBLE)
GROWTH2.313.083.093.45
INCOME0.280.420.390.48

(2 rows)

Example: The worst monthly returns of GROWTH, to see where the numbers come from (sample data: sample, analysis-market)

SELECT time, round(r * 100, 2) AS return_pct
FROM (SELECT time, pct_return(nav) AS r FROM fund_nav WHERE fund = 'GROWTH') t
WHERE r < 0
ORDER BY r
LIMIT 4;

Result:

time (TIMESTAMP)return_pct (DOUBLE)
2026-04-01T00:00:00.000Z-3.73
2025-02-01T00:00:00.000Z-3.17
2025-03-01T00:00:00.000Z-2.35
2025-01-01T00:00:00.000Z-2.13

(4 rows)