🔥 🎁 QUÀ TẶNG khi đăng ký: Phần mềm DNT SaaS FindMe VPS Guardian 12 tháng (~240 USD) và 24 tháng (~480 USD)
Cấu trúc cBot Auto Trading & module hóa dữ liệu, indicator: kiến trúc cho hệ thống lớn
ĐTĐược viết bởi Đặng Trí Thanhvào ngày 04/09/2026 lúc 08:00| lượt xem
Kiến thức04/09/2026 · 16 phút đọc

Cấu trúc cBot Auto Trading & module hóa dữ liệu, indicator: kiến trúc cho hệ thống lớn

Có một khoảnh khắc mà hầu như ai viết cBot lâu năm đều từng trải qua: bạn mở file code của chính mình, tìm chỗ cần sửa, và nhận ra mình phải đọc lại ba trăm dòng chỉ để hiểu một biến đang lấy giá trị từ đâu. Thêm một bộ lọc lại phá vỡ một logic khác ở chỗ không ngờ tới; mỗi lần chạy backtest là mỗi lần sửa code giữa những khối if lồng nhau. Rồi bạn tự hỏi một câu rất đúng lúc: một cBot lớn được tổ chức thế nào để không biến thành nỗi ác mộng bảo trì?

Đây là bài viết dành cho người đã viết được cBot chạy được trên cTrader — hiểu OnStart, OnBar, biết tạo chỉ báo — nhưng đang chạm trần khi hệ thống phình to. Chúng ta sẽ nói chuyện như hai kỹ sư phần mềm: về lý do một class khổng lồ sụp đổ, về kiến trúc module và nguyên tắc trách nhiệm đơn lẻ áp dụng cho cBot, rồi đi sâu vào hai tầng nền móng — module Data (trừu tượng hóa nguồn dữ liệu, chuẩn hóa, cache) và module Indicator (bọc chỉ báo, tính một lần, tái sử dụng) — cùng cách chúng giao tiếp qua interface và sự kiện.

Vì sao một class "khổng lồ" sớm sụp đổ khi cBot lớn dần?

Trước khi bàn giải pháp, hãy hiểu chính xác căn bệnh. Nhiều người mô tả vấn đề mơ hồ — "code rối", "khó sửa". Nhưng dưới góc kỹ thuật phần mềm, cái chết của một cBot nhồi tất cả vào một class có nguyên nhân rất cụ thể — và gọi đúng tên nó giúp bạn phòng bệnh sớm. Mẫu phản thiết kế này có tên riêng: God Object — một đối tượng "nắm giữ quá nhiều thứ và biết quá nhiều thứ". Class Robot khởi đầu là một điều phối viên mỏng, nhưng nếu mọi logic — lấy dữ liệu, tính chỉ báo, quyết định, quản rủi ro, đặt lệnh — đều chảy về một nơi, nó trở thành God Object đúng nghĩa.

Sự sụp đổ không đến đột ngột: nó đến qua bốn cơ chế kỹ thuật, mỗi cơ chế âm thầm cộng dồn.

Thứ nhất, khớp nối cao (high coupling). Khi mọi thành phần nằm chung một class, chúng chia sẻ biến thành viên: biến do logic chỉ báo cập nhật lại được logic đặt lệnh đọc trực tiếp, không có biên giới nào ngăn cách. Hệ quả là một thay đổi nhỏ lan tỏa không kiểm soát — sửa cách tính xu hướng vô tình đổi luôn hành vi của khối quản lý vốn. Người ta đo độ "lây lan" này bằng khái niệm fan-in / fan-out: một biến được bao nhiêu nơi ghi, bao nhiêu nơi đọc. Class Robot phình to thì con số đó tăng theo cấp số nhân.

Thứ hai, khuếch đại thay đổi (change amplification). Đây là cơ chế nguy hiểm nhất với cBot, vì nó tấn công đúng vào vòng lặp phát triển: sửa → backtest → quan sát → sửa tiếp. Một thay đổi tưởng chừng đơn giản — ví dụ đổi cách tính khối lượng từ lot cố định sang rủi ro phần trăm — kéo theo phải dò mọi nơi gọi ExecuteMarketOrder và nhân với Symbol.QuantityToVolumeInUnits. Càng nhiều chỗ phải sửa đồng bộ, xác suất quên một chỗ càng cao — và lỗi đó thường chỉ lộ ra khi bot đã chạy tiền thật.

