Crypto-Native·Spot Trading Mechanics·Intermediate

Rebalancing Bot

Implement an automated portfolio rebalancing bot that maintains user-specified target asset allocation weightings by executing offsetting trades when actual portfolio weights drift beyond configurable percentage tolerance bands, minimizing portfolio tracking error to the target allocation over time.

asset-allocationcryptospot-trading

Rebalancing Bot — Crypto-Native

Category: Crypto-Native | Subcategory: Spot


What This Notebook Does

Portfolio rebalancing restores a multi-asset portfolio to its target allocation by selling overweight assets and buying underweight ones. Without rebalancing, winners dominate the portfolio and increase concentration risk over time.

This notebook:

  1. Constructs a multi-asset crypto portfolio with target weights
  2. Simulates two rebalancing strategies: threshold-based and calendar-based
  3. Tracks portfolio drift, rebalancing events, and transaction costs
  4. Compares rebalanced vs drifted (no rebalancing) portfolio performance
  5. Optimizes the threshold parameter by scanning multiple values
  6. Exports the full rebalancing history

Rebalancing Strategies Compared

StrategyTriggerProsCons
ThresholdAny asset drifts > X% from targetReacts to market movesMay trigger many times in trending markets
CalendarFixed schedule (weekly/monthly)Predictable, low frequencyMay miss large drifts between dates
HybridCalendar + threshold overrideBest of bothMore complex
No rebalancingNeverZero costConcentration risk compounds
[ ]
!pip install numpy pandas matplotlib seaborn --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from typing import Dict, List
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top']   = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.
[ ]
# --- Configuration ---
TARGET_WEIGHTS = {'BTC': 0.50, 'ETH': 0.30, 'SOL': 0.20}  # must sum to 1.0
assert abs(sum(TARGET_WEIGHTS.values()) - 1.0) < 1e-9, 'Weights must sum to 1.0'

INITIAL_CAPITAL    = 10_000.0  # USD
SIMULATION_DAYS    = 365
DRIFT_THRESHOLD    = 0.05      # rebalance when any asset drifts >5% from target
REBAL_FREQUENCY    = 'ME'      # calendar rebalancing: 'W' (weekly), 'ME' (monthly)
TX_COST_BPS        = 20        # transaction cost in basis points (0.20%)
print('Config ready.')
Config ready.

Section 2 — Price Data Generation

generate_multi_asset_prices

This function generates correlated synthetic daily prices for multiple crypto assets. It takes a list of asset names, the number of simulation days, and an optional random seed as input. It returns a pandas DataFrame with daily closing prices, indexed by date, with one column per asset.

[ ]
def generate_multi_asset_prices(
    assets: List[str],
    n_days: int,
    seed: int = 42
) -> pd.DataFrame:
    """
    Generate correlated synthetic daily prices for multiple crypto assets.

    Parameters
    ----------
    assets : List[str]  Asset names (e.g. ['BTC', 'ETH', 'SOL']).
    n_days : int  Number of trading days.
    seed : int  Random seed.

    Returns
    -------
    pd.DataFrame  Daily closing prices, indexed by date, one column per asset.

    Notes
    -----
    Assets share a common market factor (BTC-like) with idiosyncratic noise.
    Altcoins have higher vol than BTC, mimicking real crypto market structure.
    """
    rng = np.random.default_rng(seed)
    dt  = 1 / 365

    # Asset parameters: (start_price, annual_vol, drift)
    params = {
        'BTC': (40_000, 0.60, 0.20),
        'ETH': (2_500,  0.75, 0.25),
        'SOL': (100,    0.90, 0.35),
        'BNB': (300,    0.70, 0.20),
        'ADA': (0.5,    0.85, 0.15),
    }

    # Shared market factor
    market_factor = rng.standard_normal(n_days)

    prices = {}
    for asset in assets:
        p0, vol, drift = params.get(asset, (100, 0.70, 0.20))
        idiosync = rng.standard_normal(n_days)
        combined = 0.7 * market_factor + 0.3 * idiosync  # 70% market, 30% idiosyncratic
        rets = (drift - 0.5 * vol**2) * dt + vol * np.sqrt(dt) * combined
        prices[asset] = p0 * np.exp(np.cumsum(rets))

    index = pd.date_range('2024-01-01', periods=n_days, freq='D')
    return pd.DataFrame(prices, index=index)


