Ba tháng trước, một khách hàng của tôi — người vận hành quỹ tự chủ với vốn 50,000 USD — gọi điện lúc 2 giờ sáng. Hệ thống backtest của anh ấy báo lỗi cross-validation với dữ liệu có vấn đề. Nguyên nhân? Khoảng 200 nến BTC/USDT bị thiếu trong giai đoạn upgrade sàn Binance, khiến chiến lược mean-reversion của anh ấy bị overfit hoàn toàn với dữ liệu không liên tục.

Tôi đã mất 6 giờ để debug, phát hiện ra vấn đề, và viết lại pipeline xử lý dữ liệu từ đầu. Bài viết này là tất cả những gì tôi đã học được — viết lại để bạn không phải đổ mồ hôi như tôi.

Mục Lục

Tại Sao Dữ Liệu K-line Quyết Định 80% Thành Bại?

Trong lĩnh vực quantitative trading, có một câu nói mà tôi luôn dạy cho các học viên của mình: "Garbage in, garbage out". Dù thuật toán của bạn có tinh vi đến đâu, nếu dữ liệu đầu vào có vấn đề, kết quả backtest sẽ không có giá trị thực tế.

Các vấn đề phổ biến với dữ liệu K-line thô:

Trong kinh nghiệm thực chiến của tôi với hơn 15 chiến lược được deploy, tôi ước tính 60% thời gian phát triển bị "nuốt chửng" bởi việc xử lý dữ liệu. Đây là lý do tôi viết bài hướng dẫn này.

Thu Thập Dữ Liệu Từ Nhiều Sàn Giao Dịch

Đầu tiên, bạn cần một hệ thống thu thập dữ liệu từ nhiều nguồn. Tôi khuyên dùng ccxt — thư viện Python hỗ trợ hơn 100 sàn với interface thống nhất.

# install: pip install ccxt pandas numpy

import ccxt
import pandas as pd
from datetime import datetime, timedelta
from typing import List, Dict
import time
import asyncio

class KLineCollector:
    """Bộ thu thập dữ liệu K-line từ nhiều sàn với retry logic"""
    
    def __init__(self):
        self.exchanges = {
            'binance': ccxt.binance({
                'options': {'defaultType': 'spot'},
                'enableRateLimit': True,
            }),
            'okx': ccxt.okx({
                'enableRateLimit': True,
            }),
            'bybit': ccxt.bybit({
                'enableRateLimit': True,
            }),
        }
        
        # Mapping timeframe
        self.timeframes = {
            '1m': '1m', '5m': '5m', '15m': '15m',
            '1h': '1h', '4h': '4h', '1d': '1d'
        }
    
    async def fetch_klines(
        self, 
        exchange_id: str, 
        symbol: str, 
        timeframe: str,
        since: datetime,
        until: datetime = None,
        limit: int = 1000
    ) -> pd.DataFrame:
        """
        Thu thập dữ liệu K-line với pagination tự động
        """
        if exchange_id not in self.exchanges:
            raise ValueError(f"Sàn {exchange_id} không được hỗ trợ")
        
        exchange = self.exchanges[exchange_id]
        all_klines = []
        current_since = int(since.timestamp() * 1000)
        end_timestamp = int(until.timestamp() * 1000) if until else None
        
        while True:
            try:
                # Rate limit protection
                await asyncio.sleep(exchange.rateLimit / 1000)
                
                ohlcv = await exchange.fetch_ohlcv(
                    symbol, 
                    timeframe,
                    current_since,
                    limit
                )
                
                if not ohlcv:
                    break
                
                all_klines.extend(ohlcv)
                
                # Update cursor
                current_since = ohlcv[-1][0] + 1
                
                # Check if we've reached the end
                if end_timestamp and current_since >= end_timestamp:
                    break
                
                # Check if we've fetched enough
                if len(ohlcv) < limit:
                    break
                    
            except ccxt.RateLimitExceeded:
                print(f"Rate limit hit, waiting 60s...")
                await asyncio.sleep(60)
            except Exception as e:
                print(f"Lỗi khi fetch {symbol} trên {exchange_id}: {e}")
                await asyncio.sleep(5)
        
        # Convert to DataFrame
        df = pd.DataFrame(
            all_klines, 
            columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']
        )
        df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
        df['exchange'] = exchange_id
        df = df.drop_duplicates(subset=['timestamp', 'exchange'])
        
        return df