Thứ ba, tải nhận thức tăng (cognitive load). Một OnBar dài 400 dòng buộc bạn nhớ đồng thời: dữ liệu đang ở trạng thái nào, chỉ báo tính xong chưa, điều kiện vào lệnh là gì, rủi ro đã kiểm chưa. Vượt ngưỡng, bạn không còn suy luận được mà chỉ code — mỗi lần mở file là một lần nạp lại toàn bộ bối cảnh, và đó là lý do sửa đổi tưởng 5 phút hóa ra mất nửa buổi.

Thứ tư, không thể kiểm thử riêng lẻ (untestable in isolation). Khi mọi thứ gắn chặt vào class Robot, bạn không thể kiểm tra riêng "phần tính tín hiệu có đúng không" mà không kéo theo phần đặt lệnh. Bạn chỉ chạy được cả bot rồi nhìn kết quả — và nếu sai, bạn không biết sai ở tầng nào. Với hệ thống nhỏ thì chấp nhận được; với hệ thống lớn, đó là án tử hình.

Có một quan niệm cần bác bỏ ngay: "bot tôi nhỏ, chưa cần kiến trúc, khi lớn rồi tách sau." Thực tế nghịch lý: tách module khi code đã rối còn tốn công hơn viết lại từ đầu, vì lúc đó bạn không còn hiểu từng dòng để tách cho đúng. Chi phí xây kiến trúc rẻ nhất khi code còn nhỏ và bạn vẫn nắm rõ ý đồ. Bài viết này không nói về "dự án tương lai" — nó nói về cách viết cBot thứ hai, thứ ba của bạn, khi vẫn còn đủ sáng suốt để đặt nền móng.

Kiến trúc module và nguyên tắc trách nhiệm đơn lẻ (SRP) áp dụng cho cBot

Giải pháp cho God Object không phải là "chia file cho đỡ dài" — mà là chia trách nhiệm theo ranh giới thay đổi. Đây chính là lúc nguyên tắc Single Responsibility Principle (SRP) của Robert C. Martin phát huy sức mạnh, và nó được diễn đạt chính xác nhất thế này:

Một class chỉ nên có một lý do để thay đổi.

Nghe đơn giản, nhưng "một lý do để thay đổi" sắc bén hơn nhiều so với "một việc". Hãy đặt câu hỏi ngược: điều gì sẽ khiến bạn phải mở file này ra sửa? Nếu có từ hai câu trả lời không liên quan, class đó đã mang quá nhiều trách nhiệm.

Hãy thử áp dụng bài test này cho một cBot điển hình. Bạn sẽ sửa code vì những lý do nào?

Lý do phải mở code ra sửaĐó là trách nhiệm của...Module tương ứng
Đổi nguồn dữ liệu, thêm khung thời gian, xử lý nến rỗngQuản lý dữ liệuData
Đổi công thức chỉ báo, thêm bộ lọc, thay đổi tham số kỹ thuậtTính toán tín hiệuIndicator
Đổi quy tắc vào/thoát lệnh, thêm điều kiện chặnRa quyết địnhStrategy
Đổi cách tính khối lượng, giới hạn rủi ro, quản lý lệnhThực thi & an toànExecution / Risk
Đổi cách ghi log, định dạng cảnh báoQuan sát vận hànhLogging

Năm dòng trong bảng là năm lý do thay đổi khác nhau — tức năm trách nhiệm không nên nhốt chung một class. Khi tách chúng thành các module, bạn đạt được tính chất quan trọng nhất của kiến trúc: mỗi thay đổi trong tương lai chỉ chạm đúng một nơi. Sửa quy tắc vào lệnh không đụng cách tính chỉ báo; đổi chỉ báo không đụng nguồn dữ liệu. Đó chính là cách giữ cho cBot "sống" qua nhiều năm, nhiều lần đổi chiến lược.

Nhưng SRP chỉ là một nửa câu chuyện. Nửa còn lại là hướng phụ thuộc (dependency direction) — các module phải xếp thành tầng và chỉ gọi theo một chiều. Hãy hình dung luồng dữ liệu của một cBot như một đường ống đi lên:

        ┌───────────────────────────────────────────┐
        │  Robot (lớp điều khiển — mỏng, chỉ ráp nối) │
        └──────────────────┬────────────────────────┘
                           │ gọi
        ┌──────────────────▼────────────────────────┐
        │  Strategy — quyết định MUA / BÁN / ĐỨNG     │
        └──────────────────┬────────────────────────┘
                           │ hỏi
        ┌──────────────────▼────────────────────────┐
        │  Indicator — trả lời: có xu hướng? quá mua? │
        └──────────────────┬────────────────────────┘
                           │ đọc
        ┌──────────────────▼────────────────────────┐
        │  Data — nguồn dữ liệu, chuẩn hóa, cache      │
        └───────────────────────────────────────────┘

