Fixed position sizes ignore market conditions. Vol targeting adjusts how much you put on based on how volatile the market currently is.
The same strategy is boring enough through the quiet months that you want to switch it off; then, in the week the market starts moving violently, it draws down far deeper than the backtest average led you to expect. The signal did not change, the market did — and the size of every bet you place stayed exactly the same.
Vol targeting means scaling position size inversely with the market's realized volatility: when volatility is above your target you put on less, when it is below you put on more — up to a cap.
A basic Type A strategy outputs a signal of 1.0 (long) or 0.0 (flat). The runner deploys the full allocated capital when the signal is 1.0. This works, but it has a hidden problem: the strategy takes the same-sized bet regardless of whether the market is calm or chaotic.
In a low-volatility period, a 1% daily move is large. In a high-volatility period, a 1% move is noise. If your strategy enters with the same position size in both environments, your actual dollar risk swings dramatically — and your drawdowns in volatile periods will be far larger than what your backtest average suggests.
Blave Agent implements vol targeting in two steps:
log_return = log(Close / Close.shift(1)) realized_vol = log_return.rolling(lookback).std() * sqrt(periods_per_year)
This computes the rolling standard deviation of log returns, then annualizes it by multiplying by sqrt(periods_per_year). The result is annualized realized volatility — the same unit as Sharpe ratio volatility.
Defaults: lookback=720 bars, periods_per_year=8760. These are calibrated for a 1h interval strategy: 720 bars = 30 days × 24 hours; 8760 = 24 × 365.
scale = (target_vol / realized_vol).clip(upper=vol_cap) scaled_signal = signal * scale
The signal (1.0 = full long) is multiplied by the volatility ratio, capped at vol_cap. The result is a fractional position size.
Defaults: target_vol=0.30 (30% annualized), vol_cap=2.0 (maximum 2× leverage).
| Realized vol (ann.) | Target vol | Raw scale | After cap | Position size |
|---|---|---|---|---|
| 10% | 30% | 3.0× | 2.0× (capped) | 200% of allocation |
| 20% | 30% | 1.5× | 1.5× | 150% of allocation |
| 30% | 30% | 1.0× | 1.0× | 100% — baseline |
| 60% | 30% | 0.5× | 0.5× | 50% of allocation |
| 90% | 30% | 0.33× | 0.33× | 33% of allocation |
At 30% realized vol the strategy runs at full size. During a crisis when realized vol spikes to 90%, the strategy automatically trims to one-third position. In a calm period at 10% vol, it scales up to the cap of 2×.
The defaults (lookback=720, periods_per_year=8760) are for a 1h strategy. For other intervals, both values must be recalculated:
| Interval | periods_per_year | lookback (30-day window) |
|---|---|---|
5min | 105,120 (12 × 24 × 365) | 8,640 bars |
1h (default) | 8,760 | 720 bars |
4h | 2,190 | 180 bars |
8h | 1,095 | 90 bars |
1d | 365 | 30 bars |
VOL_TARGETING = True
TARGET_VOL = 0.30 # 30% annualized target
VOL_CAP = 2.0 # max 2× leverage
VOL_LOOKBACK = 720 # bars in lookback window
PERIODS_PER_YEAR = 8760 # for 1h interval
def fetch_data(hdrs):
from lib.data import fetch_kline
from lib.strategy import add_realized_vol
df = fetch_kline(SYMBOL, INTERVAL, START, END, hdrs)
if VOL_TARGETING:
add_realized_vol(df, VOL_LOOKBACK, PERIODS_PER_YEAR)
return df
def compute_signals(df):
from lib.strategy import apply_vol_scaling
signal = pd.Series(np.nan, index=df.index)
# ... your signal logic here ...
if VOL_TARGETING:
return apply_vol_scaling(signal, df, TARGET_VOL, VOL_CAP)
return signal
| Parameter | Default | Meaning | When to change it |
|---|---|---|---|
TARGET_VOL | 0.30 | 30% annualized target volatility | Set it to the MDD you can accept ÷ 2 |
VOL_CAP | 2.0 | At most 2× leverage | Set 1.0 if you want no leverage at all |
VOL_LOOKBACK | 720 | Length of the volatility estimation window (in bars) | Recompute when you change the interval — see the table above |
PERIODS_PER_YEAR | 8760 | Periods per year used for annualizing (1h interval) | Recompute when you change the interval — see the table above |
Note that add_realized_vol must be called in fetch_data (before signals are computed), because apply_vol_scaling reads df['realized_vol'] which is set in-place by the first function.
The cap exists because vol targeting can produce extreme leverage in persistently low-volatility environments. If realized vol drops to 5%, the uncapped scale would be 6× — three times the allocation. On a 1-day adverse move, this can cause a catastrophic drawdown even with a fundamentally sound signal.
The default cap of 2× limits the strategy to deploying at most double the allocated capital. For strategies where you want to avoid any leverage at all, set vol_cap=1.0. This turns vol targeting into pure vol-adjusted position sizing with no leverage — position size only shrinks in high-vol, never expands past full size.
A useful rule of thumb: target_vol ≈ acceptable MDD ÷ 2. This comes from the empirical relationship between annualized volatility and maximum drawdown in trend-following strategies.
| target_vol | Implied typical MDD | Use case |
|---|---|---|
| 10% | ≈ −20% | Conservative, capital preservation priority |
| 20% | ≈ −40% | Moderate |
| 30% (default) | ≈ −60% | Aggressive — full risk budget |
periods_per_year matches your interval? The default 8760 is for 1h. It is 105,120 for 5min, 2,190 for 4h, 1,095 for 8h and 365 for 1d. Get it wrong and the annualization step is wrong, which makes every cell after it wrong.
lookback still 30 days? 720 bars = 1h × 30 days. Change it with the interval: 8,640 bars for 5min, 180 for 4h, 90 for 8h, 30 for 1d. On daily bars 30 is short — consider 252 (one year) for a steadier estimate.
target_vol matches the MDD you can accept? The rule of thumb is target_vol ≈ acceptable MDD ÷ 2: 10% for about −20%, 20% for about −40%, and the default 30% for about −60%. (For how to read MDD, see How to Read a Backtest.)
vol_cap gives you the leverage you want? The default 2.0 means up to twice the allocated capital. Set it to 1.0 and it will only shrink the position when volatility is high, never scale above the full allocation when volatility is low.
Your individual strategy's TARGET_VOL is consistent with the portfolio manager's target_vol_pct? The manager sizes overall leverage with the same logic (leverage = target_vol ÷ portfolio realized volatility). If the two disagree, combining strategies double-counts risk.
lookback bars: a longer window is a steadier estimate and a slower one, a shorter window over-reacts to a single short-lived spike. Neither lets you know in advance that volatility is about to jump.