403 lines
17 KiB
MQL5
403 lines
17 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| PinbarSignal |
|
|
//| Copyright 2025, You |
|
|
//| mql5.com |
|
|
//+------------------------------------------------------------------+
|
|
#property copyright "Copyright 2025"
|
|
#property link "https://www.mql5.com"
|
|
#property version "1.00"
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 输入参数 |
|
|
//+------------------------------------------------------------------+
|
|
input group "=====信号与运行参数======"
|
|
input string SymbolsList = "XAUUSD,EURUSD,GBPUSD,BTCUSD,ETHUSD,XRPUSD,DOGEUSD,BNBUSD,SOLUSD,LTCUSD,LINKUSD,AAVEUSD,DOTUSD"; // 多品种,英文逗号分割
|
|
input string TimeframesList = "M15,M30,H1,H4"; // 多周期,示例: "H1, M30, H4"
|
|
input string SymbolSuffix = ""; // 品种后缀
|
|
input bool EnableEmail = true; // 启用邮件通知
|
|
input string MailSubjectPrefix= "信号提醒"; // 邮件标题前缀
|
|
|
|
input group "=====Pinbar识别参数====="
|
|
input double BodyToRangeMax = 0.3; // 实体占总长度最大比例(越小越像针)
|
|
input double WickDominanceMin = 0.6; // 主影线占总长度最小比例
|
|
input double NoseToTailRatio = 2.0; // 主影线/次影线 最小倍数
|
|
|
|
input group "=====调试====="
|
|
input bool DebugSendTestOnStart = false; // 启用后,EA启动即发送测试邮件
|
|
input bool EnableChartArrows = true; // 符合Pinbar时在图表上标记箭头(仅当前图表品种)
|
|
|
|
// 全局/静态变量
|
|
datetime lastCheckedTimes[]; // 每个周期的上次处理收线时间(以首个品种为基准)
|
|
string symbols[]; // 解析后的品种数组
|
|
ENUM_TIMEFRAMES timeframes[]; // 解析后的周期数组
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 工具函数:去除字符串首尾空格 |
|
|
//+------------------------------------------------------------------+
|
|
string Trim(const string text) {
|
|
string s = text;
|
|
while(StringLen(s)>0 && (ushort)StringGetCharacter(s,0)<=32) s = StringSubstr(s,1);
|
|
while(StringLen(s)>0 && (ushort)StringGetCharacter(s,StringLen(s)-1)<=32) s = StringSubstr(s,0,StringLen(s)-1);
|
|
return s;
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 解析SymbolsList到数组 |
|
|
//+------------------------------------------------------------------+
|
|
int ParseSymbols() {
|
|
ArrayFree(symbols);
|
|
string parts[];
|
|
int n = StringSplit(SymbolsList, ',', parts);
|
|
for(int i=0;i<n;i++) {
|
|
string sym = Trim(parts[i]);
|
|
if(SymbolSuffix!="") sym += SymbolSuffix;
|
|
if(sym!="") {
|
|
int sz = ArraySize(symbols);
|
|
ArrayResize(symbols, sz+1);
|
|
symbols[sz] = sym;
|
|
}
|
|
}
|
|
if(ArraySize(symbols)==0) {
|
|
ArrayResize(symbols,1);
|
|
symbols[0] = _Symbol; // 兜底
|
|
}
|
|
return ArraySize(symbols);
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 解析 TimeframesList 到周期数组 |
|
|
//+------------------------------------------------------------------+
|
|
bool ParseTimeframes() {
|
|
ArrayFree(timeframes);
|
|
string parts[];
|
|
int n = StringSplit(TimeframesList, ',', parts);
|
|
for(int i=0;i<n;i++) {
|
|
string tok = Trim(parts[i]);
|
|
StringToUpper(tok); // MQL5 方法式:原位大写化,返回bool
|
|
ENUM_TIMEFRAMES tf = PERIOD_CURRENT;
|
|
if(tok=="M1") tf = PERIOD_M1;
|
|
else if(tok=="M2") tf = PERIOD_M2;
|
|
else if(tok=="M3") tf = PERIOD_M3;
|
|
else if(tok=="M4") tf = PERIOD_M4;
|
|
else if(tok=="M5") tf = PERIOD_M5;
|
|
else if(tok=="M6") tf = PERIOD_M6;
|
|
else if(tok=="M10") tf = PERIOD_M10;
|
|
else if(tok=="M12") tf = PERIOD_M12;
|
|
else if(tok=="M15") tf = PERIOD_M15;
|
|
else if(tok=="M20") tf = PERIOD_M20;
|
|
else if(tok=="M30") tf = PERIOD_M30;
|
|
else if(tok=="H1") tf = PERIOD_H1;
|
|
else if(tok=="H2") tf = PERIOD_H2;
|
|
else if(tok=="H3") tf = PERIOD_H3;
|
|
else if(tok=="H4") tf = PERIOD_H4;
|
|
else if(tok=="H6") tf = PERIOD_H6;
|
|
else if(tok=="H8") tf = PERIOD_H8;
|
|
else if(tok=="H12") tf = PERIOD_H12;
|
|
else if(tok=="D1") tf = PERIOD_D1;
|
|
else if(tok=="W1") tf = PERIOD_W1;
|
|
else if(tok=="MN1") tf = PERIOD_MN1;
|
|
else continue;
|
|
int sz = ArraySize(timeframes);
|
|
ArrayResize(timeframes, sz+1);
|
|
timeframes[sz] = tf;
|
|
}
|
|
if(ArraySize(timeframes)==0) {
|
|
ArrayResize(timeframes, 2);
|
|
timeframes[0] = PERIOD_H1;
|
|
timeframes[1] = PERIOD_M30;
|
|
}
|
|
ArrayResize(lastCheckedTimes, ArraySize(timeframes));
|
|
for(int k=0;k<ArraySize(lastCheckedTimes);k++) lastCheckedTimes[k]=0;
|
|
return(ArraySize(timeframes)>0);
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 读取指定品种与周期的上一根K线(已收线) |
|
|
//+------------------------------------------------------------------+
|
|
bool GetPrevBar(const string sym, const ENUM_TIMEFRAMES tf, MqlRates &bar) {
|
|
// 明确请求上一根已收线K(shift=1,count=1),避免数组顺序或未收线K的歧义
|
|
MqlRates rates[1];
|
|
int copied = CopyRates(sym, tf, 1, 1, rates);
|
|
if(copied<1) return false;
|
|
bar = rates[0];
|
|
return true;
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 判定Pinbar(返回:0=否, 1=看涨Pinbar, -1=看跌Pinbar) |
|
|
//+------------------------------------------------------------------+
|
|
int IsPinbar(const MqlRates &b, const int digits) {
|
|
double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
|
|
// 总长度(高低)
|
|
double range = b.high - b.low;
|
|
if(range <= 0) return 0;
|
|
|
|
// 实体与影线
|
|
double body = MathAbs(b.close - b.open);
|
|
double upperWick = b.high - MathMax(b.open, b.close);
|
|
double lowerWick = MathMin(b.open, b.close) - b.low;
|
|
|
|
double bodyRatio = body / range;
|
|
if(bodyRatio > BodyToRangeMax) return 0;
|
|
|
|
// 判断主影线与方向
|
|
if(upperWick > lowerWick) {
|
|
double dominance = upperWick / range; // 上影占比
|
|
double ratio = (lowerWick <= 0 ? 9999.0 : upperWick / lowerWick);
|
|
if(dominance >= WickDominanceMin && ratio >= NoseToTailRatio)
|
|
return -1; // 看跌Pinbar(长上影)
|
|
} else if(lowerWick > upperWick) {
|
|
double dominance = lowerWick / range; // 下影占比
|
|
double ratio = (upperWick <= 0 ? 9999.0 : lowerWick / upperWick);
|
|
if(dominance >= WickDominanceMin && ratio >= NoseToTailRatio)
|
|
return 1; // 看涨Pinbar(长下影)
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 将周期枚举转为短标签(M1/H1/D1等) |
|
|
//+------------------------------------------------------------------+
|
|
string TfToLabel(const ENUM_TIMEFRAMES tf) {
|
|
switch(tf) {
|
|
case PERIOD_M1: return "M1";
|
|
case PERIOD_M2: return "M2";
|
|
case PERIOD_M3: return "M3";
|
|
case PERIOD_M4: return "M4";
|
|
case PERIOD_M5: return "M5";
|
|
case PERIOD_M6: return "M6";
|
|
case PERIOD_M10: return "M10";
|
|
case PERIOD_M12: return "M12";
|
|
case PERIOD_M15: return "M15";
|
|
case PERIOD_M20: return "M20";
|
|
case PERIOD_M30: return "M30";
|
|
case PERIOD_H1: return "H1";
|
|
case PERIOD_H2: return "H2";
|
|
case PERIOD_H3: return "H3";
|
|
case PERIOD_H4: return "H4";
|
|
case PERIOD_H6: return "H6";
|
|
case PERIOD_H8: return "H8";
|
|
case PERIOD_H12: return "H12";
|
|
case PERIOD_D1: return "D1";
|
|
case PERIOD_W1: return "W1";
|
|
case PERIOD_MN1: return "MN1";
|
|
default: return EnumToString(tf);
|
|
}
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 在图表上标记Pinbar箭头(仅当前图表与主窗口) |
|
|
//+------------------------------------------------------------------+
|
|
void MarkPinbarOnChart(const string sym, const ENUM_TIMEFRAMES tf, const MqlRates &b, const int dir) {
|
|
if(!EnableChartArrows) return;
|
|
if(sym != _Symbol) return; // 仅在当前图表品种上绘制
|
|
|
|
// 生成唯一对象名:Pinbar_<TF>_<time>_<dir>
|
|
string name = StringFormat("Pinbar_%s_%I64d_%d", EnumToString(tf), (long)b.time, dir);
|
|
// 价格位置:看涨放在低点下方一点,看跌放在高点上方一点
|
|
double point = SymbolInfoDouble(sym, SYMBOL_POINT);
|
|
double price = (dir>0 ? b.low - 2.0*point : b.high + 2.0*point);
|
|
ENUM_OBJECT type = (dir>0 ? OBJ_ARROW_BUY : OBJ_ARROW_SELL);
|
|
|
|
// 若已存在同名对象则先删除
|
|
if(ObjectFind(0, name) >= 0) ObjectDelete(0, name);
|
|
bool created = ObjectCreate(0, name, type, 0, b.time, price);
|
|
if(!created) {
|
|
Print("[Pinbar] 创建箭头对象失败:", name, ", err=", GetLastError());
|
|
return;
|
|
}
|
|
ObjectSetInteger(0, name, OBJPROP_WIDTH, 1);
|
|
ObjectSetInteger(0, name, OBJPROP_BACK, false);
|
|
ObjectSetInteger(0, name, OBJPROP_HIDDEN, false);
|
|
ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
|
|
ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_CENTER);
|
|
// 颜色区分方向
|
|
color clr = (dir>0 ? clrLime : clrRed);
|
|
ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 在收线时扫描所有品种,返回汇总文本 |
|
|
//+------------------------------------------------------------------+
|
|
string ScanAllSymbolsAndSummarize(const ENUM_TIMEFRAMES tf, const datetime barTime) {
|
|
string lines = "";
|
|
for(int i=0;i<ArraySize(symbols);i++) {
|
|
string sym = symbols[i];
|
|
MqlRates b;
|
|
if(!GetPrevBar(sym, tf, b)) {
|
|
continue;
|
|
}
|
|
// 只在同一收线时间的bar上做判断(不同品种会偶发不同步,这里放宽不强制相等)
|
|
int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
|
int pinbarType = IsPinbar(b, digits);
|
|
if(pinbarType != 0) {
|
|
string type = (pinbarType > 0 ? "Bullish" : "Bearish");
|
|
// 实时打印每次检测到的Pinbar
|
|
Print(StringFormat("[Pinbar] %s | %s | %s", sym, type, TimeToString(b.time, TIME_DATE|TIME_MINUTES)));
|
|
// 在当前图表上做箭头标记(仅当前图表品种)
|
|
MarkPinbarOnChart(sym, tf, b, pinbarType);
|
|
// 邮件正文:<品种><周期>出现↑/↓Pinbar
|
|
string arrow = (pinbarType > 0 ? "↑" : "↓");
|
|
lines += StringFormat("%s %s 出现%sPinbar\n", sym, TfToLabel(tf), arrow);
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 发送测试邮件 |
|
|
//+------------------------------------------------------------------+
|
|
void SendTestEmailOnStart() {
|
|
string subject = MailSubjectPrefix;
|
|
string body = "这是一封来自 PinbarSignal 的测试邮件,用于验证终端邮件配置是否正常。";
|
|
if(EnableEmail) {
|
|
bool ok = SendMail(subject, body);
|
|
if(!ok) {
|
|
Print("[Pinbar] 测试邮件发送失败,请检查终端邮件设置。");
|
|
} else {
|
|
Print("[Pinbar] 测试邮件已发送: ", subject);
|
|
Print("[Pinbar] 邮件内容:", body);
|
|
}
|
|
} else {
|
|
Print("[Pinbar] 未启用终端邮件,测试主题: ", subject);
|
|
Print("[Pinbar] 未启用终端邮件,测试内容:\n", body);
|
|
}
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 在新的收线时触发:汇总并发送邮件 |
|
|
//+------------------------------------------------------------------+
|
|
void OnNewClosedBar(const ENUM_TIMEFRAMES tf, const datetime newBarTime) {
|
|
string result = ScanAllSymbolsAndSummarize(tf, newBarTime);
|
|
if(result=="") {
|
|
Print("[Pinbar] 本次收线未检测到信号。");
|
|
return;
|
|
}
|
|
string subject = MailSubjectPrefix;
|
|
string body = result;
|
|
if(EnableEmail) {
|
|
bool ok = SendMail(subject, body);
|
|
if(!ok) {
|
|
Print("[Pinbar] 发送邮件失败,请检查终端邮件设置。");
|
|
} else {
|
|
Print("[Pinbar] 邮件已发送: ", subject);
|
|
Print("[Pinbar] 邮件内容:", body);
|
|
}
|
|
} else {
|
|
Print("[Pinbar] 邮件未启用,主题: ", subject);
|
|
Print("[Pinbar] 邮件未启用,结果:\n", body);
|
|
}
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| 计时器:每秒检查一次是否出现新收线 |
|
|
//+------------------------------------------------------------------+
|
|
void CheckNewBarEvent() {
|
|
if(ArraySize(symbols)==0) return;
|
|
string base = symbols[0];
|
|
// 收集触发的(时间, 周期)
|
|
ENUM_TIMEFRAMES triggers_tf[];
|
|
datetime triggers_time[];
|
|
for(int t=0; t<ArraySize(timeframes); t++) {
|
|
ENUM_TIMEFRAMES tf = timeframes[t];
|
|
datetime times[2];
|
|
int copied = CopyTime(base, tf, 0, 2, times);
|
|
if(copied<2) continue;
|
|
datetime prevClosedTime = times[1];
|
|
if(prevClosedTime<=0) continue;
|
|
|
|
if(lastCheckedTimes[t] == 0) {
|
|
lastCheckedTimes[t] = prevClosedTime;
|
|
continue;
|
|
}
|
|
if(prevClosedTime != lastCheckedTimes[t]) {
|
|
lastCheckedTimes[t] = prevClosedTime;
|
|
int sz = ArraySize(triggers_tf);
|
|
ArrayResize(triggers_tf, sz+1);
|
|
ArrayResize(triggers_time, sz+1);
|
|
triggers_tf[sz] = tf;
|
|
triggers_time[sz] = prevClosedTime;
|
|
}
|
|
}
|
|
|
|
// 将同一时间的所有周期合并为一次发送
|
|
for(int i=0;i<ArraySize(triggers_time);i++) {
|
|
if(triggers_time[i]==0) continue; // 已处理
|
|
datetime tkey = triggers_time[i];
|
|
string combined = "";
|
|
// 包含当前索引
|
|
combined += ScanAllSymbolsAndSummarize(triggers_tf[i], tkey);
|
|
// 收集同一时间的其余周期
|
|
for(int j=i+1;j<ArraySize(triggers_time);j++) {
|
|
if(triggers_time[j]==tkey) {
|
|
combined += ScanAllSymbolsAndSummarize(triggers_tf[j], tkey);
|
|
triggers_time[j] = 0; // 标记已并入
|
|
}
|
|
}
|
|
// 发送一次(若有结果)
|
|
if(combined!="") {
|
|
string subject = MailSubjectPrefix;
|
|
string body = combined;
|
|
if(EnableEmail) {
|
|
bool ok = SendMail(subject, body);
|
|
if(!ok) {
|
|
Print("[Pinbar] 发送邮件失败,请检查终端邮件设置。");
|
|
} else {
|
|
Print("[Pinbar] 邮件已发送: ", subject);
|
|
Print("[Pinbar] 邮件内容:", body);
|
|
}
|
|
} else {
|
|
Print("[Pinbar] 邮件未启用,主题: ", subject);
|
|
Print("[Pinbar] 邮件未启用,结果:\n", body);
|
|
}
|
|
} else {
|
|
Print("[Pinbar] 本次收线未检测到信号。");
|
|
}
|
|
triggers_time[i] = 0; // 标记自身
|
|
}
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| Expert initialization |
|
|
//+------------------------------------------------------------------+
|
|
int OnInit() {
|
|
ParseSymbols();
|
|
ParseTimeframes();
|
|
EventSetTimer(1); // 每秒检查一次
|
|
Print("=== PinbarSignal 初始化 ===");
|
|
string tfs = "";
|
|
for(int i=0;i<ArraySize(timeframes);i++) {
|
|
if(i>0) tfs += ", ";
|
|
tfs += EnumToString(timeframes[i]);
|
|
}
|
|
Print("监控周期:", tfs);
|
|
Print("监控品种:", SymbolsList);
|
|
if(DebugSendTestOnStart) {
|
|
SendTestEmailOnStart();
|
|
}
|
|
return(INIT_SUCCEEDED);
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| Expert deinitialization |
|
|
//+------------------------------------------------------------------+
|
|
void OnDeinit(const int reason) {
|
|
EventKillTimer();
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| OnTick 保持轻量(主要逻辑在OnTimer) |
|
|
//+------------------------------------------------------------------+
|
|
void OnTick() {
|
|
// 仅保证活性,不做重逻辑
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| OnTimer:定时检查 |
|
|
//+------------------------------------------------------------------+
|
|
void OnTimer() {
|
|
CheckNewBarEvent();
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
|