Quy tắc vàng: chỉ được gọi xuống dưới, không bao giờ gọi ngược lên trên. Data không biết Indicator tồn tại; Indicator không biết Strategy; Strategy không biết cách đặt lệnh. Mỗi tầng nói chuyện với tầng kế cận qua một "hợp đồng" (interface hoặc danh sách phương thức công khai) và không quan tâm bên kia thực hiện ra sao. Nhờ đó, bạn thay được toàn bộ phần ruột của một tầng mà không đụng tới các tầng khác.

Trong bài này, chúng ta tập trung vào hai tầng nền móng — DataIndicator — vì đây là nơi dễ sai nhất, được tái sử dụng nhiều nhất, và là nền để mọi chiến lược phía trên đứng vững. Hai tầng còn lại (Strategy, Execution/Risk) chúng tôi sẽ nói ngắn ở phần giao tiếp module, vì chúng vận hành theo đúng cùng một tư duy.

Module Data: trừu tượng hóa nguồn dữ liệu, chuẩn hóa và cache

Bắt đầu từ tầng đáy — nơi ít được đầu tư nhất nhưng gây hậu quả lớn nhất. Quy luật bất biến của mọi hệ thống xử lý dữ liệu: rác vào – rác ra. Một cBot với chiến lược xuất sắc vẫn chết nếu tầng dữ liệu trả giá sai, nến thiếu, hoặc tính toán lặp mỗi tick. Module Data có ba nhiệm vụ cốt lõi, và nhiệm vụ thứ ba thường bị bỏ quên nhất.

Nhiệm vụ 1 — Trừu tượng hóa nguồn dữ liệu. Một chiến lược thực chiến hiếm khi chỉ nhìn một khung thời gian: bot M15 có thể cần xu hướng H1, phiên giao dịch, spread, giá Bid/Ask. Nếu code "xin dữ liệu" rải rác khắp nơi — chỗ gọi Bars.ClosePrices, chỗ gọi MarketData.GetBars, chỗ đọc thẳng Symbol.Bid — thì muốn đổi nguồn hay thêm khung thời gian, bạn phải dò khắp file. Module Data gom việc "đụng dữ liệu thô" về một nơi: lớp phía trên chỉ cần hỏi "cho tôi nến đóng gần nhất của M15" mà không cần biết nó đến từ đâu.

Nhiệm vụ 2 — Chuẩn hóa dữ liệu. Dữ liệu thô không phải lúc nào cũng sạch: nến rỗng ở phiên nghỉ, giá nhảy bất thường do tin tức hay lỗi nguồn cấp, mỗi khung thời gian lại có cấu trúc khác nhau. Module Data chuẩn hóa tất cả về một cấu trúc thống nhất — một kiểu BarData dùng chung — và lọc giá trị bất thường trước khi chúng kịp "ngấm" vào chỉ báo phía trên. Nếu không chuẩn hóa, mỗi nguồn một "kiểu riêng", và lỗi chỉ xuất hiện khi ghép chúng lại — đúng lúc khó truy nhất.

Nhiệm vụ 3 — Cache kết quả. Đây là chỗ nhiều bot "chết oan" nhất. OnTick chạy hàng chục lần mỗi giây; nếu mỗi lần tính lại toàn bộ chỉ báo hay quét lại lịch sử nến, bot lag, bỏ lỡ tick, vào lệnh trễ. Module Data chỉ cập nhật khi có nến mới thật sự đóng, lưu kết quả vào bộ nhớ tạm, các lần hỏi sau chỉ lấy từ cache. Nguyên tắc: tính một lần, đọc nhiều lần.

Hãy xem một module Data mẫu, khác hẳn kiểu "một class bọc vài getter": nó định nghĩa kiểu dữ liệu chuẩn hóa BarData, trừu tượng hóa nguồn qua interface IBarFeed, và một DataContext quản lý cache cho nhiều khung thời gian:

using System;
using System.Collections.Generic;
using cAlgo.API;

namespace cAlgo.Robots
{
    // Kiểu dữ liệu CHUẨN HÓA dùng chung cho mọi tầng phía trên
    // — không phụ thuộc vào cấu trúc nến riêng của cTrader
    public struct BarData
    {
        public DateTime OpenTime;
        public double Open, High, Low, Close;
        public double Spread;

        public bool IsValid => High >= Low && High > 0 && Close > 0;
    }

    // "Hợp đồng" của một nguồn dữ liệu bất kỳ
    // — đổi nguồn chỉ cần viết một class implement khác
    public interface IBarFeed
    {
        string Name { get; }
        BarData GetLastBar(int shift);
        bool HasNewBar { get; }
    }