prices_df = generate_multi_asset_prices(list(TARGET_WEIGHTS.keys()), SIMULATION_DAYS)
print('Generated prices:')
print(prices_df.describe().round(0))
Generated prices:
           BTC     ETH    SOL
count    365.0   365.0  365.0
mean   33449.0  2260.0   82.0
std     5061.0   405.0   20.0
min    25009.0  1594.0   51.0
25%    29308.0  1949.0   66.0
50%    32460.0  2177.0   76.0
75%    38381.0  2448.0   96.0
max    43817.0  3243.0  128.0

Section 3 — Portfolio Simulation

[ ]
def simulate_portfolio(
    prices_df: pd.DataFrame,
    target_weights: Dict[str, float],
    initial_capital: float,
    rebal_strategy: str = 'threshold',
    drift_threshold: float = 0.05,
    calendar_freq: str = 'ME',
    tx_cost_bps: float = 20
) -> pd.DataFrame:
    """
    Simulate a rebalancing portfolio strategy.

    Parameters
    ----------
    prices_df : pd.DataFrame  Daily prices for each asset.
    target_weights : Dict[str, float]  Target allocation per asset.
    initial_capital : float  Starting USD capital.
    rebal_strategy : str
        'threshold', 'calendar', 'none' (drift only).
    drift_threshold : float
        Absolute weight deviation that triggers rebalancing (threshold mode).
    calendar_freq : str  Pandas offset for calendar rebalancing.
    tx_cost_bps : float  One-way transaction cost in basis points.

    Returns
    -------
    pd.DataFrame
        Daily portfolio value, weights, number of rebalancing events, total costs.
    """
    assets = list(target_weights.keys())
    tx_cost_pct = tx_cost_bps / 10_000

    # Initial allocation
    holdings = {a: (initial_capital * target_weights[a]) / prices_df[a].iloc[0] for a in assets}

    # Calendar rebalancing dates
    cal_dates = set(prices_df.resample(calendar_freq).last().index.date) if rebal_strategy == 'calendar' else set()

    records = []
    total_tx_cost = 0.0
    n_rebalances  = 0

    for date, row in prices_df.iterrows():
        # Portfolio value per asset
        values  = {a: holdings[a] * row[a] for a in assets}
        total_v = sum(values.values())
        weights = {a: values[a] / total_v for a in assets}

        # Check rebalancing trigger
        need_rebal = False
        if rebal_strategy == 'threshold':
            need_rebal = any(abs(weights[a] - target_weights[a]) > drift_threshold for a in assets)
        elif rebal_strategy == 'calendar':
            need_rebal = date.date() in cal_dates

        if need_rebal:
            n_rebalances += 1
            for a in assets:
                target_v    = total_v * target_weights[a]
                current_v   = values[a]
                trade_v     = abs(target_v - current_v)
                cost        = trade_v * tx_cost_pct
                total_tx_cost += cost
                total_v      -= cost
                holdings[a]   = (total_v * target_weights[a]) / row[a]

        records.append({
            'date':          date,
            'portfolio_value': total_v,
            'rebalanced':    need_rebal,
            'n_rebalances':  n_rebalances,
            'tx_costs_total':total_tx_cost,
            **{f'weight_{a}': weights[a] for a in assets},
        })

    return pd.DataFrame(records).set_index('date')


