edgekit

edgekit.risk

Value-at-Risk & tail risk — how bad a normal-ish bad day looks (VaR), how bad the days beyond it look (CVaR / expected shortfall), and the drawdown-shape diagnostics that a single loss number misses. Three VaR methods, two ES methods, and the ulcer / tail-ratio pair.

What's inside. value_at_risk and expected_shortfall are the two headline estimators (with cvar as an alias for the latter); var_cvar returns both in one call. Below the quantile, drawdown_series reconstructs the full underwater curve, ulcer_index summarises its depth-and-duration pain, and tail_ratio compares the right tail to the left.

!Sign convention: VaR and CVaR are positive loss numbers

Both are reported as positive magnitudes of loss — a 5% VaR of 0.021 means “on the worst 5% of days you lose about 2.1%”, not -0.021. Because CVaR averages the losses beyond the VaR threshold, it is always at least as large: cvar >= var. If either comes back negative, your input is a gain at that quantile.

Value-at-Risk & expected shortfall#

value_at_risk#

The loss you should not exceed on all but the worst alpha fraction of periods — the standard one-number risk budget. Three methods trade off distributional assumptions: historical (empirical quantile, no assumption), gaussian (normal closed form), and cornish_fisher (normal adjusted for skew and kurtosis — better for fat tails).

value_at_risk(returns, alpha=0.05, method="historical") -> float
ParamTypeDefaultMeaning
returnsarray-likePer-period return series.
alphafloat0.05Tail probability (0.05 = 95% VaR).
methodstr"historical""historical", "gaussian", or "cornish_fisher".

Returns: a float — the VaR as a positive loss fraction.

import edgekit as ek
ek.risk.value_at_risk(rets, alpha=0.05)                      # historical 95% VaR
ek.risk.value_at_risk(rets, alpha=0.01, method="cornish_fisher")  # fat-tail-aware 99%

expected_shortfall#

Expected shortfall (a.k.a. CVaR) — the averageloss on the days worse than the VaR threshold. Answers the question VaR ducks: “when it does break, how bad is it?”. Two methods: historical and gaussian. cvar is an exported alias for this function.

expected_shortfall(returns, alpha=0.05, method="historical") -> float
cvar(returns, alpha=0.05, method="historical") -> float   # alias
ParamTypeDefaultMeaning
returnsarray-likePer-period return series.
alphafloat0.05Tail probability.
methodstr"historical""historical" or "gaussian".

Returns: a float — the average tail loss as a positive fraction (always >= value_at_risk).

ek.risk.expected_shortfall(rets, alpha=0.05)
ek.risk.cvar(rets, alpha=0.05)   # same thing

var_cvar#

Both risk numbers in a single call, sharing the same method and alpha — the convenience form when you report them together (which you should).

var_cvar(returns, alpha=0.05, method="historical") -> dict
ParamTypeDefaultMeaning
returnsarray-likePer-period return series.
alphafloat0.05Tail probability.
methodstr"historical"VaR method ("historical" / "gaussian" / "cornish_fisher"; ES falls back to "gaussian" for cornish_fisher).

Returns: a dict with keys "var" and "cvar", both positive loss numbers with cvar >= var.

r = ek.risk.var_cvar(rets, alpha=0.05)
r["var"], r["cvar"]
var_cvar chart
The return distribution with the VaR quantile and CVaR tail-average marked.

Drawdown shape#

drawdown_series#

The full underwater curve — the fractional distance below the running peak at every point. Where max_drawdown gives you the single worst gap, this gives you the whole shape: how often, how deep, and how long you were underwater.

drawdown_series(equity) -> np.ndarray
  • equity — a cumulative equity / wealth curve.

Returns: a numpy array of drawdowns (0 at new highs, negative or positive-magnitude below — same length as equity).

dd = ek.risk.drawdown_series(equity)

ulcer_index#