    // Nguồn dữ liệu thật: bọc nến cTrader, chuẩn hóa về BarData
    public class CTraderBarFeed : IBarFeed
    {
        private readonly Bars _bars;
        private int _lastCount = -1;

        public CTraderBarFeed(Bars bars)
        {
            _bars = bars;
            Name = bars.TimeFrame.ToString();
        }

        public string Name { get; }

        public bool HasNewBar
        {
            get
            {
                if (_lastCount == _bars.Count) return false;
                _lastCount = _bars.Count;
                return true;
            }
        }

        public BarData GetLastBar(int shift)
        {
            var raw = new BarData
            {
                OpenTime = _bars.OpenTimes.Last(shift),
                Open = _bars.OpenPrices.Last(shift),
                High = _bars.HighPrices.Last(shift),
                Low = _bars.LowPrices.Last(shift),
                Close = _bars.ClosePrices.Last(shift),
                Spread = 0 // nạp từ Symbol nếu cần
            };

            // CHUẨN HÓA: nến rỗng hoặc phiên nghỉ -> trả nến không hợp lệ
            if (!raw.IsValid)
                return default(BarData);

            return raw;
        }
    }
}

Đoạn trên mới chỉ là "nguồn". Phần thú vị là DataContext — nơi quản lý nhiều nguồn cùng lúc và giữ cache để tầng Indicator không bao giờ phải tính lặp:

namespace cAlgo.Robots
{
    // DataContext: trừu tượng hóa việc "xin dữ liệu từ nhiều khung thời gian"
    public class DataContext
    {
        private readonly Dictionary<string, IBarFeed> _feeds = new Dictionary<string, IBarFeed>();
        private readonly Dictionary<string, BarData> _cache = new Dictionary<string, BarData>();
        private bool _dirty = true;

        public void Register(string key, IBarFeed feed) => _feeds[key] = feed;

        // CACHE: chỉ làm mới một lần cho mỗi nến, các lần hỏi sau lấy từ bộ nhớ
        public void RefreshIfNeeded()
        {
            foreach (var pair in _feeds)
            {
                if (pair.Value.HasNewBar)
                    _cache[pair.Key] = pair.Value.GetLastBar(1);
            }
            _dirty = false;
        }

        // Trả dữ liệu ĐÃ chuẩn hóa và ĐÃ cache — nhanh, an toàn, đồng bộ
        public BarData Get(string key)
        {
            if (_dirty)
                RefreshIfNeeded();
            return _cache.TryGetValue(key, out var bar) ? bar : default(BarData);
        }

        // Tiện ích trả lời "câu hỏi nghiệp vụ" cho tầng Indicator
        public bool BarAbove(string key, double price)
        {
            var bar = Get(key);
            return bar.IsValid && bar.Close > price;
        }
    }
}

Hãy để ý điều quan trọng: tầng Indicator và Strategy phía trên không hề biết nến đến từ đâu. Chúng gọi _data.Get("M15"), _data.Get("H1") và nhận về một BarData đã chuẩn hóa, lọc và cache. Muốn thêm khung D1? Chỉ cần _data.Register("D1", feed) ở một chỗ duy nhất trong OnStart. Muốn đổi nguồn dữ liệu? Viết một class implement IBarFeed khác. Hàng trăm dòng chiến lược phía trên không phải đổi một dòng nào — đó là sức mạnh của trừu tượng hóa: thay đổi luôn nằm gọn về một phía.

Module Indicator: bọc chỉ báo, tính một lần, tái sử dụng

Lên một tầng, chúng ta đến nơi "trí tuệ kỹ thuật" của cBot sinh sống — nhưng cần dẹp bỏ ngay một hiểu lầm phổ biến: module Indicator không phải là nơi nhét công thức chỉ báo. Nó là nơi bạn bọc các chỉ báo thành những câu hỏi sạch sẽ để chiến lược hỏi: "Xu hướng đang tăng chứ?", "Động lượng đang yếu đi không?", "Biến động có đủ lớn cho một cú breakout không?"

Vì sao phải bọc? Có ba lý do kỹ thuật rõ ràng:

Một — tránh tính lặp và tránh tạo chỉ báo sai chỗ. Lỗi kinh điển của người mới là tạo chỉ báo bên trong OnBar/OnTick — mỗi lần sự kiện chạy lại khởi tạo toàn bộ, vừa chậm vừa sai, vì chỉ báo mới chưa kịp "nuôi" đủ lịch sử nên vài nến đầu luôn lệch. Quy tắc bất di bất dịch: chỉ báo được tạo một lần duy nhất trong `OnStart`, lưu vào biến, các tầng khác chỉ đọc kết quả.

