cTrader で cBot を作る手順 — 最初の1本を動かすまでのC#コードと構成

cTrader の自動売買は cBot と呼ばれ、C# で記述します。MQL のような専用言語ではなく汎用言語をそのまま使えるため、C# の経験があれば入り口の負担は小さい部類です。一方で、cTrader 固有の作法を知らないと最初の1本で必ずつまずく箇所がいくつかあります。

本記事では、空のプロジェクトから動く cBot を1本組み立てるまでの手順を、コード全文とあわせて整理します。プラットフォームそのものの違いは cTrader と MT4/MT5 の違いを開発視点で整理する基礎ガイド で扱っています。

cBot と インジケーターの役割の違い

最初に整理しておくと、cTrader の拡張には大きく2種類あります。

判定ロジックだけを可視化したい段階ではインジケーターで組み、発注まで任せる段になったら cBot に移すという進め方ができます。どちらも C# で書き、基底クラスが IndicatorRobot かという違いになります。

プロジェクトを作る

cTrader の Automate セクションから新規 cBot を作成すると、雛形が生成されます。生成された時点では OnStartOnTick だけを持つ空のクラスで、ここに処理を足していく形になります。

押さえておきたいのは、実行の入り口が複数あることです。

メソッド呼ばれるタイミング
OnStart()cBot 起動時に1度だけ
OnTick()値が動くたび
OnBar()新しい足が始まったとき
OnStop()停止時に1度だけ

足の確定を条件に判定する設計なら OnBar() を使います。OnTick() で同じことをやろうとすると、同じ足の中で何度も条件判定が走り、意図しない連続発注につながりやすくなります。

確定した足を読むという約束

cTrader で最初に間違えやすいのがインデックスの扱いです。OnBar() が呼ばれた時点で Bars.Count - 1いま開いたばかりの足であり、その終値はまだ確定していません。

判定に使ってよいのは Bars.Count - 2 です。ここを取り違えると、バックテストでだけ良い成績が出て、ライブでは再現しないという形で問題が表面化します。この点は cTrader バックテストの実行手順 でも詳しく扱っています。

動く cBot の全文

移動平均のクロスで判定し、リスク率からロットを計算して発注する最小構成です。そのままビルドできます。

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

namespace cAlgo.Robots
{
    [Robot(AccessRights = AccessRights.None)]
    public class SimpleMaCross : Robot
    {
        [Parameter("Fast MA Period", DefaultValue = 20, MinValue = 2, Group = "MA")]
        public int FastPeriod { get; set; }

        [Parameter("Slow MA Period", DefaultValue = 50, MinValue = 3, Group = "MA")]
        public int SlowPeriod { get; set; }

        [Parameter("Risk %", DefaultValue = 1.0, MinValue = 0.1, MaxValue = 5.0, Group = "Risk")]
        public double RiskPercent { get; set; }

        [Parameter("Stop Loss (pips)", DefaultValue = 20, MinValue = 1, Group = "Risk")]
        public double StopLossPips { get; set; }

        [Parameter("Risk Reward", DefaultValue = 1.5, MinValue = 0.1, Group = "Risk")]
        public double RiskReward { get; set; }

        private const string Label = "SimpleMaCross";

        private MovingAverage _fast;
        private MovingAverage _slow;

        protected override void OnStart()
        {
            if (FastPeriod >= SlowPeriod)
            {
                Print("Fast は Slow より小さい必要があります。停止します。");
                Stop();
                return;
            }

            _fast = Indicators.MovingAverage(Bars.ClosePrices, FastPeriod, MovingAverageType.Exponential);
            _slow = Indicators.MovingAverage(Bars.ClosePrices, SlowPeriod, MovingAverageType.Exponential);
        }

        protected override void OnBar()
        {
            int i = Bars.Count - 2;              // 確定した足
            if (i < SlowPeriod + 1) return;      // 指標が育つまで待つ

            // 同じラベルの建玉があれば何もしない
            if (Positions.FindAll(Label, SymbolName).Length > 0) return;

            bool crossUp = _fast.Result[i] > _slow.Result[i]
                        && _fast.Result[i - 1] <= _slow.Result[i - 1];

            bool crossDown = _fast.Result[i] < _slow.Result[i]
                          && _fast.Result[i - 1] >= _slow.Result[i - 1];

            if (crossUp)   Enter(TradeType.Buy);
            if (crossDown) Enter(TradeType.Sell);
        }

