DEV Community

Moon Soon
Moon Soon

Posted on • Originally published at swapapi.dev

How to Build a Crypto Portfolio Rebalancer With Python

The crypto asset management market is projected to grow from $1.96 billion in 2026 to $11.74 billion by 2034 at a 25% CAGR (Fortune Business Insights). Automated portfolio rebalancing is one of the core drivers: 78% of institutional investors now use automated rebalancing tools (Gartner via WunderTrading), and automated portfolios showed 23% better returns compared to manual management in 2025 (DarkBot).

Yet most tutorials stop at the math. This guide builds a complete crypto portfolio rebalancer in Python that calculates allocation drift, determines required trades, and executes swaps on-chain across multiple EVM chains using swapapi.dev.


What You'll Need

  • Python 3.10+ with requests and web3 installed
  • A wallet with tokens on one or more EVM chains
  • An RPC endpoint (the API returns recommended RPCs, or use Chainlist)
  • swapapi.dev -- free, no API key, no registration, 46 chains supported

Install dependencies:

pip install requests web3
Enter fullscreen mode Exit fullscreen mode

Step 1: Define Your Target Allocation

Every rebalancer starts with a target portfolio. Define each asset by chain, token address, decimal precision, and target weight.

Research from the MDPI Journal of Risk and Financial Management found that monthly rebalancing delivered 28.5% higher returns over a 52-week period compared to a fixed allocation strategy (MDPI). The optimal frequency depends on portfolio size and gas costs, but threshold-based rebalancing (trigger on drift) consistently outperforms calendar-based approaches.

PORTFOLIO = {
    "ETH": {
        "chain_id": 1,
        "address": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
        "decimals": 18,
        "target_pct": 40.0,
    },
    "USDC_ETH": {
        "chain_id": 1,
        "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
        "decimals": 6,
        "target_pct": 20.0,
    },
    "WETH_ARB": {
        "chain_id": 42161,
        "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
        "decimals": 18,
        "target_pct": 25.0,
    },
    "USDC_POLY": {
        "chain_id": 137,
        "address": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
        "decimals": 6,
        "target_pct": 15.0,
    },
}

DRIFT_THRESHOLD = 5.0
SENDER = "0xYourWalletAddress"
Enter fullscreen mode Exit fullscreen mode

The DRIFT_THRESHOLD of 5% means the rebalancer only acts when an asset drifts more than 5 percentage points from its target. This avoids unnecessary gas spend on minor fluctuations.


Step 2: Fetch Current Balances and Prices

To calculate drift, you need two things per asset: the on-chain balance and its current USD value. Use web3.py for balances and swapapi.dev to get price quotes against a common stablecoin.

import requests
from web3 import Web3

SWAP_API = "https://api.swapapi.dev/v1/swap"
NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"

STABLECOIN_BY_CHAIN = {
    1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    42161: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
    137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
}

STABLE_DECIMALS = 6


def get_balance(chain_id, token_address, rpc_url):
    w3 = Web3(Web3.HTTPProvider(rpc_url))
    if token_address == NATIVE_TOKEN:
        raw = w3.eth.get_balance(SENDER)
    else:
        erc20 = w3.eth.contract(
            address=Web3.to_checksum_address(token_address),
            abi=[{
                "inputs": [{"name": "account", "type": "address"}],
                "name": "balanceOf",
                "outputs": [{"name": "", "type": "uint256"}],
                "stateMutability": "view",
                "type": "function",
            }],
        )
        raw = erc20.functions.balanceOf(
            Web3.to_checksum_address(SENDER)
        ).call()
    return raw


def get_usd_price(chain_id, token_address, decimals):
    """Get USD price by quoting 1 unit against USDC."""
    stable = STABLECOIN_BY_CHAIN[chain_id]
    if token_address == stable:
        return 1.0
    one_unit = 10 ** decimals
    url = (
        f"{SWAP_API}/{chain_id}"
        f"?tokenIn={token_address}"
        f"&tokenOut={stable}"
        f"&amount={one_unit}"
        f"&sender={SENDER}"
    )
    resp = requests.get(url, timeout=15)
    data = resp.json()
    if not data["success"] or data["data"]["status"] != "Successful":
        raise ValueError(f"No route for {token_address} on chain {chain_id}")
    out = int(data["data"]["expectedAmountOut"])
    return out / (10 ** STABLE_DECIMALS)
