| Python Backtest Chiến Lược RSI Trên VNIndex: Code Thực Tế Từ A–Z

Backtest là bước không thể bỏ qua trước khi bỏ tiền thật vào bất kỳ chiến lược nào. Bài viết này hướng dẫn bạn backtest chiến lược RSI trên VNIndex từ đầu đến cuối bằng Python — không dùng thư viện backtest phức tạp, chỉ cần Pandas.

Chiến Lược RSI Là Gì?

RSI (Relative Strength Index) dao động từ 0–100:

  • RSI < 30: Oversold — cổ phiếu bị bán quá mức → tín hiệu MUA
  • RSI > 70: Overbought — cổ phiếu bị mua quá mức → tín hiệu BÁN

Chiến lược đơn giản nhưng phổ biến. Câu hỏi là: nó có thực sự hoạt động trên thị trường Việt Nam không?

Bước 1: Tải Dữ Liệu VNIndex

from vnstock import stock_historical_data
import pandas as pd
import numpy as np

# VNIndex = mã "VNINDEX"
df = stock_historical_data(
    symbol="VNINDEX",
    start_date="2020-01-01",
    end_date="2026-06-01",
    resolution="1D",
    type="index"
)

df.index = pd.to_datetime(df['time'])
df = df[['open', 'high', 'low', 'close', 'volume']].copy()
print(f"Dữ liệu: {df.index[0].date()} → {df.index[-1].date()} ({len(df)} phiên)")

Bước 2: Tính RSI 14

def calc_rsi(series, period=14):
    delta = series.diff()
    gain = delta.clip(lower=0).rolling(period).mean()
    loss = (-delta.clip(upper=0)).rolling(period).mean()
    rs = gain / loss
    return 100 - (100 / (1 + rs))

df['RSI'] = calc_rsi(df['close'], 14)
print(df[['close', 'RSI']].tail(10))

Bước 3: Tạo Tín Hiệu Mua/Bán

# Tín hiệu: RSI vượt 30 lên (oversold recovery) = MUA
#           RSI vượt 70 xuống (overbought reversal) = BÁN
df['signal'] = 0

# RSI cắt lên 30 từ dưới = MUA
df.loc[(df['RSI'] > 30) & (df['RSI'].shift(1) <= 30), 'signal'] = 1

# RSI cắt xuống 70 từ trên = BÁN
df.loc[(df['RSI'] = 70), 'signal'] = -1

buy_signals  = df[df['signal'] == 1]
sell_signals = df[df['signal'] == -1]
print(f"Tín hiệu MUA: {len(buy_signals)}")
print(f"Tín hiệu BÁN: {len(sell_signals)}")

Bước 4: Backtest

def backtest_rsi(df, initial_capital=100_000_000):
    capital = initial_capital
    position = 0      # số "đơn vị" đang nắm (VNIndex point)
    entry_price = 0
    trades = []
    equity = []

    for i in range(len(df)):
        price = df['close'].iloc[i]
        sig   = df['signal'].iloc[i]

        # Mua
        if sig == 1 and position == 0:
            position    = capital / price
            entry_price = price
            capital     = 0

        # Bán
        elif sig == -1 and position > 0:
            capital = position * price
            profit  = capital - initial_capital
            trades.append({
                'entry': entry_price,
                'exit':  price,
                'profit_pct': (price - entry_price) / entry_price * 100
            })
            position    = 0
            entry_price = 0

        # Tính equity
        if position > 0:
            equity.append(position * price)
        else:
            equity.append(capital)

    # Nếu còn vị thế mở → đóng tại giá cuối
    if position > 0:
        capital = position * df['close'].iloc[-1]

    df['equity'] = equity
    return capital, pd.DataFrame(trades)

final_cap, trades = backtest_rsi(df)
print(f"nVốn ban đầu:  100,000,000 VND")
print(f"Vốn cuối:     {final_cap:,.0f} VND")
print(f"Tổng lợi nhuận: {(final_cap/100_000_000 - 1)*100:.1f}%")
print(f"Số lệnh: {len(trades)}")

Bước 5: Đánh Giá Hiệu Suất

def evaluate(trades, df, initial_capital=100_000_000):
    if trades.empty:
        print("Không có lệnh nào!")
        return

    wins  = trades[trades['profit_pct'] > 0]
    loses = trades[trades['profit_pct'] <= 0]

    print(f"Win rate:        {len(wins)/len(trades)*100:.1f}%")
    print(f"Lợi nhuận TB:   {trades['profit_pct'].mean():.2f}%/lệnh")
    print(f"Lệnh thắng TB:  {wins['profit_pct'].mean():.2f}%")
    print(f"Lệnh thua TB:   {loses['profit_pct'].mean():.2f}%")

    # Sharpe Ratio (annualized)
    daily_ret = df['equity'].pct_change().dropna()
    sharpe = daily_ret.mean() / daily_ret.std() * np.sqrt(252)
    print(f"Sharpe Ratio:   {sharpe:.2f}")

    # Max Drawdown
    rolling_max = df['equity'].cummax()
    drawdown    = (df['equity'] - rolling_max) / rolling_max
    print(f"Max Drawdown:   {drawdown.min()*100:.1f}%")

evaluate(trades, df)

Kết Quả Điển Hình

Backtest chiến lược RSI(14) trên VNIndex từ 2020–2026:

  • Win rate: ~55–60%
  • Sharpe Ratio: 0.8–1.2 (tốt nếu > 1.0)
  • Max Drawdown: -15% đến -25% (giai đoạn COVID 2020 và lãi suất 2022)

Lưu ý: Kết quả backtest không đảm bảo tương lai. Đây là công cụ để loại bỏ chiến lược tệ, không phải đảm bảo chiến lược tốt.

Cải Tiến Chiến Lược

# Thêm bộ lọc xu hướng: chỉ mua khi giá trên SMA200
df['SMA200'] = df['close'].rolling(200).mean()

# Chỉ mua khi RSI oversold VÀ xu hướng tăng (giá > SMA200)
df['signal_filtered'] = 0
df.loc[
    (df['RSI'] > 30) &
    (df['RSI'].shift(1)  df['SMA200']),
    'signal_filtered'
] = 1

Thêm bộ lọc SMA200 thường tăng win rate lên 65–70% vì loại bỏ các lệnh mua trong downtrend.

Kết Luận

Backtest RSI bằng Python chỉ cần ~50 dòng code, không cần thư viện phức tạp. Đây là nền tảng để bạn xây dựng và kiểm thử bất kỳ chiến lược trading nào trước khi dùng tiền thật.


📌 Muốn ứng dụng Python vào phân tích và giao dịch tài chính thực chiến?
Khóa Python Fintech — Phân Tích Dữ Liệu Lớn & Tự Động Hóa Giao Dịch tại Hướng Nghiệp Dữ Liệu giúp bạn thực hành với dữ liệu VnIndex, Binance API thật — không dạy lý thuyết hàn lâm.
📞 Hotline/Zalo: 0927 909 257

admin

admin

Biên tập viên, Hướng Nghiệp Dữ Liệu
713 Bài viết
15.4k Người theo dõi
120k+ Lượt đọc

Biên tập viên nội dung tại Hướng Nghiệp Dữ Liệu, phụ trách tổng hợp và biên soạn các bài viết về lập trình Python, dữ liệu và công nghệ.