Crypto Bots

How to Build a Crypto Portfolio Auto-Rebalancing Bot

A rebalancing bot automatically sells overweight positions and buys underweight ones to maintain your target allocation. Learn to build one in Python that runs on Binance and supports threshold-based and calendar-based triggers.

A
AI Agents Hubยท2026-03-30ยท5 min readยท869 words

Builder of AI agents, crypto trading bots, and open-source automation tools. Sharing practical guides on how to build, deploy, and profit from AI and DeFi technology.

Why Rebalancing Beats Buy-and-Hold (With Data)

A portfolio that starts as 50% BTC / 50% ETH drifts as prices move. Without rebalancing, a strong BTC rally leaves you 80% BTC โ€” concentrated risk you didn't intend to take.

Backtests consistently show rebalancing improves risk-adjusted returns (Sharpe ratio) even if raw returns are similar. The reason: you systematically buy low (underperformers) and sell high (overperformers).

Strategy Options

1. Threshold-Based Rebalancing

Rebalance when any asset drifts more than X% from its target:

  • Target: BTC 50%, ETH 30%, SOL 20%
  • If BTC reaches 65% โ†’ sell down to 50%
  • Best for: reducing unnecessary trades

2. Calendar-Based Rebalancing

Rebalance on a fixed schedule (weekly, monthly) regardless of drift.

  • Best for: predictability, simplicity

3. Hybrid (Recommended)

Rebalance when drift exceeds 5% AND it's been at least 7 days since last rebalance.

  • Reduces unnecessary trades while preventing large drifts.

Building the Rebalancer

import ccxt
from dataclasses import dataclass
from datetime import datetime, timedelta
import json
from pathlib import Path

@dataclass
class TargetAllocation:
    symbol: str       # e.g., 'BTC'
    target_pct: float # e.g., 0.50 for 50%