Enter fullscreen mode Exit fullscreen mode

The API response includes expectedAmountOut as a string-encoded BigInt. Dividing by 10^decimals of the output token gives you the human-readable USD price. The API returns quotes in 1-5 seconds; set a 15-second HTTP timeout as recommended in the docs.


Step 3: Calculate Portfolio Drift

With balances and prices in hand, compute each asset's current percentage and its drift from target. A 2026 case study showed that a portfolio ignoring rebalancing during a Bitcoin correction crashed 47% instead of the projected 28%, while rebalanced portfolios preserved 73% of capital during the same drawdown (Investopedia.su).

def calculate_drift(portfolio):
    positions = []
    total_usd = 0.0

    for name, asset in portfolio.items():
        rpc_resp = requests.get(
            f"{SWAP_API}/{asset['chain_id']}"
            f"?tokenIn={NATIVE_TOKEN}"
            f"&tokenOut={STABLECOIN_BY_CHAIN[asset['chain_id']]}"
            f"&amount=1000000000000000000"
            f"&sender={SENDER}",
            timeout=15,
        )
        rpc_url = rpc_resp.json()["data"].get(
            "rpcUrl", "https://cloudflare-eth.com"
        )

        balance_raw = get_balance(
            asset["chain_id"], asset["address"], rpc_url
        )
        price = get_usd_price(
            asset["chain_id"], asset["address"], asset["decimals"]
        )
        balance_human = balance_raw / (10 ** asset["decimals"])
        usd_value = balance_human * price

        positions.append({
            "name": name,
            "balance_raw": balance_raw,
            "balance_human": balance_human,
            "price": price,
            "usd_value": usd_value,
            "target_pct": asset["target_pct"],
            "chain_id": asset["chain_id"],
            "address": asset["address"],
            "decimals": asset["decimals"],
        })
        total_usd += usd_value

    for pos in positions:
        pos["current_pct"] = (
            (pos["usd_value"] / total_usd * 100) if total_usd > 0 else 0
        )
        pos["drift"] = pos["current_pct"] - pos["target_pct"]

    return positions, total_usd
Enter fullscreen mode Exit fullscreen mode

The function returns a list of positions with their drift values. Positive drift means overweight (sell), negative drift means underweight (buy).


Step 4: Generate Rebalance Trades

Now determine which trades to execute. The strategy: sell overweight assets into USDC on the same chain, then buy underweight assets with USDC on their respective chains.

Strategy Frequency Best For Gas Cost
Calendar-based Weekly/Monthly Passive investors Predictable
Threshold-based On drift > X% Active portfolios Lower overall
Band-based Range around target Volatile assets Moderate
Hybrid (threshold + calendar) Drift check + monthly floor Multi-chain portfolios Optimized

The threshold-based approach used here eliminates unnecessary trades. Research shows that bi-weekly rebalancing outperformed monthly by 3.55% for mid-sized portfolios, while weekly rebalancing added transaction cost drag at smaller portfolio sizes (MDPI).

def generate_trades(positions, total_usd):
    trades = []

    for pos in positions:
        if abs(pos["drift"]) < DRIFT_THRESHOLD:
            continue

        target_usd = total_usd * pos["target_pct"] / 100
        delta_usd = target_usd - pos["usd_value"]

        if delta_usd < 0:
            sell_usd = abs(delta_usd)
            sell_amount = int(
                (sell_usd / pos["price"]) * (10 ** pos["decimals"])
            )
            trades.append({
                "action": "sell",
                "asset": pos["name"],
                "chain_id": pos["chain_id"],
                "token_in": pos["address"],
                "token_out": STABLECOIN_BY_CHAIN[pos["chain_id"]],
                "amount_raw": str(sell_amount),
                "usd_value": sell_usd,
            })
        else:
            buy_amount = int(delta_usd * (10 ** STABLE_DECIMALS))
            trades.append({
                "action": "buy",
                "asset": pos["name"],
                "chain_id": pos["chain_id"],
                "token_in": STABLECOIN_BY_CHAIN[pos["chain_id"]],
                "token_out": pos["address"],
                "amount_raw": str(buy_amount),
                "usd_value": delta_usd,
            })

    return trades