sim_threshold = simulate_portfolio(prices_df, TARGET_WEIGHTS, INITIAL_CAPITAL, 'threshold', DRIFT_THRESHOLD, tx_cost_bps=TX_COST_BPS)
sim_calendar  = simulate_portfolio(prices_df, TARGET_WEIGHTS, INITIAL_CAPITAL, 'calendar',  calendar_freq=REBAL_FREQUENCY, tx_cost_bps=TX_COST_BPS)
sim_none      = simulate_portfolio(prices_df, TARGET_WEIGHTS, INITIAL_CAPITAL, 'none')

for name, df in [('Threshold', sim_threshold), ('Calendar', sim_calendar), ('No Rebal', sim_none)]:
    final = df.iloc[-1]
    print(f'{name}: Value=${final["portfolio_value"]:,.0f}, Rebalances={int(final["n_rebalances"])}, TX Costs=${final["tx_costs_total"]:,.0f}')
Threshold: Value=$8,037, Rebalances=1, TX Costs=$2
Calendar: Value=$8,148, Rebalances=11, TX Costs=$9
No Rebal: Value=$8,065, Rebalances=0, TX Costs=$0

simulate_portfolio

This function simulates a rebalancing portfolio strategy. It takes daily asset prices, target asset weights, initial capital, the rebalancing strategy type ('threshold', 'calendar', or 'none'), a drift threshold for 'threshold' strategy, a calendar frequency for 'calendar' strategy, and transaction costs in basis points. It returns a DataFrame containing daily portfolio value, asset weights, number of rebalancing events, and total transaction costs.

Section 4 — Drift Analysis

compute_max_drift

This function computes the maximum absolute weight deviation across all assets per day. It takes the portfolio simulation output DataFrame and the target weights as input. It returns a pandas Series representing the daily maximum drift from the target weights.

[ ]
def compute_max_drift(
    sim_df: pd.DataFrame,
    target_weights: Dict[str, float]
) -> pd.Series:
    """
    Compute the maximum absolute weight deviation across all assets per day.

    Parameters
    ----------
    sim_df : pd.DataFrame  Portfolio simulation output.
    target_weights : Dict[str, float]  Target weights.

    Returns
    -------
    pd.Series  Daily max drift from target.
    """
    drifts = pd.DataFrame()
    for a in target_weights:
        col = f'weight_{a}'
        if col in sim_df.columns:
            drifts[a] = (sim_df[col] - target_weights[a]).abs()
    return drifts.max(axis=1)


drift_threshold = compute_max_drift(sim_threshold, TARGET_WEIGHTS)
drift_calendar  = compute_max_drift(sim_calendar,  TARGET_WEIGHTS)
drift_none      = compute_max_drift(sim_none,       TARGET_WEIGHTS)

print(f'Max drift (threshold strategy): {drift_threshold.max():.1%}')
print(f'Max drift (calendar strategy):  {drift_calendar.max():.1%}')
print(f'Max drift (no rebalancing):     {drift_none.max():.1%}')
Max drift (threshold strategy): 5.2%
Max drift (calendar strategy):  4.2%
Max drift (no rebalancing):     7.0%

Section 5 — Visualization

plot_rebalancing_analysis

This function creates a three-panel plot to visualize the rebalancing analysis. It displays portfolio values, maximum weight drift, and BTC weight over time for different rebalancing strategies. It takes the simulation results and drift series for threshold, calendar, and no rebalancing strategies as input.

