The Case for Automating DCA
Dollar Cost Averaging is the simplest strategy that actually works over long time horizons. You buy a fixed dollar amount of an asset at regular intervals regardless of price. No chart reading, no timing, no emotional decisions. The math is well-documented: DCA into BTC over any rolling three-year period in its history has been profitable.
The problem is not the strategy — it is the discipline. Most people start DCA manually, keep it up for a few weeks, then miss a buy because they were busy. Or they check the price, see it is “too high,” and skip. Or they see a crash and panic-sell what they accumulated. Automation removes all of that. The bot buys whether you are asleep, on vacation, or panicking.
This tutorial walks through building a DCA bot from scratch using Claude Code to generate the Python code and the Binance API to execute trades. By the end, you will have a bot that buys BTC on a schedule, logs every trade, handles errors gracefully, and can run unattended on a schedule.
What You Will Build
- A Python script that buys a fixed USD amount of BTC/USDT on Binance at configurable intervals
- Dry-run mode for testing without real money
- Trade logging to CSV
- Error handling for network failures, insufficient balance, and API rate limits
- A cron job or systemd timer for running the bot on a schedule
Prerequisites
- Python 3.10 or newer
- A Binance account with API keys (see our API setup guide if you need help)
- Claude Code installed
- A machine that stays on (VPS, home server, or even a Mac Mini)
Step 1: Set Up the Project
Create a project directory and a virtual environment:
mkdir btc-dca-bot && cd btc-dca-bot
python3 -m venv venv
source venv/bin/activate
pip install ccxt python-dotenv
Create a .env file with your Binance API credentials:
BINANCE_API_KEY=your_api_key_here
BINANCE_API_SECRET=your_api_secret_here
DRY_RUN=true
BUY_AMOUNT_USD=25
SYMBOL=BTC/USDT
Never commit the .env file to version control. Add it to .gitignore immediately.
Step 2: Generate the Bot with Claude Code
Open Claude Code in the project directory and use this prompt:
Build a Python DCA bot (dca_bot.py) that buys crypto on Binance using ccxt. Requirements:
- Load config from .env: API keys, DRY_RUN flag, BUY_AMOUNT_USD, SYMBOL
- Fetch current price, calculate the quantity to buy
- In dry-run mode, log the simulated trade. In live mode, place a market buy order
- Log every trade (timestamp, symbol, price, quantity, usd_amount, mode) to trades.csv
- Handle these errors explicitly: network timeout, insufficient balance, exchange maintenance, API rate limit
- Add a —once flag that executes a single buy and exits (for cron), and a —loop flag that runs continuously with a configurable interval
- Print a summary of total invested and average buy price from the CSV log
- Use argparse for CLI arguments
Claude Code generated a clean first draft. I made one adjustment in a follow-up prompt:
Add a minimum order size check. Binance has minimum order sizes per pair — fetch the market info from ccxt and validate before placing the order. If the buy amount is below the minimum, log a warning and skip.
Here is the final code.
Step 3: The Full Python Code
import argparse
import ccxt
import csv
import os
import sys
import time
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# ─── Configuration ────────────────────────────────────
API_KEY = os.getenv('BINANCE_API_KEY')
API_SECRET = os.getenv('BINANCE_API_SECRET')
DRY_RUN = os.getenv('DRY_RUN', 'true').lower() == 'true'
BUY_AMOUNT = float(os.getenv('BUY_AMOUNT_USD', '25'))
SYMBOL = os.getenv('SYMBOL', 'BTC/USDT')
LOG_FILE = Path('trades.csv')
LOOP_INTERVAL = int(os.getenv('LOOP_INTERVAL_HOURS', '24')) * 3600
# ─── Exchange Setup ───────────────────────────────────
def create_exchange():
if not API_KEY or not API_SECRET:
print("ERROR: BINANCE_API_KEY and BINANCE_API_SECRET must be set in .env")
sys.exit(1)
exchange = ccxt.binance({
'apiKey': API_KEY,
'secret': API_SECRET,
'enableRateLimit': True,
'options': {'defaultType': 'spot'},
})
return exchange
# ─── Minimum Order Check ─────────────────────────────
def check_minimum_order(exchange, symbol, amount_usd):
market = exchange.market(symbol)
min_cost = market.get('limits', {}).get('cost', {}).get('min', 0)
if min_cost and amount_usd < min_cost:
print(f"WARNING: Buy amount ${amount_usd} is below minimum order size ${min_cost} for {symbol}")
return False
return True
# ─── Execute Buy ──────────────────────────────────────
def execute_buy(exchange):
ticker = exchange.fetch_ticker(SYMBOL)
price = ticker['last']
if price is None or price <= 0:
print(f"ERROR: Invalid price received: {price}")
return None
quantity = BUY_AMOUNT / price
if not check_minimum_order(exchange, SYMBOL, BUY_AMOUNT):
return None
if DRY_RUN:
print(f"[DRY RUN] Would buy {quantity:.8f} {SYMBOL} at ${price:,.2f} (${BUY_AMOUNT})")
mode = "dry_run"
else:
try:
order = exchange.create_market_buy_order(SYMBOL, quantity)
filled_price = order.get('average', price)
filled_qty = order.get('filled', quantity)
print(f"[LIVE] Bought {filled_qty:.8f} {SYMBOL} at ${filled_price:,.2f} (${BUY_AMOUNT})")
price = filled_price
quantity = filled_qty
except ccxt.InsufficientFunds as e:
print(f"ERROR: Insufficient balance — {e}")
return None
mode = "live"
log_trade(price, quantity, mode)
return {'price': price, 'quantity': quantity, 'mode': mode}
# ─── Trade Logging ────────────────────────────────────
def log_trade(price, quantity, mode):
file_exists = LOG_FILE.exists()
with open(LOG_FILE, 'a', newline='') as f:
writer = csv.writer(f)
if not file_exists:
writer.writerow(['timestamp', 'symbol', 'price', 'quantity', 'usd_amount', 'mode'])
writer.writerow([
datetime.now().isoformat(),
SYMBOL,
f"{price:.2f}",
f"{quantity:.8f}",
f"{BUY_AMOUNT:.2f}",
mode,
])
# ─── Summary ─────────────────────────────────────────
def print_summary():
if not LOG_FILE.exists():
print("No trades logged yet.")
return
total_usd = 0.0
total_qty = 0.0
trade_count = 0
with open(LOG_FILE, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
total_usd += float(row['usd_amount'])
total_qty += float(row['quantity'])
trade_count += 1
if trade_count == 0:
print("No trades in log.")
return
avg_price = total_usd / total_qty if total_qty > 0 else 0
print(f"\n{'─' * 40}")
print(f"Total trades: {trade_count}")
print(f"Total invested: ${total_usd:,.2f}")
print(f"Total BTC: {total_qty:.8f}")
print(f"Average price: ${avg_price:,.2f}")
print(f"{'─' * 40}\n")
# ─── Error-Wrapped Buy ───────────────────────────────
def safe_buy(exchange):
try:
return execute_buy(exchange)
except ccxt.NetworkError as e:
print(f"NETWORK ERROR: {e} — will retry next cycle")
except ccxt.ExchangeNotAvailable as e:
print(f"EXCHANGE DOWN: {e} — will retry next cycle")
except ccxt.RateLimitExceeded as e:
print(f"RATE LIMITED: {e} — waiting 60 seconds")
time.sleep(60)
except ccxt.ExchangeError as e:
print(f"EXCHANGE ERROR: {e}")
except Exception as e:
print(f"UNEXPECTED ERROR: {e}")
return None
# ─── Main ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='BTC DCA Bot')
parser.add_argument('--once', action='store_true',
help='Execute a single buy and exit')
parser.add_argument('--loop', action='store_true',
help='Run continuously on a timer')
parser.add_argument('--summary', action='store_true',
help='Print trade summary and exit')
args = parser.parse_args()
if args.summary:
print_summary()
return
exchange = create_exchange()
exchange.load_markets()
mode_str = "DRY RUN" if DRY_RUN else "LIVE"
print(f"DCA Bot started — {mode_str} — {SYMBOL} — ${BUY_AMOUNT}/buy")
if args.once:
safe_buy(exchange)
print_summary()
elif args.loop:
print(f"Loop interval: {LOOP_INTERVAL // 3600} hours")
while True:
safe_buy(exchange)
print(f"Next buy in {LOOP_INTERVAL // 3600} hours...")
time.sleep(LOOP_INTERVAL)
else:
print("Use --once for a single buy or --loop for continuous operation.")
print("Use --summary to see trade history.")
if __name__ == '__main__':
main()
Step 4: Test in Dry Run Mode
Run a single simulated buy:
python dca_bot.py --once
You should see output like:
DCA Bot started — DRY RUN — BTC/USDT — $25.0/buy
[DRY RUN] Would buy 0.00037243 BTC/USDT at $67,125.00 ($25.0)
────────────────────────────────────
Total trades: 1
Total invested: $25.00
Total BTC: 0.00037243
Average price: $67,125.00
────────────────────────────────────
Check trades.csv to verify the log entry was written. Run it a few more times to accumulate some test data, then run python dca_bot.py --summary to see the aggregated stats.
Step 5: Go Live
When you are confident the bot works correctly:
- Edit
.envand setDRY_RUN=false - Start with a small amount — $10 or even $5. You can increase later
- Run a single live buy first:
python dca_bot.py --once - Check your Binance account to confirm the order executed
- Check
trades.csvto confirm the log matches
Step 6: Set Up a Schedule
Option A: Cron Job (Linux/Mac)
For a daily buy at 9:00 AM:
crontab -e
Add this line (adjust the path to your project):
0 9 * * * cd /home/user/btc-dca-bot && /home/user/btc-dca-bot/venv/bin/python dca_bot.py --once >> dca.log 2>&1
Option B: Systemd Timer (Linux)
For more robust scheduling with restart handling, create a systemd service and timer. This is better than cron because systemd logs failures and can notify you.
Option C: Cloud Function
Deploy to AWS Lambda or Google Cloud Functions with a scheduled trigger. This eliminates the need for a server that stays on. The trade-off is more setup complexity and the need to package your dependencies.
For most people, a cron job on a cheap VPS ($5/month on any major provider) is the simplest path.
Error Handling: What Can Go Wrong
The bot handles five categories of errors, and it is worth understanding each one because you will encounter them.
Network errors. Your internet drops or Binance’s API is slow. The bot catches ccxt.NetworkError, logs the failure, and retries on the next cycle. No trade is placed, no money is lost. This is the most common error and the least concerning.
Insufficient balance. You ran out of USDT in your Binance spot wallet. The bot catches ccxt.InsufficientFunds, logs it, and stops trying. You will need to deposit more USDT. A useful enhancement is adding a Telegram notification for this error so you notice it quickly.
Exchange maintenance. Binance occasionally goes down for maintenance. The bot catches ccxt.ExchangeNotAvailable and retries next cycle. During extended outages, you will miss a buy — which is fine for DCA. One missed buy over months of operation is irrelevant.
Rate limiting. If you somehow hit the API rate limit (unlikely with a single DCA bot), the bot waits 60 seconds and the ccxt library’s built-in rate limiter handles most of this automatically via enableRateLimit: True.
Invalid price. If the ticker returns a null or zero price (extremely rare but possible during API issues), the bot skips the trade rather than dividing by zero or buying at a nonsensical price.
Safety Checks You Should Add
The generated code is a solid starting point, but here are enhancements I added after running it for a while. You can ask Claude Code to implement any of these:
Maximum daily spend. Ask Claude Code:
Add a daily spend limit. Read trades.csv, sum all USD amounts from today, and skip the buy if the total would exceed a configurable MAX_DAILY_USD (default $100).
Price sanity check. If BTC price is more than 20% different from the 24-hour average, something might be wrong with the data feed. Skip the buy and alert.
Balance monitoring. Before each buy, check the USDT balance. If it is below a threshold (e.g., enough for only 3 more buys), log a warning so you know to top up.
Telegram notifications. This is the single most useful addition. Getting a Telegram message after each buy — or when an error occurs — gives you confidence the bot is running without needing to SSH into the server and check logs.
What DCA Does Not Do
DCA is not a trading strategy in the active sense. It does not time entries, it does not take profits, and it does not cut losses. It is a systematic accumulation method. This means:
- DCA will underperform lump-sum investing during sustained uptrends. If you have $10,000 and BTC goes up for six straight months, you would have been better off buying everything on day one.
- DCA will outperform lump-sum investing during downtrends and choppy markets, because you buy more units when prices are low.
- DCA requires a long time horizon. Running it for two months and judging the results is meaningless. Think years, not weeks.
The bot automates the mechanical part. The strategic decision — what to accumulate, how much per interval, when to stop — is still yours.
Honest Assessment of Claude Code for This Project
Claude Code handled this project well. The generated Python code was clean, correctly structured, and used ccxt idioms properly. The error handling was solid out of the box — I did not need to prompt specifically for most of the exception types, as Claude Code included them proactively.
Where it fell short: the initial version did not check Binance’s minimum order size, which would have caused the bot to fail on small buy amounts. It also did not include the --once flag, which is essential for cron-based scheduling. Both required follow-up prompts. This is typical of AI code generation — the happy path works, but operational edge cases need to be specified.
Total time from zero to working bot: about 20 minutes, including testing. Doing this without AI assistance would take 45-60 minutes for an experienced Python developer, longer for someone less familiar with ccxt.
Next Steps
- See API setup — If you skipped the Binance API configuration, follow the full Claude Code + Binance API setup guide.
- No-code alternative — If Python is not your thing, see how OpenClaw handles the same strategy in our OpenClaw BTC DCA tutorial.
- Add grid trading — Once DCA is running, consider adding a grid trading bot for more active strategies.
- Compare AI tools — See how Claude Code compares to Windsurf and Cursor for trading bot development in our AI tools comparison.