Hai — tách "cách tính" khỏi "ý nghĩa". Nếu chiến lược viết thẳng điều kiện emaFast.Result.Last(1) > emaSlow.Result.Last(1) ở khắp nơi, khi muốn đổi EMA sang SMA hay sang MACD, bạn phải dò từng chỗ. Còn nếu Indicator bọc thành câu hỏi _filter.IsUptrend() — thì bên trong dùng công thức nào, chiến lược không hề đổi. Bạn sửa "cách trả lời" trong một module, mọi thứ khác đứng yên.

Ba — tái sử dụng. Module Indicator tốt là tài sản tích lũy. Bộ lọc xu hướng bạn viết cho bot A dùng được nguyên cho bot B, bot C — chỉ cần copy module sang project mới và khởi tạo với tham số khác. Càng về sau, viết bot mới càng giống "lắp ráp từ kho module có sẵn" hơn là viết lại từ đầu.

Xem một module Indicator mẫu — lần này chọn bộ lọc khác hẳn ví dụ EMA-đôi quen thuộc: kết hợp MACD (đo động lượng) với ADX (đo sức mạnh xu hướng) để trả lời câu hỏi thực chiến: "giao dịch theo hướng đang có hay đứng ngoài?":

using cAlgo.API;
using cAlgo.API.Indicators;

namespace cAlgo.Robots
{
    // Module INDICATOR: bọc chỉ báo thành "câu trả lời" cho Strategy
    public class TrendMomentumFilter
    {
        private readonly Macd _macd;          // tạo MỘT LẦN ở OnStart
        private readonly DirectionalMovementSystem _adx;
        private readonly DataContext _data;   // đọc dữ liệu ĐÃ cache từ tầng Data

        public TrendMomentumFilter(Macd macd,
            DirectionalMovementSystem adx,
            DataContext data)
        {
            _macd = macd;
            _adx = adx;
            _data = data;
        }

        // Xu hướng tăng thật sự: MACD trên đường tín hiệu + ADX đủ mạnh
        public bool IsStrongUptrend()
        {
            var bar = _data.Get("H1");
            if (!bar.IsValid) return false;

            double macd = _macd.MACD.Last(1);
            double signal = _macd.Signal.Last(1);
            double adx = _adx.ADX.Last(1);

            return macd > signal && adx > 25 && bar.Close > bar.Open;
        }

        // Động lượng đang yếu đi: histogram thu hẹp dần (cảnh báo sớm)
        public bool IsMomentumFading()
        {
            double histNow = _macd.Histogram.Last(1);
            double histPrev = _macd.Histogram.Last(2);
            return histNow < histPrev;
        }

        public double AverageRange() => _data.Get("M15").High - _data.Get("M15").Low;
    }
}

Điểm mấu chốt khi đọc code trên: module này không tính toán gì nặng — MACD và ADX đã được cTrader tính sẵn từ OnStart, nến đã được tầng Data cache. Nó chỉ là lớp diễn giải: đọc vài giá trị, kết hợp lại, trả lời đúng/sai. Nhờ đó cực nhanh, cực dễ đọc (tên phương thức nói lên tất cả), và cực dễ thay thế — muốn đổi tiêu chí "xu hướng mạnh", bạn sửa đúng một chỗ trong class này.

Một điểm tinh tế: module Indicator ở đây đọc dữ liệu qua `DataContext` chứ không đụng trực tiếp vào Bars của cTrader — minh họa sống động cho nguyên tắc phân tầng: không "xuống tận nguồn" mà đi qua trung gian đã chuẩn hóa. Khi muốn kiểm thử với dữ liệu giả (bàn ở phần test), bạn chỉ cần đưa vào một DataContext chứa dữ liệu tự tạo — không cần cTrader thật.

Cách các module giao tiếp: interface và sự kiện

Đến đây bạn đã có các module độc lập. Câu hỏi tiếp theo mới là phần khó nhất của kiến trúc: chúng nói chuyện với nhau bằng cách nào mà vẫn giữ được sự độc lập? Có hai cơ chế bổ sung nhau, và người thiết kế cBot chuyên nghiệp dùng cả hai:

Cơ chế 1 — Gọi trực tiếp (pull). Tầng trên chủ động gọi phương thức của tầng dưới: _filter.IsStrongUptrend(), _data.Get("M15"). Đây là cách giao tiếp đơn giản, dễ đọc, dễ gỡ lỗi nhất — và đủ dùng cho phần lớn cBot. Điều kiện tiên quyết: tầng trên chỉ biết interface (danh sách phương thức công khai), không biết cách thực hiện bên trong.

