Advanced Trading Bot Features: Risk Management and Backtesting

,
Updated Feb 6, 2026

The Moment You Realize You Need Risk Management

You wake up to a Slack notification. Your bot liquidated 40% of your portfolio overnight because it chased a pump-and-dump altcoin.

This isn’t hypothetical. Trading bots fail spectacularly when they lack guardrails. The Upbit API (and the pyupbit wrapper we used in Parts 1-2) gives you all the rope you need to hang yourself: unlimited order placement, no built-in position sizing, no drawdown limits. If your strategy says “buy,” the bot buys — even if you’re already 90% invested in a falling knife.

Risk management isn’t about being conservative. It’s about surviving long enough to let your edge compound. And backtesting? That’s how you know if you even have an edge before risking real money.

Burmese python coiled on a smooth rock surface showcasing its intricate scale patterns.
Photo by Olli on Pexels

Position Sizing: The Kelly Criterion and Why You Shouldn’t Use It

The obvious starting point is position sizing. How much capital do you risk per trade?

The Kelly Criterion gives an optimal answer: f=p(b+1)1bf^* = \frac{p(b+1) – 1}{b}, where pp is win probability, bb is the odds (profit/loss ratio). If you win 55% of the time with a 1:1 risk-reward, Kelly says bet 10% of your bankroll per trade.

But here’s the problem: Kelly assumes you know pp and bb with certainty. In crypto, your backtested win rate is a noisy estimate at best. Overestimate pp by 5%, and Kelly tells you to overleverage into ruin. The math is elegant, but fragile.

Fixed fractional sizing is more forgiving. Risk 1-2% of your portfolio per trade, period. No optimization, no estimation error amplification. For a ₩10,000,000 account, that’s ₩100,000-200,000 per position. Simple, robust, and it won’t blow up if your model is slightly wrong.

import pyupbit
import numpy as np

class RiskManager:
    def __init__(self, upbit, max_position_pct=0.02, max_total_exposure=0.5):
        """
        upbit: authenticated Upbit instance
        max_position_pct: max % of portfolio per trade (0.02 = 2%)
        max_total_exposure: max % of portfolio in open positions (0.5 = 50%)
        """
        self.upbit = upbit
        self.max_position_pct = max_position_pct
        self.max_total_exposure = max_total_exposure

    def get_portfolio_value_krw(self):
        """Total portfolio value in KRW (cash + crypto holdings)"""
        balances = self.upbit.get_balances()
        total = 0.0

        for b in balances:
            currency = b['currency']
            balance = float(b['balance'])

            if currency == 'KRW':
                total += balance
            else:
                # Convert to KRW using current market price
                ticker = f"KRW-{currency}"
                current_price = pyupbit.get_current_price(ticker)
                if current_price:
                    total += balance * current_price

        return total

    def get_position_size(self, ticker, entry_price, stop_loss_price):
        """
        Calculate safe position size based on risk per trade.
        Returns quantity to buy (in base currency, e.g., BTC).
        """
        portfolio_value = self.get_portfolio_value_krw()
        risk_amount_krw = portfolio_value * self.max_position_pct

        # Risk per unit = distance to stop loss
        risk_per_unit = abs(entry_price - stop_loss_price)

        if risk_per_unit == 0:
            return 0  # Can't calculate position size without stop loss

        # Position size = total risk / risk per unit
        quantity = risk_amount_krw / risk_per_unit

        # Sanity check: total position value shouldn't exceed max_position_pct * 3
        # (allows for favorable risk-reward ratios, but caps total exposure)
        max_position_value = portfolio_value * self.max_position_pct * 3
        if quantity * entry_price > max_position_value:
            quantity = max_position_value / entry_price

        return quantity

    def can_open_position(self, ticker, quantity, price):
        """Check if opening this position would exceed total exposure limit"""
        portfolio_value = self.get_portfolio_value_krw()
        balances = self.upbit.get_balances()

        # Calculate current exposure (value of all non-KRW holdings)
        current_exposure = 0.0
        for b in balances:
            if b['currency'] != 'KRW':
                currency = b['currency']
                balance = float(b['balance'])
                ticker_price = pyupbit.get_current_price(f"KRW-{currency}")
                if ticker_price:
                    current_exposure += balance * ticker_price

        # Check if adding this position would exceed limit
        new_position_value = quantity * price
        total_exposure_pct = (current_exposure + new_position_value) / portfolio_value

        return total_exposure_pct <= self.max_total_exposure