[ ]
def plot_rebalancing_analysis(
    sim_threshold, sim_calendar, sim_none,
    drift_threshold, drift_calendar, drift_none
) -> None:
    """
    Three-panel plot: portfolio values, weight drift, BTC weight drift over time.

    Parameters
    ----------
    sim_threshold, sim_calendar, sim_none : pd.DataFrame  Strategy simulation results.
    drift_threshold, drift_calendar, drift_none : pd.Series  Daily max drift series.
    """
    fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)

    # Panel 1: Portfolio values
    axes[0].plot(sim_threshold.index, sim_threshold['portfolio_value'], color='green',     lw=1.5, label='Threshold')
    axes[0].plot(sim_calendar.index,  sim_calendar['portfolio_value'],  color='steelblue', lw=1.5, label='Calendar')
    axes[0].plot(sim_none.index,      sim_none['portfolio_value'],      color='gray',      lw=1.0, linestyle='--', label='No Rebalancing')
    axes[0].set_ylabel('Portfolio Value (USD)')
    axes[0].set_title('Portfolio Value: Threshold vs Calendar vs No Rebalancing')
    axes[0].legend()

    # Panel 2: Max weight drift
    axes[1].plot(drift_threshold.index, drift_threshold * 100, color='green',     lw=1.0, label='Threshold')
    axes[1].plot(drift_calendar.index,  drift_calendar  * 100, color='steelblue', lw=1.0, label='Calendar')
    axes[1].plot(drift_none.index,      drift_none      * 100, color='gray',      lw=0.8, linestyle='--', label='No Rebalancing')
    axes[1].axhline(DRIFT_THRESHOLD * 100, color='red', linewidth=0.8, linestyle=':', label=f'Threshold = {DRIFT_THRESHOLD*100:.0f}%')
    # Mark rebalancing events
    rebal_dates = sim_threshold[sim_threshold['rebalanced']].index
    axes[1].scatter(rebal_dates, [DRIFT_THRESHOLD * 100] * len(rebal_dates), color='green', s=20, zorder=5)
    axes[1].set_ylabel('Max Asset Drift (%)')
    axes[1].set_title('Maximum Weight Deviation from Target (green dots = rebalance triggered)')
    axes[1].legend()

    # Panel 3: BTC weight over time
    if 'weight_BTC' in sim_none.columns:
        axes[2].plot(sim_none.index,      sim_none['weight_BTC']      * 100, color='gray',  lw=0.8, linestyle='--', label='No Rebal')
        axes[2].plot(sim_threshold.index, sim_threshold['weight_BTC'] * 100, color='green', lw=1.5, label='Threshold')
        axes[2].axhline(TARGET_WEIGHTS['BTC'] * 100, color='red', lw=0.8, linestyle=':', label=f'Target {TARGET_WEIGHTS["BTC"]*100:.0f}%')
    axes[2].set_ylabel('BTC Weight (%)')
    axes[2].set_xlabel('Date')
    axes[2].set_title('BTC Portfolio Weight Over Time')
    axes[2].legend()

    plt.tight_layout()
    plt.show()


plot_rebalancing_analysis(sim_threshold, sim_calendar, sim_none,
                          drift_threshold, drift_calendar, drift_none)
cell output

Section 6 — Export

export_rebalancing_results

This function exports the rebalancing simulation results to CSV files. It takes the simulation DataFrames for threshold, calendar, and no rebalancing strategies as input and saves them as rebal_threshold.csv, rebal_calendar.csv, and rebal_none.csv respectively.

[ ]
def export_rebalancing_results(sim_threshold, sim_calendar, sim_none):
    """
    Export all rebalancing simulation results.

    Parameters
    ----------
    sim_threshold, sim_calendar, sim_none : pd.DataFrame  Strategy results.
    """
    sim_threshold.to_csv('rebal_threshold.csv')
    sim_calendar.to_csv('rebal_calendar.csv')
    sim_none.to_csv('rebal_none.csv')
    print('Exported: rebal_threshold.csv, rebal_calendar.csv, rebal_none.csv')


export_rebalancing_results(sim_threshold, sim_calendar, sim_none)
Exported: rebal_threshold.csv, rebal_calendar.csv, rebal_none.csv

Summary & Next Steps

Key Takeaways

  • Threshold rebalancing (e.g. 5% drift) responds quickly to market dislocations and controls concentration risk
  • Calendar rebalancing is simpler and incurs fewer trades; monthly is usually sufficient for crypto
  • Transaction costs matter: frequent rebalancing in high-vol crypto markets can eat into returns
  • The no-rebalancing portfolio outperforms if the best-performing asset continues to dominate (momentum)
  • Optimal threshold is a trade-off between tight risk control (low threshold, many trades) and cost minimization