        private void Enter(TradeType type)
        {
            double volume = CalculateVolume();
            if (volume <= 0) return;

            ExecuteMarketOrder(type, SymbolName, volume, Label,
                               StopLossPips, StopLossPips * RiskReward);
        }

        private double CalculateVolume()
        {
            double riskAmount = Account.Balance * RiskPercent / 100.0;
            double raw = riskAmount / (StopLossPips * Symbol.PipValue);

            // NormalizeVolumeInUnits は最小未満を 0 にせず最小へ切り上げる。
            // 切り上げ後で判定すると意図より大きいロットが通ってしまうため、
            // 必ず切り上げ前の値で弾く。
            if (raw < Symbol.VolumeInUnitsMin)
            {
                Print("計算ロット {0} が最小 {1} 未満のため見送ります。",
                      raw, Symbol.VolumeInUnitsMin);
                return 0;
            }

            return Symbol.NormalizeVolumeInUnits(raw, RoundingMode.Down);
        }
    }
}

コードで押さえている点

短いコードですが、実務で効いてくる判断がいくつか入っています。

ラベルで自分の建玉を識別する。 Positions には同じ口座の他の cBot や手動の建玉も入ります。Positions.FindAll(Label, SymbolName) で絞り込まないと、無関係な建玉を自分のものと誤認します。

指標が育つまで待つ。 i < SlowPeriod + 1 の判定を入れないと、計算に必要な本数が揃っていない区間で不正な値を読みます。

ロットは切り上げ前の値で判定する。 Symbol.NormalizeVolumeInUnits は計算結果が最小取引単位を下回っても 0 を返さず、最小単位へ切り上げます。つまり本来なら見送るべき場面でも発注が成立してしまいます。切り上げ前の raw で弾くのが要点です。ロットの考え方は ロットとは を参照してください。

リスクリワードを引数で持つ。 損切り幅に対する利確幅の比率を固定値で埋め込まず、パラメーター化しておくと後の検証がしやすくなります。

ビルドしてバックテストにかける

コードを保存するとビルドが走り、エラーがあればその場で表示されます。ビルドが通ったらバックテストタブで期間と銘柄を指定して実行します。

ここで注意したいのは、最初に出た数字を成績として受け取らないことです。パラメーターを動かせば数字は動きますし、良い組み合わせを探せば必ず何かは見つかります。それが再現性のあるものかどうかは、探索に使っていない期間で確かめるまで分かりません。

評価指標の読み方は プロフィットファクターとはドローダウンとは期待値とは で整理しています。

つまずきやすい点

最初の1本で詰まりやすい箇所をまとめます。

症状多い原因
発注が飛ばないロットが最小未満で見送られている / ラベル判定で既存建玉と誤認
同じ足で何度も発注するOnTick() で判定している
バックテストだけ成績が良い確定していない足を読んでいる
起動直後に例外が出る指標の計算に必要な本数が揃う前に読んでいる
ロットが想定より大きい切り上げ後の値で最小判定をしている

Print() を要所に入れて、判定に使った値と実際の発注内容をログで突き合わせると、原因の切り分けが早くなります。デバッグの進め方については 開発者の手記 #1 でも触れています。

まとめ

cBot の1本目は、複雑なロジックより先に「確定した足を読む」「自分の建玉だけを見る」「ロットを意図どおりに出す」の3点を正しく組むことが土台になります。ここが崩れていると、どれだけロジックを工夫しても検証結果が信用できなくなります。

動く形ができたら、パラメーターを増やす前にバックテストとライブで挙動が一致するかを確認してください。

cTrader 向けの cBot・インジケーターの開発相談や完成品ツールについては ai-programming.xyz で扱っています。体系的に学びたい場合は 未経験から cBot を自作するまでの学習ロードマップ もご覧ください。

本記事は一般的な情報提供を目的としています。投資助言ではありません。 取引判断はご自身の責任で行ってください。