From 321431d8ec372dddfcb1173f4b1f6c72415df2ae Mon Sep 17 00:00:00 2001 From: Hiroaki86 Date: Sat, 30 May 2026 08:59:40 +0900 Subject: [PATCH] Strengthen Python timeout recovery and candidate freshness --- Experts/MyProject/HIT-EA_refactor_ver6.mq5 | 2 + Include/MyLib/Common/HITExternalProcess.mqh | 98 ++++++++++++++++++- Include/MyLib/Signals/HITEntrySignal.mqh | 38 +++++-- .../MyLib/Signals/HITPythonSignalGateway.mqh | 82 +++++++++++++++- Include/MyLib/Trading/HITTradeManager.mqh | 68 ++++++++++++- Include/MyLib/Trading/SLTPManager.mqh | 26 ++--- docs/MyProject/HIT-EA_spec_en.md | 15 ++- docs/MyProject/HIT-EA_spec_ja.md | 15 ++- python_for_ea/README.md | 9 +- python_for_ea/bat/get_entry_reply.bat | 1 + python_for_ea/bat/get_trend_reply.bat | 1 + .../docs/architecture/folder-structure.md | 2 +- python_for_ea/src/ea_py/io/ohlc_csv.py | 32 ++++-- python_for_ea/src/ea_py/paths.py | 26 +++++ python_for_ea/tests/unit/test_ohlc_csv.py | 50 ++++++++++ python_for_ea/tests/unit/test_paths.py | 25 +++++ .../tests/unit/test_target_prices.py | 12 ++- 17 files changed, 454 insertions(+), 48 deletions(-) create mode 100644 python_for_ea/tests/unit/test_ohlc_csv.py diff --git a/Experts/MyProject/HIT-EA_refactor_ver6.mq5 b/Experts/MyProject/HIT-EA_refactor_ver6.mq5 index 706b7ab..d8c87f0 100644 --- a/Experts/MyProject/HIT-EA_refactor_ver6.mq5 +++ b/Experts/MyProject/HIT-EA_refactor_ver6.mq5 @@ -21,6 +21,7 @@ #define WAIT_OBJECT_0 0 #define WAIT_TIMEOUT 258 #define STILL_ACTIVE 259 +#define TASKKILL_WAIT_MILLISECONDS 5000 struct STARTUPINFO_W { @@ -185,6 +186,7 @@ struct EAState datetime last_trend_update; // ★追加:前回トレンドを更新した時刻 datetime last_target_update; // ★変更:前回ターゲット価格を更新した時刻 datetime target_loaded_at; // H1候補価格をEAへ読み込んだサーバー時刻 + datetime target_candidate_at; // H1候補価格の元になった確定足時刻 datetime last_chk; }; diff --git a/Include/MyLib/Common/HITExternalProcess.mqh b/Include/MyLib/Common/HITExternalProcess.mqh index 1ee223b..285a477 100644 --- a/Include/MyLib/Common/HITExternalProcess.mqh +++ b/Include/MyLib/Common/HITExternalProcess.mqh @@ -242,6 +242,84 @@ bool UpdateExternalProcessStatus(ExternalProcessState &process, const string lab return true; } +//+------------------------------------------------------------------+ +//| 外部プロセスツリーを強制終了する関数 +//+------------------------------------------------------------------+ +/** + * @brief タイムアウトしたcmd/bat配下のPythonをプロセスツリーごと終了します。 + * + * @param label ログ表示用ラベル。 + * @param process 終了対象の外部プロセス状態。 + * @return taskkill が正常終了した場合はtrue。 + */ +bool TerminateExternalProcessTree(const string label, ExternalProcessState &process) + { + if(process.process_id == 0) + { + Print("[", label, "] cannot terminate process tree: pid is missing."); + return false; + } + + STARTUPINFO_W startup_info = {}; + PROCESS_INFORMATION process_info = {}; + startup_info.cb = (uint)sizeof(startup_info); + + string taskkill_exe = "C:\\Windows\\System32\\taskkill.exe"; + string command_line = "\"" + taskkill_exe + "\" /PID " + + IntegerToString((long)process.process_id) + " /T /F"; + + int created = CreateProcessW( + taskkill_exe, + command_line, + 0, + 0, + 0, + CREATE_NO_WINDOW, + 0, + "C:\\Windows\\System32", + startup_info, + process_info + ); + + if(created == 0 || process_info.hProcess == 0) + { + Print("[", label, "] taskkill launch failed. pid=", process.process_id, + " err=", GetLastError()); + return false; + } + + if(process_info.hThread != 0) + CloseHandle(process_info.hThread); + + int wait_result = WaitForSingleObject(process_info.hProcess, TASKKILL_WAIT_MILLISECONDS); + uint exit_code = 1; + if(wait_result == WAIT_OBJECT_0) + { + if(GetExitCodeProcess(process_info.hProcess, exit_code) == 0) + { + Print("[", label, "] failed to get taskkill exit code. err=", GetLastError()); + exit_code = 1; + } + } + else + { + Print("[", label, "] taskkill wait failed. result=", wait_result, + " err=", GetLastError()); + } + + CloseHandle(process_info.hProcess); + if(wait_result != WAIT_OBJECT_0 || exit_code != 0) + { + Print("[", label, "] taskkill failed. pid=", process.process_id, + " exit_code=", exit_code); + return false; + } + + Print("[", label, "] timed-out Python process tree terminated. pid=", process.process_id); + ResetExternalProcessState(process); + return true; + } + //+------------------------------------------------------------------+ //| 外部プロセスがタイムアウトしているか判定する関数 //+------------------------------------------------------------------+ @@ -312,7 +390,8 @@ bool StartBatchProcess(const string bat_file, const string running_file, const string label, ExternalProcessState &process, - const string file_prefix) + const string file_prefix, + const int price_digits) { STARTUPINFO_W startup_info = {}; PROCESS_INFORMATION process_info = {}; @@ -320,7 +399,8 @@ bool StartBatchProcess(const string bat_file, startup_info.cb = (uint)sizeof(startup_info); string cmd_exe = "C:\\Windows\\System32\\cmd.exe"; - string command_line = "\"" + cmd_exe + "\" /c \"\"" + bat_file + "\" \"" + file_prefix + "\"\""; + string command_line = "\"" + cmd_exe + "\" /c \"\"" + bat_file + "\" \"" + + file_prefix + "\" \"" + IntegerToString(price_digits) + "\"\""; string current_directory = BatchWorkingDirectory(bat_file); int created = CreateProcessW( @@ -357,7 +437,8 @@ bool StartBatchProcess(const string bat_file, CreateRunningFile(running_file, process.process_id); Print("[", label, "] process started. pid=", process.process_id, - " file=", bat_file, " cwd=", current_directory, " prefix=", file_prefix); + " file=", bat_file, " cwd=", current_directory, + " prefix=", file_prefix, " digits=", price_digits); return true; } @@ -550,8 +631,17 @@ bool RecoverTimedOutProcess(const string done_file, const string running_file, c if(!UpdateExternalProcessStatus(process, label)) { if(IsExternalProcessTimedOut(process, running_file)) + { Print("[", label, "] Python exceeded ", PYTHON_TIMEOUT_SECONDS, - " seconds, but the process is still running. Keep waiting."); + " seconds. Terminating process tree before retry."); + if(TerminateExternalProcessTree(label, process)) + { + DeleteRunningFile(running_file); + DeleteDoneFile(done_file); + return true; + } + Print("[", label, "] process tree termination failed. Keep waiting."); + } return false; } diff --git a/Include/MyLib/Signals/HITEntrySignal.mqh b/Include/MyLib/Signals/HITEntrySignal.mqh index 1839f95..857939f 100644 --- a/Include/MyLib/Signals/HITEntrySignal.mqh +++ b/Include/MyLib/Signals/HITEntrySignal.mqh @@ -80,8 +80,9 @@ bool ValidateEntryPreconditions(EAState &state) if(IsTargetCandidateExpired(state)) { - Print("[Entry Skip] H1 target candidate expired. loaded_at=", - TimeToString(state.target_loaded_at, TIME_DATE | TIME_SECONDS)); + Print("[Entry Skip] H1 target candidate expired. candidate_at=", + TimeToString(TargetCandidateReferenceTime(state), TIME_DATE | TIME_SECONDS), + " loaded_at=", TimeToString(state.target_loaded_at, TIME_DATE | TIME_SECONDS)); state.chk_cnt = 0; return false; } @@ -91,6 +92,7 @@ bool ValidateEntryPreconditions(EAState &state) Print("[Entry Skip] H1 target candidate is too old for new execution. age=", TargetCandidateAgeText(state), " max_age_minutes=", input_entry_max_candidate_age_minutes, + " candidate_at=", TimeToString(TargetCandidateReferenceTime(state), TIME_DATE | TIME_SECONDS), " loaded_at=", TimeToString(state.target_loaded_at, TIME_DATE | TIME_SECONDS)); state.chk_cnt = 0; return false; @@ -113,19 +115,20 @@ bool ValidateEntryPreconditions(EAState &state) /** * @brief H1候補価格がENTRY_H1_LIMIT時間を超えて古くなっていないか判定します。 * - * @param state EA全体の状態。H1候補価格の読込時刻を参照します。 + * @param state EA全体の状態。H1候補価格の元になった確定足時刻を参照します。 * @return 候補価格が期限切れの場合はtrue。 */ bool IsTargetCandidateExpired(EAState &state) { - if(state.target_loaded_at <= 0) + datetime reference_time = TargetCandidateReferenceTime(state); + if(reference_time <= 0) return false; int expiration_seconds = ENTRY_H1_LIMIT * PeriodSeconds(PERIOD_H1); if(expiration_seconds <= 0) return false; - return (TimeCurrent() - state.target_loaded_at >= expiration_seconds); + return (TimeCurrent() - reference_time >= expiration_seconds); } //+------------------------------------------------------------------+ @@ -134,14 +137,14 @@ bool IsTargetCandidateExpired(EAState &state) /** * @brief M15確認が遅れて整った古いH1候補で新規注文しないように判定します。 * - * @param state EA全体の状態。H1候補価格の読込時刻を参照します。 + * @param state EA全体の状態。H1候補価格の元になった確定足時刻を参照します。 * @return 入力で指定した最大経過分数を超えている場合はtrue。 */ bool IsTargetCandidateTooOldForExecution(EAState &state) { if(input_entry_max_candidate_age_minutes <= 0) return false; - if(state.target_loaded_at <= 0) + if(TargetCandidateReferenceTime(state) <= 0) return false; int max_age_seconds = input_entry_max_candidate_age_minutes * 60; @@ -151,18 +154,33 @@ bool IsTargetCandidateTooOldForExecution(EAState &state) return (TargetCandidateAgeSeconds(state) > max_age_seconds); } +//+------------------------------------------------------------------+ +//| H1候補価格の鮮度判定に使う基準時刻を返す関数 +//+------------------------------------------------------------------+ +/** + * @brief Python結果の読込時刻ではなく、H1確定足由来の候補時刻を優先します。 + */ +datetime TargetCandidateReferenceTime(EAState &state) + { + if(state.target_candidate_at > 0) + return state.target_candidate_at; + + return state.target_loaded_at; + } + //+------------------------------------------------------------------+ //| H1候補価格の経過秒数を返す関数 //+------------------------------------------------------------------+ /** - * @brief H1候補価格をEAへ読み込んでからの経過秒数を返します。 + * @brief H1候補価格の元になった確定足からの経過秒数を返します。 */ int TargetCandidateAgeSeconds(EAState &state) { - if(state.target_loaded_at <= 0) + datetime reference_time = TargetCandidateReferenceTime(state); + if(reference_time <= 0) return -1; - return (int)(TimeCurrent() - state.target_loaded_at); + return (int)(TimeCurrent() - reference_time); } //+------------------------------------------------------------------+ diff --git a/Include/MyLib/Signals/HITPythonSignalGateway.mqh b/Include/MyLib/Signals/HITPythonSignalGateway.mqh index bec33a0..882a28f 100644 --- a/Include/MyLib/Signals/HITPythonSignalGateway.mqh +++ b/Include/MyLib/Signals/HITPythonSignalGateway.mqh @@ -214,6 +214,71 @@ bool TryParseStrictDouble(const string value, double &parsed) return true; } +//+------------------------------------------------------------------+ +//| H1候補IDを安全な12桁時刻トークンへ正規化する関数 +//+------------------------------------------------------------------+ +/** + * @brief Pythonから受け取ったcandidate_idを注文コメントに使える数字だけへ丸めます。 + */ +string NormalizeTargetCandidateId(const string value) + { + string text = TrimText(value); + string output = ""; + int length = StringLen(text); + + for(int i = 0; i < length && StringLen(output) < 12; i++) + { + int ch = StringGetCharacter(text, i); + if(IsDecimalDigit(ch)) + output += StringSubstr(text, i, 1); + } + + if(StringLen(output) <= 0) + return "0"; + + return output; + } + +//+------------------------------------------------------------------+ +//| H1候補IDから確定足時刻を復元する関数 +//+------------------------------------------------------------------+ +/** + * @brief `yyyyMMddHHmm` 形式のcandidate_idをdatetimeへ変換します。 + */ +bool TryParseTargetCandidateTime(const string candidate_id, datetime &candidate_at) + { + candidate_at = 0; + string text = NormalizeTargetCandidateId(candidate_id); + if(StringLen(text) != 12) + return false; + + int year = (int)StringToInteger(StringSubstr(text, 0, 4)); + int month = (int)StringToInteger(StringSubstr(text, 4, 2)); + int day = (int)StringToInteger(StringSubstr(text, 6, 2)); + int hour = (int)StringToInteger(StringSubstr(text, 8, 2)); + int minute = (int)StringToInteger(StringSubstr(text, 10, 2)); + + if(year < 2000 || month < 1 || month > 12 || + day < 1 || day > 31 || hour < 0 || hour > 23 || + minute < 0 || minute > 59) + return false; + + string datetime_text = StringFormat("%04d.%02d.%02d %02d:%02d", + year, month, day, hour, minute); + datetime parsed = StringToTime(datetime_text); + if(parsed <= 0) + return false; + + MqlDateTime parts; + TimeToStruct(parsed, parts); + if(parts.year != year || parts.mon != month || parts.day != day || + parts.hour != hour || parts.min != minute) + return false; + + candidate_at = parsed; + return true; + } + //+------------------------------------------------------------------+ //| バッチファイル(Pythonスクリプト)を実行する関数 //+------------------------------------------------------------------+ @@ -230,7 +295,9 @@ bool ExecuteBatchTrend() return false; DeleteDoneFile(done_trend_file); - return StartBatchProcess(get_trend_reply_bat, running_trend_file, "trend", g_trend_process, mt5_file_prefix); + int price_digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); + return StartBatchProcess(get_trend_reply_bat, running_trend_file, "trend", + g_trend_process, mt5_file_prefix, price_digits); } //+------------------------------------------------------------------+ @@ -249,7 +316,9 @@ bool ExecuteBatchEntry() return false; DeleteDoneFile(done_entry_file); - return StartBatchProcess(get_entry_reply_bat, running_entry_file, "entry", g_entry_process, mt5_file_prefix); + int price_digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); + return StartBatchProcess(get_entry_reply_bat, running_entry_file, "entry", + g_entry_process, mt5_file_prefix, price_digits); } @@ -504,6 +573,7 @@ void ResetTargetZones(EAState &state) { state.zone_res_chk = 0; state.zone_candidate_id = "0"; + state.target_candidate_at = 0; for(int t = 1; t <= 4; t++) { @@ -611,7 +681,12 @@ void LoadTargetZones(EAState &state) if(line_index == 3) { - state.zone_candidate_id = line; + state.zone_candidate_id = NormalizeTargetCandidateId(line); + datetime candidate_at = 0; + if(TryParseTargetCandidateTime(state.zone_candidate_id, candidate_at)) + state.target_candidate_at = candidate_at; + else + state.target_candidate_at = 0; continue; } @@ -634,6 +709,7 @@ void LoadTargetZones(EAState &state) Print("target_zones: res=", state.zone_res_chk, " id=", state.zone_candidate_id, + " candidate_at=", TimeToString(state.target_candidate_at, TIME_DATE | TIME_MINUTES), " | T1 zone=", state.zone_low[1], "-", state.zone_high[1], " tp=", state.zone_tp[1], " sl=", state.zone_sl[1], " | T2 zone=", state.zone_low[2], "-", state.zone_high[2], diff --git a/Include/MyLib/Trading/HITTradeManager.mqh b/Include/MyLib/Trading/HITTradeManager.mqh index 3c85108..744230b 100644 --- a/Include/MyLib/Trading/HITTradeManager.mqh +++ b/Include/MyLib/Trading/HITTradeManager.mqh @@ -2,6 +2,66 @@ #define HIT_TRADE_MANAGER_MQH +//+------------------------------------------------------------------+ +//| 注文ロットがブローカー制約を満たすか判定する関数 +//+------------------------------------------------------------------+ +/** + * @brief 通常注文と分割注文の最終送信前に volume min/max/step を検証します。 + */ +bool IsOrderVolumeAllowed(const double volume, const string context) + { + double min_volume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); + double max_volume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); + double step_volume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); + + if(volume <= 0.0 || min_volume <= 0.0 || max_volume <= 0.0 || step_volume <= 0.0) + { + Print("[Order Skip] invalid volume setting. context=", context, + " volume=", volume, " min=", min_volume, + " max=", max_volume, " step=", step_volume); + return false; + } + + if(volume < min_volume || volume > max_volume) + { + Print("[Order Skip] volume out of range. context=", context, + " volume=", volume, " min=", min_volume, " max=", max_volume); + return false; + } + + double steps = (volume - min_volume) / step_volume; + double nearest = MathRound(steps); + if(MathAbs(steps - nearest) > 0.000001) + { + Print("[Order Skip] volume does not match broker step. context=", context, + " volume=", volume, " min=", min_volume, " step=", step_volume); + return false; + } + + return true; + } + +//+------------------------------------------------------------------+ +//| ブローカーのロットstepから正規化桁数を推定する関数 +//+------------------------------------------------------------------+ +int VolumeDigits() + { + int volume_digits = 2; + double step_volume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); + if(step_volume > 0.0) + { + volume_digits = 0; + double step = step_volume; + while(step < 1.0 && volume_digits < 8) + { + step *= 10.0; + volume_digits++; + } + } + + return volume_digits; + } + //+------------------------------------------------------------------+ //| エントリー注文を送信する関数 (1:buy-stop, 2:buy-limit, 3:sell-stop, 4:sell-limit) //+------------------------------------------------------------------+ @@ -23,7 +83,7 @@ bool SendOrder(int orderType, double price, double tp, double sl, double volume, request.magic = magic_number; request.symbol = _Symbol; - request.volume = volume; + request.volume = NormalizeDouble(volume, VolumeDigits()); request.deviation = slippage; request.type_filling = GetOrderFillingPolicy(_Symbol); request.price = NormalizeDouble(price, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)); @@ -64,6 +124,12 @@ bool SendOrder(int orderType, double price, double tp, double sl, double volume, return false; } + string volume_context = orderTypeStr; + if(comment_suffix != "") + volume_context += " " + comment_suffix; + if(!IsOrderVolumeAllowed(volume, volume_context)) + return false; + // 注文送信 if(!OrderSend(request, result)) { diff --git a/Include/MyLib/Trading/SLTPManager.mqh b/Include/MyLib/Trading/SLTPManager.mqh index dd73f8e..3b0b30e 100644 --- a/Include/MyLib/Trading/SLTPManager.mqh +++ b/Include/MyLib/Trading/SLTPManager.mqh @@ -335,8 +335,9 @@ public: continue; double candidate_sl = 0.0; + const double evaluation_price = (type == POSITION_TYPE_BUY) ? tick.bid : tick.ask; if (!CalcHighVolatilitySL(type, - tick.bid, + evaluation_price, open_m1, open_m3, open_m5, @@ -381,7 +382,10 @@ private: /// @details GOLD/XAUUSD は 0.1、2/3桁は0.01、4/5桁は0.0001を1 pipとして扱う。 double PipSize() { - if (m_symbol == "XAUUSD" || m_symbol == "GOLD") + string normalized_symbol = m_symbol; + StringToUpper(normalized_symbol); + if (StringFind(normalized_symbol, "XAUUSD") >= 0 || + StringFind(normalized_symbol, "GOLD") >= 0) return 0.1; const int digits = (int)SymbolInfoInteger(m_symbol, SYMBOL_DIGITS); @@ -585,7 +589,7 @@ private: /// @brief 高ボラティリティ判定に基づくSL候補を計算する。 /// @return いずれかの時間足でしきい値を超え、SL候補を出力した場合は true。 bool CalcHighVolatilitySL(const ENUM_POSITION_TYPE type, - const double current_bid, + const double current_price, const double open_m1, const double open_m3, const double open_m5, @@ -599,11 +603,11 @@ private: double candidate_sl = 0.0; const double rate_ratio = 0.9; - EvaluateHighVolatilityOpen(type, current_bid, open_m1, 100.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); - EvaluateHighVolatilityOpen(type, current_bid, open_m3, 150.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); - EvaluateHighVolatilityOpen(type, current_bid, open_m5, 200.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); - EvaluateHighVolatilityOpen(type, current_bid, open_m10, 300.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); - EvaluateHighVolatilityOpen(type, current_bid, open_m15, 400.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); + EvaluateHighVolatilityOpen(type, current_price, open_m1, 100.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); + EvaluateHighVolatilityOpen(type, current_price, open_m3, 150.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); + EvaluateHighVolatilityOpen(type, current_price, open_m5, 200.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); + EvaluateHighVolatilityOpen(type, current_price, open_m10, 300.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); + EvaluateHighVolatilityOpen(type, current_price, open_m15, 400.0, rate_ratio, pip_size, digits, has_candidate, candidate_sl); if (!has_candidate) return false; @@ -614,7 +618,7 @@ private: /// @brief 1本の時間足始値から急変幅を評価し、有効なSL候補なら最良候補へ反映する。 void EvaluateHighVolatilityOpen(const ENUM_POSITION_TYPE type, - const double current_bid, + const double current_price, const double open_price, const double limit_pips, const double rate_ratio, @@ -631,12 +635,12 @@ private: if (type == POSITION_TYPE_BUY) { - movement_pips = (current_bid - open_price) / pip_size; + movement_pips = (current_price - open_price) / pip_size; sl_value = open_price + limit_pips * rate_ratio * pip_size; } else if (type == POSITION_TYPE_SELL) { - movement_pips = (open_price - current_bid) / pip_size; + movement_pips = (open_price - current_price) / pip_size; sl_value = open_price - limit_pips * rate_ratio * pip_size; } else diff --git a/docs/MyProject/HIT-EA_spec_en.md b/docs/MyProject/HIT-EA_spec_en.md index 63a5dfa..d119b59 100644 --- a/docs/MyProject/HIT-EA_spec_en.md +++ b/docs/MyProject/HIT-EA_spec_en.md @@ -87,7 +87,7 @@ ## 5. Python Integration Files -During `OnInit()`, the EA builds a `HIT__` prefix from `_Symbol` and `magic_number`, then applies it to all MT5 `Files` linkage names. Example: `HIT_GOLD_10001_ohlc_H4.csv`. The same prefix is passed to the batch file as the first argument, and the batch file exposes it to Python as `MT5_EA_FILE_PREFIX`. +During `OnInit()`, the EA builds a `HIT__` prefix from `_Symbol` and `magic_number`, then applies it to all MT5 `Files` linkage names. Example: `HIT_GOLD_10001_ohlc_H4.csv`. The same prefix is passed to the batch file as the first argument, and the batch file exposes it to Python as `MT5_EA_FILE_PREFIX`. The EA also passes the symbol's `SYMBOL_DIGITS` as the second argument, exposed to Python as `MT5_PRICE_DIGITS`. When no prefix is provided, such as manual Python execution, Python keeps using the legacy names like `ohlc_H4.csv`. @@ -131,7 +131,7 @@ Line 6: T3 Sell Stop strategy, zone_low, zone_high, tp, sl Line 7: T4 Sell Limit strategy, zone_low, zone_high, tp, sl ``` -Python creates `process_done_entry.txt` only after both `target_prices.txt` and `target_zones.txt` have been written. When split entries are disabled, the EA keeps using the legacy 13-line `target_prices.txt` path. +Python creates `process_done_entry.txt` only after both `target_prices.txt` and `target_zones.txt` have been written. Prices in `target_zones.txt` are formatted with `MT5_PRICE_DIGITS`; manual runs without this value use a conservative 5 decimal places. When split entries are disabled, the EA keeps using the legacy 13-line `target_prices.txt` path. ## 6. Entry Conditions @@ -141,8 +141,8 @@ New entries are allowed only when all conditions below are satisfied: - H4 trend analysis is complete and `trend_state.txt` can be loaded. - H1 entry candidate generation is complete and `target_prices.txt` can be loaded. - `res_chk = 1`. -- The H1 candidate set has not expired under `ENTRY_H1_LIMIT`. -- The H1 candidate set has not exceeded `input_entry_max_candidate_age_minutes` since it was loaded. +- The H1 candidate set has not expired under `ENTRY_H1_LIMIT`, measured from the closed H1 bar time that produced the candidate. +- The H1 candidate set has not exceeded `input_entry_max_candidate_age_minutes`, measured from the closed H1 bar time that produced the candidate. - The selected order type has valid `entry/tp/sl` values greater than `0.0`. - The order type is allowed by the H4 `market_state`. - The order-type price relationship and broker minimum distance rules are satisfied. @@ -190,12 +190,14 @@ New entries are allowed only when all conditions below are satisfied: - Managed orders and positions are limited to matching `_Symbol` and `magic_number`. - `input_position_limit` counts only this EA's orders and positions, not the full account. The hard internal limit is `POSITION_LIMIT`. - The EA selects the filling policy from `SYMBOL_FILLING_MODE`, preferring IOC, then FOK, then RETURN. +- Before sending either single or split orders, the EA verifies the requested lot against `SYMBOL_VOLUME_MIN/MAX/STEP`. - Pending orders receive server-side expiration when the broker supports it. - `OrderSend` return values and `MqlTradeResult.retcode` are checked. Failures are logged with `GetLastError()` or the retcode. - Split-entry order comments include `candidate_id` and slot number to prevent duplicate slot execution. - When `cancel_old_split_pending_on_new_zone = true`, a new valid `candidate_id` cancels older split pending orders, and an invalid or stopped `target_zones.txt` cancels all existing split pending orders for this EA/symbol/magic. - SLTP management only targets existing positions matching `_Symbol` and `magic_number`, and it preserves each position's current TP. - SL changes are sent only when the candidate improves protection relative to the current SL and satisfies broker stop-level and freeze-level constraints. +- `HighVolatilityLimit()` evaluates BUY positions with Bid and SELL positions with Ask, and treats GOLD/XAUUSD suffix symbols as 1 pip = 0.1. ## 9. Error Handling @@ -203,6 +205,7 @@ New entries are allowed only when all conditions below are satisfied: - If `target_prices.txt` is missing, unreadable, shorter than 13 lines, or contains a non-strict numeric line, the EA uses `res_chk = 0`. - When split entries are enabled, missing `target_zones.txt`, schema mismatch, fewer than 7 lines, `res_chk != 1`, or non-strict numeric values stop new split orders. - If a Python process exits with a non-zero code, the code is logged and the next trigger may retry. +- If a Python process remains active beyond `PYTHON_TIMEOUT_SECONDS`, the EA runs `taskkill /T /F` against the cmd/bat process tree, clears done/running files, and allows the next trigger to retry. - If only a stale running marker remains, the EA attempts PID recovery. If recovery fails and the marker is timed out, it removes the stale marker. ## 10. Changelog @@ -214,6 +217,10 @@ New entries are allowed only when all conditions below are satisfied: - Added strict numeric parsing requirements for `trend_state.txt`, `target_prices.txt`, and `target_zones.txt`, with invalid content handled as a safe stop state. - Added stale split-pending cancellation behavior when `target_zones.txt` is invalid or stopped. - Synchronized current defaults for `input_entry_max_candidate_age_minutes`, `ENTRY_H1_LIMIT`, SLTP buffers, and the Python entry-distance guard. +- Added process-tree termination and retry recovery for Python timeouts. +- Changed H1 candidate freshness checks to use the closed H1 bar time restored from `candidate_id`, instead of the Python result load time. +- Added `MT5_PRICE_DIGITS` so Python formats `target_zones.txt` prices with the active symbol's decimal precision. +- Added single-order lot validation, OHLC CSV NaN/inf and consistency checks, Ask-based SELL high-volatility SL evaluation, and GOLD/XAUUSD suffix handling. ### 2026-05-27 diff --git a/docs/MyProject/HIT-EA_spec_ja.md b/docs/MyProject/HIT-EA_spec_ja.md index 09f5d77..59a5d65 100644 --- a/docs/MyProject/HIT-EA_spec_ja.md +++ b/docs/MyProject/HIT-EA_spec_ja.md @@ -87,7 +87,7 @@ ## 5. Python連携ファイル -EAは `OnInit()` で `_Symbol` と `magic_number` から `HIT__` 形式の接頭辞を作り、MT5 `Files` 配下の連携ファイル名へ付与する。例: `HIT_GOLD_10001_ohlc_H4.csv`。batには同じ接頭辞を第1引数で渡し、bat側が `MT5_EA_FILE_PREFIX` としてPythonへ引き継ぐ。 +EAは `OnInit()` で `_Symbol` と `magic_number` から `HIT__` 形式の接頭辞を作り、MT5 `Files` 配下の連携ファイル名へ付与する。例: `HIT_GOLD_10001_ohlc_H4.csv`。batには同じ接頭辞を第1引数で渡し、bat側が `MT5_EA_FILE_PREFIX` としてPythonへ引き継ぐ。第2引数には `_Symbol` の `SYMBOL_DIGITS` を渡し、bat側が `MT5_PRICE_DIGITS` としてPythonへ引き継ぐ。 手動実行などで接頭辞が指定されない場合、Pythonは従来どおり `ohlc_H4.csv` などの旧ファイル名を使う。 @@ -131,7 +131,7 @@ EAは `OnInit()` で `TerminalInfoString(TERMINAL_DATA_PATH)` から `MQL5\pytho 7行目: T4 Sell Limit の strategy, zone_low, zone_high, tp, sl ``` -Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えてから `process_done_entry.txt` を作成する。分割エントリーが無効な場合、EAは従来どおり `target_prices.txt` を使用する。 +Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えてから `process_done_entry.txt` を作成する。`target_zones.txt` の価格小数桁は `MT5_PRICE_DIGITS` に合わせ、未指定時は安全側で5桁を使う。分割エントリーが無効な場合、EAは従来どおり `target_prices.txt` を使用する。 ## 6. エントリー条件 @@ -141,8 +141,8 @@ Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えて - H4トレンド判定結果が完了済みで、`trend_state.txt` を読み込める。 - H1候補価格生成が完了済みで、`target_prices.txt` を読み込める。 - `res_chk = 1`。 -- H1候補価格が `ENTRY_H1_LIMIT` 内で期限切れではない。 -- H1候補価格の読込から `input_entry_max_candidate_age_minutes` 分を超えていない。 +- H1候補価格が、元になったH1確定足時刻から `ENTRY_H1_LIMIT` 内で期限切れではない。 +- H1候補価格が、元になったH1確定足時刻から `input_entry_max_candidate_age_minutes` 分を超えていない。 - 対象注文タイプの `entry/tp/sl` がすべて `0.0` より大きい。 - H4 `market_state` に対して注文タイプが許可されている。 - 注文タイプごとの価格整合条件とブローカー最小距離制約を満たす。 @@ -190,12 +190,14 @@ Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えて - 注文/ポジション管理対象は `_Symbol` と `magic_number` が一致するものに限定する。 - `input_position_limit` は口座全体ではなく、自EA対象分のみを数える。内部の絶対上限は `POSITION_LIMIT` とする。 - 送信時は `SYMBOL_FILLING_MODE` を参照し、IOC、FOK、RETURNの順で利用可能な執行ポリシーを選択する。 +- 通常注文と分割注文の送信前に、ロットが `SYMBOL_VOLUME_MIN/MAX/STEP` を満たすことを検証する。 - ペンディング注文には、ブローカーが対応する場合にサーバー側の期限を設定する。 - `OrderSend` の戻り値と `MqlTradeResult.retcode` を確認し、失敗時は `GetLastError()` またはretcodeをログへ出力する。 - 分割エントリーの注文コメントには `candidate_id` とslot番号を入れ、同一slotの二重発注を抑止する。 - `cancel_old_split_pending_on_new_zone = true` の場合、新しい有効 `candidate_id` を読んだ時は旧candidateの分割pending注文を取消し、`target_zones.txt` が無効または停止値の場合は既存の分割pending注文をすべて取消する。 - SLTP管理は `_Symbol` と `magic_number` が一致する既存ポジションのみを対象とし、TPは既存値を維持する。 - SL変更は既存SLより利益保護方向へ改善する場合だけ実行し、ブローカーのstop level / freeze levelを満たさない候補は送信しない。 +- `HighVolatilityLimit()` はBUYをBid、SELLをAsk基準で評価し、GOLD/XAUUSDはサフィックス付きシンボルでも1 pip = 0.1として扱う。 ## 9. 異常系の扱い @@ -203,6 +205,7 @@ Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えて - `target_prices.txt` が存在しない、読み込めない、13行未満、またはいずれかの行が数値として厳格にパースできない場合は `res_chk = 0` として扱う。 - 分割エントリー有効時に `target_zones.txt` が存在しない、schema不一致、7行未満、`res_chk != 1`、またはいずれかの数値行を厳格にパースできない場合は新規注文を停止する。 - Pythonプロセスが異常終了した場合、終了コードをログへ出力し、次回トリガーで再実行できる状態に戻す。 +- Pythonプロセスが `PYTHON_TIMEOUT_SECONDS` を超えても実行中の場合、`taskkill /T /F` でcmd/bat配下のプロセスツリーを終了し、done/runningファイルを整理して次回トリガーで再実行できる状態に戻す。 - `running` ファイルだけが残っている場合は、PIDからプロセス復元を試みる。復元できずタイムアウト済みなら古いマーカーとして削除する。 ## 10. 変更履歴 @@ -214,6 +217,10 @@ Pythonは `target_prices.txt` と `target_zones.txt` の両方を書き終えて - `trend_state.txt`、`target_prices.txt`、`target_zones.txt` の数値読込を厳格化し、不正値は安全側の停止値として扱う仕様を追加した。 - `target_zones.txt` が無効または停止値の場合に、古い分割pending注文を全取消できる仕様を追加した。 - 現行コードに合わせて、`input_entry_max_candidate_age_minutes`、`ENTRY_H1_LIMIT`、SLTPバッファ、Python側距離ガードの既定値を修正した。 +- Pythonタイムアウト時に実行中プロセスツリーを強制終了して再試行できる仕様を追加した。 +- H1候補の鮮度判定をPython結果の読込時刻ではなく、`candidate_id` から復元したH1確定足時刻基準へ変更した。 +- `MT5_PRICE_DIGITS` によりPython側の `target_zones.txt` 出力桁数をシンボル桁数へ合わせる仕様を追加した。 +- 通常注文ロットの `SYMBOL_VOLUME_MIN/MAX/STEP` 検証、OHLC CSVのNaN/inf・OHLC整合性検証、SELL高ボラSLのAsk基準評価、GOLD/XAUUSDサフィックス対応を追加した。 ### 2026-05-27 diff --git a/python_for_ea/README.md b/python_for_ea/README.md index 1d7ba33..95f6295 100644 --- a/python_for_ea/README.md +++ b/python_for_ea/README.md @@ -36,7 +36,7 @@ MT5 EA からは `bat/` 経由でルート直下の互換スクリプトを起 パスの組み立ては `src/ea_py/paths.py` で定義しています。特殊な配置では `MT5_FILES_DIR` または `MT5_DATA_PATH` 環境変数で上書きできます。 -EAからbat経由で起動する場合は、第1引数で渡された `HIT__` 接頭辞が `MT5_EA_FILE_PREFIX` として設定されます。Pythonはこの接頭辞をすべての連携ファイル名へ付けるため、同じTerminal内で複数EAや複数シンボルを動かしてもファイルが衝突しません。手動実行で接頭辞が未設定の場合は、従来のファイル名を使います。 +EAからbat経由で起動する場合は、第1引数で渡された `HIT__` 接頭辞が `MT5_EA_FILE_PREFIX` として設定されます。第2引数で渡された `_Symbol` の価格桁数は `MT5_PRICE_DIGITS` として設定されます。Pythonはこの接頭辞をすべての連携ファイル名へ付けるため、同じTerminal内で複数EAや複数シンボルを動かしてもファイルが衝突しません。手動実行で接頭辞が未設定の場合は、従来のファイル名を使います。 ### 入力 @@ -45,7 +45,7 @@ EAからbat経由で起動する場合は、第1引数で渡された `HIT__]ohlc_H1.csv ``` -CSV は UTF-8 読み込みで、次の列を必須とします。 +CSV は UTF-8 読み込みで、次の列を必須とします。価格列はfloatへ変換し、NaN/infや `High < Low`、High/LowがOpen/Closeを含まない行は停止値へ倒すため例外にします。 ```text Time,Open,High,Low,Close @@ -127,6 +127,8 @@ H4 状態ごとの許可戦略: EA側で分割エントリーを有効にした場合は、この予測ゾーンを `split_entry_count` 本に分割してpending注文を出します。ロットは総量分割または1注文固定を選択できます。 +`target_zones.txt` の価格小数桁は `MT5_PRICE_DIGITS` に合わせます。EA/bat経由では自動設定され、手動実行で未指定の場合は安全側で5桁を使います。 + ## OpenAI 設定 H1 エントリー候補生成では OpenAI API を使用します。 @@ -143,9 +145,10 @@ $env:OPENAI_API_KEY = "..." $env:OPENAI_MODEL = "..." $env:OPENAI_REASONING_EFFORT = "low" $env:MT5_EA_FILE_PREFIX = "HIT_GOLD_10001" +$env:MT5_PRICE_DIGITS = "2" ``` -`OPENAI_MODEL` 未設定時は `src/ea_py/constants.py` の `DEFAULT_GPT_MODEL` を使います。`OPENAI_REASONING_EFFORT` は `none`, `low`, `medium`, `high`, `xhigh` のいずれかです。`MT5_EA_FILE_PREFIX` は手動実行時だけ指定すればよく、EA/bat経由では自動設定されます。 +`OPENAI_MODEL` 未設定時は `src/ea_py/constants.py` の `DEFAULT_GPT_MODEL` を使います。`OPENAI_REASONING_EFFORT` は `none`, `low`, `medium`, `high`, `xhigh` のいずれかです。`MT5_EA_FILE_PREFIX` と `MT5_PRICE_DIGITS` は手動実行時だけ指定すればよく、EA/bat経由では自動設定されます。 ## 主なモジュール diff --git a/python_for_ea/bat/get_entry_reply.bat b/python_for_ea/bat/get_entry_reply.bat index 852610b..943076b 100644 --- a/python_for_ea/bat/get_entry_reply.bat +++ b/python_for_ea/bat/get_entry_reply.bat @@ -6,6 +6,7 @@ set PYTHONIOENCODING=utf-8 set "APP_DIR=%~dp0.." set PY_FILE=get_entry_reply.py if not "%~1"=="" set "MT5_EA_FILE_PREFIX=%~1" +if not "%~2"=="" set "MT5_PRICE_DIGITS=%~2" for %%I in ("%APP_DIR%") do set "APP_DIR=%%~fI" cd /d "%APP_DIR%" diff --git a/python_for_ea/bat/get_trend_reply.bat b/python_for_ea/bat/get_trend_reply.bat index 1714276..a465b79 100644 --- a/python_for_ea/bat/get_trend_reply.bat +++ b/python_for_ea/bat/get_trend_reply.bat @@ -6,6 +6,7 @@ set PYTHONIOENCODING=utf-8 set "APP_DIR=%~dp0.." set PY_FILE=get_trend_reply.py if not "%~1"=="" set "MT5_EA_FILE_PREFIX=%~1" +if not "%~2"=="" set "MT5_PRICE_DIGITS=%~2" for %%I in ("%APP_DIR%") do set "APP_DIR=%%~fI" cd /d "%APP_DIR%" diff --git a/python_for_ea/docs/architecture/folder-structure.md b/python_for_ea/docs/architecture/folder-structure.md index 09cae5a..08a9b84 100644 --- a/python_for_ea/docs/architecture/folder-structure.md +++ b/python_for_ea/docs/architecture/folder-structure.md @@ -92,7 +92,7 @@ MT5 EAから `bat/` 経由でルート直下のPythonスクリプトが呼ばれ MT5側の `MQL5\Files` 配下に、Pythonとの連携ファイルが置かれる。 -EA/bat経由では `HIT__` 形式の接頭辞が `MT5_EA_FILE_PREFIX` としてPythonへ渡され、すべての連携ファイル名へ付与される。接頭辞が未設定の手動実行では旧ファイル名を維持する。 +EA/bat経由では `HIT__` 形式の接頭辞が `MT5_EA_FILE_PREFIX` としてPythonへ渡され、すべての連携ファイル名へ付与される。さらに `_Symbol` の価格桁数が `MT5_PRICE_DIGITS` として渡され、`target_zones.txt` の価格出力桁数に使われる。接頭辞が未設定の手動実行では旧ファイル名を維持し、価格桁数が未指定の場合は5桁を使う。 代表例: diff --git a/python_for_ea/src/ea_py/io/ohlc_csv.py b/python_for_ea/src/ea_py/io/ohlc_csv.py index 8291674..b4f5e06 100644 --- a/python_for_ea/src/ea_py/io/ohlc_csv.py +++ b/python_for_ea/src/ea_py/io/ohlc_csv.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from pathlib import Path import pandas as pd @@ -9,6 +10,7 @@ import pandas as pd from ea_py.types import OhlcBar REQUIRED_COLUMNS = ("Time", "Open", "High", "Low", "Close") +PRICE_COLUMNS = ("Open", "High", "Low", "Close") def read_ohlc_csv(path: Path) -> list[OhlcBar]: @@ -18,7 +20,8 @@ def read_ohlc_csv(path: Path) -> list[OhlcBar]: `Time` は文字列、価格列はfloatへ変換し、内部表現では既存プロンプトと チャート生成処理に合わせて `Time` を `DateTime` キーへ写す。 - 必須列が欠けている場合や価格列のfloat変換に失敗した場合は例外を送出する。 + 必須列が欠けている場合、価格列のfloat変換に失敗した場合、 + NaN/infやOHLC整合性違反がある場合は例外を送出する。 上位パイプラインはその例外を捕捉し、MT5へ停止値を返す。 """ df = pd.read_csv(path, encoding="utf-8") @@ -34,14 +37,31 @@ def read_ohlc_csv(path: Path) -> list[OhlcBar]: df["Close"] = df["Close"].astype(float) ohlc: list[OhlcBar] = [] - for _, row in df.iterrows(): + for index, row in df.iterrows(): + prices = {column: float(row[column]) for column in PRICE_COLUMNS} + invalid_columns = [column for column, value in prices.items() if not math.isfinite(value)] + if invalid_columns: + joined = ", ".join(invalid_columns) + raise ValueError(f"OHLC CSV row {index} has non-finite price(s): {joined}") + + high = prices["High"] + low = prices["Low"] + open_price = prices["Open"] + close_price = prices["Close"] + if high < low: + raise ValueError(f"OHLC CSV row {index} has High < Low") + if high < max(open_price, close_price): + raise ValueError(f"OHLC CSV row {index} has High below Open/Close") + if low > min(open_price, close_price): + raise ValueError(f"OHLC CSV row {index} has Low above Open/Close") + ohlc.append( { "DateTime": row["Time"], - "Open": float(row["Open"]), - "High": float(row["High"]), - "Low": float(row["Low"]), - "Close": float(row["Close"]), + "Open": open_price, + "High": high, + "Low": low, + "Close": close_price, } ) diff --git a/python_for_ea/src/ea_py/paths.py b/python_for_ea/src/ea_py/paths.py index 0555680..d25913a 100644 --- a/python_for_ea/src/ea_py/paths.py +++ b/python_for_ea/src/ea_py/paths.py @@ -12,6 +12,7 @@ DEFAULT_TERMINAL_ID = "5BDB0B60344C088C2FA5CA35699BAAFD" ENV_MT5_FILES_DIR = "MT5_FILES_DIR" ENV_MT5_DATA_PATH = "MT5_DATA_PATH" ENV_MT5_EA_FILE_PREFIX = "MT5_EA_FILE_PREFIX" +ENV_MT5_PRICE_DIGITS = "MT5_PRICE_DIGITS" @dataclass(frozen=True) @@ -23,6 +24,7 @@ class Mt5PathSettings: user_name: str | None = None terminal_id: str | None = None file_prefix: str | None = None + price_digits: int | None = None @dataclass(frozen=True) @@ -103,6 +105,30 @@ def prefixed_file_name(base_name: str, settings: Mt5PathSettings | None = None) return f"{prefix}_{base_name}" +def sanitize_price_digits(value: int | str | None) -> int | None: + """MT5から渡された価格桁数を安全な範囲へ丸める。""" + if value is None: + return None + + try: + digits = int(value) + except (TypeError, ValueError): + return None + + if 0 <= digits <= 10: + return digits + + return None + + +def mt5_price_digits(settings: Mt5PathSettings | None = None) -> int | None: + """MT5連携価格の小数桁数を返す。未指定や不正値はNone。""" + if settings and settings.price_digits is not None: + return sanitize_price_digits(settings.price_digits) + + return sanitize_price_digits(os.getenv(ENV_MT5_PRICE_DIGITS)) + + def terminal_files_dir(settings: Mt5PathSettings | None = None) -> Path: """MT5のMQL5/Filesディレクトリを返す。 diff --git a/python_for_ea/tests/unit/test_ohlc_csv.py b/python_for_ea/tests/unit/test_ohlc_csv.py new file mode 100644 index 0000000..2f7d3e4 --- /dev/null +++ b/python_for_ea/tests/unit/test_ohlc_csv.py @@ -0,0 +1,50 @@ +"""OHLC CSV reader tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ea_py.io.ohlc_csv import read_ohlc_csv + + +def write_csv(path: Path, body: str) -> None: + """Write a small MT5-style OHLC CSV for tests.""" + path.write_text("Time,Open,High,Low,Close\n" + body, encoding="utf-8") + + +def test_read_ohlc_csv_rejects_non_finite_prices(tmp_path: Path) -> None: + """NaN/inf values are rejected before market calculations.""" + csv_path = tmp_path / "ohlc.csv" + write_csv(csv_path, "2026.05.24 13:00,1900.0,nan,1890.0,1895.0\n") + + with pytest.raises(ValueError, match="non-finite"): + read_ohlc_csv(csv_path) + + +def test_read_ohlc_csv_rejects_inconsistent_high_low(tmp_path: Path) -> None: + """High/Low must contain Open and Close.""" + csv_path = tmp_path / "ohlc.csv" + write_csv(csv_path, "2026.05.24 13:00,1900.0,1899.0,1890.0,1895.0\n") + + with pytest.raises(ValueError, match="High below Open/Close"): + read_ohlc_csv(csv_path) + + +def test_read_ohlc_csv_accepts_valid_mt5_rows(tmp_path: Path) -> None: + """Valid MT5 rows are converted to the internal DateTime schema.""" + csv_path = tmp_path / "ohlc.csv" + write_csv(csv_path, "2026.05.24 13:00,1900.0,1902.0,1890.0,1895.0\n") + + actual = read_ohlc_csv(csv_path) + + assert actual == [ + { + "DateTime": "2026.05.24 13:00", + "Open": 1900.0, + "High": 1902.0, + "Low": 1890.0, + "Close": 1895.0, + } + ] diff --git a/python_for_ea/tests/unit/test_paths.py b/python_for_ea/tests/unit/test_paths.py index c8a1e31..35fc529 100644 --- a/python_for_ea/tests/unit/test_paths.py +++ b/python_for_ea/tests/unit/test_paths.py @@ -10,13 +10,16 @@ from ea_py.paths import ( ENV_MT5_EA_FILE_PREFIX, ENV_MT5_DATA_PATH, ENV_MT5_FILES_DIR, + ENV_MT5_PRICE_DIGITS, Mt5PathSettings, build_entry_paths, build_trend_paths, discover_mql5_dir, files_dir_from_data_path, + mt5_price_digits, prefixed_file_name, sanitize_file_prefix, + sanitize_price_digits, terminal_files_dir, ) @@ -135,3 +138,25 @@ def test_build_paths_apply_file_prefix_from_environment( actual = build_entry_paths() assert actual.done_entry == tmp_path / "HIT_GOLD_10001_process_done_entry.txt" + + +def test_sanitize_price_digits_accepts_mt5_symbol_digits() -> None: + """MT5のSYMBOL_DIGITSは安全な範囲だけ受け入れる。""" + assert sanitize_price_digits("3") == 3 + assert sanitize_price_digits(10) == 10 + assert sanitize_price_digits("-1") is None + assert sanitize_price_digits("abc") is None + + +def test_mt5_price_digits_reads_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """EA/batから渡された価格桁数をPython側で参照できる。""" + monkeypatch.setenv(ENV_MT5_PRICE_DIGITS, "3") + + assert mt5_price_digits() == 3 + + +def test_mt5_price_digits_accepts_settings() -> None: + """テストや手動実行では設定オブジェクトから価格桁数を渡せる。""" + settings = Mt5PathSettings(price_digits=2) + + assert mt5_price_digits(settings) == 2 diff --git a/python_for_ea/tests/unit/test_target_prices.py b/python_for_ea/tests/unit/test_target_prices.py index 2bfb5ca..fec79a3 100644 --- a/python_for_ea/tests/unit/test_target_prices.py +++ b/python_for_ea/tests/unit/test_target_prices.py @@ -111,7 +111,7 @@ def test_format_target_zones_includes_schema_candidate_and_all_strategies() -> N parsed = parse_lines_to_entry_zones_allow_subset("2,1895.00,1910.00,1885.00,1892.00,1898.00") sanitized = sanitize_entry_zones(parsed, selected_strategies=[2], current_price=1900.0) - actual = format_target_zones(sanitized, "202605241300") + actual = format_target_zones(sanitized, "202605241300", price_digits=2) assert actual.splitlines() == [ "2", @@ -124,6 +124,16 @@ def test_format_target_zones_includes_schema_candidate_and_all_strategies() -> N ] +def test_format_target_zones_uses_mt5_price_digits() -> None: + """MT5から渡された価格桁数に合わせてゾーン価格を出力する。""" + parsed = parse_lines_to_entry_zones_allow_subset("2,1895.1234,1910.5678,1885.2345,1892.1234,1898.5678") + sanitized = sanitize_entry_zones(parsed, selected_strategies=[2], current_price=1900.0) + + actual = format_target_zones(sanitized, "202605241300", price_digits=3) + + assert actual.splitlines()[4] == "2,1892.123,1898.568,1910.568,1885.235" + + def test_build_candidate_id_datetime_text_returns_compact_digits() -> None: """H1確定足時刻からスペースなしの候補IDを作る。""" actual = build_candidate_id("2026.05.24 13:00")