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:

  1. Simulates DCA at daily, weekly, and monthly frequencies
  2. Compares DCA vs lump-sum investment at different entry timing
  3. Tracks average cost basis over time
  4. Analyzes drawdown, recovery time, and final portfolio value
  5. Runs a sensitivity analysis on DCA interval vs outcome
  6. Exports the full purchase and portfolio history

DCA vs Lump Sum: When Each Wins

ScenarioDCA AdvantageLump Sum Advantage
Strong bull marketLower — you buy at rising pricesHigher — deploy all capital early
Bear market entryHigher — average down effectivelyLower — locked in at peak
Sideways marketEqual — both capture the same rangeEqual
Volatile rangeHigher — buy more at troughsLower — timing matters more
Psychological easeHigher — no single timing decisionLower — 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)
cell output

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
DCA Bot · BitPredict