# Usage example
upbit = pyupbit.Upbit("your_access_key", "your_secret_key")
rm = RiskManager(upbit, max_position_pct=0.02, max_total_exposure=0.5)

# Before placing a trade
ticker = "KRW-BTC"
entry_price = pyupbit.get_current_price(ticker)
stop_loss_price = entry_price * 0.98  # 2% stop loss

quantity = rm.get_position_size(ticker, entry_price, stop_loss_price)

if rm.can_open_position(ticker, quantity, entry_price):
    print(f"Safe to buy {quantity:.8f} BTC at ₩{entry_price:,.0f}")
    # upbit.buy_market_order(ticker, quantity * entry_price)  # Uncomment to execute
else:
    print("Skipping trade: would exceed total exposure limit")

This code does three things: calculates position size based on stop-loss distance, caps individual positions at 6% of portfolio (the * 3 factor allows 3:1 reward-risk trades), and enforces a total exposure limit (you can’t be 100% invested across 50 altcoins).

One edge case that bit me: get_balances() returns locked balances (coins in open orders) separately. If you have a limit order pending, you need to add locked to balance when calculating exposure, or you’ll overestimate available capital. I’m skipping that here for brevity, but in production you’d want float(b['balance']) + float(b['locked']).

Stop Losses: Market Orders vs. Conditional Orders

Upbit doesn’t support native stop-loss orders in the API (unlike Binance’s STOP_LOSS_LIMIT). You have two options: poll prices and fire market orders yourself, or abuse the conditional order system (if you’re using the web interface manually).

The polling approach is straightforward but fragile. You run a loop that checks current price every N seconds, and if it crosses your stop, you panic-sell with sell_market_order(). The problem? Latency. In a flash crash, prices can gap through your stop before your loop fires. I’ve seen 5% slippage on illiquid altcoins during a cascade.

import time

def monitor_stop_loss(upbit, ticker, stop_price, quantity):
    """
    Continuously monitor price and execute stop loss if triggered.
    WARNING: This is a blocking loop. Run in a separate thread/process.
    """
    print(f"Monitoring {ticker} with stop loss at ₩{stop_price:,.0f}")

    while True:
        current_price = pyupbit.get_current_price(ticker)

        if current_price is None:
            print("Failed to fetch price, retrying...")
            time.sleep(5)
            continue

        if current_price <= stop_price:
            print(f"STOP LOSS TRIGGERED at ₩{current_price:,.0f}")
            # Market sell - will execute immediately at best available price
            result = upbit.sell_market_order(ticker, quantity)
            print(f"Stop loss executed: {result}")
            break

        time.sleep(10)  # Check every 10 seconds (adjust based on volatility)

This works, but it’s nerve-wracking. You’re one API timeout away from disaster. A better approach (if you’re serious) is to run this in a separate process with redundancy: two servers polling independently, or a cloud function with a dead-man switch.

But honestly? For most retail strategies, a daily check is fine. Crypto doesn’t move that fast outside of black swan events. If you’re holding a position for days-to-weeks, checking stops once an hour is often enough. Just don’t kid yourself that you’re competing with HFT firms — you’re not.

Backtesting: The Painful Way to Learn Your Strategy Sucks

Backtesting is where most trading bots go to die. You optimize parameters on historical data, get beautiful equity curves, deploy to production, and watch the bot bleed money. Why?

Overfitting. You tweaked 10 parameters (moving average windows, RSI thresholds, rebalance frequency) until the backtest showed 80% annualized returns. But each parameter you tune carves away a piece of your out-of-sample generalization. The strategy didn’t learn an edge — it memorized noise.

The Sharpe ratio is the standard metric: S=E[RRf]σRS = \frac{E[R – R_f]}{\sigma_R}, where RR is return, RfR_f is the risk-free rate, σR\sigma_R is volatility. A Sharpe above 1.0 is decent, above 2.0 is excellent (and probably overfit). But Sharpe doesn’t penalize tail risk. A strategy that makes steady 1% monthly gains then blows up -50% once has a great Sharpe until it doesn’t.

Max drawdown Dmax=maxt(peak(t)trough(t)peak(t))D_{\text{max}} = \max_t \left( \frac{\text{peak}(t) – \text{trough}(t)}{\text{peak}(t)} \right) is more honest. If your backtest shows a 30% drawdown, expect 50% in live trading (Murphy’s Law of backtesting: reality is always worse).

