UlcerIndex — Ulcer Index
Root-mean-square drawdown from the running high: volatility that counts only the downside. Two instruments with the same standard deviation can have very different Ulcer Indices, and the one with the higher reading is the one that actually hurt to hold.
Signature
UlcerIndex(period, on=<source>)| Argument | Type | Required | Notes |
|---|---|---|---|
period |
int Param or literal | Yes | Window. Default 14, bounds 1–400. |
on= |
symbol, OHLCV field, or indicator | No | Source. Defaults to the trade symbol’s close. |
Formula
$$ \text{UI} = \sqrt{\frac{1}{n}\sum_i \left(100,\frac{C_i - \text{peak}_i}{\text{peak}_i}\right)^2} $$
with peak the running maximum inside the window.
Warmup
period bars; before that it measures over the bars available.
Reading the value
ui.valueui < 3Example
class UlcerGate(Strategy): """Hold only while downside volatility is contained.""" trade = Symbol() sizing = FixedCapital()
def execute(self): ui = UlcerIndex(14, on=self.trade) sma = SMA(50, on=self.trade) if ui.value < 3 and self.trade.close > sma.value and self.trade.is_flat: self.buy(self.trade, reason=f"Ulcer {ui.value:.2f}") if ui.value > 6 and self.trade.is_long: self.sell(self.trade, self.trade.position, reason="Drawdown pain rising")Notes
- Peter Martin and Byron McCann, The Investor’s Guide to Fidelity Funds (1989).
- Proven by an assert-via-trade run on SPY daily (matched a strategy-side RMS-drawdown reference to 1e-3 on every bar; zero alarm trades).
- The peak is the window’s own running high, not an all-time high — a long decline eventually reads as calm once the old peak leaves the window.
- Drawdown is measured against the running peak from the window’s first bar, where
pandas_ta’s
ui(14)measures against the trailing rolling maximum at each bar — a different construction, not a scale factor (ours reads about 0.74× the reference).