自动化·

如何为 cTrader cBot 添加新闻过滤器(含云端)

cTrader 没有给 cBot 用的财经日历 API,cTrader Cloud 还会拦截 HTTP 请求。这里给出一个桌面端和云端都能用的新闻过滤器:通过 WebSocket 获取免费财经日历,附完整 C# 代码。

在 cBot 里做新闻过滤,会撞上两堵 MetaTrader 开发者从没见过的墙。第一,cTrader Automate API 没有财经日历:平台界面上看得到日历,cBot 却读不到。第二,多数人把 cBot 放在 cTrader Cloud 上运行,而那里的 cBot 根本发不出 HTTP 请求,Http.Get() 只会返回状态码 503。

云端只放行一样东西:端口 25345 上的 WebSocket。免费的 ShowMyTrades 财经日历正是走这条路送到 cBot 手里。本指南给出完整方案:思路、协议,外加一个拿来就能编译的 cBot。

🧭 工作原理

cBot(桌面端或云端)
┌──────────────────────────
│ 每 15 分钟:
│  打开 socket,发送
│  "currency=EUR,USD&
│   impact=high&days=2&
│   format=csv"
│  解析,关闭
│ 每个 tick:IsNewsTime()
└──────────────────────────
     ws://…:25345    CSV 文本
          │             ▲
          ▼             │
ShowMyTrades
┌──────────────────────────
│ 与以下接口相同的日历:
│ /api/public/calendar/v1
│ 已缓存、已筛选,
│ 体积仅几 KB
└──────────────────────────
地址ws://showmytrades.com:25345/
您发送一行文本,参数与 HTTP API 相同:currency、impact、days、format
您收到一个文本帧,内容是 CSV(或 JSON)正文
访问权限AccessRights.None 就够了,所以这个 cBot 可以发布
密钥或账户都不需要
桌面端备用方案桌面端可以直接用 HTTP;如果 socket 连接失败,下面的代码会自动改用 HTTP

这里用的是 ws:// 而不是 wss://,因为 cTrader Cloud 连接时用的就是它。连接上传的只有这四个参数,不涉及您本人或您账户的任何信息。

📄 cBot 代码

在 cTrader Automate(自动化)里新建一个 cBot,把代码换成下面这段,然后构建(Build)。桌面端用 NuGet 上的 cTrader.Automate 编译;云端原样即可运行。

SmtNewsFilterBot.cs
// cTrader 新闻过滤器(桌面端和云端),数据来自 ShowMyTrades,免费,无需密钥。
// 文档:https://showmytrades.com/docs/api/economic-calendar
using System;
using System.Collections.Generic;
using cAlgo.API;

namespace cAlgo.Robots
{
    [Robot(AccessRights = AccessRights.None, TimeZone = TimeZones.UTC)]
    public class SmtNewsFilterBot : Robot
    {
        [Parameter("新闻:货币(逗号分隔,留空 = 全部)", DefaultValue = "EUR,USD")]
        public string NewsCurrencies { get; set; }

        [Parameter("新闻:影响级别(high | high,medium | all)", DefaultValue = "high")]
        public string NewsImpact { get; set; }

        [Parameter("新闻:公布前分钟数", DefaultValue = 30, MinValue = 0)]
        public int MinutesBefore { get; set; }

        [Parameter("新闻:公布后分钟数", DefaultValue = 30, MinValue = 0)]
        public int MinutesAfter { get; set; }

        private const string CalendarWs = "ws://showmytrades.com:25345/";
        private const string CalendarHttp = "https://showmytrades.com/api/public/calendar/v1?";
        private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

        private readonly List<KeyValuePair<DateTime, string>> _events = new List<KeyValuePair<DateTime, string>>();
        private WebSocketClient _ws;
        private bool _received;

        protected override void OnStart()
        {
            DownloadCalendar();
            Timer.Start(TimeSpan.FromMinutes(15));
        }

        protected override void OnTimer()
        {
            DownloadCalendar();
        }

        protected override void OnTick()
        {
            string reason;
            if (IsNewsTime(out reason))
            {
                // 新闻窗口内不开新仓。持仓按您自己的方式管理。
                return;
            }

            // ... 您的入场逻辑 ...
        }