Enter fullscreen mode Exit fullscreen mode

Sells execute first to generate USDC liquidity. Buys consume that liquidity. On a multi-chain portfolio, each chain's USDC balance is independent -- cross-chain bridging is outside the scope of this rebalancer (swapapi.dev is single-chain only, as noted in its docs).


Step 5: Execute Swaps via swapapi.dev

For each trade, fetch a quote and submit the transaction. The API returns ready-to-execute calldata including the router address, encoded data, and gas price.

A quick test with curl before wiring up execution:

curl "https://api.swapapi.dev/v1/swap/1?tokenIn=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE&tokenOut=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&amount=1000000000000000000&sender=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
Enter fullscreen mode Exit fullscreen mode

The execution function with full safety checks:

def execute_trade(trade, private_key):
    url = (
        f"{SWAP_API}/{trade['chain_id']}"
        f"?tokenIn={trade['token_in']}"
        f"&tokenOut={trade['token_out']}"
        f"&amount={trade['amount_raw']}"
        f"&sender={SENDER}"
        f"&maxSlippage=0.005"
    )
    resp = requests.get(url, timeout=15)
    data = resp.json()

    if not data["success"]:
        print(f"API error: {data['error']['message']}")
        return None

    status = data["data"]["status"]

    if status == "NoRoute":
        print(f"No route for {trade['asset']} on chain {trade['chain_id']}")
        return None

    if status == "Partial":
        filled_in = int(data["data"]["amountIn"])
        requested = int(trade["amount_raw"])
        fill_pct = filled_in / requested * 100
        print(f"Partial fill: {fill_pct:.1f}% for {trade['asset']}")

    impact = data["data"].get("priceImpact", 0)
    if impact < -0.05:
        print(f"Price impact too high ({impact:.2%}), skipping")
        return None

    tx_data = data["data"]["tx"]
    rpc_url = data["data"].get("rpcUrl", "https://cloudflare-eth.com")
    w3 = Web3(Web3.HTTPProvider(rpc_url))

    tx = {
        "from": SENDER,
        "to": Web3.to_checksum_address(tx_data["to"]),
        "data": tx_data["data"],
        "value": int(tx_data["value"]),
    }

    gas_estimate = w3.eth.estimate_gas(tx)
    tx["gas"] = int(gas_estimate * 1.2)
    tx["nonce"] = w3.eth.get_transaction_count(SENDER)

    signed = w3.eth.account.sign_transaction(tx, private_key)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)

    print(f"Submitted: {tx_hash.hex()}")
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
    return receipt
Enter fullscreen mode Exit fullscreen mode

Key safety measures from the API docs:

  • Check priceImpact before executing. Reject swaps with impact worse than -5%.
  • Estimate gas with a 20% buffer. If estimate_gas fails, the swap would revert on-chain.
  • Submit within 30 seconds of fetching the quote. Calldata includes a deadline.
  • Handle Partial status by comparing response amountIn to your requested amount.

Step 6: Wire It All Together

The main rebalance loop: calculate drift, generate trades, execute sells first, then buys.

def rebalance(portfolio, private_key):
    print("Calculating portfolio drift...")
    positions, total_usd = calculate_drift(portfolio)

    print(f"\nTotal portfolio value: ${total_usd:,.2f}")
    print(f"{'Asset':<12} {'Current':>8} {'Target':>8} {'Drift':>8}")
    print("-" * 40)
    for pos in positions:
        print(
            f"{pos['name']:<12} "
            f"{pos['current_pct']:>7.1f}% "
            f"{pos['target_pct']:>7.1f}% "
            f"{pos['drift']:>+7.1f}%"
        )

    trades = generate_trades(positions, total_usd)

    if not trades:
        print("\nNo rebalancing needed. All assets within threshold.")
        return

    sells = [t for t in trades if t["action"] == "sell"]
    buys = [t for t in trades if t["action"] == "buy"]

    print(f"\nExecuting {len(sells)} sells, then {len(buys)} buys...")

    for trade in sells + buys:
        print(f"\n{trade['action'].upper()} {trade['asset']}: ~${trade['usd_value']:,.2f}")
        execute_trade(trade, private_key)


