-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMT5-PositionMaster.mq5
More file actions
2207 lines (1902 loc) · 74 KB
/
MT5-PositionMaster.mq5
File metadata and controls
2207 lines (1902 loc) · 74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//+------------------------------------------------------------------+
//| MT5-PositionMaster.mq5 |
//| Copyright 2025, FanYueee |
//| https://fanyueee.ee |
//+------------------------------------------------------------------+
/**
* @file MT5-PositionMaster.mq5
* @brief MT5 倉位管理 Expert Advisor - 通過 Telegram Bot 遠程管理多個交易倉位
* @details
* 這是一個生產級別的 MT5 Expert Advisor,集成了 Telegram Bot 功能,
* 允許交易者通過 Telegram 遠程管理所有開倉倉位。
*
* 主要功能:
* - 修改所有倉位的止盈/止損價格
* - 刪除所有倉位的止盈/止損設置
* - 平掉一半倉位
*
* @author FanYueee
* @date 2025-11-10
* @version 1.0.0
*
* @note 使用前請確保:
* 1. 已在 MT5 設置中允許 WebRequest 訪問 api.telegram.org
* 2. 已創建 Telegram Bot 並獲取 Bot Token
* 3. 已獲取授權的 Telegram Chat ID
*
* @warning 請妥善保管 Bot Token,避免洩露給未授權人員
*/
#property copyright "Copyright 2025, FanYueee"
#property link "https://fanyueee.ee"
#property version "1.00"
#property description "MT5 倉位管理 Expert Advisor - 通過 Telegram Bot 遠程管理多個交易倉位"
#property strict
//+------------------------------------------------------------------+
//| 輸入參數 |
//+------------------------------------------------------------------+
/** @brief Telegram Bot Token */
input string InpBotToken = "";
/** @brief 授權的 Telegram Chat ID */
input long InpChatID = 0;
/** @brief 輪詢間隔(秒) */
input int InpPollingInterval = 2; // 輪詢間隔(秒)
/** @brief 快速模式 - 減少面板重發以提高響應速度 */
input bool InpFastMode = false; // 快速模式(減少按鈕延遲)
//+------------------------------------------------------------------+
//| 用戶狀態枚舉 |
//+------------------------------------------------------------------+
/** @brief 用戶交互狀態枚舉 */
enum UserState
{
STATE_IDLE, // 空閒狀態
STATE_WAITING_TP, // 等待輸入止盈價格
STATE_WAITING_SL // 等待輸入止損價格
};
//+------------------------------------------------------------------+
//| 全局變量 |
//+------------------------------------------------------------------+
/** @brief 上次處理的更新 ID */
long g_lastUpdateID = 0;
/** @brief Telegram API 基礎 URL */
string g_telegramAPIURL = "";
/** @brief EA 是否已正確初始化 */
bool g_isInitialized = false;
/** @brief 錯誤計數器 */
int g_errorCount = 0;
/** @brief 最大連續錯誤次數 */
const int MAX_ERROR_COUNT = 10;
/** @brief 商品點值 */
double g_point = 0.0;
/** @brief 商品小數位數 */
int g_digits = 0;
/** @brief 最後操作的詳細結果訊息 */
string g_lastOperationResult = "";
/** @brief 用戶當前狀態 */
UserState g_userState = STATE_IDLE;
//+------------------------------------------------------------------+
//| Expert 初始化函數 |
//+------------------------------------------------------------------+
/**
* @brief EA 初始化函數
* @details 在 EA 啟動時執行,進行必要的初始化設置:
* - 驗證輸入參數
* - 構建 Telegram API URL
* - 設置定時器
* - 獲取商品基本資訊
* @return INIT_SUCCEEDED 初始化成功,INIT_PARAMETERS_INCORRECT 參數錯誤,INIT_FAILED 初始化失敗
* @note 如果初始化失敗,EA 將無法正常工作
*/
int OnInit()
{
//--- 驗證輸入參數
if(StringLen(InpBotToken) == 0)
{
Print("[錯誤]:Bot Token 不能為空!請在 EA 設置中填寫 Telegram Bot Token。");
return INIT_PARAMETERS_INCORRECT;
}
if(InpChatID == 0)
{
Print("[錯誤]:Chat ID 不能為 0!請在 EA 設置中填寫授權的 Telegram Chat ID。");
return INIT_PARAMETERS_INCORRECT;
}
if(InpPollingInterval < 1)
{
Print("[錯誤]:輪詢間隔不能小於 1 秒!");
return INIT_PARAMETERS_INCORRECT;
}
//--- 構建 Telegram API URL
g_telegramAPIURL = "https://api.telegram.org/bot" + InpBotToken;
//--- 獲取商品資訊
g_point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
g_digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
//--- 設置定時器(以秒為單位)
if(!EventSetTimer(InpPollingInterval))
{
Print("錯誤:無法設置定時器!");
return INIT_FAILED;
}
//--- 重置錯誤計數器
g_errorCount = 0;
g_isInitialized = true;
Print("========================================");
Print("MT5-PositionMaster EA v1.0.0 已啟動");
Print("========================================");
//--- 獲取最新的 update ID,避免處理舊消息
GetLatestUpdateID();
//--- 發送啟動通知
string startMsg = "[成功] MT5-PositionMaster EA 已成功啟動!\n\n";
startMsg += "[系統] EA 版本:1.0.0\n";
startMsg += "[時間] 輪詢間隔:" + IntegerToString(InpPollingInterval) + " 秒\n\n";
startMsg += "輸入 /help 查看所有可用指令。";
SendTelegramMessage(startMsg);
Print("[成功] MT5-PositionMaster EA 初始化成功!");
Print("[時間] 輪詢間隔:", InpPollingInterval, " 秒");
Print("[調試] g_isInitialized 已設置為: true");
return INIT_SUCCEEDED;
}
//+------------------------------------------------------------------+
//| Expert 反初始化函數 |
//+------------------------------------------------------------------+
/**
* @brief EA 反初始化函數
* @details 在 EA 關閉時執行清理工作:
* - 刪除定時器
* - 發送關閉通知
* - 記錄日誌
* @note 確保資源正確釋放
*/
void OnDeinit(const int reason)
{
//--- 刪除定時器
EventKillTimer();
//--- 獲取原因說明
string reasonText = "";
switch(reason)
{
case REASON_PROGRAM:
reasonText = "EA 正常終止";
break;
case REASON_REMOVE:
reasonText = "EA 被從圖表移除";
break;
case REASON_RECOMPILE:
reasonText = "EA 被重新編譯(將自動重啟)";
break;
case REASON_CHARTCHANGE:
reasonText = "圖表品種或週期改變(將自動重啟)";
break;
case REASON_CHARTCLOSE:
reasonText = "圖表關閉";
break;
case REASON_PARAMETERS:
reasonText = "輸入參數改變(將自動重啟)";
break;
case REASON_ACCOUNT:
reasonText = "帳戶切換(將自動重啟)";
break;
case REASON_TEMPLATE:
reasonText = "應用新模板(將自動重啟)";
break;
case REASON_INITFAILED:
reasonText = "初始化失敗";
break;
case REASON_CLOSE:
reasonText = "終端關閉";
break;
default:
reasonText = "未知原因";
break;
}
//--- 發送關閉通知(所有情況都發送)
string msg = "MT5-PositionMaster EA 已停止\n\n";
msg += "原因代碼:" + IntegerToString(reason) + "\n";
msg += "說明:" + reasonText;
SendTelegramMessage(msg);
//--- 記錄日誌
Print("[信息] MT5-PositionMaster EA 已停止,原因:", reasonText, " (代碼:", reason, ")");
g_isInitialized = false;
}
//+------------------------------------------------------------------+
//| Expert tick 函數 |
//+------------------------------------------------------------------+
/**
* @brief EA tick 函數
* @details 每個 tick 執行一次,目前不進行任何操作
* @note 主要邏輯在 OnTimer 中處理
*/
void OnTick()
{
// 主要邏輯在 OnTimer 中處理
}
//+------------------------------------------------------------------+
//| Timer 函數 |
//+------------------------------------------------------------------+
/**
* @brief 定時器函數
* @details 定期執行(根據 InpPollingInterval 設置),負責:
* - 輪詢 Telegram 更新
* - 處理收到的指令
* - 錯誤恢復機制
* @note 這是 EA 的核心處理邏輯
*/
void OnTimer()
{
if(!g_isInitialized)
return;
//--- 檢查錯誤計數
if(g_errorCount >= MAX_ERROR_COUNT)
{
Print("[錯誤] 連續錯誤次數過多,暫停處理。請檢查網絡連接和 Bot Token。");
return;
}
//--- 輪詢 Telegram 更新
ProcessTelegramUpdates();
}
//+------------------------------------------------------------------+
//| Telegram 相關函數 |
//+------------------------------------------------------------------+
/**
* @brief 獲取最新的 update ID
* @details 獲取 Telegram Bot 最新的更新 ID,用於初始化時跳過舊消息
* @note 初始化時調用,避免處理歷史消息
*/
void GetLatestUpdateID()
{
// 方法:使用 offset=-1 獲取最新的一條更新,然後立即確認它
// 這樣可以跳過所有舊消息
string url = g_telegramAPIURL + "/getUpdates?offset=-1&limit=1";
string headers = "Content-Type: application/json\r\n";
char post[];
char result[];
string resultString;
int timeout = 5000;
int res = WebRequest("GET", url, headers, timeout, post, result, headers);
if(res == 200)
{
resultString = CharArrayToString(result);
//--- 查找最新的 update_id
int start = StringFind(resultString, "\"update_id\":");
if(start >= 0)
{
start += 13; // 長度 "\"update_id\":"
int end = StringFind(resultString, ",", start);
if(end < 0)
end = StringFind(resultString, "}", start);
if(end > start)
{
string updateIDStr = StringSubstr(resultString, start, end - start);
g_lastUpdateID = StringToInteger(updateIDStr);
// 立即確認這條消息(使用 offset = update_id + 1)
// 這會告訴 Telegram 清除所有 <= update_id 的舊消息
string confirmUrl = g_telegramAPIURL + "/getUpdates?offset=" + IntegerToString(g_lastUpdateID + 1) + "&limit=1";
char confirmResult[];
WebRequest("GET", confirmUrl, headers, timeout, post, confirmResult, headers);
}
}
}
}
/**
* @brief 處理 Telegram 更新
* @details 從 Telegram 服務器獲取新消息並處理:
* - 使用長輪詢機制獲取更新
* - 驗證 Chat ID
* - 解析並執行指令
* - 更新 update ID
* @return 成功處理返回 true,否則返回 false
* @note 使用長輪詢提高效率,減少請求次數
*/
bool ProcessTelegramUpdates()
{
// 明確指定要接收 message 和 callback_query 更新
string url = g_telegramAPIURL + "/getUpdates?offset=" + IntegerToString(g_lastUpdateID + 1) +
"&timeout=5&allowed_updates=[\"message\",\"callback_query\"]";
string headers = "Content-Type: application/json\r\n";
char post[];
char result[];
string resultString;
int timeout = 7000; // 7秒超時(5秒長輪詢 + 2秒緩衝)
int res = WebRequest("GET", url, headers, timeout, post, result, headers);
if(res == -1)
{
int error = GetLastError();
if(error == 4014) // URL 未添加到允許列表
{
Print("[錯誤] 請在 MT5 設置中允許 URL:https://api.telegram.org");
Print(" 工具 -> 選項 -> Expert Advisors -> 允許 WebRequest 訪問以下 URL 列表");
}
else
{
Print("[錯誤] WebRequest 錯誤代碼:", error);
}
g_errorCount++;
return false;
}
if(res != 200)
{
Print("[錯誤] HTTP 錯誤代碼:", res);
g_errorCount++;
return false;
}
//--- 重置錯誤計數
g_errorCount = 0;
resultString = CharArrayToString(result);
//--- 解析 JSON 響應
if(StringFind(resultString, "\"ok\":true") < 0)
{
Print("[錯誤] Telegram API 響應錯誤");
return false;
}
//--- 提取 result 數組
int resultStart = StringFind(resultString, "\"result\":[");
if(resultStart < 0)
return true; // 沒有新消息
// 找到 [ 的位置(在 "result":[ 中)
int bracketStart = resultStart + 9; // "result": 有 9 個字符
resultStart = bracketStart + 1; // 數組內容從 [ 之後開始
//--- 使用括號計數法找到 result 數組的真正結束位置
int bracketCount = 0;
int resultEnd = -1;
bool inString = false;
for(int i = bracketStart; i < StringLen(resultString); i++)
{
ushort ch = StringGetCharacter(resultString, i);
// 處理字符串內的引號
if(ch == '"')
{
// 計算前面連續的反斜杠數量
int backslashCount = 0;
int j = i - 1;
while(j >= 0 && StringGetCharacter(resultString, j) == '\\')
{
backslashCount++;
j--;
}
// 偶數個反斜杠(包括0)意味著引號不是轉義的
if(backslashCount % 2 == 0)
inString = !inString;
}
if(!inString)
{
if(ch == '[')
bracketCount++;
else if(ch == ']')
{
bracketCount--;
if(bracketCount == 0)
{
resultEnd = i;
break;
}
}
}
}
if(resultEnd < 0 || resultEnd <= resultStart)
return true; // 空結果或解析失敗
string resultArray = StringSubstr(resultString, resultStart, resultEnd - resultStart);
if(StringLen(resultArray) < 5) // 至少要有一些內容
return true; // 空數組
//--- 解析每個更新
ParseAndProcessUpdates(resultArray);
return true;
}
/**
* @brief 解析並處理更新數組
* @details 解析 Telegram 返回的更新數組,提取並處理每條消息
* @param updates JSON 格式的更新數組字符串
* @note 簡化的 JSON 解析,專門處理 Telegram 響應格式
*/
void ParseAndProcessUpdates(string updates)
{
int pos = 0;
while(pos < StringLen(updates))
{
//--- 查找下一個 { 開始符
int updateStart = StringFind(updates, "{", pos);
if(updateStart < 0)
break;
//--- 查找對應的 } 結束位置(使用括號計數)
int braceCount = 0;
int updateEnd = -1;
bool inString = false;
for(int i = updateStart; i < StringLen(updates); i++)
{
ushort ch = StringGetCharacter(updates, i);
// 處理字符串內的引號(正確處理轉義字符,包括連續的反斜杠)
if(ch == '"')
{
// 計算前面有多少個連續的反斜杠
int backslashCount = 0;
int j = i - 1;
while(j >= 0 && StringGetCharacter(updates, j) == '\\')
{
backslashCount++;
j--;
}
// 如果反斜杠數量是偶數(包括 0),則這個引號不是轉義的
if(backslashCount % 2 == 0)
inString = !inString;
}
if(!inString)
{
if(ch == '{')
braceCount++;
else if(ch == '}')
{
braceCount--;
if(braceCount == 0)
{
updateEnd = i + 1;
break;
}
}
}
}
if(updateEnd > updateStart)
{
string update = StringSubstr(updates, updateStart, updateEnd - updateStart);
ProcessSingleUpdate(update);
pos = updateEnd;
}
else
{
break;
}
}
}
/**
* @brief 處理單個更新
* @details 處理一條 Telegram 更新消息:
* - 提取 update ID
* - 驗證 Chat ID
* - 提取並處理指令或按鈕回調
* @param update JSON 格式的單個更新字符串
* @note 包含完整的安全驗證機制,支持 message 和 callback_query
*/
void ProcessSingleUpdate(string update)
{
//--- 提取 update_id
long updateID = ExtractUpdateID(update);
if(updateID <= g_lastUpdateID)
return; // 已處理過的消息
g_lastUpdateID = updateID;
//--- 檢查是否為 callback_query(按鈕點擊)
if(StringFind(update, "\"callback_query\"") >= 0)
{
//--- 提取 callback_query_id
string callbackQueryID = ExtractCallbackQueryID(update);
if(StringLen(callbackQueryID) == 0)
return;
//--- 提取 callback_data
string callbackData = ExtractCallbackData(update);
if(StringLen(callbackData) == 0)
return;
//--- 提取並驗證 chat_id(在 callback_query.message.chat.id 中)
long chatID = ExtractChatID(update);
if(chatID != InpChatID)
{
Print("[警告] 未授權的 Chat ID 嘗試訪問(callback_query):", chatID);
AnswerCallbackQuery(callbackQueryID, "未授權訪問");
return;
}
//--- 處理按鈕回調
ProcessCallbackQuery(callbackData, callbackQueryID);
return;
}
//--- 處理普通消息
//--- 提取 chat_id
long chatID = ExtractChatID(update);
//--- 驗證 Chat ID
if(chatID != InpChatID)
{
Print("[警告] 未授權的 Chat ID 嘗試訪問:", chatID);
SendTelegramMessageToChatID("[錯誤] 未授權訪問!此 Bot 僅供授權用戶使用。", chatID);
return;
}
//--- 提取消息文本
string messageText = ExtractMessageText(update);
if(StringLen(messageText) == 0)
return; // 沒有文本消息
//--- 處理指令
ProcessCommand(messageText);
}
/**
* @brief 提取 update ID
* @details 從 JSON 字符串中提取 update_id 字段
* @param json JSON 格式字符串
* @return update ID,失敗返回 0
*/
long ExtractUpdateID(string json)
{
int start = StringFind(json, "\"update_id\":");
if(start < 0)
return 0;
start += 13;
int end = StringFind(json, ",", start);
if(end < 0)
end = StringFind(json, "}", start);
if(end <= start)
return 0;
string idStr = StringSubstr(json, start, end - start);
return StringToInteger(idStr);
}
/**
* @brief 提取 Chat ID
* @details 從 JSON 字符串中提取 chat ID 字段
* @param json JSON 格式字符串
* @return Chat ID,失敗返回 0
*/
long ExtractChatID(string json)
{
// 查找 "chat" 字段
int start = StringFind(json, "\"chat\"");
if(start < 0)
return 0;
// 從 "chat" 之後查找 "id"
start = StringFind(json, "\"id\"", start);
if(start < 0)
return 0;
// 找到 "id": 之後的數字開始位置
start = StringFind(json, ":", start);
if(start < 0)
return 0;
start++; // 跳過冒號
// 跳過空格
while(start < StringLen(json))
{
ushort ch = StringGetCharacter(json, start);
if(ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r')
break;
start++;
}
// 查找數字結束位置
int end = start;
while(end < StringLen(json))
{
ushort ch = StringGetCharacter(json, end);
// 數字、負號、或空格以外的字符表示結束
if(ch != '-' && ch != '+' && (ch < '0' || ch > '9'))
break;
end++;
}
if(end <= start)
return 0;
string idStr = StringSubstr(json, start, end - start);
StringTrimLeft(idStr);
StringTrimRight(idStr);
return StringToInteger(idStr);
}
/**
* @brief 提取消息文本
* @details 從 JSON 字符串中提取消息文本內容
* @param json JSON 格式字符串
* @return 消息文本,失敗返回空字符串
* @note 處理了文本中的轉義字符
*/
string ExtractMessageText(string json)
{
int start = StringFind(json, "\"text\":\"");
if(start < 0)
return "";
start += 8;
int end = start;
//--- 查找字符串結束位置(考慮轉義字符)
for(int i = start; i < StringLen(json); i++)
{
ushort ch = StringGetCharacter(json, i);
if(ch == '"' && (i == 0 || StringGetCharacter(json, i - 1) != '\\'))
{
end = i;
break;
}
}
if(end <= start)
return "";
return StringSubstr(json, start, end - start);
}
/**
* @brief 提取 Callback Query ID
* @details 從 JSON 字符串中提取 callback_query.id 字段
* @param json JSON 格式字符串
* @return Callback Query ID,失敗返回空字符串
*/
string ExtractCallbackQueryID(string json)
{
//--- 查找 "callback_query" 字段
int start = StringFind(json, "\"callback_query\"");
if(start < 0)
return "";
//--- 從 "callback_query" 之後查找 "id"
start = StringFind(json, "\"id\":\"", start);
if(start < 0)
return "";
start += 6; // 跳過 "id":"
int end = StringFind(json, "\"", start);
if(end <= start)
return "";
return StringSubstr(json, start, end - start);
}
/**
* @brief 提取 Callback Data
* @details 從 JSON 字符串中提取 callback_query.data 字段
* @param json JSON 格式字符串
* @return Callback Data,失敗返回空字符串
*/
string ExtractCallbackData(string json)
{
//--- 查找 "data" 字段(在 callback_query 中)
int start = StringFind(json, "\"callback_query\"");
if(start < 0)
return "";
//--- 從 "callback_query" 之後查找 "data"
start = StringFind(json, "\"data\":\"", start);
if(start < 0)
return "";
start += 8; // 跳過 "data":"
int end = start;
//--- 查找字符串結束位置(考慮轉義字符)
for(int i = start; i < StringLen(json); i++)
{
ushort ch = StringGetCharacter(json, i);
if(ch == '"' && (i == 0 || StringGetCharacter(json, i - 1) != '\\'))
{
end = i;
break;
}
}
if(end <= start)
return "";
return StringSubstr(json, start, end - start);
}
/**
* @brief 發送 Telegram 消息
* @details 向預設的 Chat ID 發送消息
* @param message 要發送的消息文本
* @return 成功返回 true,失敗返回 false
* @note 使用 Markdown 格式支持
*/
bool SendTelegramMessage(string message)
{
return SendTelegramMessageToChatID(message, InpChatID);
}
/**
* @brief 發送 Telegram 消息到指定 Chat ID
* @details 向指定的 Chat ID 發送消息
* @param message 要發送的消息文本
* @param chatID 目標 Chat ID
* @return 成功返回 true,失敗返回 false
* @warning 消息需要進行 URL 編碼
*/
bool SendTelegramMessageToChatID(string message, long chatID)
{
string url = g_telegramAPIURL + "/sendMessage";
//--- URL 編碼消息
string encodedMessage = UrlEncode(message);
//--- 構建 POST 數據
string postData = "chat_id=" + IntegerToString(chatID) + "&text=" + encodedMessage + "&parse_mode=HTML";
char post[];
char result[];
string headers = "Content-Type: application/x-www-form-urlencoded\r\n";
StringToCharArray(postData, post, 0, WHOLE_ARRAY, CP_UTF8);
ArrayResize(post, ArraySize(post) - 1); // 移除字符串結束符
int res = WebRequest("POST", url, headers, 2000, post, result, headers);
if(res != 200)
{
Print("[錯誤] 發送消息失敗,HTTP 代碼:", res);
return false;
}
return true;
}
/**
* @brief URL 編碼
* @details 將字符串進行 URL 編碼,用於 HTTP 請求
* @param str 原始字符串
* @return URL 編碼後的字符串
* @note 處理特殊字符,確保 HTTP 請求正確
*/
string UrlEncode(string str)
{
string result = "";
uchar bytes[];
// 將字符串轉換為 UTF-8 字節數組
int len = StringToCharArray(str, bytes, 0, WHOLE_ARRAY, CP_UTF8);
if(len > 0)
len--; // 移除字符串結束符
for(int i = 0; i < len; i++)
{
uchar ch = bytes[i];
// 不需要編碼的字符(RFC 3986)
if((ch >= 'A' && ch <= 'Z') ||
(ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') ||
ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
result += CharToString(ch);
}
else if(ch == ' ')
{
result += "+";
}
else
{
// 使用正確的十六進制格式(大寫)
result += StringFormat("%%%02X", ch);
}
}
return result;
}
/**
* @brief 發送帶有 Inline Keyboard 的 Telegram 消息
* @details 向默認 Chat ID 發送帶有內聯鍵盤按鈕的消息
* @param message 要發送的消息文本
* @param inlineKeyboard Inline Keyboard JSON 字符串(格式:[[{...}, {...}], [...]])
* @return 成功返回 true,失敗返回 false
* @note inlineKeyboard 應該是有效的 JSON 數組格式
*/
bool SendTelegramMessageWithKeyboard(string message, string inlineKeyboard)
{
string url = g_telegramAPIURL + "/sendMessage";
//--- URL 編碼消息
string encodedMessage = UrlEncode(message);
//--- 構建 reply_markup JSON
string replyMarkup = "{\"inline_keyboard\":" + inlineKeyboard + "}";
string encodedReplyMarkup = UrlEncode(replyMarkup);
//--- 構建 POST 數據
string postData = "chat_id=" + IntegerToString(InpChatID) +
"&text=" + encodedMessage +
"&parse_mode=HTML" +
"&reply_markup=" + encodedReplyMarkup;
char post[];
char result[];
string headers = "Content-Type: application/x-www-form-urlencoded\r\n";
StringToCharArray(postData, post, 0, WHOLE_ARRAY, CP_UTF8);
ArrayResize(post, ArraySize(post) - 1);
int res = WebRequest("POST", url, headers, 2000, post, result, headers);
if(res != 200)
{
Print("[錯誤] 發送帶按鈕的消息失敗,HTTP 代碼:", res);
return false;
}
return true;
}
/**
* @brief 回應 Callback Query
* @details 必須調用此函數來回應用戶的按鈕點擊,否則按鈕會持續顯示加載狀態
* @param callbackQueryID Callback Query ID
* @param text 可選的通知文本(顯示在屏幕頂部)
* @return 成功返回 true,失敗返回 false
* @note 即使不需要顯示通知,也必須調用此函數
*/
bool AnswerCallbackQuery(string callbackQueryID, string text = "")
{
string url = g_telegramAPIURL + "/answerCallbackQuery";
//--- 構建 POST 數據
string postData = "callback_query_id=" + callbackQueryID;
if(StringLen(text) > 0)
{
postData += "&text=" + UrlEncode(text);
}
char post[];
char result[];
string headers = "Content-Type: application/x-www-form-urlencoded\r\n";
StringToCharArray(postData, post, 0, WHOLE_ARRAY, CP_UTF8);
ArrayResize(post, ArraySize(post) - 1);
int res = WebRequest("POST", url, headers, 2000, post, result, headers);
if(res != 200)
{
Print("[錯誤] 回應 Callback Query 失敗,HTTP 代碼:", res);
return false;
}
return true;
}
/**
* @brief 發送操作菜單面板
* @details 發送包含所有操作按鈕的 Inline Keyboard 面板
* @return 成功返回 true,失敗返回 false
* @note 面板包含:平倉一半、平掉全部、設置TP/SL、刪除TP/SL
*/
bool SendMenuPanel()
{
//--- 構建按鈕 JSON(使用繁體中文)
string buttons = "[[" +
"{\"text\":\"✂️ 平倉一半\", \"callback_data\":\"CH\"}," +
"{\"text\":\"🚫 平掉全部\", \"callback_data\":\"CA\"}" +
"],[" +
"{\"text\":\"🎯 設置TP\", \"callback_data\":\"SETTP\"}," +
"{\"text\":\"🛡️ 設置SL\", \"callback_data\":\"SETSL\"}" +
"],[" +
"{\"text\":\"❌ 刪除TP\", \"callback_data\":\"RTP\"}," +
"{\"text\":\"❌ 刪除SL\", \"callback_data\":\"RSL\"}" +
"]]";
string message = "📋 <b>倉位管理面板</b>\n\n" +
"請選擇要執行的操作:";
return SendTelegramMessageWithKeyboard(message, buttons);
}
/**
* @brief 處理 Callback Query(按鈕點擊)
* @details 處理用戶點擊 Inline Keyboard 按鈕的回調
* @param callbackData 按鈕的 callback_data 值
* @param callbackQueryID Callback Query ID(用於回應)
* @note 根據不同的 callback_data 執行相應操作
*/
void ProcessCallbackQuery(string callbackData, string callbackQueryID)
{
Print("[DEBUG] 收到 Callback Query: ", callbackData);
//--- 立即回應 callback query(避免按鈕持續加載)
AnswerCallbackQuery(callbackQueryID);
//--- 如果用戶點擊了其他操作按鈕(非設置TP/SL),自動取消等待輸入狀態
if(g_userState != STATE_IDLE && callbackData != "SETTP" && callbackData != "SETSL")
{
Print("[DEBUG] 自動取消等待輸入狀態,執行新操作:", callbackData);
g_userState = STATE_IDLE;
}
//--- 根據按鈕 ID 執行相應操作
if(callbackData == "CH")
{
//--- 平倉一半
int totalPos = PositionsTotal();
if(totalPos == 0)
{
SendTelegramMessage("[信息] 當前沒有開倉倉位");
SendMenuPanel(); // 重新發送面板
return;
}
if(totalPos == 1)
{
SendTelegramMessage("[信息] 只有1個倉位,不執行平倉操作");
SendMenuPanel();