        protected override void OnStop()
        {
            CloseSocket();
        }

        // 有数据公布处于暂停窗口内时返回 true;reason 说明是哪一条。
        // Server.TimeInUtc 是 cTrader 自己的 UTC 时钟:平台知道经纪商的时区偏移,
        // 因此可以放心与日历比较(ParseCsv 会用服务器时间核对它)。
        public bool IsNewsTime(out string reason)
        {
            DateTime now = Server.TimeInUtc;
            foreach (KeyValuePair<DateTime, string> e in _events)
            {
                if (now >= e.Key.AddMinutes(-MinutesBefore) && now <= e.Key.AddMinutes(MinutesAfter))
                {
                    reason = e.Value;
                    return true;
                }
            }
            reason = null;
            return false;
        }

        private string Query()
        {
            string q = "format=csv&days=2&impact=" + NewsImpact;
            if (!string.IsNullOrWhiteSpace(NewsCurrencies)) q += "&currency=" + NewsCurrencies.Replace(" ", "");
            return q;
        }

        private void DownloadCalendar()
        {
            if (RunningMode != RunningMode.RealTime) return; // 回测器中没有网络

            // cTrader Cloud 禁止 cBot 发出 HTTP 请求,只允许端口 25345 上的 WebSocket,
            // 所以各处都以 socket 为主通道;桌面端以 HTTP 作为备用。
            try
            {
                CloseSocket();
                _received = false;
                _ws = new WebSocketClient(new WebSocketClientOptions { Timeout = TimeSpan.FromSeconds(15) });
                _ws.Connected += OnSocketConnected;
                _ws.TextReceived += OnSocketText;
                _ws.Disconnected += OnSocketDisconnected;
                _ws.Connect(new Uri(CalendarWs));
            }
            catch (Exception ex)
            {
                Print("[SMT] 日历 socket 连接失败:{0}", ex.Message);
                DownloadOverHttp();
            }
        }

        private void OnSocketConnected(WebSocketClientConnectedEventArgs args)
        {
            try { _ws.Send(Query()); }
            catch (Exception ex) { Print("[SMT] 日历请求发送失败:{0}", ex.Message); }
        }

        private void OnSocketText(WebSocketClientTextReceivedEventArgs args)
        {
            _received = true;
            if (args.Text.StartsWith("Error:")) Print("[SMT] 日历:{0}", args.Text);
            else ParseCsv(args.Text);
            CloseSocket();
        }

        private void OnSocketDisconnected(WebSocketClientDisconnectEventArgs args)
        {
            if (!_received) DownloadOverHttp(); // 仅限桌面端:云端会返回 503
        }

        private void DownloadOverHttp()
        {
            try
            {
                HttpResponse r = Http.Get(CalendarHttp + Query());
                if (r.IsSuccessful) ParseCsv(r.Body);
                else Print("[SMT] 日历 HTTP 请求失败:{0}", r.StatusCode);
            }
            catch (Exception ex) { Print("[SMT] 日历 HTTP 请求失败:{0}", ex.Message); }
        }

        private void CloseSocket()
        {
            WebSocketClient ws = _ws;
            _ws = null;
            if (ws == null) return;
            try { ws.Connected -= OnSocketConnected; ws.TextReceived -= OnSocketText; ws.Disconnected -= OnSocketDisconnected; } catch (Exception) { }
            try { if (ws.State == WebSocketClientState.Open) ws.Close(WebSocketClientCloseStatus.NormalClosure, "done"); } catch (Exception) { }
            try { ws.Dispose(); } catch (Exception) { }
        }