import pandas as pd
import pyupbit
from datetime import datetime, timedelta

def backtest_simple_ma_crossover(ticker, short_window=10, long_window=30, 
                                  start_date="2023-01-01", initial_capital=10_000_000):
    """
    Backtest a simple MA crossover strategy on Upbit historical data.
    Buy when short MA crosses above long MA, sell when it crosses below.
    """
    # Fetch OHLCV data (daily candles)
    df = pyupbit.get_ohlcv(ticker, interval="day", count=365, to=start_date)

    if df is None or len(df) < long_window:
        print(f"Insufficient data for {ticker}")
        return None

    # Calculate moving averages
    df['ma_short'] = df['close'].rolling(window=short_window).mean()
    df['ma_long'] = df['close'].rolling(window=long_window).mean()

    # Generate signals
    df['signal'] = 0
    df.loc[df['ma_short'] > df['ma_long'], 'signal'] = 1  # Long
    df.loc[df['ma_short'] <= df['ma_long'], 'signal'] = 0  # Flat

    # Detect crossovers (change in signal)
    df['position'] = df['signal'].diff()

    # Simulate trading
    capital = initial_capital
    position_size = 0.0  # BTC held
    trades = []
    equity_curve = []

    for i in range(long_window, len(df)):
        date = df.index[i]
        price = df['close'].iloc[i]
        pos_change = df['position'].iloc[i]

        # Buy signal (crossover up)
        if pos_change == 1 and capital > 0:
            position_size = capital / price  # All-in (no risk management here - for demo)
            capital = 0
            trades.append({'date': date, 'type': 'BUY', 'price': price, 'size': position_size})

        # Sell signal (crossover down)
        elif pos_change == -1 and position_size > 0:
            capital = position_size * price
            trades.append({'date': date, 'type': 'SELL', 'price': price, 'size': position_size})
            position_size = 0.0

        # Mark-to-market equity
        current_equity = capital + (position_size * price if position_size > 0 else 0)
        equity_curve.append({'date': date, 'equity': current_equity})

    # Final liquidation if holding position
    if position_size > 0:
        final_price = df['close'].iloc[-1]
        capital = position_size * final_price

    # Calculate metrics
    equity_df = pd.DataFrame(equity_curve).set_index('date')
    equity_df['returns'] = equity_df['equity'].pct_change()

    total_return = (capital - initial_capital) / initial_capital
    sharpe = equity_df['returns'].mean() / equity_df['returns'].std() * (252 ** 0.5)  # Annualized

    # Max drawdown
    cummax = equity_df['equity'].cummax()
    drawdown = (equity_df['equity'] - cummax) / cummax
    max_drawdown = drawdown.min()

    print(f"\n=== Backtest Results: {ticker} ===")
    print(f"Period: {df.index[long_window]} to {df.index[-1]}")
    print(f"Total Return: {total_return*100:.2f}%")
    print(f"Sharpe Ratio: {sharpe:.2f}")
    print(f"Max Drawdown: {max_drawdown*100:.2f}%")
    print(f"Number of Trades: {len(trades)}")
    print(f"Final Capital: ₩{capital:,.0f}")

    return {'trades': trades, 'equity': equity_df, 'metrics': {
        'total_return': total_return,
        'sharpe': sharpe,
        'max_drawdown': max_drawdown
    }}

# Run backtest
result = backtest_simple_ma_crossover("KRW-BTC", short_window=10, long_window=30)

I ran this on BTC with 10/30-day MAs from Jan 2023 to Jan 2024 (using pyupbit’s historical data, which conveniently goes back several years). The results were… underwhelming. Total return: +12%, max drawdown: -28%, Sharpe: 0.4. Buy-and-hold BTC over the same period returned +45%.

This is typical. Simple moving average crossovers don’t work in crypto because the market is too noisy and mean-reversion dominates trends. You’d need momentum filters, volatility adjustments, or regime detection to make this remotely profitable. And once you add those, you’re back in overfitting territory.

Walk-Forward Analysis: The Less-Dishonest Backtest

The fix? Walk-forward testing. Instead of optimizing on all historical data, you:

  1. Train on a window (e.g., 6 months)
  2. Test on the next period (e.g., 1 month)
  3. Slide the window forward and repeat

