Almgren–Chriss Execution with Volatility-Adaptive Trade Slices
Summary
This trading-system example adapts Almgren–Chriss optimal execution to position entries and exits. It estimates a time-varying execution parameter from rolling price volatility, candle range, and volume, then uses that parameter to choose the fraction of remaining stake for each slice. When the parameter approaches zero, the schedule becomes an even time-weighted sequence. Slice count, spacing, volatility lookback, risk aversion, and impact calibration are configurable.
The example places an initial partial entry and adds slices at set intervals; when an exit condition is met, it reduces the position in slices. RSI thresholds serve as illustrative entry and partial-exit signals, while a fixed stop loss and return target are also specified. The description explicitly says to adapt these signals. No backtest, execution-cost comparison, or empirical results are supplied, and the model relies on candle range and volume as impact proxies. Its schedules therefore illustrate a framework, not evidence that the implementation improves fills in a particular market.
Key ideas
- The slice schedule balances volatility risk against estimated temporary and permanent market impact.
- A rolling estimate based on volatility, candle range, and volume adjusts the fraction traded in each slice.
- As risk aversion approaches zero, the schedule reduces to equal-sized time-weighted slices.
- The example uses RSI thresholds for entries and partial exits, but these signals are intended to be replaced as needed.
- The document provides no empirical execution results, and its candle-based impact estimates may not capture actual trading costs.
Tags
Full text
# AlmgrenChrissStrategy
# AlmgrenChrissStrategy
Almgren-Chriss optimal execution strategy.
Balances market impact and volatility risk using adaptive order slices.
The entry and exit signals are examples and should be adapted to your strategy.
Adopted from:
https://github.com/joshuapjacob/almgren-chriss-optimal-execution
- twap_num_slices: desired number of execution slices.
- twap_interval_minutes: time between execution slices.
- vol_window: lookback period used for calculation.
- factor_lambda: risk-aversion parameter.
- eta_volume_fraction: volume fraction used to calibrate temporary market impact.
- gamma_volume_fraction: volume fraction used to calibrate permanent market impact.
## Source (GPL-3.0)
```python
from datetime import datetime, timedelta
import math
import pandas as pd
import numpy as np
from freqtrade.exchange import timeframe_to_minutes
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy
import logging
logger = logging.getLogger(__name__)
import talib.abstract as ta
class AlmgrenChrissStrategy(IStrategy):
"""
Almgren-Chriss optimal execution strategy.
Balances market impact and volatility risk using adaptive order slices.
The entry and exit signals are examples and should be adapted to your strategy.
Adopted from:
https://github.com/joshuapjacob/almgren-chriss-optimal-execution
- twap_num_slices: desired number of execution slices.
- twap_interval_minutes: time between execution slices.
- vol_window: lookback period used for calculation.
- factor_lambda: risk-aversion parameter.
- eta_volume_fraction: volume fraction used to calibrate temporary market impact.
- gamma_volume_fraction: volume fraction used to calibrate permanent market impact.
"""
timeframe = "15m"
stoploss = -0.10
minimal_roi = {"0": 0.02}
process_only_new_candles = True
startup_candle_count = 30
can_short = True
position_adjustment_enable = True
twap_num_slices = 10
twap_interval_minutes = 1
vol_window = 96
factor_lambda = 0.01
eta_volume_fraction = 0.01
gamma_volume_fraction = 0.1
kappa_default = 0.6
kappa_max = 5.0
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe["rsi"] = ta.RSI(dataframe)
dataframe["kappa"] = self._ac_kappa_series(dataframe, metadata["pair"])
return dataframe
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe.loc[
(dataframe["rsi"] < 45) & (dataframe["volume"] > 0),
"enter_long",
] = 1
# Short entry
dataframe.loc[
(dataframe["rsi"] > 55) & (dataframe["volume"] > 0),
"enter_short",
] = 1
return dataframe
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
return dataframe
def should_exit_partially(self, trade: Trade, current_time: datetime) -> bool:
"""
Determine whether the trade should be partially exited with slices.
This method is only intended to be called from strategy callbacks.
"""
dataframe, _ = self.dp.get_analyzed_dataframe(
trade.pair, self.timeframe
)
if dataframe.empty:
return False
last_candle = dataframe.iloc[-1]
rsi = last_candle["rsi"]
if trade.is_short:
return rsi < 45
return rsi > 55
def _ac_kappa_series(self, dataframe: pd.DataFrame, pair: str) -> pd.Series:
if dataframe.empty:
return pd.Series(dtype=float, index=dataframe.index)
sigma = dataframe["close"].rolling(self.vol_window).std()
avg_spread = (dataframe["high"] - dataframe["low"]).rolling(self.vol_window).mean()
avg_volume = dataframe["volume"].rolling(self.vol_window).mean()
candle_minutes = timeframe_to_minutes(self.timeframe)
tau = self.twap_interval_minutes / candle_minutes
eta = avg_spread / (self.eta_volume_fraction * avg_volume)
gamma = avg_spread / (self.gamma_volume_fraction * avg_volume)
eta_tilde = eta - 0.5 * gamma * tau
eta_tilde = eta_tilde.where(eta_tilde > 0, eta)
sigma_tau = sigma * math.sqrt(tau)
kappa_tilde_sq = (self.factor_lambda * sigma_tau ** 2) / eta_tilde
acosh_arg = (0.5 * kappa_tilde_sq * tau ** 2 + 1.0).clip(lower=1.0)
raw_kappa = np.arccosh(acosh_arg) / tau
return raw_kappa.clip(upper=self.kappa_max).fillna(self.kappa_default)
def _get_kappa(self, pair: str, current_time: datetime | None = None) -> float:
"""
Get kappa value for slices execution.
This method is only intended to be called from strategy callbacks.
"""
if self.dp is None:
return self.kappa_default
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty or "kappa" not in dataframe:
return self.kappa_default
value = dataframe["kappa"].iloc[-1]
return self.kappa_default if pd.isna(value) else float(value)
def _ac_next_slice_fraction(self, remaining_slices: int, kappa: float) -> float:
"""
Fraction of the *currently remaining* amount to trade in the next
slice, given `remaining_slices` .
kappa == 0 reduces exactly to 1/remaining_slices plain TWAP.
"""
m = remaining_slices
if m <= 1:
return 1.0
if kappa <= 1e-12:
return 1.0 / m
denominator = math.sinh(kappa * m)
if denominator == 0:
return 1.0 / m
numerator = math.sinh(kappa * (m - 1))
frac = 1.0 - (numerator / denominator)
return min(max(frac, 0.0), 1.0)
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float | None, max_stake: float,
leverage: float, entry_tag: str | None, side: str,
**kwargs) -> float:
first_fraction = self._ac_next_slice_fraction(self.twap_num_slices, self._get_kappa(pair))
return proposed_stake * first_fraction
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float,
min_stake: float | None, max_stake: float,
current_entry_rate: float, current_exit_rate: float,
current_entry_profit: float, current_exit_profit: float,
**kwargs
) -> float | None | tuple[float | None, str | None]:
if trade.has_open_orders:
return None
filled_entries = trade.select_filled_orders(trade.entry_side)
entry_slices_done = len(filled_entries)
filled_exits = trade.select_filled_orders(trade.exit_side)
exit_slices_done = len(filled_exits)
already_exiting = exit_slices_done > 0
if already_exiting or self.should_exit_partially(trade, current_time):
return self._next_exit_slice(trade, current_time, filled_exits, exit_slices_done)
if entry_slices_done < self.twap_num_slices:
return self._next_entry_slice(trade, current_time, filled_entries, entry_slices_done, min_stake)
return None
def _next_entry_slice(self, trade: Trade, current_time: datetime,
filled_entries: list, slices_done: int, min_stake: float | None
) -> float | None | tuple[float | None, str | None]:
last_fill_time = filled_entries[-1].order_filled_utc if filled_entries else trade.open_date_utc
next_slice_due_at = last_fill_time + timedelta(minutes=self.twap_interval_minutes)
if current_time < next_slice_due_at:
return None
kappa = self._get_kappa(trade.pair)
first_fraction = self._ac_next_slice_fraction(self.twap_num_slices, kappa)
ac_total_stake = filled_entries[0].stake_amount_filled / first_fraction
stake_already_filled = sum(o.stake_amount_filled for o in filled_entries)
remaining_stake = ac_total_stake - stake_already_filled
remaining_slices = self.twap_num_slices - slices_done
if remaining_stake <= 0 or remaining_slices <= 0:
return None
fraction = self._ac_next_slice_fraction(remaining_slices, kappa)
next_slice_stake = remaining_stake * fraction
if next_slice_stake < (min_stake or 0):
return None
return next_slice_stake
def _next_exit_slice(self, trade: Trade, current_time: datetime,
filled_exits: list, slices_done: int
) -> float | None | tuple[float | None, str | None]:
if slices_done >= self.twap_num_slices:
return None
last_fill_time = filled_exits[-1].order_filled_utc if filled_exits else current_time
next_slice_due_at = last_fill_time + timedelta(minutes=self.twap_interval_minutes)
if slices_done > 0 and current_time < next_slice_due_at:
return None
remaining_slices = self.twap_num_slices - slices_done
if remaining_slices <= 1:
return -trade.stake_amount
fraction = self._ac_next_slice_fraction(remaining_slices, self._get_kappa(trade.pair))
slice_stake = trade.stake_amount * fraction
return -slice_stake
```Shown in full with attribution under the source's licence. Licence: GPL-3.0
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.