Cơ chế 2 — Sự kiện (push). Có những thông tin nhiều nơi cùng cần biết, và việc "mỗi nơi tự đi hỏi" sinh ra code trùng lặp. Ví dụ điển hình nhất với cBot: sự kiện "có nến mới đóng". Khi một nến M15 đóng, ba nơi cần phản ứng — cập nhật cache dữ liệu, đánh giá tín hiệu, kiểm tra lệnh mở. Nếu mỗi nơi tự dò tìm, bạn lại rơi vào cảnh "sửa một chỗ quên ba chỗ". Giải pháp: tầng Data phát ra sự kiện, các module đăng ký lắng nghe.

Xem cách xây luồng sự kiện bằng C# — dùng event và delegate thuần túy:

using System;

namespace cAlgo.Robots
{
    // Dữ liệu truyền kèm sự kiện "nến mới"
    public class NewBarEventArgs : EventArgs
    {
        public string Symbol { get; set; }
        public string TimeFrame { get; set; }
        public DateTime BarTime { get; set; }
    }

    // Nguồn phát sự kiện: DataContext giờ cũng là một "kênh thông báo"
    public partial class DataContext
    {
        public event EventHandler<NewBarEventArgs> NewBar;

        protected void RaiseNewBar(string symbol, string tf, DateTime time)
        {
            NewBar?.Invoke(this, new NewBarEventArgs
            {
                Symbol = symbol,
                TimeFrame = tf,
                BarTime = time
            });
        }
    }
}

Còn đây là một "hợp đồng" cho tầng Strategy — tách hoàn toàn quyết định khỏi cách thực thi:

namespace cAlgo.Robots
{
    // Strategy chỉ cần implement hợp đồng này — Robot không cần biết nội dung
    public interface ITradeDecision
    {
        TradeType? Decide();
    }

    // Một chiến lược cụ thể: ghép Data + Indicator, trả về quyết định
    public class MomentumStrategy : ITradeDecision
    {
        private readonly DataContext _data;
        private readonly TrendMomentumFilter _filter;

        public MomentumStrategy(DataContext data, TrendMomentumFilter filter)
        {
            _data = data;
            _filter = filter;
        }

        public TradeType? Decide()
        {
            if (!_data.Get("M15").IsValid)
                return null; // chưa đủ dữ liệu -> đứng ngoài

            if (_filter.IsStrongUptrend() && !_filter.IsMomentumFading())
                return TradeType.Buy;

            if (_filter.IsMomentumFading())
                return TradeType.Sell; // động lượng yếu -> thoát / đảo chiều

            return null;
        }
    }
}

Bây giờ nhìn lại vai trò của class Robot trong kiến trúc này. Nó không còn là God Object — nó là người ráp nối (composer): trong OnStart, nó tạo các module và nối chúng lại với nhau; trong OnBar, nó chỉ đánh thức cỗ máy và thực thi quyết định:

using System;
using cAlgo.API;
using cAlgo.API.Indicators;

namespace cAlgo.Robots
{
    [Robot(AccessRights = AccessRights.None)]
    public class ModularMomentumBot : Robot
    {
        private DataContext _data;
        private ITradeDecision _strategy;

        protected override void OnStart()
        {
            // 1. Xây tầng DATA: đăng ký 2 khung thời gian
            _data = new DataContext();
            _data.Register("M15", new CTraderBarFeed(Bars));
            _data.Register("H1", new CTraderBarFeed(MarketData.GetBars(TimeFrame.Hour, SymbolName)));

            // 2. Xây tầng INDICATOR: chỉ báo tạo MỘT LẦN tại đây
            var macd = Indicators.Macd(Bars.ClosePrices, 12, 26, 9);
            var adx = Indicators.DirectionalMovementSystem(14);
            var filter = new TrendMomentumFilter(macd, adx, _data);

            // 3. Xây tầng STRATEGY và nối vào
            _strategy = new MomentumStrategy(_data, filter);
        }

        protected override void OnBar()
        {
            // 4. Đánh thức: dữ liệu tự phát hiện nến mới, tự cập nhật cache
            _data.RefreshIfNeeded();

            // 5. Hỏi chiến lược, KHÔNG biết chiến lược nghĩ thế nào
            var decision = _strategy.Decide();
            if (decision == null || Positions.Count > 0)
                return;

            var bar = _data.Get("M15");
            double volume = Symbol.QuantityToVolumeInUnits(0.01);
            ExecuteMarketOrder(decision.Value, SymbolName, volume, "ModularBot");
        }
    }
}

