Tax Loss Harvesting Crypto Bot: Automate Your Tax Strategy
Learn how to build a crypto tax loss harvesting bot using Python that automatically detects underwater positions and sells them to offset capital gains on Binance, Bybit, and OKX.
Learn how to build a crypto tax loss harvesting bot using Python that automatically detects underwater positions and sells them to offset capital gains on Binance, Bybit, and OKX.
Every bear market is a tax opportunity in disguise. When your ETH is down 40% and your SOL is bleeding, the worst thing you can do is sit there watching red candles. Tax loss harvesting lets you sell those losing positions, lock in the loss for tax purposes, and immediately redeploy the capital — all while the IRS effectively subsidizes part of your portfolio recovery. The problem is that doing this manually across 10-20 assets, while monitoring prices around the clock, is nearly impossible for a human. That is exactly what a tax loss harvesting bot is built for.
Tax loss harvesting is the practice of selling an asset at a loss to realize that loss on paper, then using it to offset capital gains elsewhere in your portfolio. In traditional markets, this strategy has been used for decades. In crypto, it is even more powerful because the wash sale rule — which bars investors from buying back a substantially identical security within 30 days of selling at a loss — does not currently apply to cryptocurrencies in the US. Crypto is classified as property, not a security, meaning you can sell ETH at a loss today and rebuy ETH immediately, while still claiming the tax loss. That loophole does not exist in stock markets.
Here is a concrete example. You bought 2 ETH at $3,500 each — $7,000 total. ETH is now at $2,100. Your unrealized loss is $2,800. If you sell now, you can use that $2,800 loss to offset $2,800 in gains from other trades, potentially saving hundreds or thousands in taxes depending on your bracket. Then you immediately rebuy 2 ETH at $2,100. Your tax position improved, and your ETH exposure is unchanged. A bot handles the timing, execution, and record-keeping for every position in your portfolio automatically.
The bot runs a continuous loop: fetch your current balances, compare each position's current price against your average cost basis, and trigger a market sell when the unrealized loss crosses a defined threshold — say, negative 10%. After execution, it logs the transaction with a timestamp, order ID, and fill price for your accountant or tax software. If configured, it queues a rebuy after a cooldown period to future-proof the strategy against potential wash sale legislation.
The scan interval is typically hourly for most portfolios. Running it more frequently than every few minutes risks hitting exchange rate limits and also generates noise — a position that dips 10% and recovers within minutes is not a genuine harvesting opportunity. On Binance and Bybit, the public API is generous enough to support scans every 5 to 10 minutes if needed for more aggressive strategies. VoiceOfChain users often combine this bot with real-time signal alerts — when VoiceOfChain flags a bearish trend continuation on an asset you hold, that is a compelling trigger to harvest the loss rather than wait for a deeper drawdown that may never recover.
Tax laws change. Several US legislative proposals have included crypto under wash sale rules. Always consult a crypto tax professional before deploying an automated harvesting strategy. Tools like Koinly, CoinTracker, or TaxBit can reconcile bot transactions automatically.
The foundation is a class that handles portfolio scanning and trade execution. The ccxt library provides a unified interface to over 100 exchanges including Binance, Bybit, OKX, Coinbase Advanced, and KuCoin. Install it with pip install ccxt. The bot requires your average cost basis per asset — either hardcoded, loaded from a CSV, or pulled from a portfolio tracking API. We pass it in as a plain dictionary so the class stays exchange-agnostic.
import ccxt
from datetime import datetime
class TaxLossHarvestingBot:
def __init__(self, exchange_id, api_key, api_secret, loss_threshold=-0.10):
self.exchange = getattr(ccxt, exchange_id)({
'apiKey': api_key,
'secret': api_secret,
'enableRateLimit': True
})
self.loss_threshold = loss_threshold # -0.10 = trigger at 10% loss
self.harvest_log = []
def get_positions(self):
# Fetch all non-stablecoin balances with live prices
balance = self.exchange.fetch_balance()
stables = ('USDT', 'USDC', 'BUSD', 'DAI', 'TUSD')
positions = []
for coin, amount in balance['total'].items():
if amount > 0 and coin not in stables:
ticker = self.exchange.fetch_ticker(f'{coin}/USDT')
positions.append({
'symbol': coin,
'amount': amount,
'current_price': ticker['last']
})
return positions
def calculate_pnl(self, avg_buy_price, current_price):
# Returns decimal: -0.15 means down 15%
return (current_price - avg_buy_price) / avg_buy_price
def should_harvest(self, pnl):
return pnl <= self.loss_threshold
def harvest(self, symbol, amount):
pair = f'{symbol}/USDT'
order = self.exchange.create_market_sell_order(pair, amount)
entry = {
'timestamp': datetime.utcnow().isoformat(),
'symbol': symbol,
'amount': amount,
'order_id': order['id'],
'status': order['status']
}
self.harvest_log.append(entry)
return entry
def run(self, cost_basis: dict):
# Pass cost_basis as {symbol: avg_buy_price}, e.g. {'ETH': 3500}
positions = self.get_positions()
harvested = []
for pos in positions:
sym = pos['symbol']
if sym not in cost_basis:
continue
pnl = self.calculate_pnl(cost_basis[sym], pos['current_price'])
if self.should_harvest(pnl):
print(f'{sym} down {pnl:.1%} — harvesting loss')
result = self.harvest(sym, pos['amount'])
harvested.append(result)
return harvested
The run() method is the heart of the bot. Call it on a schedule — via a cron job, APScheduler, or a simple asyncio loop — passing in your cost basis dictionary. It returns a list of all harvested positions in that iteration, which you write to a CSV or database for tax reporting. The harvest_log attribute accumulates across iterations for the session.
Each exchange requires slightly different API setup. On Binance, create an API key with Spot trading permission enabled — withdrawal permission is not needed. Bybit uses a V5 unified account API. OKX requires an additional passphrase that you set when creating the key. The ccxt library abstracts the underlying differences, so once the client is instantiated, the order placement code is identical across all three.
import ccxt
# Binance — Spot API, no withdrawal permission needed
binance = ccxt.binance({
'apiKey': 'YOUR_BINANCE_KEY',
'secret': 'YOUR_BINANCE_SECRET',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
# Bybit — V5 unified account
bybit = ccxt.bybit({
'apiKey': 'YOUR_BYBIT_KEY',
'secret': 'YOUR_BYBIT_SECRET',
'enableRateLimit': True
})
# OKX — requires passphrase set at key creation time
okx = ccxt.okx({
'apiKey': 'YOUR_OKX_KEY',
'secret': 'YOUR_OKX_SECRET',
'password': 'YOUR_OKX_PASSPHRASE',
'enableRateLimit': True
})
def execute_harvest(exchange, symbol, amount, dry_run=True):
pair = f'{symbol}/USDT'
if dry_run:
ticker = exchange.fetch_ticker(pair)
price = ticker['last']
print(f'[DRY RUN] Would sell {amount} {symbol} at ~{price}')
return {'dry_run': True, 'symbol': symbol, 'amount': amount}
try:
order = exchange.create_market_sell_order(pair, amount)
return {
'success': True,
'order_id': order['id'],
'filled': order['filled'],
'cost': order['cost']
}
except ccxt.InsufficientFunds as e:
return {'success': False, 'error': f'Insufficient funds: {e}'}
except ccxt.NetworkError as e:
return {'success': False, 'error': f'Network error: {e}'}
except ccxt.ExchangeError as e:
return {'success': False, 'error': f'Exchange error: {e}'}
# Example: harvest 0.5 ETH on Binance — dry run first
result = execute_harvest(binance, 'ETH', 0.5, dry_run=True)
print(result)
Always start in dry_run=True mode and run for at least a week before going live. The bot will log exactly what it would have sold and at what price, giving you confidence that thresholds are calibrated correctly. KuCoin and Gate.io follow the same pattern — swap the exchange name in the constructor. Coinbase Advanced Trade via ccxt works identically but has lower rate limits, so increase your scan interval to 15-30 minutes if using it.
A misconfigured harvesting bot can cause real damage — selling positions you did not intend to touch, generating dozens of taxable events in a single session, or triggering fees that exceed the tax benefit on small positions. A clean config file separates thresholds and safety limits from execution logic, making the bot auditable and easy to adjust without touching core code.
# config.py
BOT_CONFIG = {
# Exchange selection
'exchange': 'binance', # binance | bybit | okx | coinbase | kucoin | gate
# Harvesting thresholds
'thresholds': {
'loss_pct': -0.10, # Sell when unrealized loss >= 10%
'min_position_usd': 50, # Skip positions worth less than $50
'max_harvests_per_day': 5 # Hard cap on daily sell executions
},
# Timing
'timing': {
'scan_interval_sec': 3600, # Scan portfolio every 60 minutes
'rebuy_cooldown_days': 31 # Wait before rebuying same asset
},
# Asset filters
'assets': {
'whitelist': ['BTC', 'ETH', 'SOL', 'AVAX', 'MATIC', 'LINK', 'ARB'],
'blacklist': ['USDT', 'USDC', 'BUSD', 'DAI'] # Never sell stables
},
# Safety limits
'safety': {
'max_portfolio_pct': 0.25, # Never harvest more than 25% at once
'dry_run': True # Set False only when ready for live trading
}
}
The rebuy_cooldown_days setting is a courtesy safeguard, not a legal requirement in the US given current crypto tax law. But because legislation has repeatedly come close to applying wash sale rules to crypto, the 31-day buffer future-proofs your strategy without meaningful cost. Keep loss_pct conservative — 10% to 15% is a reasonable range that avoids triggering on ordinary intraday volatility. Platforms like Gate.io and Bitget have tighter rate limits, so you may need to push scan_interval_sec to 1800 or more when connecting through their APIs.
| Exchange | ccxt Support | Requests / Min | Spot API | Notes |
|---|---|---|---|---|
| Binance | Full | 1200 | Yes | Best liquidity, easiest setup |
| Bybit | Full | 600 | Yes | V5 API, unified account |
| OKX | Full | 600 | Yes | Requires API passphrase |
| Coinbase Advanced | Full | 300 | Yes | Lower limits, US-friendly |
| KuCoin | Full | 600 | Yes | Strong altcoin selection |
| Gate.io | Full | 300 | Yes | Widest asset coverage |
| Bitget | Full | 600 | Yes | Growing liquidity, low fees |
Pairing this bot with a real-time signal source dramatically improves timing decisions. VoiceOfChain provides live market alerts that can confirm whether a drawdown reflects a genuine trend shift or just noise — context that a raw percentage threshold alone cannot provide. If VoiceOfChain signals sustained bearish momentum on an asset already sitting at a loss, that is a much stronger case to harvest than a dip caused by a single large liquidation cascade.
A tax loss harvesting bot is one of the highest-ROI tools a crypto trader can build. The code is approachable, ccxt handles exchange complexity for Binance, Bybit, OKX, and others with a consistent interface, and the tax savings are real and immediate. Start in dry_run mode, validate your cost basis data, monitor the logs for a week, then flip the switch to live. Combine it with signal context from VoiceOfChain to time your harvests with intention rather than just reacting to arbitrary thresholds — and make every bear market work for your bottom line.