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.
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.
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.
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.
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.valuesOn 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.
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.
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).
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.
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_labelscmap_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()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.
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
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.