Hãy đếm xem class Robot này còn bao nhiêu dòng logic thật sự: gần như bằng không. Nó không biết xu hướng tính ra sao, không biết MACD là gì, không biết cache hoạt động thế nào — nó chỉ ráp các module lại và đặt lệnh theo quyết định nhận được. Đây là mục tiêu của toàn bộ bài viết: càng nhiều logic nằm trong các module có tên rõ ràng, class Robot càng mỏng, hệ thống càng dễ sống lâu. Muốn thử chiến lược hoàn toàn mới, bạn không sửa bot — chỉ cần viết một class implement ITradeDecision khác và "cắm" vào; toàn bộ Data, Indicator, cache, quản lý lệnh đứng yên.

Thử nghiệm (test) và tái sử dụng module giữa các cBot

Nhiều người nghĩ "test" với cBot đồng nghĩa với chạy backtest. Điều đó đúng — nhưng mới chỉ là một nửa. Kiến trúc module cho phép bạn làm được một cấp độ test tinh tế hơn: test từng module riêng lẻ trước khi ráp vào hệ thống.

Điểm mấu chốt: các module chúng ta xây không phụ thuộc vào class `Robot` — chúng chỉ nhận dữ liệu qua tham số và interface. Nhờ vậy bạn "thay dữ liệu thật bằng dữ liệu giả" để kiểm tra logic thuần túy. Hãy tưởng tượng một phiên gỡ lỗi: bot báo tín hiệu mua sai ở một nến cụ thể. Với kiến trúc module, bạn khoanh vùng ngay — Data trả sai giá, Indicator diễn giải sai, hay Strategy ra quyết định sai? — thay vì dò cả trăm dòng trong một class.

Một kỹ thuật thực hành hữu ích là viết một nạp thử (harness) — một cBot tạm để "bơm" dữ liệu mẫu vào module và in kết quả ra cửa sổ Output:

// Ví dụ một nạp thử đơn giản — kiểm tra riêng module Indicator
// (dán tạm trong OnStart một cBot test, xem xong thì xóa)
protected override void OnStart()
{
    // Tạo DataContext rỗng với dữ liệu mẫu
    var fake = new DataContext();
    // ... bơm các BarData giả vào cache theo kịch bản cần kiểm tra

    // Kiểm tra module Indicator trả lời đúng câu hỏi không
    // (cần tạo chỉ báo thật qua Indicators.Macd...)
    Print("Bắt đầu nạp thử module Indicator");
}

Dĩ nhiên, với chỉ báo thật của cTrader bạn vẫn cần chúng được tạo từ Indicators — nên cách thực dụng nhất là bật/tắt module trong backtest: chạy với module A bật, module B tắt, so sánh kết quả rồi đảo lại. Chênh lệch giữa hai lần chạy cho bạn biết chính xác module nào đóng góp gì — một kỹ thuật chẩn đoán mạnh mà kiến trúc module mang lại miễn phí.

Khi các module đã được kiểm chứng, bạn bước vào giai đoạn tích lũy tài sản — phần thưởng lớn nhất của toàn bộ công sức này. Hãy xây cho mình một thư viện module theo đúng quy trình:

BướcViệc làmKết quả
1Viết module dùng chung (Data, Indicator) độc lập với cBotCó thể copy sang project khác nguyên vẹn
2Kiểm chứng từng module bằng backtest bật/tắtBiết chắc module nào đáng tin
3Giữ module ở một project "thư viện" riêngKhông phải sửa nhiều bản copy
4Bot mới chỉ "kéo" module có sẵn + viết Strategy mớiViết bot nhanh gấp nhiều lần

Kinh nghiệm thực tế: sau 3–4 cBot, bạn sẽ có đủ bộ module cho đa số ý tưởng — lọc xu hướng, lọc biến động, lọc phiên, quản lý rủi ro. Mỗi ý tưởng mới không còn là "dự án từ số 0" mà là một phiên lắp ráp: chọn Data, chọn Indicator, viết một Strategy mỏng, chạy backtest. Chính ở giai đoạn này, khoản đầu tư vào kiến trúc ban đầu bắt đầu trả lãi kép.

Câu hỏi thường gặp (FAQ)

Hỏi: Tôi chỉ viết bot vài chục dòng cho riêng mình, kiến trúc module có phải "làm quá" không?

Đáp: Với bot một lần dùng, không có kế hoạch phát triển thì chưa cần. Nhưng hầu hết bot đều "lớn nhanh" hơn dự kiến — bạn sẽ thêm bộ lọc, thêm khung thời gian, thêm quy tắc quản lý lệnh. Nguyên tắc thực dụng: tách module ngay khi sắp thêm tính năng thứ hai, lúc code còn nhỏ và bạn còn hiểu rõ nó. Chi phí lúc đó gần bằng không; chi phí tách khi code đã rối thì rất đắt.

Hỏi: Tôi tạo chỉ báo trong OnBar thay vì OnStart, vì sao không nên?

