← Back to Home
A Simple bull-only inverse-volatility crypto basket

A Simple bull-only inverse-volatility crypto basket

I explore here a simple strategy with no risk-management or exits. I want to see a risk-aware allocation and weekly rebalance will still work good enough. Later we can add a risk amnagement layer to improve the results. This strategy does two things: it only takes crypto exposure when Bitcoin is in an uptrend, and when it is allowed to invest, it allocates across a basket using inverse-volatility weights. If Bitcoin is not bullish, allocations are forced to zero and the portfolio stays in cash. There is no stop-loss, no take-profit, and no position-level risk control.

Imports and parameters

import numpy as np
import pandas as pd
import vectorbt as vbt
import matplotlib.pyplot as plt

START_DATE = "2025-01-01"
END_DATE = "2025-12-31"
LOOKBACK_DAYS = 30
HOLDING_DAYS = 7
MARKET_REGIME_MA_PERIOD = 50

pairs = ["BTCUSDC", "ETHUSDC", "BNBUSDC", "ADAUSDC", "SOLUSDC"]

LOOKBACK_DAYS defines the volatility estimation window. HOLDING_DAYS defines how often weights are recomputed. MARKET_REGIME_MA_PERIOD defines the trend filter length applied to BTC.

Download close prices and clean the data

close = vbt.BinanceData.download(
    pairs, start=START_DATE, end=END_DATE, interval="1d"
).get("Close")

close = close[pairs].astype(float)
close = close.replace([np.inf, -np.inf], np.nan).ffill()
close = close.dropna(how="any")

This pulls daily closes from Binance via vectorbt, forward-fills missing values, and drops any remaining rows with gaps. The cleaning step matters because NaNs can cause portfolio cash/value series to become invalid.

Define the Bitcoin bullish regime gate

reg_ma = close["BTCUSDC"].rolling(MARKET_REGIME_MA_PERIOD).mean()
is_bull = (close["BTCUSDC"] > reg_ma).fillna(False)

When BTC’s close is above its moving average, is_bull is True. Otherwise it is False. This is the only market timing rule in the strategy.

Compute inverse-volatility target weights on a schedule

weights = pd.DataFrame(0.0, index=close.index, columns=pairs)
start_i = max(LOOKBACK_DAYS, MARKET_REGIME_MA_PERIOD)

for i in range(start_i, len(close.index), HOLDING_DAYS):
    if not bool(is_bull.iloc[i]):
        continue

    daily = close.iloc[i - LOOKBACK_DAYS:i].pct_change().dropna(how="all")
    vol = daily.std().replace(0, 1e-9)

    inv_vol = 1.0 / vol
    w = inv_vol / inv_vol.sum()

    weights.iloc[i, :] = w.values

On each rebalance date (every HOLDING_DAYS), the code checks the regime. If BTC is bearish, it leaves weights at zero (cash). If BTC is bullish, it measures each asset’s recent volatility and assigns weights proportional to 1/vol, then normalizes so weights sum to 1.

Inverse-volatility weighting pushes more capital toward assets that have been less volatile over the lookback window and less toward the most volatile names in the basket.

Hold weights between rebalances and apply the regime filter

weights = weights.replace(0.0, np.nan).ffill().fillna(0.0)

weights = weights.mul(is_bull.astype(float), axis=0)

The forward-fill step keeps the last computed weights active until the next rebalance date. Multiplying by is_bull forces all weights to zero on bearish days, even if the previous rebalance set allocations.

Safety cleanup and normalization

weights = weights.replace([np.inf, -np.inf], 0.0).fillna(0.0)
row_sum = weights.sum(axis=1)
weights.loc[row_sum > 0, :] = weights.loc[row_sum > 0, :].div(row_sum[row_sum > 0], axis=0)

This ensures weights are finite and that, when “risk on”, the row sums to 1. When “risk off”, the row sum stays 0 (cash).

Build the portfolio with target-percent orders

pf = vbt.Portfolio.from_orders(
    close,
    size=weights,
    size_type="targetpercent",
    fees=0.001,
    freq="1D",
    cash_sharing=True,
    init_cash=10_000,
)

size_type="targetpercent" tells vectorbt to treat each row of weights as the desired portfolio allocation for that day. Fees are set to 0.1% per trade.