Sử dụng

collector = KLineCollector()

Thu thập dữ liệu BTC/USDT 1h trong 2 năm

df = asyncio.run(collector.fetch_klines( exchange_id='binance', symbol='BTC/USDT', timeframe='1h', since=datetime(2022, 1, 1), until=datetime(2024, 1, 1) )) print(f"Đã thu thập {len(df)} nến từ Binance") print(df.head())

Điểm mấu chốt ở đây là cơ chế pagination tự động. API của sàn giao dịch thường giới hạn 1000-1500 nến mỗi request. Để lấy 2 năm dữ liệu 1h (khoảng 17,520 nến), bạn cần ít nhất 12 requests.

Quy Trình Làm Sạch Dữ Liệu K-line Chuyên Nghiệp

Đây là phần quan trọng nhất — và cũng là phần bị nhiều người bỏ qua nhất. Tôi đã thấy quá nhiều chiến lược "holy grail" thất bại chỉ vì dữ liệu không được làm sạch đúng cách.

import numpy as np
from typing import Tuple, List
import warnings
warnings.filterwarnings('ignore')

class KLineCleaner:
    """
    Bộ làm sạch dữ liệu K-line toàn diện
    Phát triển dựa trên kinh nghiệm xử lý hơn 50GB dữ liệu thực tế
    """
    
    def __init__(self, expected_timeframe_minutes: int):
        self.timeframe_ms = expected_timeframe_minutes * 60 * 1000
        self.stats = {
            'missing_detected': 0,
            'duplicates_removed': 0,
            'outliers_cleaned': 0,
            'gaps_filled': 0
        }
    
    def clean(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Pipeline làm sạch hoàn chỉnh
        """
        df = df.copy()
        df = df.sort_values('timestamp').reset_index(drop=True)
        
        # Bước 1: Loại bỏ duplicate
        original_len = len(df)
        df = df.drop_duplicates(subset=['timestamp'], keep='first')
        self.stats['duplicates_removed'] = original_len - len(df)
        
        # Bước 2: Phát hiện và xử lý nến thiếu
        df = self._detect_missing_candles(df)
        
        # Bước 3: Xử lý outlier (Z-score method)
        df = self._remove_outliers(df, z_threshold=5.0)
        
        # Bước 4: Validation logic consistency
        df = self._validate_ohlc_logic(df)
        
        # Bước 5: Smooth volume anomalies
        df = self._clean_volume(df)
        
        return df
    
    def _detect_missing_candles(self, df: pd.DataFrame) -> pd.DataFrame:
        """Phát hiện các nến bị thiếu trong chuỗi"""
        df = df.copy()
        timestamps = df['timestamp'].values
        
        # Tính khoảng cách giữa các nến liên tiếp
        diffs = np.diff(timestamps)
        
        # Tìm các vị trí có gap bất thường (> 1.5x timeframe)
        expected_diff = self.timeframe_ms
        anomaly_threshold = expected_diff * 1.5
        
        missing_indices = np.where(diffs > anomaly_threshold)[0]
        
        if len(missing_indices) > 0:
            self.stats['missing_detected'] = len(missing_indices)
            print(f"Cảnh báo: Phát hiện {len(missing_indices)} gap trong dữ liệu")
            
            # Đánh dấu các vùng có vấn đề
            df['has_gap'] = False
            for idx in missing_indices:
                gap_size = diffs[idx] // expected_diff
                print(f"  - Gap tại index {idx}: thiếu ~{gap_size} nến")
        
        return df
    
    def _remove_outliers(self, df: pd.DataFrame, z_threshold: float = 5.0) -> pd.DataFrame:
        """
        Loại bỏ outliers sử dụng Z-score trên returns
        Phương pháp này conservative hơn so với loại trên giá trị tuyệt đối
        """
        df = df.copy()
        
        # Tính log returns
        df['returns'] = np.log(df['close'] / df['close'].shift(1))
        
        # Z-score
        mean_ret = df['returns'].mean()
        std_ret = df['returns'].std()
        
        df['z_score'] = (df['returns'] - mean_ret) / std_ret
        
        # Đánh dấu outliers nhưng KHÔNG xóa
        # Thay vào đó, cap giá trị (winsorize)
        outliers = np.abs(df['z_score']) > z_threshold
        self.stats['outliers_cleaned'] = outliers.sum()
        
        if outliers.sum() > 0:
            print(f"Cảnh báo: {outliers.sum()} outliers được phát hiện")
            
            # Cap giá trị
            upper_bound = mean_ret + z_threshold * std_ret
            lower_bound = mean_ret - z_threshold * std_ret
            
            df.loc[df['returns'] > upper_bound, 'close'] = \
                df.loc[df['returns'] > upper_bound, 'close'].shift(1) * np.exp(upper_bound)
            df.loc[df['returns'] < lower_bound, 'close'] = \
                df.loc[df['returns'] < lower_bound, 'close'].shift(1) * np.exp(lower_bound)
        
        df = df.drop(columns=['returns', 'z_score'], errors='ignore')
        return df
    
    def _validate_ohlc_logic(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Đảm bảo logic OHLC nhất quán:
        - High >= Open, Close
        - Low <= Open, Close
        """
        df = df.copy()
        
        invalid = (
            (df['high'] < df['open']) |
            (df['high'] < df['close']) |
            (df['low'] > df['open']) |
            (df['low'] > df['close'])
        )
        
        if invalid.sum() > 0:
            print(f"Cảnh báo: {invalid.sum()} nến có logic OHLC không hợp lệ")
            
            # Fix: recalculate high/low
            df.loc[invalid, 'high'] = df.loc[invalid, ['open', 'close']].max(axis=1)
            df.loc[invalid, 'low'] = df.loc[invalid, ['open', 'close']].min(axis=1)
        
        return df
    
    def _clean_volume(self, df: pd.DataFrame, window: int = 20) -> pd.DataFrame:
        """
        Xử lý volume spike sử dụng rolling median
        Thường xảy ra do wash trading hoặc deposit/withdrawal
        """
        df = df.copy()
        
        # Rolling median để detect spike
        rolling_median = df['volume'].rolling(window=window, center=True).median()
        rolling_std = df['volume'].rolling(window=window, center=True).std()
        
        # Volume > 5 std từ median được coi là spike
        volume_spike = df['volume'] > (rolling_median + 5 * rolling_std)
        
        if volume_spike.sum() > 0:
            print(f"Cảnh báo: {volume_spike.sum()} volume spike được phát hiện")
            
            # Replace spike với median
            df.loc[volume_spike, 'volume'] = rolling_median[volume_spike]
        
        return df
    
    def get_stats(self) -> dict:
        return self.stats

Sử dụng

cleaner = KLineCleaner(expected_timeframe_minutes=60) # 1h timeframe df_clean = cleaner.clean(df) print("\n=== Statistics ===") for key, value in cleaner.get_stats().items(): print(f"{key}: {value}")

Nguyên tắc quan trọng khi làm sạch dữ liệu:

Kiến Trúc Database Cho Backtest Hiệu Năng Cao

Với dữ liệu K-line, tôi khuyên dùng TimescaleDB (PostgreSQL với time-series extension) hoặc ClickHouse cho production. Với mục đích cá nhân/hobby, PostgreSQL thường đủ tốt.

-- Schema thiết kế cho dữ liệu K-line
-- Tối ưu cho query theo time range và symbol

-- Bảng chính: raw_klines (lưu dữ liệu thô, chưa clean)
CREATE TABLE raw_klines (
    id BIGSERIAL PRIMARY KEY,
    exchange VARCHAR(20) NOT NULL,
    symbol VARCHAR(20) NOT NULL,
    timeframe VARCHAR(5) NOT NULL,
    timestamp BIGINT NOT NULL,
    open NUMERIC(20, 8) NOT NULL,
    high NUMERIC(20, 8) NOT NULL,
    low NUMERIC(20, 8) NOT NULL,
    close NUMERIC(20, 8) NOT NULL,
    volume NUMERIC(20, 8) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    
    -- Unique constraint để tránh duplicate
    UNIQUE(exchange, symbol, timeframe, timestamp)
);

-- Index cho query hiệu năng cao
CREATE INDEX idx_raw_klines_lookup 
    ON raw_klines (exchange, symbol, timeframe, timestamp DESC);

-- Bảng đã clean: processed_klines
CREATE TABLE processed_klines (
    id BIGSERIAL PRIMARY KEY,
    exchange VARCHAR(20) NOT NULL,
    symbol VARCHAR(20) NOT NULL,
    timeframe VARCHAR(5) NOT NULL,
    timestamp BIGINT NOT NULL,
    open NUMERIC(20, 8) NOT NULL,
    high NUMERIC(20, 8) NOT NULL,
    low NUMERIC(20, 8) NOT NULL,
    close NUMERIC(20, 8) NOT NULL,
    volume NUMERIC(20, 8) NOT NULL,
    
    -- Metadata về quality
    is_valid BOOLEAN DEFAULT TRUE,
    cleaning_flags JSONB,
    
    UNIQUE(exchange, symbol, timeframe, timestamp)
);

CREATE INDEX idx_processed_lookup 
    ON processed_klines (exchange, symbol, timeframe, timestamp DESC);

-- Bảng cho backtest results
CREATE TABLE backtest_results (
    id BIGSERIAL PRIMARY KEY,
    strategy_name VARCHAR(100) NOT NULL,
    symbol VARCHAR(20) NOT NULL,
    timeframe VARCHAR(5) NOT NULL,
    
    -- Performance metrics
    total_return NUMERIC(10, 4),
    sharpe_ratio NUMERIC(10, 4),
    max_drawdown NUMERIC(10, 4),
    win_rate NUMERIC(10, 4),
    profit_factor NUMERIC(10, 4),
    total_trades INTEGER,
    
    -- Thông số chiến lược (stored as JSONB)
    parameters JSONB,
    
    -- Data range
    start_date BIGINT,
    end_date BIGINT,
    
    created_at TIMESTAMP DEFAULT NOW()
);

-- Convert thành TimescaleDB hypertable (nếu dùng TimescaleDB)
-- SELECT create_hypertable('processed_klines', 'timestamp');

-- Materialized view cho technical indicators (cache)
CREATE MATERIALIZED VIEW mv_btc_1h_indicators AS
SELECT 
    p.*,
    -- SMA
    AVG(p.close) OVER (ORDER BY p.timestamp ROWS BETWEEN 19 PRECEDING AND 0 FOLLOWING) as sma_20,
    AVG(p.close) OVER (ORDER BY p.timestamp ROWS BETWEEN 49 PRECEDING AND 0 FOLLOWING) as sma_50,
    
    -- RSI
    WITH returns AS (
        SELECT 
            close / LAG(close) OVER (ORDER BY timestamp) - 1 as ret
        FROM processed_klines
        WHERE symbol = 'BTC/USDT' AND timeframe = '1h'
    ),
    rsi_calc AS (
        SELECT 
            100 - (100 / (1 + AVG(CASE WHEN ret > 0 THEN ret ELSE 0 END) / 
                          NULLIF(ABS(AVG(CASE WHEN ret < 0 THEN ret ELSE 0 END)), 0)))
            as rsi
        FROM returns
    )
    
FROM processed_klines p
WHERE p.symbol = 'BTC/USDT' AND p.timeframe = '1h';

CREATE UNIQUE INDEX ON mv_btc_1h_indicators (timestamp);
# Kết nối Python với PostgreSQL
import psycopg2
from sqlalchemy import create_engine
import pandas as pd

class KLineDatabase:
    """Quản lý lưu trữ và query dữ liệu K-line"""
    
    def __init__(self, connection_string: str):
        self.engine = create_engine(connection_string)
    
    def save_raw_klines(self, df: pd.DataFrame) -> int:
        """Lưu dữ liệu thô vào database"""
        df = df.copy()
        
        # Map columns
        df = df.rename(columns={
            'exchange': 'exchange',
            'open': 'open',
            'high': 'high',
            'low': 'low',
            'close': 'close',
            'volume': 'volume',
            'timestamp': 'timestamp'
        })
        
        # Add required columns
        df['timeframe'] = '1h'  # Configurable
        df['symbol'] = 'BTC/USDT'  # Configurable
        
        # Save với ON CONFLICT DO NOTHING
        sql = """
            INSERT INTO raw_klines 
            (exchange, symbol, timeframe, timestamp, open, high, low, close, volume)
            VALUES (:exchange, :symbol, :timeframe, :timestamp, :open, :high, :low, :close, :volume)
            ON CONFLICT (exchange, symbol, timeframe, timestamp) DO NOTHING
        """
        
        with self.engine.connect() as conn:
            result = conn.execute(sql, df.to_dict('records'))
            return result.rowcount
    
    def get_processed_data(
        self, 
        symbol: str, 
        timeframe: str,
        start_ts: int,
        end_ts: int,
        exchanges: list = None
    ) -> pd.DataFrame:
        """Query dữ liệu đã xử lý với filter"""
        
        if exchanges is None:
            exchanges = ['binance', 'okx', 'bybit']
        
        sql = """
            SELECT * FROM processed_klines
            WHERE symbol = :symbol
            AND timeframe = :timeframe
            AND timestamp BETWEEN :start AND :end
            AND exchange = ANY(:exchanges)
            ORDER BY timestamp ASC
        """
        
        with self.engine.connect() as conn:
            df = pd.read_sql(
                sql, 
                conn, 
                params={
                    'symbol': symbol,
                    'timeframe': timeframe,
                    'start': start_ts,
                    'end': end_ts,
                    'exchanges': exchanges
                }
            )
        
        return df

Kết nối

db = KLineDatabase('postgresql://user:password@localhost:5432/kline_db')

Triển Khai Backtest Engine

Đây là phần cuối cùng — chạy backtest thực sự. Tôi sử dụng backtrader hoặc viết custom engine cho flexibility. Framework dưới đây là phiên bản simplified mà tôi dùng cho rapid prototyping.

import numpy as np
from dataclasses import dataclass
from typing import List, Dict, Optional
from enum import Enum

class PositionSide(Enum):
    LONG = 1
    SHORT = -1
    FLAT = 0

@dataclass
class Trade:
    entry_time: int
    entry_price: float
    size: float
    side: PositionSide
    exit_time: Optional[int] = None
    exit_price: Optional[float] = None
    pnl: Optional[float] = None

class BacktestEngine:
    """
    Simple backtest engine cho chiến lược mean-reversion và momentum
    Thiết kế dựa trên kinh nghiệm chạy 10,000+ backtests
    """
    
    def __init__(
        self, 
        initial_capital: float = 10000,
        commission: float = 0.001,  # 0.1% per trade
        slippage: float = 0.0005   # 0.05% slippage
    ):
        self.initial_capital = initial_capital
        self.commission = commission
        self.slippage = slippage
        
        self.capital = initial_capital
        self.position = 0
        self.position_side = PositionSide.FLAT
        self.trades: List[Trade] = []
        self.equity_curve = []
        
        # Stats
        self.total_pnl = 0
        self.winning_trades = 0
        self.losing_trades = 0
    
    def run(
        self, 
        df: pd.DataFrame, 
        strategy_func,
        params: dict
    ) -> Dict:
        """
        Chạy backtest với strategy function
        
        Args:
            df: DataFrame với columns ['timestamp', 'open', 'high', 'low', 'close', 'volume']
            strategy_func: Function nhận df và params, trả về signals
            params: Strategy parameters
        """
        # Reset state
        self.__init__(self.initial_capital, self.commission, self.slippage)
        
        # Generate signals
        signals = strategy_func(df, params)
        
        # Run simulation
        for i in range(len(df)):
            row = df.iloc[i]
            signal = signals.iloc[i] if i < len(signals) else 0
            
            self._process_signal(row, signal)
            self._update_equity(row)
        
        return self._calculate_metrics()
    
    def _process_signal(self, row, signal: int):
        """
        Xử lý signal và execute trades
        
        Signal: 1 = Long, -1 = Short, 0 = Flat
        """
        current_price = row['close']
        
        # Entry logic
        if signal == 1 and self.position_side == PositionSide.FLAT:
            # Buy signal, flat position
            size = self.capital / current_price
            cost = size * current_price * (1 + self.commission + self.slippage)
            
            if cost <= self.capital:
                self.capital -= cost
                self.position = size
                self.position_side = PositionSide.LONG
                self.trades.append(Trade(
                    entry_time=row['timestamp'],
                    entry_price=current_price,
                    size=size,
                    side=PositionSide.LONG
                ))
        
        elif signal == -1 and self.position_side == PositionSide.FLAT:
            # Short signal
            size = self.capital / current_price
            proceeds = size * current_price * (1 - self.commission - self.slippage)
            
            self.capital += proceeds
            self.position = size
            self.position_side = PositionSide.SHORT
            self.trades.append(Trade(
                entry_time=row['timestamp'],
                entry_price=current_price,
                size=size,
                side=PositionSide.SHORT
            ))
        
        # Exit logic (opposite signal)
        elif signal == 0 and self.position_side != PositionSide.FLAT:
            self._close_position(current_price, row['timestamp'])
        
        # Force close if signal flips
        elif (signal == 1 and self.position_side == PositionSide.SHORT) or \
             (signal == -1 and self.position_side == PositionSide.LONG):
            self._close_position(current_price, row['timestamp'])
            # Immediately open new position
            self._process_signal(row, signal)
    
    def _close_position(self, price: float, timestamp: int):
        """Close current position"""
        if self.position_side == PositionSide.LONG:
            proceeds = self.position * price * (1 - self.commission - self.slippage)
            self.capital += proceeds
        else:  # SHORT
            cost = self.position * price * (1 + self.commission + self.slippage)
            self.capital -= cost
        
        # Update last trade
        if self.trades:
            last_trade = self.trades[-1]
            last_trade.exit_time = timestamp
            last_trade.exit_price = price
            
            if last_trade.side == PositionSide.LONG:
                last_trade.pnl = (price - last_trade.entry_price) / last_trade.entry_price
            else:
                last_trade.pnl = (last_trade.entry_price - price) / last_trade.entry_price
            
            self.total_pnl += last_trade.pnl
            
            if last_trade.pnl > 0:
                self.winning_trades += 1
            else:
                self.losing_trades += 1
        
        self.position = 0
        self.position_side = PositionSide.FLAT
    
    def _update_equity(self, row):
        """Update equity curve"""
        if self.position_side == PositionSide.LONG:
            position_value = self.position * row['close']
            equity = self.capital + position_value
        elif self.position_side == PositionSide.SHORT:
            position_value = self.position * (2 * self.trades[-1].entry_price - row['close'])
            equity = self.capital
        else:
            equity = self.capital
        
        self.equity_curve.append({
            'timestamp': row['timestamp'],
            'equity': equity
        })
    
    def _calculate_metrics(self) -> Dict:
        """Tính toán performance metrics"""
        equity_arr = np.array([e['equity'] for e in self.equity_curve])
        
        # Returns
        returns = np.diff(equity_arr) / equity_arr[:-1]
        returns = returns[~np.isnan(returns) & ~np.isinf(returns)]
        
        # Total return
        total_return = (equity_arr[-1] / self.initial_capital - 1) * 100
        
        # Sharpe ratio (annualized, assuming 1h timeframe)
        if len(returns) > 0 and np.std(returns) > 0:
            sharpe = np.mean(returns) / np.std(returns) * np.sqrt(8760)  # hours/year
        else:
            sharpe = 0
        
        # Max drawdown
        cumulative = equity_arr / self.initial_capital
        running_max = np.maximum.accumulate(cumulative)
        drawdown = (cumulative - running_max) / running_max
        max_drawdown = abs(np.min(drawdown)) * 100
        
        # Win rate
        total_trades = self.winning_trades + self.losing_trades
        win_rate = self.winning_trades / total_trades if total_trades > 0 else 0
        
        # Profit factor
        winning_pnl = sum([t.pnl for t in self.trades if t.pnl and t.pnl > 0])
        losing_pnl = abs(sum([t.pnl for t in self.trades if t.pnl and t.pnl < 0]))
        profit_factor = winning_pnl / losing_pnl if losing_pnl > 0 else 0
        
        return {
            'total_return': total_return,
            'sharpe_ratio': sharpe,
            'max_drawdown': max_drawdown,
            'win_rate': win_rate,
            'profit_factor': profit_factor,
            'total_trades': total_trades,
            'winning_trades': self.winning_trades,
            'losing_trades': self.losing_trades,
            'final_capital': equity_arr[-1]
        }

Example strategy: Mean Reversion với Bollinger Bands

def bollinger_mean_reversion(df: pd.DataFrame, params: dict) -> pd.Series: """Chiến lược mean reversion sử dụng Bollinger Bands""" window = params.get('bb_window', 20) std_mult = params.get('bb_std', 2) # Calculate Bollinger Bands df = df.copy() df['sma'] =