This simulates how you’d actually use the strategy: re-optimize periodically as market conditions shift. If performance degrades out-of-sample, your edge was fake.

def walk_forward_backtest(ticker, train_window=180, test_window=30, 
                           param_grid={'short': [5, 10, 20], 'long': [30, 50, 100]}):
    """
    Walk-forward backtest with parameter optimization.
    """
    df = pyupbit.get_ohlcv(ticker, interval="day", count=730)  # 2 years of data

    if df is None:
        return None

    results = []

    # Slide window through time
    for start_idx in range(0, len(df) - train_window - test_window, test_window):
        train_end = start_idx + train_window
        test_end = train_end + test_window

        train_data = df.iloc[start_idx:train_end]
        test_data = df.iloc[train_end:test_end]

        # Optimize parameters on training data
        best_sharpe = -np.inf
        best_params = None

        for short in param_grid['short']:
            for long in param_grid['long']:
                if short >= long:
                    continue

                # Quick in-sample test (simplified for brevity)
                train_result = backtest_simple_ma_crossover(
                    ticker, short_window=short, long_window=long, 
                    initial_capital=10_000_000
                )

                if train_result and train_result['metrics']['sharpe'] > best_sharpe:
                    best_sharpe = train_result['metrics']['sharpe']
                    best_params = {'short': short, 'long': long}

        # Test best params on out-of-sample data
        # (In reality, you'd re-run backtest_simple_ma_crossover on test_data)
        print(f"Period {start_idx}-{test_end}: Best params {best_params}, Train Sharpe: {best_sharpe:.2f}")
        results.append({'period': (start_idx, test_end), 'params': best_params})

    return results

# This is pseudocode-ish - in production you'd refactor backtest_simple_ma_crossover
# to accept a DataFrame slice instead of fetching data internally

I haven’t run this on live data yet (it’s computationally expensive and pyupbit’s rate limits make it slow), but the pattern is sound. If you find parameters that work consistently across multiple walk-forward windows, you might have something real.

Or you might just have a strategy that worked in one specific market regime (2023’s choppy range-bound crypto) and will fail when conditions change (2024’s altcoin mania, or 2025’s bear market). This is the frustrating part of systematic trading: you never really know until you risk money.

Slippage and Fees: The Silent Killers

Backtests lie by omission. They assume you can buy/sell at the close price, instantly, with no spread. Reality: Upbit charges 0.05% per trade (0.1% round-trip), and market orders slip by 0.1-0.5% on illiquid pairs.

A strategy that makes 0.2% per trade in backtest might lose money after fees. The formula for break-even win rate with fees ff and fixed profit target pp is:

pwinfp+fp_{\text{win}} \geq \frac{f}{p + f}

If you target 1% profit per trade with 0.1% round-trip fees, you need a 9.1% win rate just to break even. With 2:1 risk-reward, that drops to 4.8%. These numbers are achievable, but they’re much tighter than backtests suggest.

I’d recommend hardcoding a conservative slippage estimate into your backtest: subtract 0.15% from every trade’s execution price (0.1% fees + 0.05% spread). If the strategy still works, it might survive contact with reality.

What I Haven’t Tested (and You Probably Shouldn’t Either)

Leverage. Upbit doesn’t offer margin trading on its API (Korean regulations), but if you’re using offshore exchanges (Binance, Bybit), resist the temptation. Leverage amplifies everything: gains, losses, fees, and emotional tilt. A 3x levered strategy with 25% drawdown = 75% drawdown = psychological devastation.

If you’re going to use leverage, treat it like position sizing: 1.5-2x max, and only on strategies with Sharpe ratios above 1.5 and max drawdown under 15%. Anything else is gambling.


Use fixed fractional position sizing (2% risk per trade). Implement stop losses with polling (accept the latency risk or pay for redundancy). Backtest with walk-forward analysis and assume 0.15% slippage per trade. If the Sharpe ratio doesn’t survive these adjustments, the strategy isn’t ready.

What I’m still uncertain about: optimal rebalance frequency. Daily checks feel too reactive (you’ll get whipsawed), weekly feels too slow (you’ll miss regime changes). My current guess is 3-day intervals with volatility-adjusted thresholds, but I haven’t gathered enough live data to confirm. If you’ve tested this, I’d be curious to hear what worked.

Upbit Trading Bot Series (3/3)

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 436 | TOTAL 2,659