Plot equity vs benchmark and show allocation breakdown

pv = pf.value()
bv = pf.benchmark_value()

asset_values = pf.asset_value(group_by=False)
cash_values = pf.cash()
def _clean_label(c):
    if isinstance(c, tuple):
        return str(c[0])
    return str(c)

asset_labels = [_clean_label(c) for c in asset_values.columns]
labels = ["Cash"] + asset_labels
cmap_colors = list(plt.cm.tab10.colors)
colors = ['#d3d3d3'] + cmap_colors[:len(asset_values.columns)]

pf_ret = pv.iloc[-1] / pv.iloc[0] - 1
bm_ret = bv.iloc[-1] / bv.iloc[0] - 1

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 9), sharex=True)

ax1.plot(pv, label="Inverse Vol Weight + Regime Filter", color="blue")
ax1.plot(bv, label="Benchmark (average buy & hold)", color="orange", linestyle="--")
ax1.grid(True)
ax1.legend(fontsize=9)

x_last = pv.index[-1]
ax1.annotate(f"PF: {pf_ret*100:.2f}%", xy=(x_last, pv.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", color="blue", clip_on=False)
ax1.annotate(f"BM: {bm_ret*100:.2f}%", xy=(x_last, bv.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", color="orange", clip_on=False)
ax1.margins(x=0.08)

ax2.stackplot(
    asset_values.index,
    cash_values,
    *[asset_values[col] for col in asset_values.columns],
    labels=labels,
    colors=colors,
    alpha=0.8
)

ax2.set_title("Portfolio Allocation Breakdown")
ax2.set_ylabel("Value ($)")
ax2.grid(True, alpha=0.3)
ax2.legend(loc="upper left", bbox_to_anchor=(1., 1), frameon=True)

plt.tight_layout()
plt.show()
Pasted image 20260304144202.png

The equity chart compares the strategy to the benchmark. The stackplot shows how much of the portfolio value sits in cash versus each asset over time—useful for visually verifying that the regime filter is actually shutting exposure off when BTC is below its MA.

Stats output

print('--- Portfolio Statistics ---')
print(pf.stats())

This prints vectorbt’s performance table (returns, drawdowns, exposure, and other metrics) for quick inspection.

--- Portfolio Statistics ---
Start                          2025-01-01 00:00:00+00:00
End                            2025-12-30 00:00:00+00:00
Period                                 364 days 00:00:00
Start Value                                      10000.0
End Value                                   12495.159806
Total Return [%]                               24.951598
Benchmark Return [%]                           -18.65276
Max Gross Exposure [%]                             100.0
Total Fees Paid                                231.91472
Max Drawdown [%]                               18.123019
Max Drawdown Duration                  139 days 00:00:00
Total Trades                                         344
Total Closed Trades                                  344
Total Open Trades                                      0
Open Trade PnL                                       0.0
Win Rate [%]                                   87.209302
Best Trade [%]                                 67.847259
Worst Trade [%]                               -21.712555
Avg Winning Trade [%]                          17.807012
Avg Losing Trade [%]                           -2.987342
Avg Winning Trade Duration              25 days 04:14:24
Avg Losing Trade Duration     10 days 07:38:10.909090909
Profit Factor                                   1.927634
Expectancy                                      7.253372
Sharpe Ratio                                    0.900239
Calmar Ratio                                    1.381011
Omega Ratio                                     1.227476
Sortino Ratio                                   1.312111
Name: group, dtype: object

Conclusion

This strategy is a simple “risk-on/risk-off” crypto basket. Bitcoin’s trend decides whether you hold anything at all, and inverse-volatility decides how you spread exposure when you do. The main benefit is behavioral and structural simplicity: you avoid being continuously exposed during sustained BTC downtrends, and you prevent the most volatile coin in the basket from automatically dominating portfolio swings.

The trade-offs are equally clear. The moving-average regime filter will lag, so it can miss early parts of rallies and can exit after some damage is done. Inverse-volatility is backward-looking, so weights can shift after volatility spikes rather than before them. With no stop-loss or other risk controls, large gaps and fast drawdowns can still happen while the regime is bullish, and weekly rebalancing plus fees can meaningfully impact results.