The Ulcer Index — the root-mean-square of the drawdown series, so it penalises deep and prolonged drawdowns rather than just the single worst point. A pain metric that rewards curves which recover quickly.

ulcer_index(equity) -> float
  • equity — a cumulative equity curve.

Returns: a float — the RMS drawdown (lower is smoother).

tail_ratio#

The ratio of the right-tail quantile to the (absolute) left-tail quantile — how big the good extremes are relative to the bad ones. > 1 means the upside tail dominates (the positively-skewed profile trend-following wants); < 1 means losses tail harder than gains.

tail_ratio(returns, q=0.05) -> float       # >1 upside tail dominates
ParamTypeDefaultMeaning
returnsarray-likePer-period return series.
qfloat0.05Tail fraction to compare on each side.

Returns: a float — right-tail / |left-tail| ratio.

ek.risk.tail_ratio(rets, q=0.05)   # >1 is the shape a trend edge should have

Extreme value theory#

Historical VaR runs out of data exactly where it matters — there are only a handful of observations in the far tail. Extreme value theory fits a parametric model to the exceedances over a high threshold (peaks-over-threshold), letting you extrapolate to quantiles beyond the sample. gpd_fit fits the generalized Pareto tail, evt_var_es turns it into VaR/ES numbers, and hill is the classic tail-index estimator.

gpd_fit#

Fit a generalized Pareto distribution to the losses exceeding the q-quantile threshold — the peaks-over-threshold (POT) method, estimated by probability-weighted moments (no scipy). The shape xi is the number to stare at: xi > 0 means a genuinely fat (power-law) tail; near 0 is exponential; negative is a bounded tail.

gpd_fit(losses, q=0.95) -> dict
ParamTypeDefaultMeaning
lossesarray-likeLoss series as positive magnitudes (e.g. -returns clipped to losses).
qfloat0.95Threshold quantile — losses above it are the exceedances fitted.

Returns: a dict with keys "xi" (GPD shape), "beta" (GPD scale), "threshold" (the loss level at q), and "n_exceed" (how many losses exceeded it).

import edgekit as ek
fit = ek.risk.gpd_fit(losses, q=0.95)
fit["xi"]   # > 0 => power-law tail; historical VaR will understate the extremes

evt_var_es#

EVT-based VaR and expected shortfall at level alpha, from a GPD tail fitted above the q-threshold. The point of the exercise: an honest 99% or 99.9% number even when the sample barely contains any observations that deep. Follows the module’s sign convention — both come back as positive loss magnitudes.

evt_var_es(returns, alpha=0.01, q=0.95) -> tuple[float, float]
ParamTypeDefaultMeaning
returnsarray-likePer-period return series (losses extracted internally).
alphafloat0.01Tail probability of the VaR/ES level (0.01 = 99%).
qfloat0.95POT threshold quantile for the tail fit.

Returns: a (var, es) tuple of floats — positive loss numbers with es >= var, same convention as value_at_risk / expected_shortfall.

var99, es99 = ek.risk.evt_var_es(rets, alpha=0.01, q=0.95)
# compare against the historical estimate — EVT usually reads higher, and is usually right
ek.risk.value_at_risk(rets, alpha=0.01)

hill#

The Hill estimator of the tail index alpha from the klargest losses — the classic answer to “how fat is this tail?”. Smaller alpha = fatter tail; roughly, moments above order alphado not exist (alpha < 4 and kurtosis is meaningless, alpha < 2 and even the variance is suspect). Note it estimates alpha ≈ 1/xi, the reciprocal of the GPD shape.

hill(losses, k=None) -> float
ParamTypeDefaultMeaning
lossesarray-likeLoss series as positive magnitudes.
kint | NoneNoneNumber of top order statistics to use (default ~sqrt(n)).

Returns: a float tail index (smaller = fatter tail).

a = ek.risk.hill(losses)         # ~3 is typical for daily equity returns
a = ek.risk.hill(losses, k=100)  # sensitivity-check across k before trusting it

See also#