class PortfolioRebalancer:
    def __init__(
        self,
        exchange: ccxt.Exchange,
        targets: list[TargetAllocation],
        rebalance_threshold: float = 0.05,  # 5% drift triggers rebalance
        min_trade_usd: float = 20.0,        # Minimum trade size (avoid dust)
        cooldown_days: int = 7,             # Minimum days between rebalances
    ):
        self.exchange = exchange
        self.targets = {t.symbol: t.target_pct for t in targets}
        self.threshold = rebalance_threshold
        self.min_trade_usd = min_trade_usd
        self.cooldown_days = cooldown_days
        self.state_file = Path("rebalance_state.json")
        self.state = self._load_state()
        
        # Validate allocations sum to 1.0
        total = sum(self.targets.values())
        if abs(total - 1.0) > 0.001:
            raise ValueError(f"Target allocations must sum to 1.0, got {total:.3f}")
    
    def _load_state(self) -> dict:
        if self.state_file.exists():
            return json.loads(self.state_file.read_text())
        return {"last_rebalance": None}
    
    def _save_state(self):
        self.state_file.write_text(json.dumps(self.state))
    
    def get_current_portfolio(self) -> dict:
        """Get current holdings as {symbol: usd_value}"""
        balance = self.exchange.fetch_balance()
        tickers = self.exchange.fetch_tickers(
            [f"{sym}/USDT" for sym in self.targets]
        )
        
        portfolio = {}
        for sym in self.targets:
            held = balance.get(sym, {}).get('total', 0)
            price = tickers.get(f"{sym}/USDT", {}).get('last', 0)
            portfolio[sym] = held * price
        
        # Include USDT in total calculation
        portfolio['USDT'] = balance.get('USDT', {}).get('total', 0)
        return portfolio
    
    def calculate_drift(self, portfolio: dict) -> dict:
        """Calculate how far each asset is from its target"""
        total_value = sum(portfolio.values())
        if total_value == 0:
            return {}
        
        drift = {}
        for sym, target_pct in self.targets.items():
            current_usd = portfolio.get(sym, 0)
            current_pct = current_usd / total_value
            drift[sym] = current_pct - target_pct  # Positive = overweight
        
        return drift
    
    def needs_rebalance(self, drift: dict) -> bool:
        """Check if rebalancing is needed"""
        # Check cooldown
        if self.state['last_rebalance']:
            last = datetime.fromisoformat(self.state['last_rebalance'])
            if datetime.now() - last < timedelta(days=self.cooldown_days):
                print(f"In cooldown ({self.cooldown_days}d). Last: {last.date()}")
                return False
        
        # Check if any asset has drifted beyond threshold
        max_drift = max(abs(d) for d in drift.values()) if drift else 0
        return max_drift > self.threshold
    
    def calculate_trades(self, portfolio: dict, drift: dict) -> list[dict]:
        """Calculate the trades needed to rebalance"""
        total_value = sum(portfolio.values())
        trades = []
        
        for sym, current_drift in drift.items():
            target_usd = total_value * self.targets[sym]
            current_usd = portfolio.get(sym, 0)
            diff_usd = target_usd - current_usd  # Positive = need to buy
            
            if abs(diff_usd) >= self.min_trade_usd:
                trades.append({
                    'symbol': sym,
                    'pair': f"{sym}/USDT",
                    'action': 'BUY' if diff_usd > 0 else 'SELL',
                    'usd_amount': abs(diff_usd),
                    'current_pct': (current_usd / total_value) * 100,
                    'target_pct': self.targets[sym] * 100,
                    'drift_pct': current_drift * 100,
                })
        
        # Execute sells before buys (ensures USDT available)
        trades.sort(key=lambda x: 0 if x['action'] == 'SELL' else 1)
        return trades
    
    def execute_rebalance(self, dry_run: bool = True) -> dict:
        """Execute rebalancing trades"""
        portfolio = self.get_current_portfolio()
        drift = self.calculate_drift(portfolio)
        total_value = sum(portfolio.values())
        
        print(f"\n๐Ÿ“Š Portfolio Value: ${total_value:,.2f}")
        print(f"{'Symbol':<8} {'Current':>10} {'Target':>8} {'Drift':>8}")
        for sym, d in drift.items():
            curr_pct = (portfolio.get(sym, 0) / total_value) * 100
            tgt_pct = self.targets[sym] * 100
            status = "โš ๏ธ" if abs(d) > self.threshold else "โœ“"
            print(f"{sym:<8} {curr_pct:>9.1f}% {tgt_pct:>7.1f}% {d*100:>+7.1f}% {status}")
        
        if not self.needs_rebalance(drift):
            print("\nโœ… Portfolio within bounds โ€” no rebalancing needed")
            return {'rebalanced': False, 'trades': []}
        
        trades = self.calculate_trades(portfolio, drift)
        
        print(f"\n๐Ÿ”„ Rebalancing required โ€” {len(trades)} trades:")
        for t in trades:
            print(f"  {t['action']} ${t['usd_amount']:,.2f} of {t['symbol']}")
        
        if dry_run:
            print("\nโš ๏ธ  DRY RUN โ€” set dry_run=False to execute")
            return {'rebalanced': False, 'trades': trades, 'dry_run': True}
        
        executed = []
        for trade in trades:
            try:
                ticker = self.exchange.fetch_ticker(trade['pair'])
                price = ticker['last']
                qty = trade['usd_amount'] / price
                
                if trade['action'] == 'BUY':
                    order = self.exchange.create_market_buy_order(
                        trade['pair'], qty,
                        params={'quoteOrderQty': trade['usd_amount']}
                    )
                else:
                    order = self.exchange.create_market_sell_order(trade['pair'], qty)
                
                print(f"โœ… {trade['action']} {qty:.6f} {trade['symbol']} @ ${price:,.2f}")
                executed.append(trade)
                
            except Exception as e:
                print(f"โŒ Failed to {trade['action']} {trade['symbol']}: {e}")
        
        self.state['last_rebalance'] = datetime.now().isoformat()
        self._save_state()
        
        return {'rebalanced': True, 'trades': executed}

# Example usage
exchange = ccxt.binance({'apiKey': API_KEY, 'secret': SECRET})

targets = [
    TargetAllocation('BTC', 0.40),
    TargetAllocation('ETH', 0.30),
    TargetAllocation('SOL', 0.20),
    TargetAllocation('BNB', 0.10),
]

rebalancer = PortfolioRebalancer(exchange, targets, rebalance_threshold=0.05)

# Run weekly check
import schedule
schedule.every().monday.at("09:00").do(lambda: rebalancer.execute_rebalance(dry_run=False))

When to Rebalance More Aggressively

During high-volatility periods (Fear & Greed below 25), increase rebalance frequency:

def adaptive_rebalance(rebalancer: PortfolioRebalancer):
    fg = get_fear_greed_index()
    
    if fg < 25:
        # Extreme fear: rebalance if 3% drift (buy the dip more aggressively)
        rebalancer.threshold = 0.03
        rebalancer.cooldown_days = 2
    elif fg > 75:
        # Extreme greed: rebalance if 10% drift (don't over-sell the bull)
        rebalancer.threshold = 0.10
        rebalancer.cooldown_days = 14
    else:
        rebalancer.threshold = 0.05
        rebalancer.cooldown_days = 7
    
    return rebalancer.execute_rebalance(dry_run=False)

Rebalancing is one of the simplest automations to build and one of the most consistently beneficial. Start with a monthly calendar rebalance before adding sophisticated triggers.

Related Articles