rebalance(PORTFOLIO, "0xYourPrivateKey")
Enter fullscreen mode Exit fullscreen mode

Step 7: Add ERC-20 Approval Handling

When selling ERC-20 tokens (not native ETH/POL/BNB), you must approve the router contract to spend your tokens before the swap. The API response tells you the router address in tx.to.

ERC20_APPROVE_ABI = [{
    "inputs": [
        {"name": "spender", "type": "address"},
        {"name": "amount", "type": "uint256"},
    ],
    "name": "approve",
    "outputs": [{"name": "", "type": "bool"}],
    "stateMutability": "nonpayable",
    "type": "function",
}]

MAX_UINT256 = 2**256 - 1


def ensure_approval(chain_id, token_address, spender, rpc_url, private_key):
    if token_address == NATIVE_TOKEN:
        return

    w3 = Web3(Web3.HTTPProvider(rpc_url))
    token = w3.eth.contract(
        address=Web3.to_checksum_address(token_address),
        abi=ERC20_APPROVE_ABI + [{
            "inputs": [
                {"name": "owner", "type": "address"},
                {"name": "spender", "type": "address"},
            ],
            "name": "allowance",
            "outputs": [{"name": "", "type": "uint256"}],
            "stateMutability": "view",
            "type": "function",
        }],
    )

    current = token.functions.allowance(
        Web3.to_checksum_address(SENDER),
        Web3.to_checksum_address(spender),
    ).call()

    if current >= MAX_UINT256 // 2:
        return

    tx = token.functions.approve(
        Web3.to_checksum_address(spender), MAX_UINT256
    ).build_transaction({
        "from": SENDER,
        "nonce": w3.eth.get_transaction_count(SENDER),
    })

    signed = w3.eth.account.sign_transaction(tx, private_key)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
    print(f"Approved {token_address} for router {spender}")
Enter fullscreen mode Exit fullscreen mode

After approval confirms, re-fetch a fresh quote before submitting the swap. The calldata is time-sensitive (30-second deadline), so never reuse a stale quote.


Frequently Asked Questions

How often should a crypto portfolio rebalancer execute?

Research shows that threshold-based rebalancing (trigger on 5%+ drift) outperforms fixed schedules for most portfolio sizes. Monthly rebalancing delivered 28.5% higher returns than no rebalancing, while bi-weekly outperformed monthly by 3.55% for portfolios under $50,000. For larger portfolios, weekly rebalancing becomes optimal as the trade value outweighs gas costs.

What chains does swapapi.dev support for rebalancing?

The API supports 46 EVM chains including Ethereum, Arbitrum, Base, Polygon, BSC, Optimism, Avalanche, and more. Each chain operates independently -- the API is single-chain only, so cross-chain rebalancing requires separate swaps on each chain plus a bridge step outside the API.

How do I handle partial fills during rebalancing?

The API returns a Partial status when only part of your requested amount can be filled due to liquidity constraints. The response amountIn reflects the partial amount, not your original request. Your rebalancer should compare the two values, decide whether the partial fill is acceptable, and avoid retrying the same amount in a loop since the liquidity cap will not change.

Does swapapi.dev require an API key?

No. The API is free with no API key, no registration, and no account required. Rate limiting is approximately 30 requests per minute per IP. For a rebalancer checking 4-10 assets, this is more than sufficient.

How do I avoid excessive slippage on large rebalance trades?

Check the priceImpact field in the API response before executing. Reject any swap with price impact worse than -5% (i.e., priceImpact < -0.05). For large trades, consider splitting into smaller amounts and executing sequentially, checking price impact on each chunk.


Get Started

swapapi.dev provides free, keyless token swap quotes and executable calldata across 46 EVM chains. The full OpenAPI spec documents every parameter and response field.

To build your crypto portfolio rebalancer:

  1. Define your target allocation across chains and tokens
  2. Use the /v1/swap/{chainId} endpoint to get live prices and executable trades
  3. Set a drift threshold to avoid over-trading
  4. Execute sells before buys to generate stablecoin liquidity

No API key. No registration. Start building at swapapi.dev.

Top comments (0)