        // CSV:ts,time,currency,impact,forecast,previous,actual,title(title 在最后,可能带引号),
        // 最后一行是 "# server_time=<unix> server_time_iso=<iso>",即服务器的 UTC 时钟。
        private void ParseCsv(string csv)
        {
            List<KeyValuePair<DateTime, string>> list = new List<KeyValuePair<DateTime, string>>();
            foreach (string raw in csv.Split('\n'))
            {
                string line = raw.TrimEnd('\r');
                if (line.Length == 0 || line.StartsWith("ts,")) continue;
                if (line.StartsWith("# server_time="))
                {
                    long serverTime;
                    if (long.TryParse(line.Substring(14, 10), out serverTime))
                    {
                        double skew = (Server.TimeInUtc - Epoch.AddSeconds(serverTime)).TotalSeconds;
                        if (Math.Abs(skew) > 60) Print("[SMT] 警告:cTrader 的 UTC 时钟与日历服务器相差 {0:F0} 秒", skew);
                    }
                    continue;
                }
                string[] f = line.Split(',');
                if (f.Length < 8) continue;
                long ts;
                if (!long.TryParse(f[0], out ts)) continue;
                DateTime at = Epoch.AddSeconds(ts);
                string title = AfterComma(line, 7).Replace("\"\"", "\"");
                if (title.Length >= 2 && title[0] == '"') title = title.Substring(1, title.Length - 2);
                list.Add(new KeyValuePair<DateTime, string>(at, f[2] + " " + f[3] + ": " + title));
            }
            _events.Clear();
            _events.AddRange(list);
            Print("[SMT] 日历:已加载 {0} 个事件", _events.Count);
        }

        private static string AfterComma(string line, int n)
        {
            int pos = -1;
            for (int i = 0; i < n; i++)
            {
                pos = line.IndexOf(',', pos + 1);
                if (pos < 0) return "";
            }
            return line.Substring(pos + 1);
        }
    }
}

想把过滤器加进现有的 cBot:把 CalendarWs 到 AfterComma() 之间的代码全部复制过去,在 OnStart() 和一个 15 分钟的定时器里调用 DownloadCalendar(),每次入场前检查 IsNewsTime()。

🧪 试运行

  1. 构建 cBot,在图表上添加一个实例。几秒内日志里应出现 [SMT] 日历:已加载 N 个事件。清淡的周末显示为零,属于正常。
  2. 把新闻:影响级别设为 all,新闻:公布前分钟数设成一个很大的数,看到过滤器生效后,再改回实际要用的数值。
  3. 把同一个实例放到云端运行,日志里还是那一行。用 socket 图的就是这个。

⏱️ 时间与回测器

日历的时间戳都是 UTC,代码拿它和 Server.TimeInUtc 比较,经纪商的时区和夏令时根本不参与。每次下载的末尾还附有服务器自己的 UTC 时钟(# server_time=…):cBot 会拿它核对 Server.TimeInUtc,两者相差超过一分钟就打印警告,时钟出了错不会没人发现。回测器和优化器不能联网:DownloadCalendar() 在那里直接返回,过滤器不起作用。要回测新闻过滤器,得给 cBot 附带一份历史事件文件,那是另一项工作。

🎛️ 调整

  • 持仓不受影响。要怎么处理,可以用 IsNewsTime() 来判断:平仓、追踪止损,或者放着不管。
  • 加上中影响(high,medium),事件数量会变成三倍。剥头皮往往用得上,波段系统很少需要。
  • 同一账户跑多个 cBot 时,各自单独下载:十个 cBot 每小时合计 40 个小请求,远低于每个 IP 地址每分钟 60 条 socket 消息的上限。

❓ 常见问题

为什么用 WebSocket 而不是 HTTPS? 因为 cTrader Cloud 不让 cBot 发 HTTP 请求,WebSocket 连接也只放行端口 25345。在桌面端,同一个 cBot 走 HTTPS 也行,socket 连不上时代码会自动改用 HTTPS。

用不加密的 ws:// 发送安全吗? 请求里只有四个参数(货币、影响级别、天数、格式),不含您本人或账户的任何信息;返回的是公开的财经日历。没有什么需要保护的。同样的数据也能通过 HTTPS 从 API 端点获取。

需要 AccessRights.FullAccess 吗? 不需要。WebSocketClient 和 Http 在 AccessRights.None 下就能使用,这也是 cTrader Store 的要求。

需要 ShowMyTrades 账户吗? 不需要。如果还想公开这个 cBot 的交易结果,连接 cTrader 账户是另一回事,只需一次 OAuth 授权。

🔗 相关内容


唯一特别的只有 socket 这部分,而它已经替您写好了。剩下的还是您的策略,只不过在新闻前后少了一种亏钱的方式。免费追踪账户,在回撤图表上看看差别。