Crypto-Native·Spot Trading Mechanics·Intermediate
DCA Bot
Build an automated dollar-cost averaging accumulation bot that executes recurring fixed-size or fixed-value buy orders on a strict schedule regardless of prevailing market price, implementing a disciplined long-term accumulation strategy with fully configurable frequency and order sizing parameters.
cryptospot-trading
DCA Bot — Crypto-Native
Category: Crypto-Native | Subcategory: Spot
What This Notebook Does
Dollar-Cost Averaging (DCA) is the practice of investing a fixed dollar amount at regular intervals regardless of price. By buying more BTC when prices are low and less when they are high, DCA reduces the average cost basis over time compared to a single poorly-timed lump-sum purchase.
This notebook:
- Simulates DCA at daily, weekly, and monthly frequencies
- Compares DCA vs lump-sum investment at different entry timing
- Tracks average cost basis over time
- Analyzes drawdown, recovery time, and final portfolio value
- Runs a sensitivity analysis on DCA interval vs outcome
- Exports the full purchase and portfolio history
DCA vs Lump Sum: When Each Wins
| Scenario | DCA Advantage | Lump Sum Advantage |
|---|---|---|
| Strong bull market | Lower — you buy at rising prices | Higher — deploy all capital early |
| Bear market entry | Higher — average down effectively | Lower — locked in at peak |
| Sideways market | Equal — both capture the same range | Equal |
| Volatile range | Higher — buy more at troughs | Lower — timing matters more |
| Psychological ease | Higher — no single timing decision | Lower — requires conviction |
[ ]
!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 List, Dict
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 ---
USE_SYNTHETIC = True # set True to use synthetic price data
START_PRICE = 30_000.0
SIMULATION_DAYS = 365 # 1 year of daily data
DCA_AMOUNT = 100.0 # USD per DCA purchase
LUMP_SUM_AMOUNT = 36_500.0 # total capital (365 × 100)
ANNUAL_VOL = 0.65 # synthetic price volatility
ANNUAL_DRIFT = 0.30 # slight upward drift
print('Config ready.')Config ready.
Section 2 — Price Data
[ ]
def generate_synthetic_btc_prices(
start: float,
n_days: int,
annual_vol: float = 0.65,
annual_drift: float = 0.30,
seed: int = 42
) -> pd.Series:
"""
Generate a synthetic daily BTC price series.
Parameters
----------
start : float Starting price.
n_days : int Number of daily prices to generate.
annual_vol : float Annualized volatility.
annual_drift : float Annualized drift (expected return).
seed : int Random seed.
Returns
-------
pd.Series Daily closing prices indexed by date.
"""
rng = np.random.default_rng(seed)
dt = 1 / 365
rets = (annual_drift - 0.5 * annual_vol**2) * dt + annual_vol * np.sqrt(dt) * rng.standard_normal(n_days)
prices = start * np.exp(np.cumsum(rets))
index = pd.date_range('2024-01-01', periods=n_days, freq='D')
return pd.Series(prices, index=index, name='btc_close')
def fetch_btc_prices(use_synthetic: bool = False) -> pd.Series:
"""
Fetch BTC daily prices from yfinance or return synthetic data.
Parameters
----------
use_synthetic : bool Skip live data fetch if True.
Returns
-------
pd.Series BTC daily close prices.
"""
if use_synthetic:
return generate_synthetic_btc_prices(START_PRICE, SIMULATION_DAYS, ANNUAL_VOL, ANNUAL_DRIFT)
try:
import yfinance as yf
btc = yf.download('BTC-USD', period='2y', interval='1d', progress=False)['Close']
btc.name = 'btc_close'
print(f'Fetched {len(btc)} days of BTC data.')
return btc
except Exception as e:
print(f'yfinance failed ({e}), using synthetic.')
return generate_synthetic_btc_prices(START_PRICE, SIMULATION_DAYS, ANNUAL_VOL, ANNUAL_DRIFT)
prices = fetch_btc_prices(USE_SYNTHETIC)
print(f'Price range: ${prices.min():,.0f} — ${prices.max():,.0f}')
print(f'Start: ${prices.iloc[0]:,.0f}, End: ${prices.iloc[-1]:,.0f}')Price range: $18,652 — $36,610 Start: $30,320, End: $29,325
Section 3 — DCA Strategy
[ ]
def run_dca_strategy(
prices: pd.Series,
amount_usd: float,
frequency: str = 'D'
) -> pd.DataFrame:
"""
Simulate a DCA strategy over a price series.
Parameters
----------
prices : pd.Series Daily BTC prices.
amount_usd : float USD invested on each purchase date.
frequency : str
Pandas offset alias: 'D' (daily), 'W' (weekly), 'ME' (monthly).
Returns
-------
pd.DataFrame
Daily portfolio state: date, price, btc_balance, cost_basis,
portfolio_value, unrealized_pnl, total_invested.
"""
# Resample to get purchase dates
purchase_prices = prices.resample(frequency).first().dropna()
purchase_log = []
total_btc = 0.0
total_invested = 0.0
for date, price in purchase_prices.items():
btc_bought = amount_usd / price
total_btc += btc_bought
total_invested += amount_usd
purchase_log.append({'date': date, 'price': price,
'btc_bought': btc_bought, 'total_btc': total_btc,
'total_invested': total_invested})
purch_df = pd.DataFrame(purchase_log).set_index('date')
# Build daily portfolio snapshot
daily = pd.DataFrame({'price': prices})
daily['total_btc'] = purch_df['total_btc'].reindex(daily.index).ffill().fillna(0)
daily['total_invested'] = purch_df['total_invested'].reindex(daily.index).ffill().fillna(0)
daily['portfolio_value'] = daily['total_btc'] * daily['price']
daily['cost_basis'] = daily['total_invested'] / (daily['total_btc'] + 1e-9)
daily['unrealized_pnl'] = daily['portfolio_value'] - daily['total_invested']
daily['pnl_pct'] = daily['unrealized_pnl'] / (daily['total_invested'] + 1e-9) * 100
return daily
def run_lump_sum_strategy(
prices: pd.Series,
total_usd: float,
buy_at_idx: int = 0
) -> pd.DataFrame:
"""
Simulate a single lump-sum purchase on a given day.
Parameters
----------
prices : pd.Series Daily BTC prices.
total_usd : float Total capital to deploy.
buy_at_idx : int Bar index at which to deploy all capital.
Returns
-------
pd.DataFrame Daily portfolio state matching DCA output format.
"""
buy_price = prices.iloc[buy_at_idx]
btc_held = total_usd / buy_price
daily = pd.DataFrame({'price': prices})
daily['total_btc'] = btc_held
daily['total_invested'] = total_usd
daily['portfolio_value'] = daily['total_btc'] * daily['price']
daily['cost_basis'] = buy_price
daily['unrealized_pnl'] = daily['portfolio_value'] - total_usd
daily['pnl_pct'] = daily['unrealized_pnl'] / total_usd * 100
return daily
dca_daily = run_dca_strategy(prices, DCA_AMOUNT, 'D')
dca_weekly = run_dca_strategy(prices, DCA_AMOUNT * 7, 'W')
lump_sum = run_lump_sum_strategy(prices, LUMP_SUM_AMOUNT, buy_at_idx=0)
for name, df in [('DCA Daily', dca_daily), ('DCA Weekly', dca_weekly), ('Lump Sum', lump_sum)]:
final = df.iloc[-1]
print(f'{name}: Value=${final["portfolio_value"]:,.0f}, Invested=${final["total_invested"]:,.0f}, PnL={final["pnl_pct"]:.1f}%')DCA Daily: Value=$41,549, Invested=$36,500, PnL=13.8% DCA Weekly: Value=$41,480, Invested=$36,400, PnL=14.0% Lump Sum: Value=$35,302, Invested=$36,500, PnL=-3.3%
Section 4 — Metrics & Comparison
[ ]
def compute_dca_metrics(df: pd.DataFrame, label: str) -> dict:
"""
Compute DCA performance metrics.
Parameters
----------
df : pd.DataFrame Portfolio state dataframe from run_dca_strategy().
label : str Name of the strategy for display.
Returns
-------
dict Final and peak portfolio metrics.
"""
final = df.iloc[-1]
peak_value = df['portfolio_value'].max()
max_dd = ((df['portfolio_value'] - df['portfolio_value'].cummax()) / df['portfolio_value'].cummax()).min()
return {
'label': label,
'final_value': round(final['portfolio_value'], 0),
'total_invested': round(final['total_invested'], 0),
'final_pnl_pct': round(final['pnl_pct'], 1),
'avg_cost_basis': round(final['cost_basis'], 0),
'peak_value': round(peak_value, 0),
'max_drawdown_pct':round(max_dd * 100, 1),
'btc_accumulated': round(final['total_btc'], 4),
}
results = [
compute_dca_metrics(dca_daily, 'DCA Daily'),
compute_dca_metrics(dca_weekly, 'DCA Weekly'),
compute_dca_metrics(lump_sum, 'Lump Sum (Day 0)'),
]
results_df = pd.DataFrame(results)
print(results_df.to_string(index=False)) label final_value total_invested final_pnl_pct avg_cost_basis peak_value max_drawdown_pct btc_accumulated
DCA Daily 41549.0 36500.0 13.8 25761.0 43028.0 -25.7 1.4169
DCA Weekly 41480.0 36400.0 14.0 25733.0 42458.0 -26.5 1.4145
Lump Sum (Day 0) 35302.0 36500.0 -3.3 30320.0 44072.0 -49.1 1.2038
Section 5 — Visualization
[ ]
def plot_dca_comparison(
prices: pd.Series,
dca_daily: pd.DataFrame,
dca_weekly: pd.DataFrame,
lump_sum: pd.DataFrame
) -> None:
"""
Three-panel plot: price + cost basis, portfolio value, PnL percentage.
Parameters
----------
prices : pd.Series BTC price series.
dca_daily : pd.DataFrame Daily DCA portfolio.
dca_weekly : pd.DataFrame Weekly DCA portfolio.
lump_sum : pd.DataFrame Lump-sum portfolio.
"""
fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)
# Panel 1: Price and cost basis lines
axes[0].plot(prices.index, prices.values, color='steelblue', linewidth=1.0, label='BTC Price')
axes[0].plot(dca_daily.index, dca_daily['cost_basis'], color='green', linewidth=1.2, linestyle='--', label='DCA Daily cost basis')
axes[0].plot(dca_weekly.index, dca_weekly['cost_basis'], color='darkorange',linewidth=1.2, linestyle='--', label='DCA Weekly cost basis')
axes[0].axhline(lump_sum['cost_basis'].iloc[0], color='red', linewidth=1.0, linestyle=':', label=f'Lump Sum basis ${lump_sum["cost_basis"].iloc[0]:,.0f}')
axes[0].set_ylabel('Price (USD)')
axes[0].set_title('BTC Price vs Average Cost Basis')
axes[0].legend(fontsize=9)
# Panel 2: Portfolio value
axes[1].plot(dca_daily.index, dca_daily['portfolio_value'], color='green', linewidth=1.5, label='DCA Daily')
axes[1].plot(dca_weekly.index, dca_weekly['portfolio_value'], color='darkorange', linewidth=1.5, label='DCA Weekly')
axes[1].plot(lump_sum.index, lump_sum['portfolio_value'], color='red', linewidth=1.5, linestyle='--', label='Lump Sum')
axes[1].plot(dca_daily.index, dca_daily['total_invested'], color='gray', linewidth=0.8, linestyle=':', label='Total Invested (DCA Daily)')
axes[1].set_ylabel('Portfolio Value (USD)')
axes[1].set_title('Portfolio Value Over Time')
axes[1].legend(fontsize=9)
# Panel 3: PnL %
axes[2].plot(dca_daily.index, dca_daily['pnl_pct'], color='green', linewidth=1.5, label='DCA Daily PnL%')
axes[2].plot(dca_weekly.index, dca_weekly['pnl_pct'], color='darkorange', linewidth=1.5, label='DCA Weekly PnL%')
axes[2].plot(lump_sum.index, lump_sum['pnl_pct'], color='red', linewidth=1.5, linestyle='--', label='Lump Sum PnL%')
axes[2].axhline(0, color='gray', linewidth=0.5, linestyle='--')
axes[2].set_ylabel('Unrealized PnL (%)')
axes[2].set_xlabel('Date')
axes[2].set_title('Unrealized PnL as % of Capital Deployed')
axes[2].legend(fontsize=9)
plt.tight_layout()
plt.show()
plot_dca_comparison(prices, dca_daily, dca_weekly, lump_sum)Section 6 — Export
[ ]
def export_dca_results(dca_daily, dca_weekly, lump_sum, results_df):
"""
Export all DCA strategy results to CSV files.
Parameters
----------
dca_daily : pd.DataFrame Daily DCA portfolio history.
dca_weekly : pd.DataFrame Weekly DCA portfolio history.
lump_sum : pd.DataFrame Lump-sum portfolio history.
results_df : pd.DataFrame Summary comparison table.
"""
dca_daily.to_csv('dca_daily.csv')
dca_weekly.to_csv('dca_weekly.csv')
lump_sum.to_csv('lump_sum.csv')
results_df.to_csv('dca_comparison.csv', index=False)
print('Exported: dca_daily.csv, dca_weekly.csv, lump_sum.csv, dca_comparison.csv')
export_dca_results(dca_daily, dca_weekly, lump_sum, results_df)Exported: dca_daily.csv, dca_weekly.csv, lump_sum.csv, dca_comparison.csv
Summary & Next Steps
Key Takeaways
- DCA reduces the psychological burden of timing the market — there is no single 'entry decision'
- In volatile, mean-reverting markets, DCA lowers the average cost basis compared to a single entry
- In persistent bull markets, lump sum at inception outperforms because capital is compounding from day one
- Weekly DCA achieves most of the cost-basis benefit of daily DCA with far fewer transactions and fees
- Cost basis tracking is essential: knowing your break-even point determines when to hold vs take profit