Cryptocurrency markets are characterized by extreme volatility, frequent regime shifts, and nonlinear dynamics. Traditional linear models, such as autoregressive (AR) processes, often fail to capture the complexity of these dynamics. Neural networks, with their universal function approximation capability, offer a flexible alternative by extending AR models into the nonlinear domain.
In this study, we use a Nonlinear Autoregressive Neural Network (NN-AR) to forecast daily volatility for ETH-USD and apply the forecasts to a target-volatility allocation strategy. The objective is to maintain a controlled risk level while exploiting periods of favorable volatility conditions.
A standard AR(\(p\)) model is given by:
\[ y_t = c + \sum_{i=1}^p \phi_i y_{t-i} + \varepsilon_t \]
where \(y_t\) is the target variable, \(p\) is the lag order, \(\phi_i\) are coefficients, and \(\varepsilon_t\) is noise. This model assumes a linear relationship between past observations and the current value.
A neural network generalizes this by replacing the linear combination with a nonlinear mapping \(f_\theta(\cdot)\) parameterized by weights \(\theta\):
\[ y_t = f_\theta(y_{t-1}, y_{t-2}, \dots, y_{t-p}) + \varepsilon_t \]
This nonlinear autoregressive structure allows the model to capture complex relationships and interactions between lagged values, making it suitable for financial time series that exhibit nonlinearity, volatility clustering, and asymmetric effects.
In our case, \(y_t\) will be the daily realized volatility of ETH-USD, computed from OHLC prices using the Garman–Klass estimator.
Given a forecast \(\hat{\sigma}_{t+1}\) of next-day volatility, we scale our portfolio exposure to maintain a desired annualized volatility target \(\sigma_{\text{target}}\):
\[ w_t = \operatorname{clip}\left( \frac{\sigma_{\text{target, daily}}}{\hat{\sigma}_{t+1}},\; 0,\; L_{\max} \right) \]
where \(\sigma_{\text{target, daily}} = \sigma_{\text{target}} / \sqrt{252}\) and \(L_{\max}\) is a leverage cap.
The portfolio return is:
\[ r_t^{\text{strat}} = w_{t-1} \cdot r_t \]
Optionally, transaction costs are subtracted based on daily turnover.
The following Python code:
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error
# =========================
# Configuration
# =========================
TICKER = "ETH-USD"
START = "2020-01-01"
LAGS = 3
ANN_VOL_TARGET = 0.25
MAX_LEVERAGE = 2.0
REFIT_EVERY_DAYS = 7
TRAIN_MIN_YEARS = 1
TC_BPS_PER_TURNOVER = 10.0
OUT_DIR_PREFIX = "nnar_tv_"
# =========================
# Data and volatility
# =========================
def load_data(ticker, start):
df = yf.download(ticker, start=start, auto_adjust=True, progress=False)
if df.empty:
raise ValueError("No data downloaded; check ticker/date.")
return df
def garman_klass_vol(df):
rs = 0.5 * (np.log(df['High'] / df['Low']))**2 - \
(2*np.log(2)-1) * (np.log(df['Close'] / df['Open']))**2
vol = np.sqrt(np.maximum(rs, 0))
vol.name = "rv"
return vol
def daily_log_returns(prices):
r = np.log(prices).diff()
r.name = "ret"
return r
# =========================
# Features
# =========================
def build_feature_table(rv, ret, lags):
df = pd.concat([rv, ret], axis=1)
for k in range(1, lags+1):
df[f"rv_lag_{k}"] = df["rv"].shift(k)
df[f"ret_lag_{k}"] = df["ret"].shift(k)
df = df.dropna()
return df
# =========================
# NN-AR forecaster
# =========================
class NNARVolForecaster:
def __init__(self, hidden=(32,16), max_iter=800, random_state=42):
self.model = MLPRegressor(hidden_layer_sizes=hidden, activation="relu",
solver="adam", max_iter=max_iter, random_state=random_state)
self.scaler = StandardScaler()
self.fitted = False
def fit(self, X, y):
Xs = self.scaler.fit_transform(X)
self.model.fit(Xs, y)
self.fitted = True
def predict(self, X):
if not self.fitted:
raise RuntimeError("Model not fitted.")
Xs = self.scaler.transform(X)
return self.model.predict(Xs)
def walk_forward_forecast(features, first_train_end_idx):
cols_X = [c for c in features.columns if c != "rv"]
vhat = pd.Series(index=features.index, dtype=float)
model = NNARVolForecaster()
last_fit_loc = None
for loc in range(first_train_end_idx, len(features)):
if (last_fit_loc is None) or ((loc - last_fit_loc) >= REFIT_EVERY_DAYS):
train = features.iloc[:loc]
X_train = train[cols_X]
y_train = train["rv"]
model.fit(X_train, y_train)
last_fit_loc = loc
row = features.iloc[[loc]][cols_X]
vhat.iloc[loc] = model.predict(row)[0]
out = features.copy()
out["vhat"] = vhat
return out
# =========================
# Target-vol overlay
# =========================
def target_vol_weights(vhat_daily, ann_vol_target=0.10, max_leverage=1.5):
sigma_target_daily = ann_vol_target / np.sqrt(252.0)
w = sigma_target_daily / vhat_daily.replace(0, np.nan)
w = w.clip(lower=0, upper=max_leverage).fillna(0.0)
return w
def apply_transaction_costs(ret_series, weights, bps_per_turnover=0.0):
turnover = weights.diff().abs().fillna(0.0)
daily_cost = turnover * (bps_per_turnover / 1e4)
return ret_series - daily_cost
# =========================
# Performance metrics
# =========================
def annualized_vol(returns):
return returns.std() * np.sqrt(252.0)
def sharpe(returns, rf=0.0):
ex = returns - rf/252.0
vol = ex.std()
return (ex.mean() / vol * np.sqrt(252.0)) if vol > 0 else np.nan
def max_drawdown(series):
cum = (1 + series).cumprod()
peak = cum.cummax()
dd = (cum / peak - 1.0).min()
return dd
# =========================
# Main
# =========================
def main():
df = load_data(TICKER, START)
rv = garman_klass_vol(df)
ret = daily_log_returns(df["Close"])
feat = build_feature_table(rv, ret, LAGS).dropna()
first_train_end_idx = feat.index.get_indexer_for(
[feat.index[0] + pd.DateOffset(years=TRAIN_MIN_YEARS)]
)
if len(first_train_end_idx) == 0 or first_train_end_idx[0] <= 0:
raise ValueError("Not enough history for initial training window.")
first_train_end_loc = first_train_end_idx[0]
wf = walk_forward_forecast(feat, first_train_end_loc).dropna(subset=["vhat"])
w = target_vol_weights(wf["vhat"], ANN_VOL_TARGET, MAX_LEVERAGE)
strat_ret_gross = w * wf["ret"]
strat_ret = apply_transaction_costs(strat_ret_gross, w, TC_BPS_PER_TURNOVER)
bh_ret = wf["ret"]
metrics = pd.DataFrame({
"Total Return": [(1+bh_ret).prod()-1, (1+strat_ret).prod()-1],
"Ann. Vol": [annualized_vol(bh_ret), annualized_vol(strat_ret)],
"Sharpe": [sharpe(bh_ret), sharpe(strat_ret)],
"Max Drawdown": [max_drawdown(bh_ret), max_drawdown(strat_ret)]
}, index=["Buy & Hold", "NNAR Target-Vol"])
print("\nVolatility Forecast MSE:", mean_squared_error(wf["rv"], wf["vhat"]))
print("\nPerformance Summary:")
print(metrics)
eq_bh = (1 + bh_ret).cumprod()
eq_st = (1 + strat_ret).cumprod()
fig, ax = plt.subplots(figsize=(12,4))
ax.plot(eq_bh.index, eq_bh.values, label="Buy & Hold")
ax.plot(eq_st.index, eq_st.values, label="NNAR Target-Vol")
ax.set_title("Equity Curves")
ax.legend()
ax.grid(True, alpha=0.3)
fig.savefig(f"{OUT_DIR_PREFIX}{TICKER}_equity.png", dpi=150)
if __name__ == "__main__":
main()
This framework shows how a neural network can be embedded inside a nonlinear autoregressive structure to forecast crypto volatility and directly link the forecast to portfolio construction. For ETH-USD, the combination of a short lag window, weekly re-fitting, and a higher annual volatility target improves responsiveness and return capture compared to equity markets.
The model:
You can push this further and make it more advanced by adding:
Directional filter
Only take positive weights when the trend is up, and go to cash or
reduce exposure when the short-term momentum is negative.
This prevents leveraging up into crashes.
Dynamic vol target
Increase target vol during strong uptrends, reduce it during choppy
markets.
Drawdown stop
If cumulative drawdown exceeds X%, cut exposure to zero for a cooldown
period.
That would turn the NN-AR Target-Vol strategy into a full tactical allocation system instead of just risk-scaling.