Đáp: Chỉ báo cần dữ liệu lịch sử để "chạy nóng" trước khi cho giá trị đáng tin. Tạo trong OnBar/OnTick nghĩa là mỗi lần lại khởi tạo lại từ đầu — vừa chậm, vừa khiến vài nến đầu tiên trả giá trị sai lệch. Chỉ báo phải được tạo một lần duy nhất trong `OnStart` và lưu vào biến; các module phía trên chỉ đọc kết quả.

Hỏi: Module hóa có làm cBot chạy chậm hơn không?

Đáp: Ngược lại. Module Data dùng cache (chỉ cập nhật khi có nến mới), module Indicator không tạo lại chỉ báo, giao tiếp qua interface không tốn chi phí đáng kể. So với kiểu "mỗi tick tính lại mọi thứ trong một class", cBot module hóa làm đúng sẽ chạy mượt hơn rõ rệt.

Hỏi: Làm sao để module dùng chung cho nhiều cBot mà không phải copy-paste mỗi lần?

Đáp: Cách đơn giản nhất là giữ chúng trong một project "thư viện" riêng và copy sang cBot mới khi cần — cTrader Automate biên dịch toàn bộ file .cs trong project, nên bạn duy trì một bộ module chuẩn dùng nhất quán. Điều kiện: module phải độc lập với class Robot, chỉ nhận dữ liệu qua tham số, thì mới "kéo đi" được.

Hỏi: Khi nào tôi nên tách thêm một module mới?

Đáp: Hai tín hiệu rõ ràng: (1) một class bắt đầu có nhiều hơn một "lý do để thay đổi" — ví dụ bạn phải sửa nó vừa khi đổi chỉ báo vừa khi đổi cách tính khối lượng; (2) bạn sắp phải copy-paste một đoạn logic sang chỗ khác. Gặp một trong hai dấu hiệu, hãy dừng và tách — đó là thời điểm vàng.

Hỏi: Backtest với kiến trúc module có gì khác biệt so với trước?

Đáp: Khác biệt lớn nhất là khả năng bật/tắt module. Thay vì chỉ biết "bot lời hay lỗi", bạn biết chính xác module nào đóng góp lợi nhuận, module nào đang kéo tụt kết quả. Điều này giúp bạn ra quyết định dựa trên bằng chứng thay vì cảm tính — và tránh được cạm bẫy overfit vì bạn hiểu vai trò của từng thành phần.

Kết luận

Có một ranh giới mà mọi người viết cBot đều phải vượt qua: từ chỗ "viết code chạy được" đến chỗ "xây hệ thống sống được lâu dài". Bài viết này đã vẽ lại con đường vượt ranh giới đó theo đúng ngôn ngữ của kỹ thuật phần mềm: hiểu vì sao God Object sụp đổ; tổ chức code theo trách nhiệm đơn lẻ với luồng phụ thuộc một chiều; xây module Data trừu tượng hóa nguồn dữ liệu, chuẩn hóa và cache; bọc chỉ báo thành module Indicator trả lời câu hỏi sạch sẽ; nối chúng bằng interface và sự kiện để class Robot chỉ còn là người ráp nối; cuối cùng, kiểm chứng từng module rồi tích lũy thành thư viện riêng.

Điều đáng nhớ nhất: kiến trúc không phải thứ dành cho "dự án lớn mai sau" — nó là thói quen viết code của ngày hôm nay. Mỗi lần giữ cho một class chỉ có một lý do để thay đổi, mỗi lần tách một đoạn logic tái sử dụng được ra khỏi OnBar, bạn đang trả trước một khoản nhỏ để mua lại sự bình yên cho hàng trăm giờ bảo trì phía trước. Khi hệ thống lớn dần, thứ bạn xây không chỉ là một cBot — mà là một nền tảng để thử hết ý tưởng này đến ý tưởng khác mà không sợ "động vào là vỡ".

Nếu bạn muốn đi từ một cBot "chạy được" lên một hệ thống có kiến trúc chuẩn — từ module hóa Data, Indicator đến hoàn thiện Strategy, Execution, Risk và quy trình backtest — DNT Digital có lộ trình thực hành được thiết kế theo đúng thứ tự này, giúp bạn rút ngắn hàng trăm giờ tự mò mẫm. Bắt đầu từ chính cBot bạn đang viết dở: hôm nay, hãy thử tách riêng phần dữ liệu và phần chỉ báo ra khỏi class chính — và cảm nhận sự khác biệt.

▶️ Xem video: Cấu trúc cBot chuẩn Auto Trading & module hóa
Đọc xong rồi? Hãy thử ngay — miễn phí 7 ngày.