From eb5a54717790c81d88364ba0f022efe42ee90a3f Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 05:19:48 +0300 Subject: [PATCH 01/11] feat(bridges): add metatrader file command writer --- .github/workflows/ubuntu-smoke.yml | 12 + .github/workflows/windows-smoke.yml | 6 + CMakeLists.txt | 47 +++ .../metatrader_file_command_writer_smoke.cpp | 165 ++++++++ examples/mql/OptionXFileBridge.mqh | 396 ++++++++++++++++++ .../mql/OptionXFileBridgeSignalExample.mq4 | 65 +++ .../mql/OptionXFileBridgeSignalExample.mq5 | 66 +++ .../file-transport-and-adapters.md | 60 ++- .../file-transport-and-adapters.ru.md | 59 ++- guides/build-and-test.md | 11 +- .../optionx_cpp/bridges/metatrader_file.hpp | 1 + .../MetaTraderFileCommandWriter.hpp | 261 ++++++++++++ tests/bridge_umbrella_include_test.cpp | 4 + tests/metatrader_file_command_writer_test.cpp | 139 ++++++ 14 files changed, 1284 insertions(+), 8 deletions(-) create mode 100644 examples/metatrader_file_command_writer_smoke.cpp create mode 100644 examples/mql/OptionXFileBridge.mqh create mode 100644 examples/mql/OptionXFileBridgeSignalExample.mq4 create mode 100644 examples/mql/OptionXFileBridgeSignalExample.mq5 create mode 100644 include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp create mode 100644 tests/metatrader_file_command_writer_test.cpp diff --git a/.github/workflows/ubuntu-smoke.yml b/.github/workflows/ubuntu-smoke.yml index 23b5c40..f27a2e3 100644 --- a/.github/workflows/ubuntu-smoke.yml +++ b/.github/workflows/ubuntu-smoke.yml @@ -54,12 +54,24 @@ jobs: - name: Run metatrader_file_bridge_test run: ./build-linux/metatrader_file_bridge_test --gtest_brief=1 + - name: Build metatrader_file_command_writer_test + run: cmake --build build-linux --target metatrader_file_command_writer_test -j + + - name: Run metatrader_file_command_writer_test + run: ./build-linux/metatrader_file_command_writer_test --gtest_brief=1 + - name: Build metatrader_file_bridge_smoke run: cmake --build build-linux --target metatrader_file_bridge_smoke -j - name: Run metatrader_file_bridge_smoke run: ./build-linux/metatrader_file_bridge_smoke --self-test + - name: Build metatrader_file_command_writer_smoke + run: cmake --build build-linux --target metatrader_file_command_writer_smoke -j + + - name: Run metatrader_file_command_writer_smoke + run: ./build-linux/metatrader_file_command_writer_smoke --self-test + - name: Build market_data_subscription_contract_test run: cmake --build build-linux --target market_data_subscription_contract_test -j diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index 9b375bf..a0ac2f5 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -32,11 +32,15 @@ jobs: cmake --build build-windows --config Debug --target metatrader_file_config_include_test metatrader_file_bridge_test + metatrader_file_command_writer_test bridge_umbrella_include_test - name: Build MetaTrader file smoke example run: cmake --build build-windows --config Debug --target metatrader_file_bridge_smoke + - name: Build MetaTrader file command writer smoke example + run: cmake --build build-windows --config Debug --target metatrader_file_command_writer_smoke + - name: Run MetaTrader file tests shell: pwsh run: | @@ -44,5 +48,7 @@ jobs: .\build-windows\Debug\metatrader_paths_test.exe --gtest_brief=1 .\build-windows\Debug\metatrader_file_config_include_test.exe --gtest_brief=1 .\build-windows\Debug\metatrader_file_bridge_test.exe --gtest_brief=1 + .\build-windows\Debug\metatrader_file_command_writer_test.exe --gtest_brief=1 .\build-windows\Debug\bridge_umbrella_include_test.exe --gtest_brief=1 .\build-windows\Debug\metatrader_file_bridge_smoke.exe --self-test + .\build-windows\Debug\metatrader_file_command_writer_smoke.exe --self-test diff --git a/CMakeLists.txt b/CMakeLists.txt index 517387a..4e1395d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -485,6 +485,52 @@ if(OPTIONX_BUILD_EXAMPLES) -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/copy_runtime_dlls.cmake" ) endif() + + add_executable(metatrader_file_command_writer_smoke examples/metatrader_file_command_writer_smoke.cpp) + target_compile_features(metatrader_file_command_writer_smoke PRIVATE cxx_std_17) + + target_include_directories(metatrader_file_command_writer_smoke PRIVATE + ${EXAMPLE_INCLUDE_DIRS} + ${EXAMPLE_DEPS_INCLUDE_DIRS} + ) + + target_link_directories(metatrader_file_command_writer_smoke PRIVATE ${EXAMPLE_LIBRARY_DIRS}) + target_compile_definitions( + metatrader_file_command_writer_smoke PRIVATE + ${EXAMPLE_DEFINES} + LOGIT_BASE_PATH="${LOGIT_BASE_PATH_FWD}" + ) + if(MINGW) + target_compile_options(metatrader_file_command_writer_smoke PRIVATE -Wa,-mbig-obj) + endif() + if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) + target_link_libraries(metatrader_file_command_writer_smoke PRIVATE ${EXAMPLE_LIBS}) + if(WIN32) + target_link_libraries(metatrader_file_command_writer_smoke PRIVATE ${OPTIONX_WINDOWS_SYSTEM_LIBS}) + endif() + else() + target_link_libraries(metatrader_file_command_writer_smoke PRIVATE ${EXAMPLE_LIBS} optionx_cpp) + endif() + + if(OPTIONX_BUILD_DEPS AND NOT OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) + add_dependencies(metatrader_file_command_writer_smoke mdbx-static AES) + endif() + + foreach(dll ${EXAMPLE_DLL_FILES}) + add_custom_command(TARGET metatrader_file_command_writer_smoke POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${dll}" "$" + ) + endforeach() + + if(WIN32) + add_custom_command(TARGET metatrader_file_command_writer_smoke POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DOPTIONX_RUNTIME_DLL_DIR="${EXAMPLE_BUILD_LIBS_DIR}/bin" + -DOPTIONX_RUNTIME_TARGET_DIR="$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/copy_runtime_dlls.cmake" + ) + endif() endif() if(OPTIONX_BUILD_TESTS) @@ -528,6 +574,7 @@ if(OPTIONX_BUILD_TESTS) list(APPEND OPTIONX_LIGHTWEIGHT_TESTS bridge_umbrella_include_test metatrader_file_bridge_test + metatrader_file_command_writer_test metatrader_file_config_include_test ) endif() diff --git a/examples/metatrader_file_command_writer_smoke.cpp b/examples/metatrader_file_command_writer_smoke.cpp new file mode 100644 index 0000000..948f278 --- /dev/null +++ b/examples/metatrader_file_command_writer_smoke.cpp @@ -0,0 +1,165 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using optionx::bridges::metatrader_file::MetaTraderFileBridgeConfig; +using optionx::bridges::metatrader_file::MetaTraderFileCommandWriter; +using optionx::bridges::metatrader_file::MetaTraderFileTradeCommand; + +bool has_arg(int argc, char** argv, const std::string& value) { + for (int i = 1; i < argc; ++i) { + if (argv[i] == value) { + return true; + } + } + return false; +} + +std::string option_value(int argc, char** argv, const std::string& name) { + for (int i = 1; i + 1 < argc; ++i) { + if (argv[i] == name) { + return argv[i + 1]; + } + } + return {}; +} + +std::filesystem::path smoke_root() { + return std::filesystem::temp_directory_path() / + "optionx-metatrader-file-command-writer-smoke"; +} + +MetaTraderFileBridgeConfig default_config(const bool self_test) { + MetaTraderFileBridgeConfig config; + config.bridge_id = 1; + config.client_id = "mql-smoke"; + if (self_test || config.common_files_root.empty()) { + config.common_files_root = smoke_root().u8string(); + } + return config; +} + +bool load_config(const std::string& path, MetaTraderFileBridgeConfig& config) { + if (path.empty()) { + return true; + } + + std::ifstream input(path); + if (!input) { + std::cerr << "Could not open config: " << path << '\n'; + return false; + } + + try { + std::ostringstream buffer; + buffer << input.rdbuf(); + config.from_json(optionx::utils::parse_json_with_comments(buffer.str())); + } catch (const std::exception& ex) { + std::cerr << "Could not parse config: " << ex.what() << '\n'; + return false; + } + return true; +} + +std::string read_text_file(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +void print_usage() { + std::cout + << "Usage: metatrader_file_command_writer_smoke [--self-test] [--config path]\n" + << "Writes account.balance.get, signal.submit and trade.open commands to commands.ndjson.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + if (has_arg(argc, argv, "--help")) { + print_usage(); + return 0; + } + + const bool self_test = has_arg(argc, argv, "--self-test"); + auto config = default_config(self_test); + if (!load_config(option_value(argc, argv, "--config"), config)) { + return 2; + } + + const auto validation = config.validate(); + if (!validation.first) { + std::cerr << "Invalid config: " << validation.second << '\n'; + return 2; + } + + if (self_test) { + std::error_code ec; + std::filesystem::remove_all(std::filesystem::u8path(config.common_files_root), ec); + } + + try { + MetaTraderFileCommandWriter writer(config); + + const auto balance = writer.account_balance_get("7", "smoke-balance"); + + MetaTraderFileTradeCommand signal; + signal.symbol = "EURUSD"; + signal.order_type = "BUY"; + signal.amount_value = "1.00"; + signal.currency = "USD"; + signal.duration_ms = 60000; + signal.signal_name = "writer_smoke"; + signal.account_id = "7"; + signal.id = "smoke-signal"; + signal.idempotency_key = "smoke-signal-idem"; + signal.unique_hash = "smoke-signal-hash"; + const auto submitted = writer.signal_submit(signal); + + MetaTraderFileTradeCommand trade = signal; + trade.id = "smoke-trade"; + trade.idempotency_key = "smoke-trade-idem"; + trade.unique_hash = "smoke-trade-hash"; + const auto opened = writer.trade_open(trade); + + const auto content = read_text_file(writer.layout().commands_log()); + std::cout << "MetaTrader command writer root: " + << writer.layout().root.u8string() << '\n'; + std::cout << "Commands log: " + << writer.layout().commands_log().u8string() << '\n'; + std::cout << "Wrote file_seq values: " + << balance.file_seq << ", " + << submitted.file_seq << ", " + << opened.file_seq << '\n'; + std::cout << content; + + if (self_test) { + const bool ok = + content.find("\"account.balance.get\"") != std::string::npos && + content.find("\"signal.submit\"") != std::string::npos && + content.find("\"trade.open\"") != std::string::npos && + content.find("\"file_seq\"") != std::string::npos && + content.find("\"idempotency_key\"") != std::string::npos && + content.find("\"valid_until_ms\"") != std::string::npos; + if (!ok) { + std::cerr << "Self-test did not find the expected command fields.\n"; + return 3; + } + } + } catch (const std::exception& ex) { + std::cerr << "Smoke failed: " << ex.what() << '\n'; + return 1; + } + + return 0; +} diff --git a/examples/mql/OptionXFileBridge.mqh b/examples/mql/OptionXFileBridge.mqh new file mode 100644 index 0000000..10ad4e1 --- /dev/null +++ b/examples/mql/OptionXFileBridge.mqh @@ -0,0 +1,396 @@ +#ifndef OPTIONX_FILE_BRIDGE_MQH +#define OPTIONX_FILE_BRIDGE_MQH + +// Minimal OptionX MetaTrader Common\Files client. +// Copy this file to MQL4\Include or MQL5\Include, or place it next to an EA, +// script, or indicator that includes it. + +class COptionXFileBridge { +private: + int m_bridge_id; + string m_client_id; + string m_namespace_subdir; + int m_default_valid_for_ms; + long m_next_file_seq; + long m_operation_counter; + + string NormalizePath(string value) const { + StringReplace(value, "/", "\\"); + while (StringFind(value, "\\\\") >= 0) + StringReplace(value, "\\\\", "\\"); + while (StringLen(value) > 0 && StringSubstr(value, 0, 1) == "\\") + value = StringSubstr(value, 1); + while (StringLen(value) > 0 && + StringSubstr(value, StringLen(value) - 1, 1) == "\\") + value = StringSubstr(value, 0, StringLen(value) - 1); + return value; + } + + string JoinPath(const string left, const string right) const { + if (left == "") return NormalizePath(right); + if (right == "") return NormalizePath(left); + return NormalizePath(left + "\\" + right); + } + + string JsonEscape(const string value) const { + string out = ""; + for (int i = 0; i < StringLen(value); ++i) { + ushort ch = StringGetCharacter(value, i); + if (ch == 34) out += "\\\""; + else if (ch == 92) out += "\\\\"; + else if (ch == 8) out += "\\b"; + else if (ch == 9) out += "\\t"; + else if (ch == 10) out += "\\n"; + else if (ch == 12) out += "\\f"; + else if (ch == 13) out += "\\r"; + else out += StringSubstr(value, i, 1); + } + return out; + } + + string JsonString(const string value) const { + return "\"" + JsonEscape(value) + "\""; + } + + string Base36Encode(long value) const { + string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + if (value <= 0) return "0"; + + string out = ""; + while (value > 0) { + int index = (int)(value % 36); + out = StringSubstr(digits, index, 1) + out; + value /= 36; + } + return out; + } + + string RandomBase36(const int length) const { + string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + string out = ""; + for (int i = 0; i < length; ++i) { + out += StringSubstr(digits, MathRand() % 36, 1); + } + return out; + } + + long UnixTimeMs() const { + return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); + } + + bool EnsureDirectory(const string path) const { + if (path == "") return true; + if (FolderIsExist(path, FILE_COMMON)) return true; + return FolderCreate(path, FILE_COMMON); + } + + bool EnsureClientRoot() const { + string current = ""; + string root = ClientRoot(); + int start = 0; + while (start < StringLen(root)) { + int pos = StringFind(root, "\\", start); + string part = (pos < 0) + ? StringSubstr(root, start) + : StringSubstr(root, start, pos - start); + current = JoinPath(current, part); + if (!EnsureDirectory(current)) { + Print("OptionX: could not create Common\\Files directory: ", current); + return false; + } + if (pos < 0) break; + start = pos + 1; + } + return true; + } + + bool AppendLine(const string relative_path, const string line) const { + if (!EnsureClientRoot()) return false; + + int handle = FileOpen( + relative_path, + FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) { + handle = FileOpen( + relative_path, + FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + } + if (handle == INVALID_HANDLE) { + Print("OptionX: could not open command log: ", relative_path, + ", error=", GetLastError()); + return false; + } + + FileSeek(handle, 0, SEEK_END); + FileWriteString(handle, line + "\n"); + FileFlush(handle); + FileClose(handle); + return true; + } + + long ParseLongField(const string text, const string name) const { + int key = StringFind(text, "\"" + name + "\""); + if (key < 0) return 0; + int colon = StringFind(text, ":", key); + if (colon < 0) return 0; + + int start = colon + 1; + while (start < StringLen(text)) { + ushort ch = StringGetCharacter(text, start); + if (ch != 32 && ch != 9 && ch != 13 && ch != 10) break; + ++start; + } + + int end = start; + while (end < StringLen(text)) { + ushort ch = StringGetCharacter(text, end); + if (ch < 48 || ch > 57) break; + ++end; + } + if (end <= start) return 0; + return (long)StringToInteger(StringSubstr(text, start, end - start)); + } + + long MaxFileSeqInCommands() const { + int handle = FileOpen(CommandsPath(), FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) return 0; + + long max_seq = 0; + while (!FileIsEnding(handle)) { + string line = FileReadString(handle); + long seq = ParseLongField(line, "file_seq"); + if (seq > max_seq) max_seq = seq; + } + FileClose(handle); + return max_seq; + } + + long LastCheckpointSeq() const { + int handle = FileOpen( + CommandsCheckpointPath(), + FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) return 0; + + string text = ""; + while (!FileIsEnding(handle)) + text += FileReadString(handle); + FileClose(handle); + return ParseLongField(text, "last_file_seq"); + } + + string RequestEnvelope( + const long file_seq, + const string id, + const string method, + const string params) const { + return "{" + "\"file_seq\":" + IntegerToString(file_seq) + "," + "\"jsonrpc\":\"2.0\"," + "\"id\":" + JsonString(id) + "," + "\"method\":" + JsonString(method) + "," + "\"params\":" + params + + "}"; + } + + string TradeParams( + const string section_name, + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id, + const string operation_key, + const long valid_until_ms) const { + string identity = "\"unique_hash\":" + JsonString(operation_key); + if (signal_name != "") + identity += ",\"signal_name\":" + JsonString(signal_name); + + string params = "{" + "\"context\":{" + "\"idempotency_key\":" + JsonString(operation_key) + "," + "\"valid_until_ms\":" + IntegerToString(valid_until_ms) + + "}," + "\"identity\":{" + identity + "},"; + + if (account_id != "") { + params += "\"routing\":{\"selector\":{" + "\"kind\":\"account\"," + "\"account_id\":" + JsonString(account_id) + + "}},"; + } + + params += + "\"" + section_name + "\":{" + "\"symbol\":" + JsonString(symbol) + "," + "\"order_type\":" + JsonString(order_type) + "," + "\"option_type\":\"SPRINT\"," + "\"amount\":{" + "\"value\":" + JsonString(DoubleToString(amount, 2)) + "," + "\"currency\":" + JsonString(currency) + + "}," + "\"expiry\":{" + "\"kind\":\"duration\"," + "\"duration_ms\":" + IntegerToString(duration_ms) + + "}" + "}" + "}"; + return params; + } + + bool AppendRequest(const string id, const string method, const string params) { + if (m_next_file_seq <= 0) { + long visible_max = MaxFileSeqInCommands(); + long checkpoint = LastCheckpointSeq(); + m_next_file_seq = MathMax(visible_max, checkpoint) + 1; + } + + string line = RequestEnvelope(m_next_file_seq, id, method, params); + if (!AppendLine(CommandsPath(), line)) + return false; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + return true; + } + +public: + COptionXFileBridge() { + m_bridge_id = 1; + m_client_id = "default"; + m_namespace_subdir = "OptionX\\Bridge\\v1"; + m_default_valid_for_ms = 60000; + m_next_file_seq = 0; + m_operation_counter = 0; + } + + void Configure( + const int bridge_id, + const string client_id, + const string namespace_subdir = "OptionX\\Bridge\\v1", + const int default_valid_for_ms = 60000) { + m_bridge_id = bridge_id; + m_client_id = client_id; + m_namespace_subdir = NormalizePath(namespace_subdir); + m_default_valid_for_ms = default_valid_for_ms; + m_next_file_seq = 0; + } + + string ClientRoot() const { + return JoinPath( + JoinPath(m_namespace_subdir, IntegerToString(m_bridge_id)), + m_client_id); + } + + string CommandsPath() const { + return JoinPath(ClientRoot(), "commands.ndjson"); + } + + string CommandsCheckpointPath() const { + return JoinPath(ClientRoot(), "commands.checkpoint.json"); + } + + string MakeOperationKey(const string prefix = "mql") { + string key = prefix + "_" + + Base36Encode(UnixTimeMs()) + "_" + + RandomBase36(16) + "_" + + Base36Encode(m_operation_counter); + ++m_operation_counter; + return key; + } + + bool AccountBalanceGet(const string account_id = "", string operation_key = "") { + if (operation_key == "") + operation_key = MakeOperationKey(); + + string params = "{}"; + if (account_id != "") + params = "{\"account_id\":" + JsonString(account_id) + "}"; + return AppendRequest(operation_key, "account.balance.get", params); + } + + bool SignalSubmit( + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id = "", + string operation_key = "", + const int valid_for_ms = 0) { + if (operation_key == "") + operation_key = MakeOperationKey(); + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + "signal", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + return AppendRequest(operation_key, "signal.submit", params); + } + + bool TradeOpen( + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id = "", + string operation_key = "", + const int valid_for_ms = 0) { + if (operation_key == "") + operation_key = MakeOperationKey(); + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + "trade", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + return AppendRequest(operation_key, "trade.open", params); + } + + bool CleanupCommandsIfCheckpointCaughtUp() const { + long max_seq = MaxFileSeqInCommands(); + if (max_seq <= 0) + return false; + + long checkpoint = LastCheckpointSeq(); + if (checkpoint < max_seq) { + Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, + ", visible_max=", max_seq); + return false; + } + + int handle = FileOpen( + CommandsPath(), + FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not clear command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + FileClose(handle); + Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); + return true; + } +}; + +#endif // OPTIONX_FILE_BRIDGE_MQH diff --git a/examples/mql/OptionXFileBridgeSignalExample.mq4 b/examples/mql/OptionXFileBridgeSignalExample.mq4 new file mode 100644 index 0000000..b33f8bf --- /dev/null +++ b/examples/mql/OptionXFileBridgeSignalExample.mq4 @@ -0,0 +1,65 @@ +#property strict +#property indicator_chart_window + +#include + +extern int InpBridgeId = 1; +extern string InpClientId = "mt4-terminal"; +extern string InpAccountId = ""; +extern double InpAmount = 1.0; +extern int InpDurationMs = 60000; +extern string InpOrderType = "BUY"; +extern bool InpSendTradeOpen = false; + +COptionXFileBridge g_optionx; + +int OnInit() { + MathSrand((int)GetTickCount()); + + g_optionx.Configure(InpBridgeId, InpClientId); + Print("OptionX MT4 file bridge example"); + Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); + Print("OptionX command log: ", g_optionx.CommandsPath()); + + bool balance_ok = g_optionx.AccountBalanceGet(InpAccountId); + bool signal_ok = g_optionx.SignalSubmit( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL4_Example", + InpAccountId); + + bool trade_ok = true; + if (InpSendTradeOpen) { + trade_ok = g_optionx.TradeOpen( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL4_Example", + InpAccountId); + } + + Print("OptionX sent commands: balance=", balance_ok, + ", signal=", signal_ok, + ", trade=", trade_ok); + g_optionx.CleanupCommandsIfCheckpointCaughtUp(); + return INIT_SUCCEEDED; +} + +int OnCalculate( + const int rates_total, + const int prev_calculated, + const datetime &time[], + const double &open[], + const double &high[], + const double &low[], + const double &close[], + const long &tick_volume[], + const long &volume[], + const int &spread[]) { + return rates_total; +} diff --git a/examples/mql/OptionXFileBridgeSignalExample.mq5 b/examples/mql/OptionXFileBridgeSignalExample.mq5 new file mode 100644 index 0000000..eabced7 --- /dev/null +++ b/examples/mql/OptionXFileBridgeSignalExample.mq5 @@ -0,0 +1,66 @@ +#property strict +#property indicator_chart_window +#property indicator_plots 0 + +#include + +input int InpBridgeId = 1; +input string InpClientId = "mt5-terminal"; +input string InpAccountId = ""; +input double InpAmount = 1.0; +input int InpDurationMs = 60000; +input string InpOrderType = "BUY"; +input bool InpSendTradeOpen = false; + +COptionXFileBridge g_optionx; + +int OnInit() { + MathSrand((uint)GetTickCount()); + + g_optionx.Configure(InpBridgeId, InpClientId); + Print("OptionX MT5 file bridge example"); + Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); + Print("OptionX command log: ", g_optionx.CommandsPath()); + + bool balance_ok = g_optionx.AccountBalanceGet(InpAccountId); + bool signal_ok = g_optionx.SignalSubmit( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL5_Example", + InpAccountId); + + bool trade_ok = true; + if (InpSendTradeOpen) { + trade_ok = g_optionx.TradeOpen( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL5_Example", + InpAccountId); + } + + Print("OptionX sent commands: balance=", balance_ok, + ", signal=", signal_ok, + ", trade=", trade_ok); + g_optionx.CleanupCommandsIfCheckpointCaughtUp(); + return INIT_SUCCEEDED; +} + +int OnCalculate( + const int rates_total, + const int prev_calculated, + const datetime &time[], + const double &open[], + const double &high[], + const double &low[], + const double &close[], + const long &tick_volume[], + const long &volume[], + const int &spread[]) { + return rates_total; +} diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index e29680d..79d3c7d 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -66,6 +66,8 @@ file-transport building blocks: - `bridges/metatrader_file.hpp` as the umbrella include; - `MetaTraderFileBridgeConfig` for the MetaQuotes Common Files root, client directory identity, polling interval and NDJSON line limits; +- `MetaTraderFileCommandWriter` for C++/tooling clients that need to append + canonical `signal.submit`, `trade.open` and `account.balance.get` requests; - `metatrader_file::detail` helpers for path-safe IDs, NDJSON append/read, owner-side log cleanup, JSON-RPC request/response/notification documents, atomic state snapshots and bounded JSON reads; @@ -73,9 +75,61 @@ file-transport building blocks: location, Common Files root and terminal data directories; - small helpers for `balance.updated` and `trade.updated` notification payloads. -This slice does not yet define a long-running `BaseBridge` polling loop, MQL -advisor code or a broker execution adapter. Those pieces should use these -helpers in follow-up implementation PRs. +The bridge side is implemented by `MetaTraderFileBridge`. The writer side is +kept smaller on purpose: `MetaTraderFileCommandWriter` is a reusable command +generator, not a background transport loop. It should be used by smoke tools, +tests and C++ applications that need to create MetaTrader-compatible command +logs. + +### Writer Helpers And Smoke Generator + +The C++ writer helper appends one compact JSON-RPC request per line to +`commands.ndjson`. It assigns `file_seq`, generates compact Base36 operation +keys when the caller does not provide `id` / `context.idempotency_key`, and +adds `context.valid_until_ms` for trade-affecting commands. + +Supported convenience methods: + +- `MetaTraderFileCommandWriter::signal_submit(...)`; +- `MetaTraderFileCommandWriter::trade_open(...)`; +- `MetaTraderFileCommandWriter::account_balance_get(...)`. + +The example `examples/metatrader_file_command_writer_smoke.cpp` writes one +`account.balance.get`, one `signal.submit` and one `trade.open` command. In +self-test mode it uses a temporary Common Files root and verifies that the +generated log contains `file_seq`, JSON-RPC `id`, `context.idempotency_key` and +`context.valid_until_ms`. + +Owner-side cleanup is explicit: the writer may clear `commands.ndjson` only +after `commands.checkpoint.json.last_file_seq` is greater than or equal to the +greatest visible `file_seq` in the command log. Clearing before that checkpoint +can drop commands that the bridge has not processed yet. + +### MQL4/MQL5 Client Header + +The sample MQL client lives in `examples/mql/OptionXFileBridge.mqh`. It exposes +a small `COptionXFileBridge` class with the same three command helpers: + +- `AccountBalanceGet(...)`; +- `SignalSubmit(...)`; +- `TradeOpen(...)`. + +Copy `OptionXFileBridge.mqh` to `MQL4\Include` or `MQL5\Include` (or place it +next to the indicator/advisor/script), then include it from MQL: + +```mql +#include +``` + +Ready-to-run examples are provided as: + +- `examples/mql/OptionXFileBridgeSignalExample.mq4`; +- `examples/mql/OptionXFileBridgeSignalExample.mq5`. + +The examples print the resolved client root and command log path with `Print`, +send an `account.balance.get` request and submit a simple signal for the current +chart symbol. `trade.open` is available behind an input flag so the example does +not open a direct trade accidentally. ### MetaTrader Discovery Utility diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 6ceaa32..067bff6 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -60,6 +60,8 @@ Reader хранит свой checkpoint и не переписывает append- - `bridges/metatrader_file.hpp` как umbrella include; - `MetaTraderFileBridgeConfig` для MetaQuotes Common Files root, client directory identity, polling interval и NDJSON line limits; +- `MetaTraderFileCommandWriter` для C++/tooling clients, которым нужно писать + canonical `signal.submit`, `trade.open` и `account.balance.get` requests; - helpers в `metatrader_file::detail` для path-safe IDs, NDJSON append/read, owner-side cleanup, JSON-RPC request/response/notification documents, atomic state snapshots и bounded JSON reads; @@ -67,9 +69,60 @@ Reader хранит свой checkpoint и не переписывает append- Common Files root и terminal data directories; - helpers для `balance.updated`, `trade.updated` и `state.json` payloads. -Этот slice еще не задает long-running `BaseBridge` polling loop, MQL advisor -code или broker execution adapter. Эти части должны использовать helpers в -следующем implementation PR. +Bridge-side реализован через `MetaTraderFileBridge`. Writer-side намеренно +меньше: `MetaTraderFileCommandWriter` это reusable command generator, а не +background transport loop. Его можно использовать в smoke tools, tests и C++ +applications, которые формируют MetaTrader-compatible command logs. + +### Writer Helpers And Smoke Generator + +C++ writer helper добавляет в `commands.ndjson` один compact JSON-RPC request +на строку. Он назначает `file_seq`, генерирует compact Base36 operation keys, +если caller не передал `id` / `context.idempotency_key`, и добавляет +`context.valid_until_ms` для commands, влияющих на торговлю. + +Supported convenience methods: + +- `MetaTraderFileCommandWriter::signal_submit(...)`; +- `MetaTraderFileCommandWriter::trade_open(...)`; +- `MetaTraderFileCommandWriter::account_balance_get(...)`. + +Example `examples/metatrader_file_command_writer_smoke.cpp` пишет один +`account.balance.get`, один `signal.submit` и один `trade.open` command. В +self-test mode используется temporary Common Files root и проверяется, что в log +есть `file_seq`, JSON-RPC `id`, `context.idempotency_key` и +`context.valid_until_ms`. + +Owner-side cleanup должен быть явным: writer может очищать `commands.ndjson` +только после того, как `commands.checkpoint.json.last_file_seq` стал больше или +равен максимальному visible `file_seq` в command log. Очистка раньше checkpoint +может удалить commands, которые bridge еще не обработал. + +### MQL4/MQL5 Client Header + +Sample MQL client лежит в `examples/mql/OptionXFileBridge.mqh`. Он дает +небольшой class `COptionXFileBridge` с теми же тремя helpers: + +- `AccountBalanceGet(...)`; +- `SignalSubmit(...)`; +- `TradeOpen(...)`. + +Скопируй `OptionXFileBridge.mqh` в `MQL4\Include` или `MQL5\Include` +(или положи рядом с indicator/advisor/script), после чего подключи: + +```mql +#include +``` + +Ready-to-run examples: + +- `examples/mql/OptionXFileBridgeSignalExample.mq4`; +- `examples/mql/OptionXFileBridgeSignalExample.mq5`. + +Examples пишут через `Print` resolved client root и command log path, +отправляют `account.balance.get` и simple signal для текущего chart symbol. +`trade.open` доступен через input flag, чтобы example случайно не открывал +direct trade. ### MetaTrader Discovery Utility diff --git a/guides/build-and-test.md b/guides/build-and-test.md index 4e6ea3a..25e4ab7 100644 --- a/guides/build-and-test.md +++ b/guides/build-and-test.md @@ -133,8 +133,10 @@ $env:OPTIONX_INTRADE_BAR_CONFIG_FILE="tests\intrade_bar_api\intrade_bar_api.loca GitHub CI also runs a focused Windows smoke job for MetaTrader file transport helpers. It builds and runs `metatrader_paths_test`, `metatrader_file_config_include_test`, `metatrader_file_bridge_test` and -`bridge_umbrella_include_test` on `windows-latest` so Windows filesystem API -paths such as atomic replacement and exclusive temp creation are covered by CI. +`bridge_umbrella_include_test` on `windows-latest`, and runs both +`metatrader_file_bridge_smoke` and `metatrader_file_command_writer_smoke`. +Windows filesystem API paths such as atomic replacement, exclusive temp +creation and MetaTrader command-log generation are covered by CI. При изменении публичных aggregate headers или include policy добавляй или обновляй тест, который подключает intended public entry point: @@ -162,6 +164,11 @@ targets. - `examples/test_intrade_bar_http_client_module.cpp` - `examples/test_service_session_db.cpp` - `examples/test_trade_manager_module.cpp` +- `examples/metatrader_file_bridge_smoke.cpp` +- `examples/metatrader_file_command_writer_smoke.cpp` +- `examples/mql/OptionXFileBridge.mqh` +- `examples/mql/OptionXFileBridgeSignalExample.mq4` +- `examples/mql/OptionXFileBridgeSignalExample.mq5` Перед изменением user-facing API проверь ближайший example и обнови его, если старый сценарий стал недействительным. diff --git a/include/optionx_cpp/bridges/metatrader_file.hpp b/include/optionx_cpp/bridges/metatrader_file.hpp index dfefb24..2d26191 100644 --- a/include/optionx_cpp/bridges/metatrader_file.hpp +++ b/include/optionx_cpp/bridges/metatrader_file.hpp @@ -49,6 +49,7 @@ #include "metatrader_file/detail/MetaTraderFileProtocol.hpp" #include "metatrader_file/detail/MetaTraderFileIdempotencyStore.hpp" #include "metatrader_file/detail/MetaTraderFileBridgeUtils.hpp" +#include "metatrader_file/MetaTraderFileCommandWriter.hpp" #include "metatrader_file/MetaTraderFileBridge.hpp" #endif // OPTIONX_HEADER_BRIDGES_METATRADER_FILE_HPP_INCLUDED diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp new file mode 100644 index 0000000..155f8aa --- /dev/null +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -0,0 +1,261 @@ +#pragma once +#ifndef OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_COMMAND_WRITER_HPP_INCLUDED +#define OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_COMMAND_WRITER_HPP_INCLUDED + +/// \file MetaTraderFileCommandWriter.hpp +/// \brief Defines writer-side helpers for MetaTrader Common\Files commands. + +namespace optionx::bridges::metatrader_file { + + /// \struct MetaTraderFileTradeCommand + /// \brief Input data for `signal.submit` and `trade.open` command generation. + struct MetaTraderFileTradeCommand { + std::string symbol = "EURUSD"; ///< Trading symbol as seen by the receiver. + std::string order_type = "BUY"; ///< Protocol order side, for example `BUY` or `SELL`. + std::string option_type = "SPRINT"; ///< Protocol option type. + std::string amount_value = "1.00"; ///< Decimal amount string preserving caller scale. + std::string currency = "USD"; ///< Amount currency. + std::uint64_t duration_ms = 60000; ///< Expiry duration for binary-option style trades. + std::string signal_name; ///< Optional strategy/signal name. + std::string unique_hash; ///< Optional domain dedupe key; generated when empty. + std::string account_id; ///< Optional account routing target. + std::string id; ///< Optional JSON-RPC id; generated when empty. + std::string idempotency_key; ///< Optional operation key; generated when empty. + std::int64_t valid_until_ms = 0; ///< Absolute Unix deadline; generated from `valid_for_ms` when zero. + std::int64_t valid_for_ms = 60000; ///< Relative command lifetime used when `valid_until_ms` is zero. + }; + + /// \struct MetaTraderFileWrittenCommand + /// \brief Metadata returned after a command is appended to `commands.ndjson`. + struct MetaTraderFileWrittenCommand { + std::uint64_t file_seq = 0; ///< Writer sequence assigned to the appended line. + std::string id; ///< JSON-RPC request id written to the command. + std::string idempotency_key; ///< Effective idempotency key, if the command has one. + std::filesystem::path commands_log; ///< Writer-owned command log path. + nlohmann::json document; ///< Full command document that was appended. + }; + + /// \class MetaTraderFileCommandWriter + /// \brief Appends canonical OptionX file-transport commands for MQL/C++ clients. + /// \details This helper is the writer-side counterpart to `MetaTraderFileBridge`. + /// It owns `commands.ndjson`: it assigns monotonic `file_seq`, appends one + /// compact JSON-RPC request per line, and may clear the command log only after + /// the bridge checkpoint confirms that all visible commands were consumed. + class MetaTraderFileCommandWriter { + public: + /// \brief Creates a command writer from a validated bridge file configuration. + explicit MetaTraderFileCommandWriter(MetaTraderFileBridgeConfig config) + : m_config(std::move(config)), + m_layout(detail::make_layout(m_config)) {} + + /// \brief Ensures directories exist and initializes the next `file_seq`. + void initialize() { + detail::ensure_runtime_directories(m_layout); + const auto checkpoint = read_reader_checkpoint(); + m_next_file_seq = detail::next_file_seq_after_checkpoint( + m_layout.commands_log(), + checkpoint, + m_config.max_line_bytes); + m_initialized = true; + } + + /// \brief Returns the resolved file layout. + const detail::FileTransportLayout& layout() const noexcept { + return m_layout; + } + + /// \brief Returns the next writer sequence, initializing the writer if needed. + std::uint64_t next_file_seq() { + ensure_initialized(); + return m_next_file_seq; + } + + /// \brief Appends a `signal.submit` command. + MetaTraderFileWrittenCommand signal_submit(MetaTraderFileTradeCommand command) { + prepare_trade_command(command, "mql"); + return append_request( + command.id, + "signal.submit", + make_trade_params(command, "signal")); + } + + /// \brief Appends a `trade.open` command. + MetaTraderFileWrittenCommand trade_open(MetaTraderFileTradeCommand command) { + prepare_trade_command(command, "mql"); + return append_request( + command.id, + "trade.open", + make_trade_params(command, "trade")); + } + + /// \brief Appends an `account.balance.get` query command. + MetaTraderFileWrittenCommand account_balance_get( + std::string account_id = {}, + std::string id = {}) { + ensure_initialized(); + if (id.empty()) { + id = make_compact_operation_key("mql"); + } + + nlohmann::json params = nlohmann::json::object(); + if (!account_id.empty()) { + params["account_id"] = std::move(account_id); + } + return append_request(std::move(id), "account.balance.get", std::move(params)); + } + + /// \brief Clears `commands.ndjson` only when the bridge checkpoint caught up. + /// \return True when the log was cleared; false when cleanup is not safe yet. + bool clear_commands_if_checkpoint_caught_up() { + ensure_initialized(); + const auto checkpoint = read_reader_checkpoint(); + const auto visible_max = detail::max_file_seq_in_ndjson( + m_layout.commands_log(), + m_config.max_line_bytes); + if (visible_max == 0 || checkpoint < visible_max) { + return false; + } + + detail::clear_file_atomic(m_layout.commands_log()); + if (checkpoint == std::numeric_limits::max()) { + throw std::overflow_error("MetaTrader file command writer file_seq overflow."); + } + m_next_file_seq = checkpoint + 1; + return true; + } + + private: + void ensure_initialized() { + if (!m_initialized) { + initialize(); + } + } + + std::uint64_t read_reader_checkpoint() const { + std::error_code ec; + const auto exists = std::filesystem::exists(m_layout.commands_checkpoint(), ec); + if (ec) { + throw std::runtime_error("Failed to inspect MetaTrader command checkpoint: " + ec.message()); + } + if (!exists) { + return 0; + } + const auto checkpoint = detail::read_json_file( + m_layout.commands_checkpoint(), + m_config.max_line_bytes); + if (!checkpoint.is_object() || !checkpoint.contains("last_file_seq")) { + throw std::runtime_error("MetaTrader command checkpoint is missing last_file_seq."); + } + const auto& value = checkpoint.at("last_file_seq"); + if (value.is_number_unsigned()) { + return value.get(); + } + if (value.is_number_integer()) { + const auto signed_value = value.get(); + if (signed_value >= 0) { + return static_cast(signed_value); + } + } + throw std::runtime_error("MetaTrader command checkpoint last_file_seq must be a non-negative integer."); + } + + static void prepare_trade_command( + MetaTraderFileTradeCommand& command, + const std::string& key_prefix) { + if (command.idempotency_key.empty()) { + command.idempotency_key = make_compact_operation_key(key_prefix); + } + if (command.id.empty()) { + command.id = command.idempotency_key; + } + if (command.unique_hash.empty()) { + command.unique_hash = command.idempotency_key; + } + if (command.valid_until_ms <= 0) { + const auto lifetime = command.valid_for_ms > 0 ? command.valid_for_ms : 60000; + command.valid_until_ms = detail::unix_time_ms() + lifetime; + } + } + + static nlohmann::json make_trade_params( + const MetaTraderFileTradeCommand& command, + const char* trade_key) { + nlohmann::json params = { + {"context", { + {"idempotency_key", command.idempotency_key}, + {"valid_until_ms", command.valid_until_ms} + }}, + {"identity", { + {"unique_hash", command.unique_hash} + }}, + {trade_key, { + {"symbol", command.symbol}, + {"order_type", command.order_type}, + {"option_type", command.option_type}, + {"amount", { + {"value", command.amount_value}, + {"currency", command.currency} + }}, + {"expiry", { + {"kind", "duration"}, + {"duration_ms", command.duration_ms} + }} + }} + }; + + if (!command.signal_name.empty()) { + params["identity"]["signal_name"] = command.signal_name; + } + if (!command.account_id.empty()) { + params["routing"] = { + {"selector", { + {"kind", "account"}, + {"account_id", command.account_id} + }} + }; + } + return params; + } + + MetaTraderFileWrittenCommand append_request( + nlohmann::json id, + std::string method, + nlohmann::json params) { + ensure_initialized(); + const auto file_seq = m_next_file_seq; + const auto document = detail::make_file_jsonrpc_request( + file_seq, + std::move(id), + std::move(method), + std::move(params)); + detail::append_json_line( + m_layout.commands_log(), + document, + m_config.max_line_bytes); + + if (m_next_file_seq == std::numeric_limits::max()) { + throw std::overflow_error("MetaTrader file command writer file_seq overflow."); + } + ++m_next_file_seq; + + MetaTraderFileWrittenCommand written; + written.file_seq = file_seq; + written.commands_log = m_layout.commands_log(); + written.document = document; + written.id = detail::json_id_to_string(document.at("id")); + const auto& context = document.at("params").value("context", nlohmann::json::object()); + if (context.is_object() && context.contains("idempotency_key")) { + written.idempotency_key = context.at("idempotency_key").get(); + } + return written; + } + + MetaTraderFileBridgeConfig m_config; + detail::FileTransportLayout m_layout; + std::uint64_t m_next_file_seq = 1; + bool m_initialized = false; + }; + +} // namespace optionx::bridges::metatrader_file + +#endif // OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_COMMAND_WRITER_HPP_INCLUDED diff --git a/tests/bridge_umbrella_include_test.cpp b/tests/bridge_umbrella_include_test.cpp index 085af0f..b8a7c34 100644 --- a/tests/bridge_umbrella_include_test.cpp +++ b/tests/bridge_umbrella_include_test.cpp @@ -23,11 +23,15 @@ TEST(BridgeUmbrellaIncludeTest, ExposesTradingViewBridgeFamily) { TEST(BridgeUmbrellaIncludeTest, ExposesMetaTraderFileBridgeFamily) { optionx::bridges::metatrader_file::MetaTraderFileBridgeConfig config; + optionx::bridges::metatrader_file::MetaTraderFileCommandWriter writer(config); + optionx::bridges::metatrader_file::MetaTraderFileTradeCommand command; optionx::bridges::metatrader_file::MetaTraderFileBridge bridge; EXPECT_EQ( config.bridge_type(), optionx::BridgeType::METATRADER_FILE_TRANSPORT); + EXPECT_EQ(writer.layout().commands_log().filename(), "commands.ndjson"); + EXPECT_EQ(command.amount_value, "1.00"); EXPECT_EQ(bridge.client_root(), std::filesystem::path()); } diff --git a/tests/metatrader_file_command_writer_test.cpp b/tests/metatrader_file_command_writer_test.cpp new file mode 100644 index 0000000..9958d5b --- /dev/null +++ b/tests/metatrader_file_command_writer_test.cpp @@ -0,0 +1,139 @@ +#include + +#include + +#include +#include +#include +#include + +namespace { + +class ScopedPathCleanup { +public: + explicit ScopedPathCleanup(std::filesystem::path cleanup_path) + : path(std::move(cleanup_path)) {} + + ~ScopedPathCleanup() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } + + std::filesystem::path path; +}; + +std::filesystem::path make_temp_root() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("optionx_metatrader_file_writer_test_" + std::to_string(stamp)); +} + +optionx::bridges::metatrader_file::MetaTraderFileBridgeConfig make_config( + const std::filesystem::path& root) { + optionx::bridges::metatrader_file::MetaTraderFileBridgeConfig config; + config.common_files_root = root.u8string(); + config.bridge_id = 42; + config.client_id = "terminal-01"; + config.max_line_bytes = 8192; + return config; +} + +} // namespace + +TEST(MetaTraderFileCommandWriter, AppendsCanonicalCommands) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + auto writer = mtfile::MetaTraderFileCommandWriter(make_config(root)); + + mtfile::MetaTraderFileTradeCommand signal; + signal.symbol = "EURUSD"; + signal.order_type = "BUY"; + signal.amount_value = "1.25"; + signal.currency = "USD"; + signal.duration_ms = 300000; + signal.signal_name = "writer_test"; + signal.account_id = "7"; + signal.id = "cmd-signal"; + signal.idempotency_key = "idem-signal"; + signal.unique_hash = "signal-hash"; + signal.valid_until_ms = protocol::unix_time_ms() + 60000; + + const auto signal_written = writer.signal_submit(signal); + EXPECT_EQ(signal_written.file_seq, 1u); + EXPECT_EQ(signal_written.id, "cmd-signal"); + EXPECT_EQ(signal_written.idempotency_key, "idem-signal"); + + mtfile::MetaTraderFileTradeCommand trade = signal; + trade.id = "cmd-trade"; + trade.idempotency_key = "idem-trade"; + trade.unique_hash = "trade-hash"; + const auto trade_written = writer.trade_open(trade); + EXPECT_EQ(trade_written.file_seq, 2u); + + const auto balance_written = writer.account_balance_get("7", "cmd-balance"); + EXPECT_EQ(balance_written.file_seq, 3u); + EXPECT_TRUE(balance_written.idempotency_key.empty()); + + const auto records = protocol::read_ndjson_since_file_seq( + writer.layout().commands_log(), + 0, + 8192); + ASSERT_EQ(records.size(), 3u); + + const auto& signal_doc = records[0].document; + EXPECT_EQ(signal_doc.at("file_seq").get(), 1u); + EXPECT_EQ(signal_doc.at("jsonrpc").get(), "2.0"); + EXPECT_EQ(signal_doc.at("id").get(), "cmd-signal"); + EXPECT_EQ(signal_doc.at("method").get(), "signal.submit"); + const auto& signal_params = signal_doc.at("params"); + EXPECT_EQ(signal_params.at("context").at("idempotency_key").get(), "idem-signal"); + EXPECT_GE(signal_params.at("context").at("valid_until_ms").get(), signal.valid_until_ms); + EXPECT_EQ(signal_params.at("identity").at("unique_hash").get(), "signal-hash"); + EXPECT_EQ(signal_params.at("identity").at("signal_name").get(), "writer_test"); + EXPECT_EQ(signal_params.at("routing").at("selector").at("account_id").get(), "7"); + EXPECT_EQ(signal_params.at("signal").at("amount").at("value").get(), "1.25"); + EXPECT_EQ(signal_params.at("signal").at("expiry").at("duration_ms").get(), 300000u); + + EXPECT_EQ(records[1].document.at("method").get(), "trade.open"); + EXPECT_EQ(records[1].document.at("params").at("trade").at("symbol").get(), "EURUSD"); + EXPECT_EQ(records[1].document.at("params").at("context").at("idempotency_key").get(), "idem-trade"); + + EXPECT_EQ(records[2].document.at("method").get(), "account.balance.get"); + EXPECT_EQ(records[2].document.at("params").at("account_id").get(), "7"); +} + +TEST(MetaTraderFileCommandWriter, RecoversSequenceAndCleansAfterCheckpoint) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + const auto config = make_config(root); + mtfile::MetaTraderFileCommandWriter writer(config); + ASSERT_EQ(writer.account_balance_get({}, "cmd-1").file_seq, 1u); + ASSERT_EQ(writer.account_balance_get({}, "cmd-2").file_seq, 2u); + + mtfile::MetaTraderFileCommandWriter recovered(config); + EXPECT_EQ(recovered.next_file_seq(), 3u); + EXPECT_FALSE(recovered.clear_commands_if_checkpoint_caught_up()); + + protocol::write_json_file_atomic( + recovered.layout().commands_checkpoint(), + protocol::make_log_checkpoint(2)); + + EXPECT_TRUE(recovered.clear_commands_if_checkpoint_caught_up()); + EXPECT_EQ(protocol::max_file_seq_in_ndjson(recovered.layout().commands_log(), 8192), 0u); + EXPECT_EQ(recovered.next_file_seq(), 3u); + + ASSERT_EQ(recovered.account_balance_get({}, "cmd-3").file_seq, 3u); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 56f15dfd62b6a2b7d1c19bc558b363c07de619af Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 08:05:19 +0300 Subject: [PATCH 02/11] docs(mql): mirror terminal folder layout --- .../file-transport-and-adapters.md | 24 +- .../file-transport-and-adapters.ru.md | 22 +- guides/build-and-test.md | 7 +- .../Include/OptionX}/OptionXFileBridge.mqh | 0 .../OptionXFileBridgeSignalExample.mq4 | 2 +- .../Include/OptionX/OptionXFileBridge.mqh | 396 ++++++++++++++++++ .../OptionXFileBridgeSignalExample.mq5 | 2 +- 7 files changed, 434 insertions(+), 19 deletions(-) rename {examples/mql => mql/MQL4/Include/OptionX}/OptionXFileBridge.mqh (100%) rename {examples/mql => mql/MQL4/Indicators/OptionX}/OptionXFileBridgeSignalExample.mq4 (97%) create mode 100644 mql/MQL5/Include/OptionX/OptionXFileBridge.mqh rename {examples/mql => mql/MQL5/Indicators/OptionX}/OptionXFileBridgeSignalExample.mq5 (97%) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 79d3c7d..89a6a01 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -107,24 +107,34 @@ can drop commands that the bridge has not processed yet. ### MQL4/MQL5 Client Header -The sample MQL client lives in `examples/mql/OptionXFileBridge.mqh`. It exposes -a small `COptionXFileBridge` class with the same three command helpers: +The sample MQL client is laid out like a real terminal data folder. The header +lives in: + +```text +mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +``` + +It exposes a small `COptionXFileBridge` class with the same three command +helpers: - `AccountBalanceGet(...)`; - `SignalSubmit(...)`; - `TradeOpen(...)`. -Copy `OptionXFileBridge.mqh` to `MQL4\Include` or `MQL5\Include` (or place it -next to the indicator/advisor/script), then include it from MQL: +Copy the matching `mql/MQL4` or `mql/MQL5` tree into the terminal data folder, +or copy only the `OptionX` subfolders into existing `MQL4\Include` / +`MQL4\Indicators` or `MQL5\Include` / `MQL5\Indicators` directories. Include +the header from MQL as: ```mql -#include +#include ``` Ready-to-run examples are provided as: -- `examples/mql/OptionXFileBridgeSignalExample.mq4`; -- `examples/mql/OptionXFileBridgeSignalExample.mq5`. +- `mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4`; +- `mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5`. The examples print the resolved client root and command log path with `Print`, send an `account.balance.get` request and submit a simple signal for the current diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 067bff6..aba9fdb 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -100,24 +100,32 @@ Owner-side cleanup должен быть явным: writer может очищ ### MQL4/MQL5 Client Header -Sample MQL client лежит в `examples/mql/OptionXFileBridge.mqh`. Он дает -небольшой class `COptionXFileBridge` с теми же тремя helpers: +Sample MQL client разложен как реальный terminal data folder. Header лежит в: + +```text +mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +``` + +Он дает небольшой class `COptionXFileBridge` с теми же тремя helpers: - `AccountBalanceGet(...)`; - `SignalSubmit(...)`; - `TradeOpen(...)`. -Скопируй `OptionXFileBridge.mqh` в `MQL4\Include` или `MQL5\Include` -(или положи рядом с indicator/advisor/script), после чего подключи: +Скопируй подходящее дерево `mql/MQL4` или `mql/MQL5` в terminal data folder +или перенеси только subfolders `OptionX` в существующие `MQL4\Include` / +`MQL4\Indicators` или `MQL5\Include` / `MQL5\Indicators`. Header подключается +так: ```mql -#include +#include ``` Ready-to-run examples: -- `examples/mql/OptionXFileBridgeSignalExample.mq4`; -- `examples/mql/OptionXFileBridgeSignalExample.mq5`. +- `mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4`; +- `mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5`. Examples пишут через `Print` resolved client root и command log path, отправляют `account.balance.get` и simple signal для текущего chart symbol. diff --git a/guides/build-and-test.md b/guides/build-and-test.md index 25e4ab7..f280e08 100644 --- a/guides/build-and-test.md +++ b/guides/build-and-test.md @@ -166,9 +166,10 @@ targets. - `examples/test_trade_manager_module.cpp` - `examples/metatrader_file_bridge_smoke.cpp` - `examples/metatrader_file_command_writer_smoke.cpp` -- `examples/mql/OptionXFileBridge.mqh` -- `examples/mql/OptionXFileBridgeSignalExample.mq4` -- `examples/mql/OptionXFileBridgeSignalExample.mq5` +- `mql/MQL4/Include/OptionX/OptionXFileBridge.mqh` +- `mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4` +- `mql/MQL5/Include/OptionX/OptionXFileBridge.mqh` +- `mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5` Перед изменением user-facing API проверь ближайший example и обнови его, если старый сценарий стал недействительным. diff --git a/examples/mql/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh similarity index 100% rename from examples/mql/OptionXFileBridge.mqh rename to mql/MQL4/Include/OptionX/OptionXFileBridge.mqh diff --git a/examples/mql/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 similarity index 97% rename from examples/mql/OptionXFileBridgeSignalExample.mq4 rename to mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 index b33f8bf..c8b7b09 100644 --- a/examples/mql/OptionXFileBridgeSignalExample.mq4 +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -1,7 +1,7 @@ #property strict #property indicator_chart_window -#include +#include extern int InpBridgeId = 1; extern string InpClientId = "mt4-terminal"; diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh new file mode 100644 index 0000000..10ad4e1 --- /dev/null +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -0,0 +1,396 @@ +#ifndef OPTIONX_FILE_BRIDGE_MQH +#define OPTIONX_FILE_BRIDGE_MQH + +// Minimal OptionX MetaTrader Common\Files client. +// Copy this file to MQL4\Include or MQL5\Include, or place it next to an EA, +// script, or indicator that includes it. + +class COptionXFileBridge { +private: + int m_bridge_id; + string m_client_id; + string m_namespace_subdir; + int m_default_valid_for_ms; + long m_next_file_seq; + long m_operation_counter; + + string NormalizePath(string value) const { + StringReplace(value, "/", "\\"); + while (StringFind(value, "\\\\") >= 0) + StringReplace(value, "\\\\", "\\"); + while (StringLen(value) > 0 && StringSubstr(value, 0, 1) == "\\") + value = StringSubstr(value, 1); + while (StringLen(value) > 0 && + StringSubstr(value, StringLen(value) - 1, 1) == "\\") + value = StringSubstr(value, 0, StringLen(value) - 1); + return value; + } + + string JoinPath(const string left, const string right) const { + if (left == "") return NormalizePath(right); + if (right == "") return NormalizePath(left); + return NormalizePath(left + "\\" + right); + } + + string JsonEscape(const string value) const { + string out = ""; + for (int i = 0; i < StringLen(value); ++i) { + ushort ch = StringGetCharacter(value, i); + if (ch == 34) out += "\\\""; + else if (ch == 92) out += "\\\\"; + else if (ch == 8) out += "\\b"; + else if (ch == 9) out += "\\t"; + else if (ch == 10) out += "\\n"; + else if (ch == 12) out += "\\f"; + else if (ch == 13) out += "\\r"; + else out += StringSubstr(value, i, 1); + } + return out; + } + + string JsonString(const string value) const { + return "\"" + JsonEscape(value) + "\""; + } + + string Base36Encode(long value) const { + string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + if (value <= 0) return "0"; + + string out = ""; + while (value > 0) { + int index = (int)(value % 36); + out = StringSubstr(digits, index, 1) + out; + value /= 36; + } + return out; + } + + string RandomBase36(const int length) const { + string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + string out = ""; + for (int i = 0; i < length; ++i) { + out += StringSubstr(digits, MathRand() % 36, 1); + } + return out; + } + + long UnixTimeMs() const { + return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); + } + + bool EnsureDirectory(const string path) const { + if (path == "") return true; + if (FolderIsExist(path, FILE_COMMON)) return true; + return FolderCreate(path, FILE_COMMON); + } + + bool EnsureClientRoot() const { + string current = ""; + string root = ClientRoot(); + int start = 0; + while (start < StringLen(root)) { + int pos = StringFind(root, "\\", start); + string part = (pos < 0) + ? StringSubstr(root, start) + : StringSubstr(root, start, pos - start); + current = JoinPath(current, part); + if (!EnsureDirectory(current)) { + Print("OptionX: could not create Common\\Files directory: ", current); + return false; + } + if (pos < 0) break; + start = pos + 1; + } + return true; + } + + bool AppendLine(const string relative_path, const string line) const { + if (!EnsureClientRoot()) return false; + + int handle = FileOpen( + relative_path, + FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) { + handle = FileOpen( + relative_path, + FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + } + if (handle == INVALID_HANDLE) { + Print("OptionX: could not open command log: ", relative_path, + ", error=", GetLastError()); + return false; + } + + FileSeek(handle, 0, SEEK_END); + FileWriteString(handle, line + "\n"); + FileFlush(handle); + FileClose(handle); + return true; + } + + long ParseLongField(const string text, const string name) const { + int key = StringFind(text, "\"" + name + "\""); + if (key < 0) return 0; + int colon = StringFind(text, ":", key); + if (colon < 0) return 0; + + int start = colon + 1; + while (start < StringLen(text)) { + ushort ch = StringGetCharacter(text, start); + if (ch != 32 && ch != 9 && ch != 13 && ch != 10) break; + ++start; + } + + int end = start; + while (end < StringLen(text)) { + ushort ch = StringGetCharacter(text, end); + if (ch < 48 || ch > 57) break; + ++end; + } + if (end <= start) return 0; + return (long)StringToInteger(StringSubstr(text, start, end - start)); + } + + long MaxFileSeqInCommands() const { + int handle = FileOpen(CommandsPath(), FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) return 0; + + long max_seq = 0; + while (!FileIsEnding(handle)) { + string line = FileReadString(handle); + long seq = ParseLongField(line, "file_seq"); + if (seq > max_seq) max_seq = seq; + } + FileClose(handle); + return max_seq; + } + + long LastCheckpointSeq() const { + int handle = FileOpen( + CommandsCheckpointPath(), + FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) return 0; + + string text = ""; + while (!FileIsEnding(handle)) + text += FileReadString(handle); + FileClose(handle); + return ParseLongField(text, "last_file_seq"); + } + + string RequestEnvelope( + const long file_seq, + const string id, + const string method, + const string params) const { + return "{" + "\"file_seq\":" + IntegerToString(file_seq) + "," + "\"jsonrpc\":\"2.0\"," + "\"id\":" + JsonString(id) + "," + "\"method\":" + JsonString(method) + "," + "\"params\":" + params + + "}"; + } + + string TradeParams( + const string section_name, + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id, + const string operation_key, + const long valid_until_ms) const { + string identity = "\"unique_hash\":" + JsonString(operation_key); + if (signal_name != "") + identity += ",\"signal_name\":" + JsonString(signal_name); + + string params = "{" + "\"context\":{" + "\"idempotency_key\":" + JsonString(operation_key) + "," + "\"valid_until_ms\":" + IntegerToString(valid_until_ms) + + "}," + "\"identity\":{" + identity + "},"; + + if (account_id != "") { + params += "\"routing\":{\"selector\":{" + "\"kind\":\"account\"," + "\"account_id\":" + JsonString(account_id) + + "}},"; + } + + params += + "\"" + section_name + "\":{" + "\"symbol\":" + JsonString(symbol) + "," + "\"order_type\":" + JsonString(order_type) + "," + "\"option_type\":\"SPRINT\"," + "\"amount\":{" + "\"value\":" + JsonString(DoubleToString(amount, 2)) + "," + "\"currency\":" + JsonString(currency) + + "}," + "\"expiry\":{" + "\"kind\":\"duration\"," + "\"duration_ms\":" + IntegerToString(duration_ms) + + "}" + "}" + "}"; + return params; + } + + bool AppendRequest(const string id, const string method, const string params) { + if (m_next_file_seq <= 0) { + long visible_max = MaxFileSeqInCommands(); + long checkpoint = LastCheckpointSeq(); + m_next_file_seq = MathMax(visible_max, checkpoint) + 1; + } + + string line = RequestEnvelope(m_next_file_seq, id, method, params); + if (!AppendLine(CommandsPath(), line)) + return false; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + return true; + } + +public: + COptionXFileBridge() { + m_bridge_id = 1; + m_client_id = "default"; + m_namespace_subdir = "OptionX\\Bridge\\v1"; + m_default_valid_for_ms = 60000; + m_next_file_seq = 0; + m_operation_counter = 0; + } + + void Configure( + const int bridge_id, + const string client_id, + const string namespace_subdir = "OptionX\\Bridge\\v1", + const int default_valid_for_ms = 60000) { + m_bridge_id = bridge_id; + m_client_id = client_id; + m_namespace_subdir = NormalizePath(namespace_subdir); + m_default_valid_for_ms = default_valid_for_ms; + m_next_file_seq = 0; + } + + string ClientRoot() const { + return JoinPath( + JoinPath(m_namespace_subdir, IntegerToString(m_bridge_id)), + m_client_id); + } + + string CommandsPath() const { + return JoinPath(ClientRoot(), "commands.ndjson"); + } + + string CommandsCheckpointPath() const { + return JoinPath(ClientRoot(), "commands.checkpoint.json"); + } + + string MakeOperationKey(const string prefix = "mql") { + string key = prefix + "_" + + Base36Encode(UnixTimeMs()) + "_" + + RandomBase36(16) + "_" + + Base36Encode(m_operation_counter); + ++m_operation_counter; + return key; + } + + bool AccountBalanceGet(const string account_id = "", string operation_key = "") { + if (operation_key == "") + operation_key = MakeOperationKey(); + + string params = "{}"; + if (account_id != "") + params = "{\"account_id\":" + JsonString(account_id) + "}"; + return AppendRequest(operation_key, "account.balance.get", params); + } + + bool SignalSubmit( + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id = "", + string operation_key = "", + const int valid_for_ms = 0) { + if (operation_key == "") + operation_key = MakeOperationKey(); + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + "signal", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + return AppendRequest(operation_key, "signal.submit", params); + } + + bool TradeOpen( + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id = "", + string operation_key = "", + const int valid_for_ms = 0) { + if (operation_key == "") + operation_key = MakeOperationKey(); + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + "trade", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + return AppendRequest(operation_key, "trade.open", params); + } + + bool CleanupCommandsIfCheckpointCaughtUp() const { + long max_seq = MaxFileSeqInCommands(); + if (max_seq <= 0) + return false; + + long checkpoint = LastCheckpointSeq(); + if (checkpoint < max_seq) { + Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, + ", visible_max=", max_seq); + return false; + } + + int handle = FileOpen( + CommandsPath(), + FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not clear command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + FileClose(handle); + Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); + return true; + } +}; + +#endif // OPTIONX_FILE_BRIDGE_MQH diff --git a/examples/mql/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 similarity index 97% rename from examples/mql/OptionXFileBridgeSignalExample.mq5 rename to mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 index eabced7..a8c1362 100644 --- a/examples/mql/OptionXFileBridgeSignalExample.mq5 +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -2,7 +2,7 @@ #property indicator_chart_window #property indicator_plots 0 -#include +#include input int InpBridgeId = 1; input string InpClientId = "mt5-terminal"; From bb660d4045b44a83c3452fdc8e8c1273219babaa Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 08:12:30 +0300 Subject: [PATCH 03/11] fix(bridges): harden metatrader command writers --- .../file-transport-and-adapters.md | 6 + .../file-transport-and-adapters.ru.md | 6 + .../MetaTraderFileCommandWriter.hpp | 47 ++-- .../Include/OptionX/OptionXFileBridge.mqh | 259 ++++++++++++++++-- .../OptionXFileBridgeSignalExample.mq4 | 3 +- .../Include/OptionX/OptionXFileBridge.mqh | 259 ++++++++++++++++-- .../OptionXFileBridgeSignalExample.mq5 | 3 +- tests/metatrader_file_command_writer_test.cpp | 98 +++++++ 8 files changed, 612 insertions(+), 69 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 89a6a01..3f85e98 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -141,6 +141,12 @@ send an `account.balance.get` request and submit a simple signal for the current chart symbol. `trade.open` is available behind an input flag so the example does not open a direct trade accidentally. +The MQL header opens text files with `CP_UTF8` and shared read/write flags. It +also repairs an incomplete tail before appending the first new command after a +restart. If `commands.checkpoint.json` exists but is unreadable or malformed, or +if `commands.ndjson` cannot be scanned safely, the helper fails closed and does +not write a new command with a reset `file_seq`. + ### MetaTrader Discovery Utility MetaTrader path discovery is a reusable utility, not ad-hoc logic inside the diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index aba9fdb..ac0d04a 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -132,6 +132,12 @@ Examples пишут через `Print` resolved client root и command log path, `trade.open` доступен через input flag, чтобы example случайно не открывал direct trade. +MQL header открывает text files с `CP_UTF8` и shared read/write flags. Он также +ремонтирует incomplete tail перед первым новым append после restart. Если +`commands.checkpoint.json` существует, но не читается или malformed, либо если +`commands.ndjson` нельзя безопасно просканировать, helper fails closed и не +пишет новую command со сброшенным `file_seq`. + ### MetaTrader Discovery Utility Поиск путей MetaTrader реализован отдельной reusable utility, а не ad-hoc diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp index 155f8aa..3a1bd7d 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -41,6 +41,8 @@ namespace optionx::bridges::metatrader_file { /// It owns `commands.ndjson`: it assigns monotonic `file_seq`, appends one /// compact JSON-RPC request per line, and may clear the command log only after /// the bridge checkpoint confirms that all visible commands were consumed. + /// Public methods serialize initialization, sequence allocation, append and + /// cleanup with an internal mutex. class MetaTraderFileCommandWriter { public: /// \brief Creates a command writer from a validated bridge file configuration. @@ -50,13 +52,8 @@ namespace optionx::bridges::metatrader_file { /// \brief Ensures directories exist and initializes the next `file_seq`. void initialize() { - detail::ensure_runtime_directories(m_layout); - const auto checkpoint = read_reader_checkpoint(); - m_next_file_seq = detail::next_file_seq_after_checkpoint( - m_layout.commands_log(), - checkpoint, - m_config.max_line_bytes); - m_initialized = true; + std::lock_guard lock(m_mutex); + initialize_locked(); } /// \brief Returns the resolved file layout. @@ -66,14 +63,16 @@ namespace optionx::bridges::metatrader_file { /// \brief Returns the next writer sequence, initializing the writer if needed. std::uint64_t next_file_seq() { - ensure_initialized(); + std::lock_guard lock(m_mutex); + ensure_initialized_locked(); return m_next_file_seq; } /// \brief Appends a `signal.submit` command. MetaTraderFileWrittenCommand signal_submit(MetaTraderFileTradeCommand command) { + std::lock_guard lock(m_mutex); prepare_trade_command(command, "mql"); - return append_request( + return append_request_locked( command.id, "signal.submit", make_trade_params(command, "signal")); @@ -81,8 +80,9 @@ namespace optionx::bridges::metatrader_file { /// \brief Appends a `trade.open` command. MetaTraderFileWrittenCommand trade_open(MetaTraderFileTradeCommand command) { + std::lock_guard lock(m_mutex); prepare_trade_command(command, "mql"); - return append_request( + return append_request_locked( command.id, "trade.open", make_trade_params(command, "trade")); @@ -92,7 +92,8 @@ namespace optionx::bridges::metatrader_file { MetaTraderFileWrittenCommand account_balance_get( std::string account_id = {}, std::string id = {}) { - ensure_initialized(); + std::lock_guard lock(m_mutex); + ensure_initialized_locked(); if (id.empty()) { id = make_compact_operation_key("mql"); } @@ -101,13 +102,14 @@ namespace optionx::bridges::metatrader_file { if (!account_id.empty()) { params["account_id"] = std::move(account_id); } - return append_request(std::move(id), "account.balance.get", std::move(params)); + return append_request_locked(std::move(id), "account.balance.get", std::move(params)); } /// \brief Clears `commands.ndjson` only when the bridge checkpoint caught up. /// \return True when the log was cleared; false when cleanup is not safe yet. bool clear_commands_if_checkpoint_caught_up() { - ensure_initialized(); + std::lock_guard lock(m_mutex); + ensure_initialized_locked(); const auto checkpoint = read_reader_checkpoint(); const auto visible_max = detail::max_file_seq_in_ndjson( m_layout.commands_log(), @@ -125,9 +127,19 @@ namespace optionx::bridges::metatrader_file { } private: - void ensure_initialized() { + void initialize_locked() { + detail::ensure_runtime_directories(m_layout); + const auto checkpoint = read_reader_checkpoint(); + m_next_file_seq = detail::next_file_seq_after_checkpoint( + m_layout.commands_log(), + checkpoint, + m_config.max_line_bytes); + m_initialized = true; + } + + void ensure_initialized_locked() { if (!m_initialized) { - initialize(); + initialize_locked(); } } @@ -217,11 +229,11 @@ namespace optionx::bridges::metatrader_file { return params; } - MetaTraderFileWrittenCommand append_request( + MetaTraderFileWrittenCommand append_request_locked( nlohmann::json id, std::string method, nlohmann::json params) { - ensure_initialized(); + ensure_initialized_locked(); const auto file_seq = m_next_file_seq; const auto document = detail::make_file_jsonrpc_request( file_seq, @@ -254,6 +266,7 @@ namespace optionx::bridges::metatrader_file { detail::FileTransportLayout m_layout; std::uint64_t m_next_file_seq = 1; bool m_initialized = false; + mutable std::mutex m_mutex; }; } // namespace optionx::bridges::metatrader_file diff --git a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh index 10ad4e1..611e540 100644 --- a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -2,8 +2,7 @@ #define OPTIONX_FILE_BRIDGE_MQH // Minimal OptionX MetaTrader Common\Files client. -// Copy this file to MQL4\Include or MQL5\Include, or place it next to an EA, -// script, or indicator that includes it. +// Place this file under MQL4\Include\OptionX or MQL5\Include\OptionX. class COptionXFileBridge { private: @@ -11,8 +10,11 @@ private: string m_client_id; string m_namespace_subdir; int m_default_valid_for_ms; + int m_max_line_chars; + int m_max_command_log_bytes; long m_next_file_seq; long m_operation_counter; + bool m_configured; string NormalizePath(string value) const { StringReplace(value, "/", "\\"); @@ -26,6 +28,42 @@ private: return value; } + bool IsSafeFileId(const string value) const { + if (value == "" || value == "." || value == "..") + return false; + if (StringSubstr(value, StringLen(value) - 1, 1) == ".") + return false; + + for (int i = 0; i < StringLen(value); ++i) { + ushort ch = StringGetCharacter(value, i); + bool ok = + (ch >= 48 && ch <= 57) || + (ch >= 65 && ch <= 90) || + (ch >= 97 && ch <= 122) || + ch == 45 || + ch == 46; + if (!ok) return false; + } + return true; + } + + bool IsSafeNamespaceSubdir(const string value) const { + string normalized = NormalizePath(value); + if (normalized == "") return false; + int start = 0; + while (start < StringLen(normalized)) { + int pos = StringFind(normalized, "\\", start); + string part = (pos < 0) + ? StringSubstr(normalized, start) + : StringSubstr(normalized, start, pos - start); + if (!IsSafeFileId(part)) + return false; + if (pos < 0) break; + start = pos + 1; + } + return true; + } + string JoinPath(const string left, const string right) const { if (left == "") return NormalizePath(right); if (right == "") return NormalizePath(left); @@ -104,16 +142,113 @@ private: return true; } + int TextReadFlags() const { + return FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int TextWriteFlags() const { + return FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int TextReadWriteFlags() const { + return FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int BinaryReadFlags() const { + return FILE_READ | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int BinaryWriteFlags() const { + return FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + bool RepairIncompleteTail(const string relative_path) const { + if (!FileIsExist(relative_path, FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(relative_path, BinaryReadFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not inspect command log tail: ", relative_path, + ", error=", GetLastError()); + return false; + } + + long size = (long)FileSize(handle); + if (size <= 0) { + FileClose(handle); + return true; + } + if (size > m_max_command_log_bytes) { + FileClose(handle); + Print("OptionX: command log is larger than configured limit: ", size); + return false; + } + + uchar bytes[]; + ArrayResize(bytes, (int)size); + int read = FileReadArray(handle, bytes, 0, (int)size); + FileClose(handle); + if (read != (int)size) { + Print("OptionX: could not read command log for tail repair: ", relative_path, + ", read=", read, ", expected=", size); + return false; + } + + if (bytes[(int)size - 1] == 10) + return true; + + int keep = 0; + for (int i = (int)size - 1; i >= 0; --i) { + if (bytes[i] == 10) { + keep = i + 1; + break; + } + } + + ResetLastError(); + handle = FileOpen(relative_path, BinaryWriteFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not truncate incomplete command log tail: ", + relative_path, ", error=", GetLastError()); + return false; + } + if (keep > 0) + FileWriteArray(handle, bytes, 0, keep); + FileFlush(handle); + FileClose(handle); + Print("OptionX: repaired incomplete command log tail, kept bytes=", keep); + return true; + } + bool AppendLine(const string relative_path, const string line) const { if (!EnsureClientRoot()) return false; + if (StringLen(line) > m_max_line_chars) { + Print("OptionX: command line exceeds configured character limit: ", + StringLen(line)); + return false; + } + if (!RepairIncompleteTail(relative_path)) + return false; + ResetLastError(); int handle = FileOpen( relative_path, - FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextReadWriteFlags(), + 0, + CP_UTF8); if (handle == INVALID_HANDLE) { + ResetLastError(); handle = FileOpen( relative_path, - FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextWriteFlags(), + 0, + CP_UTF8); } if (handle == INVALID_HANDLE) { Print("OptionX: could not open command log: ", relative_path, @@ -128,11 +263,12 @@ private: return true; } - long ParseLongField(const string text, const string name) const { + bool TryParseLongField(const string text, const string name, long &value) const { + value = 0; int key = StringFind(text, "\"" + name + "\""); - if (key < 0) return 0; + if (key < 0) return false; int colon = StringFind(text, ":", key); - if (colon < 0) return 0; + if (colon < 0) return false; int start = colon + 1; while (start < StringLen(text)) { @@ -147,35 +283,66 @@ private: if (ch < 48 || ch > 57) break; ++end; } - if (end <= start) return 0; - return (long)StringToInteger(StringSubstr(text, start, end - start)); + if (end <= start) return false; + value = (long)StringToInteger(StringSubstr(text, start, end - start)); + return true; } - long MaxFileSeqInCommands() const { - int handle = FileOpen(CommandsPath(), FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); - if (handle == INVALID_HANDLE) return 0; + bool TryMaxFileSeqInCommands(long &max_seq) const { + max_seq = 0; + if (!FileIsExist(CommandsPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(CommandsPath(), TextReadFlags(), 0, CP_UTF8); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not read command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } - long max_seq = 0; while (!FileIsEnding(handle)) { string line = FileReadString(handle); - long seq = ParseLongField(line, "file_seq"); + if (line == "") continue; + long seq = 0; + if (!TryParseLongField(line, "file_seq", seq) || seq <= 0) { + FileClose(handle); + Print("OptionX: command log contains a malformed line without positive file_seq."); + return false; + } if (seq > max_seq) max_seq = seq; } FileClose(handle); - return max_seq; + return true; } - long LastCheckpointSeq() const { + bool TryLastCheckpointSeq(long &checkpoint) const { + checkpoint = 0; + if (!FileIsExist(CommandsCheckpointPath(), FILE_COMMON)) + return true; + + ResetLastError(); int handle = FileOpen( CommandsCheckpointPath(), - FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); - if (handle == INVALID_HANDLE) return 0; + TextReadFlags(), + 0, + CP_UTF8); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not read command checkpoint: ", + CommandsCheckpointPath(), ", error=", GetLastError()); + return false; + } string text = ""; while (!FileIsEnding(handle)) text += FileReadString(handle); FileClose(handle); - return ParseLongField(text, "last_file_seq"); + + if (!TryParseLongField(text, "last_file_seq", checkpoint) || checkpoint < 0) { + Print("OptionX: command checkpoint is malformed or missing last_file_seq."); + return false; + } + return true; } string RequestEnvelope( @@ -240,9 +407,20 @@ private: } bool AppendRequest(const string id, const string method, const string params) { + if (!m_configured) { + Print("OptionX: file bridge is not configured."); + return false; + } if (m_next_file_seq <= 0) { - long visible_max = MaxFileSeqInCommands(); - long checkpoint = LastCheckpointSeq(); + if (!RepairIncompleteTail(CommandsPath())) + return false; + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) { + Print("OptionX: command sequence recovery failed; command was not written."); + return false; + } m_next_file_seq = MathMax(visible_max, checkpoint) + 1; } @@ -262,20 +440,45 @@ public: m_client_id = "default"; m_namespace_subdir = "OptionX\\Bridge\\v1"; m_default_valid_for_ms = 60000; + m_max_line_chars = 65536; + m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; m_operation_counter = 0; + m_configured = true; } - void Configure( + bool Configure( const int bridge_id, const string client_id, const string namespace_subdir = "OptionX\\Bridge\\v1", const int default_valid_for_ms = 60000) { + if (bridge_id <= 0) { + Print("OptionX: bridge_id must be positive."); + m_configured = false; + return false; + } + if (!IsSafeFileId(client_id)) { + Print("OptionX: client_id must be a safe [A-Za-z0-9.-]+ identifier."); + m_configured = false; + return false; + } + if (!IsSafeNamespaceSubdir(namespace_subdir)) { + Print("OptionX: namespace_subdir must be a safe relative path."); + m_configured = false; + return false; + } + if (default_valid_for_ms <= 0) { + Print("OptionX: default_valid_for_ms must be positive."); + m_configured = false; + return false; + } m_bridge_id = bridge_id; m_client_id = client_id; m_namespace_subdir = NormalizePath(namespace_subdir); m_default_valid_for_ms = default_valid_for_ms; m_next_file_seq = 0; + m_configured = true; + return true; } string ClientRoot() const { @@ -368,11 +571,15 @@ public: } bool CleanupCommandsIfCheckpointCaughtUp() const { - long max_seq = MaxFileSeqInCommands(); + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + return false; if (max_seq <= 0) return false; - long checkpoint = LastCheckpointSeq(); + long checkpoint = 0; + if (!TryLastCheckpointSeq(checkpoint)) + return false; if (checkpoint < max_seq) { Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, ", visible_max=", max_seq); @@ -381,7 +588,9 @@ public: int handle = FileOpen( CommandsPath(), - FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextWriteFlags(), + 0, + CP_UTF8); if (handle == INVALID_HANDLE) { Print("OptionX: could not clear command log: ", CommandsPath(), ", error=", GetLastError()); diff --git a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 index c8b7b09..19f093b 100644 --- a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -16,7 +16,8 @@ COptionXFileBridge g_optionx; int OnInit() { MathSrand((int)GetTickCount()); - g_optionx.Configure(InpBridgeId, InpClientId); + if (!g_optionx.Configure(InpBridgeId, InpClientId)) + return INIT_FAILED; Print("OptionX MT4 file bridge example"); Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); Print("OptionX command log: ", g_optionx.CommandsPath()); diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh index 10ad4e1..611e540 100644 --- a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -2,8 +2,7 @@ #define OPTIONX_FILE_BRIDGE_MQH // Minimal OptionX MetaTrader Common\Files client. -// Copy this file to MQL4\Include or MQL5\Include, or place it next to an EA, -// script, or indicator that includes it. +// Place this file under MQL4\Include\OptionX or MQL5\Include\OptionX. class COptionXFileBridge { private: @@ -11,8 +10,11 @@ private: string m_client_id; string m_namespace_subdir; int m_default_valid_for_ms; + int m_max_line_chars; + int m_max_command_log_bytes; long m_next_file_seq; long m_operation_counter; + bool m_configured; string NormalizePath(string value) const { StringReplace(value, "/", "\\"); @@ -26,6 +28,42 @@ private: return value; } + bool IsSafeFileId(const string value) const { + if (value == "" || value == "." || value == "..") + return false; + if (StringSubstr(value, StringLen(value) - 1, 1) == ".") + return false; + + for (int i = 0; i < StringLen(value); ++i) { + ushort ch = StringGetCharacter(value, i); + bool ok = + (ch >= 48 && ch <= 57) || + (ch >= 65 && ch <= 90) || + (ch >= 97 && ch <= 122) || + ch == 45 || + ch == 46; + if (!ok) return false; + } + return true; + } + + bool IsSafeNamespaceSubdir(const string value) const { + string normalized = NormalizePath(value); + if (normalized == "") return false; + int start = 0; + while (start < StringLen(normalized)) { + int pos = StringFind(normalized, "\\", start); + string part = (pos < 0) + ? StringSubstr(normalized, start) + : StringSubstr(normalized, start, pos - start); + if (!IsSafeFileId(part)) + return false; + if (pos < 0) break; + start = pos + 1; + } + return true; + } + string JoinPath(const string left, const string right) const { if (left == "") return NormalizePath(right); if (right == "") return NormalizePath(left); @@ -104,16 +142,113 @@ private: return true; } + int TextReadFlags() const { + return FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int TextWriteFlags() const { + return FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int TextReadWriteFlags() const { + return FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int BinaryReadFlags() const { + return FILE_READ | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + int BinaryWriteFlags() const { + return FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE; + } + + bool RepairIncompleteTail(const string relative_path) const { + if (!FileIsExist(relative_path, FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(relative_path, BinaryReadFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not inspect command log tail: ", relative_path, + ", error=", GetLastError()); + return false; + } + + long size = (long)FileSize(handle); + if (size <= 0) { + FileClose(handle); + return true; + } + if (size > m_max_command_log_bytes) { + FileClose(handle); + Print("OptionX: command log is larger than configured limit: ", size); + return false; + } + + uchar bytes[]; + ArrayResize(bytes, (int)size); + int read = FileReadArray(handle, bytes, 0, (int)size); + FileClose(handle); + if (read != (int)size) { + Print("OptionX: could not read command log for tail repair: ", relative_path, + ", read=", read, ", expected=", size); + return false; + } + + if (bytes[(int)size - 1] == 10) + return true; + + int keep = 0; + for (int i = (int)size - 1; i >= 0; --i) { + if (bytes[i] == 10) { + keep = i + 1; + break; + } + } + + ResetLastError(); + handle = FileOpen(relative_path, BinaryWriteFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not truncate incomplete command log tail: ", + relative_path, ", error=", GetLastError()); + return false; + } + if (keep > 0) + FileWriteArray(handle, bytes, 0, keep); + FileFlush(handle); + FileClose(handle); + Print("OptionX: repaired incomplete command log tail, kept bytes=", keep); + return true; + } + bool AppendLine(const string relative_path, const string line) const { if (!EnsureClientRoot()) return false; + if (StringLen(line) > m_max_line_chars) { + Print("OptionX: command line exceeds configured character limit: ", + StringLen(line)); + return false; + } + if (!RepairIncompleteTail(relative_path)) + return false; + ResetLastError(); int handle = FileOpen( relative_path, - FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextReadWriteFlags(), + 0, + CP_UTF8); if (handle == INVALID_HANDLE) { + ResetLastError(); handle = FileOpen( relative_path, - FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextWriteFlags(), + 0, + CP_UTF8); } if (handle == INVALID_HANDLE) { Print("OptionX: could not open command log: ", relative_path, @@ -128,11 +263,12 @@ private: return true; } - long ParseLongField(const string text, const string name) const { + bool TryParseLongField(const string text, const string name, long &value) const { + value = 0; int key = StringFind(text, "\"" + name + "\""); - if (key < 0) return 0; + if (key < 0) return false; int colon = StringFind(text, ":", key); - if (colon < 0) return 0; + if (colon < 0) return false; int start = colon + 1; while (start < StringLen(text)) { @@ -147,35 +283,66 @@ private: if (ch < 48 || ch > 57) break; ++end; } - if (end <= start) return 0; - return (long)StringToInteger(StringSubstr(text, start, end - start)); + if (end <= start) return false; + value = (long)StringToInteger(StringSubstr(text, start, end - start)); + return true; } - long MaxFileSeqInCommands() const { - int handle = FileOpen(CommandsPath(), FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); - if (handle == INVALID_HANDLE) return 0; + bool TryMaxFileSeqInCommands(long &max_seq) const { + max_seq = 0; + if (!FileIsExist(CommandsPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(CommandsPath(), TextReadFlags(), 0, CP_UTF8); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not read command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } - long max_seq = 0; while (!FileIsEnding(handle)) { string line = FileReadString(handle); - long seq = ParseLongField(line, "file_seq"); + if (line == "") continue; + long seq = 0; + if (!TryParseLongField(line, "file_seq", seq) || seq <= 0) { + FileClose(handle); + Print("OptionX: command log contains a malformed line without positive file_seq."); + return false; + } if (seq > max_seq) max_seq = seq; } FileClose(handle); - return max_seq; + return true; } - long LastCheckpointSeq() const { + bool TryLastCheckpointSeq(long &checkpoint) const { + checkpoint = 0; + if (!FileIsExist(CommandsCheckpointPath(), FILE_COMMON)) + return true; + + ResetLastError(); int handle = FileOpen( CommandsCheckpointPath(), - FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); - if (handle == INVALID_HANDLE) return 0; + TextReadFlags(), + 0, + CP_UTF8); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not read command checkpoint: ", + CommandsCheckpointPath(), ", error=", GetLastError()); + return false; + } string text = ""; while (!FileIsEnding(handle)) text += FileReadString(handle); FileClose(handle); - return ParseLongField(text, "last_file_seq"); + + if (!TryParseLongField(text, "last_file_seq", checkpoint) || checkpoint < 0) { + Print("OptionX: command checkpoint is malformed or missing last_file_seq."); + return false; + } + return true; } string RequestEnvelope( @@ -240,9 +407,20 @@ private: } bool AppendRequest(const string id, const string method, const string params) { + if (!m_configured) { + Print("OptionX: file bridge is not configured."); + return false; + } if (m_next_file_seq <= 0) { - long visible_max = MaxFileSeqInCommands(); - long checkpoint = LastCheckpointSeq(); + if (!RepairIncompleteTail(CommandsPath())) + return false; + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) { + Print("OptionX: command sequence recovery failed; command was not written."); + return false; + } m_next_file_seq = MathMax(visible_max, checkpoint) + 1; } @@ -262,20 +440,45 @@ public: m_client_id = "default"; m_namespace_subdir = "OptionX\\Bridge\\v1"; m_default_valid_for_ms = 60000; + m_max_line_chars = 65536; + m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; m_operation_counter = 0; + m_configured = true; } - void Configure( + bool Configure( const int bridge_id, const string client_id, const string namespace_subdir = "OptionX\\Bridge\\v1", const int default_valid_for_ms = 60000) { + if (bridge_id <= 0) { + Print("OptionX: bridge_id must be positive."); + m_configured = false; + return false; + } + if (!IsSafeFileId(client_id)) { + Print("OptionX: client_id must be a safe [A-Za-z0-9.-]+ identifier."); + m_configured = false; + return false; + } + if (!IsSafeNamespaceSubdir(namespace_subdir)) { + Print("OptionX: namespace_subdir must be a safe relative path."); + m_configured = false; + return false; + } + if (default_valid_for_ms <= 0) { + Print("OptionX: default_valid_for_ms must be positive."); + m_configured = false; + return false; + } m_bridge_id = bridge_id; m_client_id = client_id; m_namespace_subdir = NormalizePath(namespace_subdir); m_default_valid_for_ms = default_valid_for_ms; m_next_file_seq = 0; + m_configured = true; + return true; } string ClientRoot() const { @@ -368,11 +571,15 @@ public: } bool CleanupCommandsIfCheckpointCaughtUp() const { - long max_seq = MaxFileSeqInCommands(); + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + return false; if (max_seq <= 0) return false; - long checkpoint = LastCheckpointSeq(); + long checkpoint = 0; + if (!TryLastCheckpointSeq(checkpoint)) + return false; if (checkpoint < max_seq) { Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, ", visible_max=", max_seq); @@ -381,7 +588,9 @@ public: int handle = FileOpen( CommandsPath(), - FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); + TextWriteFlags(), + 0, + CP_UTF8); if (handle == INVALID_HANDLE) { Print("OptionX: could not clear command log: ", CommandsPath(), ", error=", GetLastError()); diff --git a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 index a8c1362..4076c1b 100644 --- a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -17,7 +17,8 @@ COptionXFileBridge g_optionx; int OnInit() { MathSrand((uint)GetTickCount()); - g_optionx.Configure(InpBridgeId, InpClientId); + if (!g_optionx.Configure(InpBridgeId, InpClientId)) + return INIT_FAILED; Print("OptionX MT5 file bridge example"); Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); Print("OptionX command log: ", g_optionx.CommandsPath()); diff --git a/tests/metatrader_file_command_writer_test.cpp b/tests/metatrader_file_command_writer_test.cpp index 9958d5b..8ac0661 100644 --- a/tests/metatrader_file_command_writer_test.cpp +++ b/tests/metatrader_file_command_writer_test.cpp @@ -4,7 +4,11 @@ #include #include +#include +#include +#include #include +#include #include namespace { @@ -133,6 +137,100 @@ TEST(MetaTraderFileCommandWriter, RecoversSequenceAndCleansAfterCheckpoint) { ASSERT_EQ(recovered.account_balance_get({}, "cmd-3").file_seq, 3u); } +TEST(MetaTraderFileCommandWriter, FailsClosedOnMalformedCheckpoint) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + const auto config = make_config(root); + const auto layout = protocol::make_layout(config); + protocol::ensure_runtime_directories(layout); + protocol::write_text_file_atomic(layout.commands_checkpoint(), "{\"not_last_file_seq\":1}"); + + mtfile::MetaTraderFileCommandWriter writer(config); + EXPECT_THROW( + static_cast(writer.account_balance_get({}, "cmd-1")), + std::runtime_error); + EXPECT_EQ(protocol::max_file_seq_in_ndjson(layout.commands_log(), 8192), 0u); +} + +TEST(MetaTraderFileCommandWriter, RepairsIncompleteTailBeforeAppend) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + const auto config = make_config(root); + mtfile::MetaTraderFileCommandWriter writer(config); + ASSERT_EQ(writer.account_balance_get({}, "cmd-1").file_seq, 1u); + + { + std::ofstream out(writer.layout().commands_log(), std::ios::binary | std::ios::app); + ASSERT_TRUE(out); + out << "{\"file_seq\":2,\"jsonrpc\":\"2.0\",\"method\":\"trade.op"; + } + + ASSERT_EQ(writer.account_balance_get({}, "cmd-2").file_seq, 2u); + const auto records = protocol::read_ndjson_since_file_seq( + writer.layout().commands_log(), + 0, + 8192); + + ASSERT_EQ(records.size(), 2u); + EXPECT_EQ(records[0].file_seq, 1u); + EXPECT_EQ(records[1].file_seq, 2u); + EXPECT_EQ(records[1].document.at("id").get(), "cmd-2"); +} + +TEST(MetaTraderFileCommandWriter, SerializesConcurrentAppends) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + mtfile::MetaTraderFileCommandWriter writer(make_config(root)); + std::mutex errors_mutex; + std::vector errors; + std::vector threads; + + constexpr int command_count = 24; + threads.reserve(command_count); + for (int i = 0; i < command_count; ++i) { + threads.emplace_back([&writer, &errors, &errors_mutex, i]() { + try { + writer.account_balance_get({}, "cmd-" + std::to_string(i)); + } catch (const std::exception& ex) { + std::lock_guard lock(errors_mutex); + errors.push_back(ex.what()); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + + if (!errors.empty()) { + FAIL() << errors.front(); + } + const auto records = protocol::read_ndjson_since_file_seq( + writer.layout().commands_log(), + 0, + 8192); + ASSERT_EQ(records.size(), static_cast(command_count)); + + std::set sequences; + for (const auto& record : records) { + sequences.insert(record.file_seq); + } + EXPECT_EQ(sequences.size(), static_cast(command_count)); + EXPECT_EQ(*sequences.begin(), 1u); + EXPECT_EQ(*sequences.rbegin(), static_cast(command_count)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 7beb00f4b790d0a67e327d6906d49b4d0edf3aa5 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 09:09:47 +0300 Subject: [PATCH 04/11] fix(mql): compile file bridge header in mt5 --- mql/MQL4/Include/OptionX/OptionXFileBridge.mqh | 13 +++++++++---- mql/MQL5/Include/OptionX/OptionXFileBridge.mqh | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh index 611e540..d430d62 100644 --- a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -118,8 +118,13 @@ private: bool EnsureDirectory(const string path) const { if (path == "") return true; - if (FolderIsExist(path, FILE_COMMON)) return true; - return FolderCreate(path, FILE_COMMON); + ResetLastError(); + if (FolderCreate(path, FILE_COMMON)) + return true; + int error = GetLastError(); + // MetaTrader reports an existing directory through the common + // "file/folder already exists" code on recent MT4/MT5 builds. + return error == 5019; } bool EnsureClientRoot() const { @@ -192,9 +197,9 @@ private: uchar bytes[]; ArrayResize(bytes, (int)size); - int read = FileReadArray(handle, bytes, 0, (int)size); + uint read = FileReadArray(handle, bytes, 0, (int)size); FileClose(handle); - if (read != (int)size) { + if (read != (uint)size) { Print("OptionX: could not read command log for tail repair: ", relative_path, ", read=", read, ", expected=", size); return false; diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh index 611e540..d430d62 100644 --- a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -118,8 +118,13 @@ private: bool EnsureDirectory(const string path) const { if (path == "") return true; - if (FolderIsExist(path, FILE_COMMON)) return true; - return FolderCreate(path, FILE_COMMON); + ResetLastError(); + if (FolderCreate(path, FILE_COMMON)) + return true; + int error = GetLastError(); + // MetaTrader reports an existing directory through the common + // "file/folder already exists" code on recent MT4/MT5 builds. + return error == 5019; } bool EnsureClientRoot() const { @@ -192,9 +197,9 @@ private: uchar bytes[]; ArrayResize(bytes, (int)size); - int read = FileReadArray(handle, bytes, 0, (int)size); + uint read = FileReadArray(handle, bytes, 0, (int)size); FileClose(handle); - if (read != (int)size) { + if (read != (uint)size) { Print("OptionX: could not read command log for tail repair: ", relative_path, ", read=", read, ", expected=", size); return false; From 3c6b8e26095f49bed3d74f3e7c867336ab0085ca Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 12:51:23 +0300 Subject: [PATCH 05/11] fix(bridges): serialize metatrader command appends --- .../file-transport-and-adapters.md | 29 +- .../file-transport-and-adapters.ru.md | 26 +- .../optionx_cpp/bridges/metatrader_file.hpp | 1 + .../MetaTraderFileCommandWriter.hpp | 87 ++++-- .../detail/MetaTraderFileProtocol.hpp | 164 +++++++++- .../Include/OptionX/OptionXFileBridge.mqh | 293 ++++++++++++++---- .../Include/OptionX/OptionXFileBridge.mqh | 293 ++++++++++++++---- tests/metatrader_file_command_writer_test.cpp | 125 ++++++++ 8 files changed, 845 insertions(+), 173 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 3f85e98..8e7dcd1 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -54,9 +54,10 @@ than one-file-per-message. Each append log has one writer: - the bridge is the only writer of `events.ndjson`; - the bridge is the only writer of `state.json`. -This avoids writer/writer locking while keeping the format easy to implement in -MQL4/MQL5. Readers keep their own checkpoint and never rewrite another side's -append log. +This keeps the format easy to implement in MQL4/MQL5. Readers keep their own +checkpoint and never rewrite another side's append log. When several runtime +objects or processes can write the same logical log, they must share a per-log +exclusive lock. ### C++ MVP Surface @@ -283,10 +284,24 @@ the file to the last complete LF-terminated record, or to an empty file when no complete record exists. This prevents a crashed half-record from being glued to the next valid record. -The single-writer rule is per log file and includes all threads/tasks inside -that writer. Repair, append and owner-side clear operations for the same log -must be serialized by the caller, for example through one owner queue or a -per-log mutex. The low-level append helper is intentionally unlocked. +The single-writer rule is per logical log and includes all threads/tasks inside +that writer. Repair, sequence recovery, append and owner-side clear operations +for the same log must be serialized. If more than one runtime object or process +can touch the same path, use a per-log lock file or equivalent OS file lock; an +in-process mutex alone is not sufficient. The low-level append helper is +intentionally unlocked. + +Before appending, a writer must serialize the exact UTF-8 bytes that will be +written, including the trailing LF, and check: + +```text +current_log_size + encoded_record_bytes <= max_command_log_bytes +``` + +If there is not enough space, the writer may first clear its own log only when +the reader checkpoint confirms that all currently visible records were consumed. +Otherwise it must fail before append; it must not publish a record that pushes +the log beyond the configured bridge read limit. Readers must enforce `max_line_bytes` while streaming the file, not after loading the full tail into memory. A complete malformed line may be skipped and diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index ac0d04a..7dea133 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -53,6 +53,10 @@ one-file-per-message. У каждого append-log только один writer: Reader хранит свой checkpoint и не переписывает append-log другой стороны. +Если несколько runtime-объектов или процессов могут писать в один логический +лог, они обязаны использовать общий per-log exclusive lock. Reader всё равно +хранит свой checkpoint и не переписывает append-log другой стороны. + ### C++ MVP Surface Первый C++ implementation slice открывает только reusable building blocks: @@ -258,10 +262,24 @@ file ends with an incomplete line: truncate to the last complete LF-terminated record, or to an empty file when no complete record exists. This prevents a crashed half-record from being glued to the next valid record. -Single-writer rule is per log file and includes all threads/tasks inside that -writer. Repair, append and owner-side clear operations for the same log must be -serialized by caller, for example through one owner queue or a per-log mutex. -Low-level append helper is intentionally unlocked. +Single-writer rule действует на уровне логического лога и включает все +threads/tasks внутри writer'а. Repair, sequence recovery, append и owner-side +clear для одного и того же лога должны быть сериализованы. Если один и тот же +path может трогать больше одного runtime-объекта или процесса, нужен per-log +lock file или эквивалентный OS file lock; одного in-process mutex +недостаточно. Low-level append helper намеренно остаётся unlocked. + +Перед append writer обязан сериализовать точные UTF-8 bytes, включая trailing +LF, и проверить: + +```text +current_log_size + encoded_record_bytes <= max_command_log_bytes +``` + +Если места не хватает, writer может сначала очистить свой лог только когда +reader checkpoint подтверждает, что все текущие видимые records уже consumed. +Иначе он должен fail before append и не публиковать record, который выведет лог +за настроенный bridge read limit. Reader must enforce `max_line_bytes` while streaming the file, not after loading the full tail into memory. A complete malformed line may be skipped and reported diff --git a/include/optionx_cpp/bridges/metatrader_file.hpp b/include/optionx_cpp/bridges/metatrader_file.hpp index 2d26191..451f82f 100644 --- a/include/optionx_cpp/bridges/metatrader_file.hpp +++ b/include/optionx_cpp/bridges/metatrader_file.hpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp index 3a1bd7d..3704cfd 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -41,8 +41,10 @@ namespace optionx::bridges::metatrader_file { /// It owns `commands.ndjson`: it assigns monotonic `file_seq`, appends one /// compact JSON-RPC request per line, and may clear the command log only after /// the bridge checkpoint confirms that all visible commands were consumed. - /// Public methods serialize initialization, sequence allocation, append and - /// cleanup with an internal mutex. + /// Public methods serialize object state with an internal mutex. Mutations + /// of `commands.ndjson` additionally take a sibling lock file so several + /// writer objects/processes can share the same client root without assigning + /// duplicate `file_seq` values or clearing each other's records. class MetaTraderFileCommandWriter { public: /// \brief Creates a command writer from a validated bridge file configuration. @@ -53,6 +55,7 @@ namespace optionx::bridges::metatrader_file { /// \brief Ensures directories exist and initializes the next `file_seq`. void initialize() { std::lock_guard lock(m_mutex); + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); initialize_locked(); } @@ -64,7 +67,9 @@ namespace optionx::bridges::metatrader_file { /// \brief Returns the next writer sequence, initializing the writer if needed. std::uint64_t next_file_seq() { std::lock_guard lock(m_mutex); + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); ensure_initialized_locked(); + refresh_next_file_seq_locked(); return m_next_file_seq; } @@ -109,32 +114,27 @@ namespace optionx::bridges::metatrader_file { /// \return True when the log was cleared; false when cleanup is not safe yet. bool clear_commands_if_checkpoint_caught_up() { std::lock_guard lock(m_mutex); + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); ensure_initialized_locked(); - const auto checkpoint = read_reader_checkpoint(); - const auto visible_max = detail::max_file_seq_in_ndjson( - m_layout.commands_log(), - m_config.max_line_bytes); - if (visible_max == 0 || checkpoint < visible_max) { - return false; - } - - detail::clear_file_atomic(m_layout.commands_log()); - if (checkpoint == std::numeric_limits::max()) { - throw std::overflow_error("MetaTrader file command writer file_seq overflow."); - } - m_next_file_seq = checkpoint + 1; - return true; + return clear_commands_if_checkpoint_caught_up_locked(); } private: void initialize_locked() { detail::ensure_runtime_directories(m_layout); + refresh_next_file_seq_locked(); + m_initialized = true; + } + + void refresh_next_file_seq_locked() { const auto checkpoint = read_reader_checkpoint(); - m_next_file_seq = detail::next_file_seq_after_checkpoint( + const auto recovered = detail::next_file_seq_after_checkpoint( m_layout.commands_log(), checkpoint, m_config.max_line_bytes); - m_initialized = true; + if (!m_initialized || recovered > m_next_file_seq) { + m_next_file_seq = recovered; + } } void ensure_initialized_locked() { @@ -171,6 +171,49 @@ namespace optionx::bridges::metatrader_file { throw std::runtime_error("MetaTrader command checkpoint last_file_seq must be a non-negative integer."); } + bool clear_commands_if_checkpoint_caught_up_locked() { + const auto checkpoint = read_reader_checkpoint(); + const auto visible_max = detail::max_file_seq_in_ndjson( + m_layout.commands_log(), + m_config.max_line_bytes); + if (visible_max == 0 || checkpoint < visible_max) { + return false; + } + + detail::clear_file_atomic(m_layout.commands_log()); + if (checkpoint == std::numeric_limits::max()) { + throw std::overflow_error("MetaTrader file command writer file_seq overflow."); + } + m_next_file_seq = checkpoint + 1; + return true; + } + + void ensure_command_log_capacity_locked(const std::size_t appended_bytes) { + if (appended_bytes > m_config.max_command_log_bytes) { + throw std::runtime_error( + "MetaTrader file command would exceed configured command log byte limit."); + } + + detail::repair_incomplete_ndjson_tail(m_layout.commands_log()); + auto current_size = detail::file_size_or_zero(m_layout.commands_log()); + if (current_size <= m_config.max_command_log_bytes && + appended_bytes <= m_config.max_command_log_bytes - current_size) { + return; + } + + if (clear_commands_if_checkpoint_caught_up_locked()) { + current_size = detail::file_size_or_zero(m_layout.commands_log()); + if (current_size <= m_config.max_command_log_bytes && + appended_bytes <= m_config.max_command_log_bytes - current_size) { + return; + } + } + + throw std::runtime_error( + "MetaTrader command log would exceed configured byte limit; " + "wait for bridge checkpoint or clean up commands.ndjson."); + } + static void prepare_trade_command( MetaTraderFileTradeCommand& command, const std::string& key_prefix) { @@ -233,16 +276,20 @@ namespace optionx::bridges::metatrader_file { nlohmann::json id, std::string method, nlohmann::json params) { + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); ensure_initialized_locked(); + refresh_next_file_seq_locked(); const auto file_seq = m_next_file_seq; const auto document = detail::make_file_jsonrpc_request( file_seq, std::move(id), std::move(method), std::move(params)); - detail::append_json_line( + const auto line = document.dump(-1); + ensure_command_log_capacity_locked(line.size() + 1); + detail::append_serialized_json_line( m_layout.commands_log(), - document, + line, m_config.max_line_bytes); if (m_next_file_seq == std::numeric_limits::max()) { diff --git a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp index 27fd2a9..acde0db 100644 --- a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp @@ -135,6 +135,141 @@ namespace optionx::bridges::metatrader_file::detail { #endif } + /// \brief Returns file size, or zero when the file does not exist. + inline std::uint64_t file_size_or_zero(const std::filesystem::path& file) { + std::error_code ec; + const auto exists = std::filesystem::exists(file, ec); + if (ec) { + throw std::runtime_error("Failed to inspect file size: " + ec.message()); + } + if (!exists) { + return 0; + } + const auto size = std::filesystem::file_size(file, ec); + if (ec) { + throw std::runtime_error("Failed to read file size: " + ec.message()); + } + return size; + } + + /// \brief Builds the sibling lock-file path used for serialized writer ownership. + inline std::filesystem::path log_lock_file_path(const std::filesystem::path& log_file) { + return log_file.parent_path() / (log_file.filename().u8string() + ".lock"); + } + + /// \class ScopedLogFileLock + /// \brief Holds an exclusive advisory lock for one NDJSON log mutation. + /// \details The file transport still stores data in append-only text files, + /// so writers must serialize sequence recovery, tail repair, append and + /// cleanup across objects and processes. The lock file is allowed to remain + /// on disk; ownership is the OS handle/lock, not the directory entry. + class ScopedLogFileLock { + public: + explicit ScopedLogFileLock( + const std::filesystem::path& log_file, + const std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)) + : m_lock_file(log_lock_file_path(log_file)) { + const auto parent = m_lock_file.parent_path(); + if (!parent.empty()) { + std::error_code ec; + std::filesystem::create_directories(parent, ec); + if (ec) { + throw std::runtime_error("Failed to create file transport lock directory: " + ec.message()); + } + } + + const auto deadline = std::chrono::steady_clock::now() + timeout; + std::error_code last_error; + do { + if (try_acquire(last_error)) { + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + + throw std::runtime_error( + "Timed out acquiring MetaTrader file transport log lock: " + + m_lock_file.u8string() + + (last_error ? ": " + last_error.message() : std::string())); + } + + ScopedLogFileLock(const ScopedLogFileLock&) = delete; + ScopedLogFileLock& operator=(const ScopedLogFileLock&) = delete; + + ScopedLogFileLock(ScopedLogFileLock&&) = delete; + ScopedLogFileLock& operator=(ScopedLogFileLock&&) = delete; + + ~ScopedLogFileLock() { +#if defined(_WIN32) + if (m_handle != INVALID_HANDLE_VALUE) { + ::CloseHandle(m_handle); + } +#elif defined(__unix__) || defined(__APPLE__) + if (m_fd >= 0) { + struct flock lock {}; + lock.l_type = F_UNLCK; + lock.l_whence = SEEK_SET; + static_cast(::fcntl(m_fd, F_SETLK, &lock)); + ::close(m_fd); + } +#else + if (m_owns_directory_lock) { + std::error_code ec; + std::filesystem::remove(m_lock_file, ec); + } +#endif + } + + private: + bool try_acquire(std::error_code& ec) noexcept { + ec.clear(); +#if defined(_WIN32) + HANDLE handle = ::CreateFileW( + m_lock_file.c_str(), + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + if (handle == INVALID_HANDLE_VALUE) { + ec = std::error_code(static_cast(::GetLastError()), std::system_category()); + return false; + } + m_handle = handle; + return true; +#elif defined(__unix__) || defined(__APPLE__) + const int fd = ::open(m_lock_file.c_str(), O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); + if (fd < 0) { + ec = std::error_code(errno, std::generic_category()); + return false; + } + struct flock lock {}; + lock.l_type = F_WRLCK; + lock.l_whence = SEEK_SET; + if (::fcntl(fd, F_SETLK, &lock) != 0) { + ec = std::error_code(errno, std::generic_category()); + ::close(fd); + return false; + } + m_fd = fd; + return true; +#else + m_owns_directory_lock = std::filesystem::create_directory(m_lock_file, ec); + return m_owns_directory_lock; +#endif + } + + std::filesystem::path m_lock_file; +#if defined(_WIN32) + HANDLE m_handle = INVALID_HANDLE_VALUE; +#elif defined(__unix__) || defined(__APPLE__) + int m_fd = -1; +#else + bool m_owns_directory_lock = false; +#endif + }; + /// \brief Returns true when an exclusive-create operation failed because the file exists. inline bool is_file_exists_error(const std::error_code& ec) noexcept { #if defined(_WIN32) @@ -453,16 +588,13 @@ namespace optionx::bridges::metatrader_file::detail { return file_seq; } - /// \brief Appends one compact JSON line and a trailing LF. - /// \details One NDJSON file must have exactly one writer. A record is - /// visible to readers only after its trailing `\n`. This helper is not - /// internally synchronized; callers must serialize all append/repair/clear - /// operations for the same log file, typically through one owner queue. - inline void append_json_line( + /// \brief Appends one already serialized JSON line and a trailing LF. + /// \details The caller must repair incomplete tails and enforce any whole-log + /// byte limit before calling this helper. + inline void append_serialized_json_line( const std::filesystem::path& file, - const nlohmann::json& document, + const std::string& line, const std::size_t max_line_bytes) { - const auto line = document.dump(-1); if (line.size() > max_line_bytes) { throw std::runtime_error("MetaTrader file transport NDJSON line exceeds configured byte limit."); } @@ -476,8 +608,6 @@ namespace optionx::bridges::metatrader_file::detail { } } - repair_incomplete_ndjson_tail(file); - std::ofstream out(file, std::ios::binary | std::ios::app); if (!out) { throw std::runtime_error("Failed to open NDJSON log: " + file.u8string()); @@ -489,6 +619,20 @@ namespace optionx::bridges::metatrader_file::detail { } } + /// \brief Appends one compact JSON line and a trailing LF. + /// \details One NDJSON file must have exactly one writer. A record is + /// visible to readers only after its trailing `\n`. This helper is not + /// internally synchronized; callers must serialize all append/repair/clear + /// operations for the same log file, typically through one owner queue or + /// `ScopedLogFileLock`. + inline void append_json_line( + const std::filesystem::path& file, + const nlohmann::json& document, + const std::size_t max_line_bytes) { + repair_incomplete_ndjson_tail(file); + append_serialized_json_line(file, document.dump(-1), max_line_bytes); + } + /// \brief Reads complete NDJSON records starting at a byte offset. /// \details The last line is ignored until it has a trailing `\n`. inline NdjsonReadResult read_ndjson_from_offset( diff --git a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh index d430d62..9998ed5 100644 --- a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -10,7 +10,7 @@ private: string m_client_id; string m_namespace_subdir; int m_default_valid_for_ms; - int m_max_line_chars; + int m_max_line_bytes; int m_max_command_log_bytes; long m_next_file_seq; long m_operation_counter; @@ -152,16 +152,6 @@ private: FILE_SHARE_READ | FILE_SHARE_WRITE; } - int TextWriteFlags() const { - return FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | - FILE_SHARE_READ | FILE_SHARE_WRITE; - } - - int TextReadWriteFlags() const { - return FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | - FILE_SHARE_READ | FILE_SHARE_WRITE; - } - int BinaryReadFlags() const { return FILE_READ | FILE_BIN | FILE_COMMON | FILE_SHARE_READ | FILE_SHARE_WRITE; @@ -172,6 +162,75 @@ private: FILE_SHARE_READ | FILE_SHARE_WRITE; } + int ExclusiveBinaryLockFlags() const { + return FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON; + } + + string CommandsLockPath() const { + return JoinPath(ClientRoot(), "commands.ndjson.lock"); + } + + int AcquireCommandLock() const { + if (!EnsureClientRoot()) return INVALID_HANDLE; + + for (int attempt = 0; attempt < 256; ++attempt) { + ResetLastError(); + int handle = FileOpen(CommandsLockPath(), ExclusiveBinaryLockFlags()); + if (handle != INVALID_HANDLE) + return handle; + } + + Print("OptionX: could not acquire command log lock: ", CommandsLockPath(), + ", error=", GetLastError()); + return INVALID_HANDLE; + } + + void ReleaseCommandLock(const int handle) const { + if (handle != INVALID_HANDLE) + FileClose(handle); + } + + bool EncodeUtf8Bytes(const string text, uchar &bytes[], int &byte_count) const { + ArrayResize(bytes, 0); + byte_count = StringToCharArray(text, bytes, 0, StringLen(text), CP_UTF8); + if (byte_count < 0) { + Print("OptionX: could not encode command line as UTF-8."); + return false; + } + ArrayResize(bytes, byte_count); + return true; + } + + bool CurrentCommandLogSize(long &size) const { + size = 0; + if (!FileIsExist(CommandsPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(CommandsPath(), BinaryReadFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not inspect command log size: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + size = (long)FileSize(handle); + FileClose(handle); + return true; + } + + bool ClearCommandsUnlocked() const { + ResetLastError(); + int handle = FileOpen(CommandsPath(), BinaryWriteFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not clear command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + FileFlush(handle); + FileClose(handle); + return true; + } + bool RepairIncompleteTail(const string relative_path) const { if (!FileIsExist(relative_path, FILE_COMMON)) return true; @@ -195,8 +254,24 @@ private: return false; } + uchar tail[]; + ArrayResize(tail, 1); + FileSeek(handle, size - 1, SEEK_SET); + uint tail_read = FileReadArray(handle, tail, 0, 1); + if (tail_read != 1) { + FileClose(handle); + Print("OptionX: could not read command log tail byte: ", relative_path, + ", read=", tail_read); + return false; + } + if (tail[0] == 10) { + FileClose(handle); + return true; + } + uchar bytes[]; ArrayResize(bytes, (int)size); + FileSeek(handle, 0, SEEK_SET); uint read = FileReadArray(handle, bytes, 0, (int)size); FileClose(handle); if (read != (uint)size) { @@ -223,37 +298,70 @@ private: relative_path, ", error=", GetLastError()); return false; } - if (keep > 0) - FileWriteArray(handle, bytes, 0, keep); + if (keep > 0) { + uint written = FileWriteArray(handle, bytes, 0, keep); + if (written != (uint)keep) { + FileClose(handle); + Print("OptionX: could not rewrite command log after tail repair: ", + relative_path, ", written=", written, ", expected=", keep); + return false; + } + } FileFlush(handle); FileClose(handle); Print("OptionX: repaired incomplete command log tail, kept bytes=", keep); return true; } - bool AppendLine(const string relative_path, const string line) const { - if (!EnsureClientRoot()) return false; - if (StringLen(line) > m_max_line_chars) { - Print("OptionX: command line exceeds configured character limit: ", - StringLen(line)); + bool EnsureCommandLogCapacityUnlocked( + const int append_bytes, + const long visible_max, + const long checkpoint) { + if (append_bytes > m_max_command_log_bytes) { + Print("OptionX: command line would exceed configured command log byte limit: ", + append_bytes); return false; } - if (!RepairIncompleteTail(relative_path)) + + long current_size = 0; + if (!CurrentCommandLogSize(current_size)) return false; + if (current_size > m_max_command_log_bytes) { + Print("OptionX: command log is larger than configured limit: ", current_size); + return false; + } + if (current_size + append_bytes <= m_max_command_log_bytes) + return true; + + if (visible_max > 0 && checkpoint >= visible_max) { + if (!ClearCommandsUnlocked()) + return false; + current_size = 0; + if (current_size + append_bytes <= m_max_command_log_bytes) + return true; + } + + Print("OptionX: command log would exceed configured byte limit; ", + "wait for bridge checkpoint or clean up commands.ndjson."); + return false; + } + + bool AppendLineBytesUnlocked( + const string relative_path, + uchar &payload[], + const int payload_size) const { + if (!EnsureClientRoot()) return false; ResetLastError(); int handle = FileOpen( relative_path, - TextReadWriteFlags(), - 0, - CP_UTF8); + FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE); if (handle == INVALID_HANDLE) { ResetLastError(); handle = FileOpen( relative_path, - TextWriteFlags(), - 0, - CP_UTF8); + BinaryWriteFlags()); } if (handle == INVALID_HANDLE) { Print("OptionX: could not open command log: ", relative_path, @@ -262,7 +370,13 @@ private: } FileSeek(handle, 0, SEEK_END); - FileWriteString(handle, line + "\n"); + uint written = FileWriteArray(handle, payload, 0, payload_size); + if (written != (uint)payload_size) { + FileClose(handle); + Print("OptionX: incomplete command log write: written=", written, + ", expected=", payload_size); + return false; + } FileFlush(handle); FileClose(handle); return true; @@ -416,27 +530,56 @@ private: Print("OptionX: file bridge is not configured."); return false; } - if (m_next_file_seq <= 0) { + + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { if (!RepairIncompleteTail(CommandsPath())) - return false; + break; + long visible_max = 0; long checkpoint = 0; if (!TryMaxFileSeqInCommands(visible_max) || !TryLastCheckpointSeq(checkpoint)) { Print("OptionX: command sequence recovery failed; command was not written."); - return false; + break; } - m_next_file_seq = MathMax(visible_max, checkpoint) + 1; - } - string line = RequestEnvelope(m_next_file_seq, id, method, params); - if (!AppendLine(CommandsPath(), line)) - return false; + long recovered_next = MathMax(visible_max, checkpoint) + 1; + if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) + m_next_file_seq = recovered_next; - Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, - " id=", id); - ++m_next_file_seq; - return true; + string line = RequestEnvelope(m_next_file_seq, id, method, params); + uchar line_bytes[]; + int line_byte_count = 0; + if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) + break; + if (line_byte_count > m_max_line_bytes) { + Print("OptionX: command line exceeds configured byte limit: ", + line_byte_count); + break; + } + + uchar payload[]; + int payload_size = 0; + if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) + break; + if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) + break; + if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + break; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + ok = true; + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } public: @@ -445,7 +588,7 @@ public: m_client_id = "default"; m_namespace_subdir = "OptionX\\Bridge\\v1"; m_default_valid_for_ms = 60000; - m_max_line_chars = 65536; + m_max_line_bytes = 65536; m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; m_operation_counter = 0; @@ -456,7 +599,9 @@ public: const int bridge_id, const string client_id, const string namespace_subdir = "OptionX\\Bridge\\v1", - const int default_valid_for_ms = 60000) { + const int default_valid_for_ms = 60000, + const int max_line_bytes = 65536, + const int max_command_log_bytes = 8388608) { if (bridge_id <= 0) { Print("OptionX: bridge_id must be positive."); m_configured = false; @@ -477,10 +622,22 @@ public: m_configured = false; return false; } + if (max_line_bytes <= 0) { + Print("OptionX: max_line_bytes must be positive."); + m_configured = false; + return false; + } + if (max_command_log_bytes < max_line_bytes) { + Print("OptionX: max_command_log_bytes must be at least max_line_bytes."); + m_configured = false; + return false; + } m_bridge_id = bridge_id; m_client_id = client_id; m_namespace_subdir = NormalizePath(namespace_subdir); m_default_valid_for_ms = default_valid_for_ms; + m_max_line_bytes = max_line_bytes; + m_max_command_log_bytes = max_command_log_bytes; m_next_file_seq = 0; m_configured = true; return true; @@ -575,35 +732,39 @@ public: return AppendRequest(operation_key, "trade.open", params); } - bool CleanupCommandsIfCheckpointCaughtUp() const { - long max_seq = 0; - if (!TryMaxFileSeqInCommands(max_seq)) - return false; - if (max_seq <= 0) + bool CleanupCommandsIfCheckpointCaughtUp() { + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) return false; - long checkpoint = 0; - if (!TryLastCheckpointSeq(checkpoint)) - return false; - if (checkpoint < max_seq) { - Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, - ", visible_max=", max_seq); - return false; - } + bool ok = false; + do { + if (!RepairIncompleteTail(CommandsPath())) + break; + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + break; + if (max_seq <= 0) + break; - int handle = FileOpen( - CommandsPath(), - TextWriteFlags(), - 0, - CP_UTF8); - if (handle == INVALID_HANDLE) { - Print("OptionX: could not clear command log: ", CommandsPath(), - ", error=", GetLastError()); - return false; - } - FileClose(handle); - Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); - return true; + long checkpoint = 0; + if (!TryLastCheckpointSeq(checkpoint)) + break; + if (checkpoint < max_seq) { + Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, + ", visible_max=", max_seq); + break; + } + + if (!ClearCommandsUnlocked()) + break; + m_next_file_seq = checkpoint + 1; + Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); + ok = true; + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } }; diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh index d430d62..9998ed5 100644 --- a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -10,7 +10,7 @@ private: string m_client_id; string m_namespace_subdir; int m_default_valid_for_ms; - int m_max_line_chars; + int m_max_line_bytes; int m_max_command_log_bytes; long m_next_file_seq; long m_operation_counter; @@ -152,16 +152,6 @@ private: FILE_SHARE_READ | FILE_SHARE_WRITE; } - int TextWriteFlags() const { - return FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | - FILE_SHARE_READ | FILE_SHARE_WRITE; - } - - int TextReadWriteFlags() const { - return FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON | - FILE_SHARE_READ | FILE_SHARE_WRITE; - } - int BinaryReadFlags() const { return FILE_READ | FILE_BIN | FILE_COMMON | FILE_SHARE_READ | FILE_SHARE_WRITE; @@ -172,6 +162,75 @@ private: FILE_SHARE_READ | FILE_SHARE_WRITE; } + int ExclusiveBinaryLockFlags() const { + return FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON; + } + + string CommandsLockPath() const { + return JoinPath(ClientRoot(), "commands.ndjson.lock"); + } + + int AcquireCommandLock() const { + if (!EnsureClientRoot()) return INVALID_HANDLE; + + for (int attempt = 0; attempt < 256; ++attempt) { + ResetLastError(); + int handle = FileOpen(CommandsLockPath(), ExclusiveBinaryLockFlags()); + if (handle != INVALID_HANDLE) + return handle; + } + + Print("OptionX: could not acquire command log lock: ", CommandsLockPath(), + ", error=", GetLastError()); + return INVALID_HANDLE; + } + + void ReleaseCommandLock(const int handle) const { + if (handle != INVALID_HANDLE) + FileClose(handle); + } + + bool EncodeUtf8Bytes(const string text, uchar &bytes[], int &byte_count) const { + ArrayResize(bytes, 0); + byte_count = StringToCharArray(text, bytes, 0, StringLen(text), CP_UTF8); + if (byte_count < 0) { + Print("OptionX: could not encode command line as UTF-8."); + return false; + } + ArrayResize(bytes, byte_count); + return true; + } + + bool CurrentCommandLogSize(long &size) const { + size = 0; + if (!FileIsExist(CommandsPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen(CommandsPath(), BinaryReadFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not inspect command log size: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + size = (long)FileSize(handle); + FileClose(handle); + return true; + } + + bool ClearCommandsUnlocked() const { + ResetLastError(); + int handle = FileOpen(CommandsPath(), BinaryWriteFlags()); + if (handle == INVALID_HANDLE) { + Print("OptionX: could not clear command log: ", CommandsPath(), + ", error=", GetLastError()); + return false; + } + FileFlush(handle); + FileClose(handle); + return true; + } + bool RepairIncompleteTail(const string relative_path) const { if (!FileIsExist(relative_path, FILE_COMMON)) return true; @@ -195,8 +254,24 @@ private: return false; } + uchar tail[]; + ArrayResize(tail, 1); + FileSeek(handle, size - 1, SEEK_SET); + uint tail_read = FileReadArray(handle, tail, 0, 1); + if (tail_read != 1) { + FileClose(handle); + Print("OptionX: could not read command log tail byte: ", relative_path, + ", read=", tail_read); + return false; + } + if (tail[0] == 10) { + FileClose(handle); + return true; + } + uchar bytes[]; ArrayResize(bytes, (int)size); + FileSeek(handle, 0, SEEK_SET); uint read = FileReadArray(handle, bytes, 0, (int)size); FileClose(handle); if (read != (uint)size) { @@ -223,37 +298,70 @@ private: relative_path, ", error=", GetLastError()); return false; } - if (keep > 0) - FileWriteArray(handle, bytes, 0, keep); + if (keep > 0) { + uint written = FileWriteArray(handle, bytes, 0, keep); + if (written != (uint)keep) { + FileClose(handle); + Print("OptionX: could not rewrite command log after tail repair: ", + relative_path, ", written=", written, ", expected=", keep); + return false; + } + } FileFlush(handle); FileClose(handle); Print("OptionX: repaired incomplete command log tail, kept bytes=", keep); return true; } - bool AppendLine(const string relative_path, const string line) const { - if (!EnsureClientRoot()) return false; - if (StringLen(line) > m_max_line_chars) { - Print("OptionX: command line exceeds configured character limit: ", - StringLen(line)); + bool EnsureCommandLogCapacityUnlocked( + const int append_bytes, + const long visible_max, + const long checkpoint) { + if (append_bytes > m_max_command_log_bytes) { + Print("OptionX: command line would exceed configured command log byte limit: ", + append_bytes); return false; } - if (!RepairIncompleteTail(relative_path)) + + long current_size = 0; + if (!CurrentCommandLogSize(current_size)) return false; + if (current_size > m_max_command_log_bytes) { + Print("OptionX: command log is larger than configured limit: ", current_size); + return false; + } + if (current_size + append_bytes <= m_max_command_log_bytes) + return true; + + if (visible_max > 0 && checkpoint >= visible_max) { + if (!ClearCommandsUnlocked()) + return false; + current_size = 0; + if (current_size + append_bytes <= m_max_command_log_bytes) + return true; + } + + Print("OptionX: command log would exceed configured byte limit; ", + "wait for bridge checkpoint or clean up commands.ndjson."); + return false; + } + + bool AppendLineBytesUnlocked( + const string relative_path, + uchar &payload[], + const int payload_size) const { + if (!EnsureClientRoot()) return false; ResetLastError(); int handle = FileOpen( relative_path, - TextReadWriteFlags(), - 0, - CP_UTF8); + FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE); if (handle == INVALID_HANDLE) { ResetLastError(); handle = FileOpen( relative_path, - TextWriteFlags(), - 0, - CP_UTF8); + BinaryWriteFlags()); } if (handle == INVALID_HANDLE) { Print("OptionX: could not open command log: ", relative_path, @@ -262,7 +370,13 @@ private: } FileSeek(handle, 0, SEEK_END); - FileWriteString(handle, line + "\n"); + uint written = FileWriteArray(handle, payload, 0, payload_size); + if (written != (uint)payload_size) { + FileClose(handle); + Print("OptionX: incomplete command log write: written=", written, + ", expected=", payload_size); + return false; + } FileFlush(handle); FileClose(handle); return true; @@ -416,27 +530,56 @@ private: Print("OptionX: file bridge is not configured."); return false; } - if (m_next_file_seq <= 0) { + + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { if (!RepairIncompleteTail(CommandsPath())) - return false; + break; + long visible_max = 0; long checkpoint = 0; if (!TryMaxFileSeqInCommands(visible_max) || !TryLastCheckpointSeq(checkpoint)) { Print("OptionX: command sequence recovery failed; command was not written."); - return false; + break; } - m_next_file_seq = MathMax(visible_max, checkpoint) + 1; - } - string line = RequestEnvelope(m_next_file_seq, id, method, params); - if (!AppendLine(CommandsPath(), line)) - return false; + long recovered_next = MathMax(visible_max, checkpoint) + 1; + if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) + m_next_file_seq = recovered_next; - Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, - " id=", id); - ++m_next_file_seq; - return true; + string line = RequestEnvelope(m_next_file_seq, id, method, params); + uchar line_bytes[]; + int line_byte_count = 0; + if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) + break; + if (line_byte_count > m_max_line_bytes) { + Print("OptionX: command line exceeds configured byte limit: ", + line_byte_count); + break; + } + + uchar payload[]; + int payload_size = 0; + if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) + break; + if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) + break; + if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + break; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + ok = true; + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } public: @@ -445,7 +588,7 @@ public: m_client_id = "default"; m_namespace_subdir = "OptionX\\Bridge\\v1"; m_default_valid_for_ms = 60000; - m_max_line_chars = 65536; + m_max_line_bytes = 65536; m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; m_operation_counter = 0; @@ -456,7 +599,9 @@ public: const int bridge_id, const string client_id, const string namespace_subdir = "OptionX\\Bridge\\v1", - const int default_valid_for_ms = 60000) { + const int default_valid_for_ms = 60000, + const int max_line_bytes = 65536, + const int max_command_log_bytes = 8388608) { if (bridge_id <= 0) { Print("OptionX: bridge_id must be positive."); m_configured = false; @@ -477,10 +622,22 @@ public: m_configured = false; return false; } + if (max_line_bytes <= 0) { + Print("OptionX: max_line_bytes must be positive."); + m_configured = false; + return false; + } + if (max_command_log_bytes < max_line_bytes) { + Print("OptionX: max_command_log_bytes must be at least max_line_bytes."); + m_configured = false; + return false; + } m_bridge_id = bridge_id; m_client_id = client_id; m_namespace_subdir = NormalizePath(namespace_subdir); m_default_valid_for_ms = default_valid_for_ms; + m_max_line_bytes = max_line_bytes; + m_max_command_log_bytes = max_command_log_bytes; m_next_file_seq = 0; m_configured = true; return true; @@ -575,35 +732,39 @@ public: return AppendRequest(operation_key, "trade.open", params); } - bool CleanupCommandsIfCheckpointCaughtUp() const { - long max_seq = 0; - if (!TryMaxFileSeqInCommands(max_seq)) - return false; - if (max_seq <= 0) + bool CleanupCommandsIfCheckpointCaughtUp() { + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) return false; - long checkpoint = 0; - if (!TryLastCheckpointSeq(checkpoint)) - return false; - if (checkpoint < max_seq) { - Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, - ", visible_max=", max_seq); - return false; - } + bool ok = false; + do { + if (!RepairIncompleteTail(CommandsPath())) + break; + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + break; + if (max_seq <= 0) + break; - int handle = FileOpen( - CommandsPath(), - TextWriteFlags(), - 0, - CP_UTF8); - if (handle == INVALID_HANDLE) { - Print("OptionX: could not clear command log: ", CommandsPath(), - ", error=", GetLastError()); - return false; - } - FileClose(handle); - Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); - return true; + long checkpoint = 0; + if (!TryLastCheckpointSeq(checkpoint)) + break; + if (checkpoint < max_seq) { + Print("OptionX: command cleanup skipped, checkpoint=", checkpoint, + ", visible_max=", max_seq); + break; + } + + if (!ClearCommandsUnlocked()) + break; + m_next_file_seq = checkpoint + 1; + Print("OptionX: cleared commands.ndjson after checkpoint ", checkpoint); + ok = true; + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } }; diff --git a/tests/metatrader_file_command_writer_test.cpp b/tests/metatrader_file_command_writer_test.cpp index 8ac0661..1828f05 100644 --- a/tests/metatrader_file_command_writer_test.cpp +++ b/tests/metatrader_file_command_writer_test.cpp @@ -42,6 +42,27 @@ optionx::bridges::metatrader_file::MetaTraderFileBridgeConfig make_config( return config; } +std::string make_filler_line( + const std::uint64_t file_seq, + const std::size_t target_line_bytes) { + const std::string prefix = + "{\"file_seq\":" + std::to_string(file_seq) + + ",\"jsonrpc\":\"2.0\",\"id\":\"fill\",\"method\":\"account.balance.get\",\"params\":{\"padding\":\""; + const std::string suffix = "\"}}"; + if (target_line_bytes <= prefix.size() + suffix.size()) { + throw std::runtime_error("filler line target is too small"); + } + return prefix + std::string(target_line_bytes - prefix.size() - suffix.size(), 'x') + suffix; +} + +void write_text_append( + const std::filesystem::path& file, + const std::string& text) { + std::ofstream out(file, std::ios::binary | std::ios::app); + ASSERT_TRUE(out); + out << text; +} + } // namespace TEST(MetaTraderFileCommandWriter, AppendsCanonicalCommands) { @@ -231,6 +252,110 @@ TEST(MetaTraderFileCommandWriter, SerializesConcurrentAppends) { EXPECT_EQ(*sequences.rbegin(), static_cast(command_count)); } +TEST(MetaTraderFileCommandWriter, RejectsAppendThatWouldExceedCommandLogLimit) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + auto config = make_config(root); + config.max_line_bytes = 512; + config.max_command_log_bytes = config.max_line_bytes; + const auto layout = protocol::make_layout(config); + protocol::ensure_runtime_directories(layout); + write_text_append(layout.commands_log(), make_filler_line(1, config.max_line_bytes - 1) + "\n"); + ASSERT_EQ(std::filesystem::file_size(layout.commands_log()), config.max_command_log_bytes); + + mtfile::MetaTraderFileCommandWriter writer(config); + EXPECT_THROW( + static_cast(writer.account_balance_get({}, "cmd-over-limit")), + std::runtime_error); + + const auto records = protocol::read_ndjson_since_file_seq( + layout.commands_log(), + 0, + config.max_line_bytes); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0].file_seq, 1u); +} + +TEST(MetaTraderFileCommandWriter, ClearsCaughtUpFullLogBeforeAppend) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + auto config = make_config(root); + config.max_line_bytes = 512; + config.max_command_log_bytes = config.max_line_bytes; + const auto layout = protocol::make_layout(config); + protocol::ensure_runtime_directories(layout); + write_text_append(layout.commands_log(), make_filler_line(1, config.max_line_bytes - 1) + "\n"); + protocol::write_json_file_atomic(layout.commands_checkpoint(), protocol::make_log_checkpoint(1)); + + mtfile::MetaTraderFileCommandWriter writer(config); + const auto written = writer.account_balance_get({}, "cmd-after-cleanup"); + EXPECT_EQ(written.file_seq, 2u); + + const auto records = protocol::read_ndjson_since_file_seq( + layout.commands_log(), + 0, + config.max_line_bytes); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0].file_seq, 2u); + EXPECT_EQ(records[0].document.at("id").get(), "cmd-after-cleanup"); +} + +TEST(MetaTraderFileCommandWriter, SerializesIndependentWriterInstances) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + const auto config = make_config(root); + std::mutex errors_mutex; + std::vector errors; + std::vector threads; + + constexpr int command_count = 24; + threads.reserve(command_count); + for (int i = 0; i < command_count; ++i) { + threads.emplace_back([config, &errors, &errors_mutex, i]() { + try { + mtfile::MetaTraderFileCommandWriter writer(config); + writer.account_balance_get({}, "cmd-instance-" + std::to_string(i)); + } catch (const std::exception& ex) { + std::lock_guard lock(errors_mutex); + errors.push_back(ex.what()); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + + if (!errors.empty()) { + FAIL() << errors.front(); + } + + const auto records = protocol::read_ndjson_since_file_seq( + protocol::make_layout(config).commands_log(), + 0, + config.max_line_bytes); + ASSERT_EQ(records.size(), static_cast(command_count)); + + std::set sequences; + for (const auto& record : records) { + sequences.insert(record.file_seq); + } + EXPECT_EQ(sequences.size(), static_cast(command_count)); + EXPECT_EQ(*sequences.begin(), 1u); + EXPECT_EQ(*sequences.rbegin(), static_cast(command_count)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From e457b7a90bb14504050ffe3495f762b40585ca86 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 12:57:40 +0300 Subject: [PATCH 06/11] fix(bridges): guard in-process file log locks --- .../optionx_cpp/bridges/metatrader_file.hpp | 1 + .../detail/MetaTraderFileProtocol.hpp | 44 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/include/optionx_cpp/bridges/metatrader_file.hpp b/include/optionx_cpp/bridges/metatrader_file.hpp index 451f82f..9de1afc 100644 --- a/include/optionx_cpp/bridges/metatrader_file.hpp +++ b/include/optionx_cpp/bridges/metatrader_file.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include diff --git a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp index acde0db..0017276 100644 --- a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp @@ -218,11 +218,45 @@ namespace optionx::bridges::metatrader_file::detail { std::filesystem::remove(m_lock_file, ec); } #endif + release_process_lock(); } private: - bool try_acquire(std::error_code& ec) noexcept { + bool try_acquire_process_lock() { + std::lock_guard lock(process_lock_mutex()); + if (process_locks().count(m_process_lock_key) != 0) { + return false; + } + process_locks().insert(m_process_lock_key); + m_process_lock_acquired = true; + return true; + } + + void release_process_lock() noexcept { + if (!m_process_lock_acquired) { + return; + } + std::lock_guard lock(process_lock_mutex()); + process_locks().erase(m_process_lock_key); + m_process_lock_acquired = false; + } + + static std::mutex& process_lock_mutex() { + static std::mutex mutex; + return mutex; + } + + static std::set& process_locks() { + static std::set locks; + return locks; + } + + bool try_acquire(std::error_code& ec) { ec.clear(); + if (!try_acquire_process_lock()) { + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + return false; + } #if defined(_WIN32) HANDLE handle = ::CreateFileW( m_lock_file.c_str(), @@ -234,6 +268,7 @@ namespace optionx::bridges::metatrader_file::detail { nullptr); if (handle == INVALID_HANDLE_VALUE) { ec = std::error_code(static_cast(::GetLastError()), std::system_category()); + release_process_lock(); return false; } m_handle = handle; @@ -242,6 +277,7 @@ namespace optionx::bridges::metatrader_file::detail { const int fd = ::open(m_lock_file.c_str(), O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); if (fd < 0) { ec = std::error_code(errno, std::generic_category()); + release_process_lock(); return false; } struct flock lock {}; @@ -250,17 +286,23 @@ namespace optionx::bridges::metatrader_file::detail { if (::fcntl(fd, F_SETLK, &lock) != 0) { ec = std::error_code(errno, std::generic_category()); ::close(fd); + release_process_lock(); return false; } m_fd = fd; return true; #else m_owns_directory_lock = std::filesystem::create_directory(m_lock_file, ec); + if (!m_owns_directory_lock) { + release_process_lock(); + } return m_owns_directory_lock; #endif } std::filesystem::path m_lock_file; + std::string m_process_lock_key = m_lock_file.lexically_normal().u8string(); + bool m_process_lock_acquired = false; #if defined(_WIN32) HANDLE m_handle = INVALID_HANDLE_VALUE; #elif defined(__unix__) || defined(__APPLE__) From fac20cda3835be7023d010149eefefb042379e94 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 13:54:50 +0300 Subject: [PATCH 07/11] fix(mql): derive operation keys from file sequence --- .../file-transport-and-adapters.md | 11 + .../file-transport-and-adapters.ru.md | 12 + .../Include/OptionX/OptionXFileBridge.mqh | 211 ++++++++++++------ .../OptionXFileBridgeSignalExample.mq4 | 2 - .../Include/OptionX/OptionXFileBridge.mqh | 211 ++++++++++++------ .../OptionXFileBridgeSignalExample.mq5 | 2 - 6 files changed, 301 insertions(+), 148 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 8e7dcd1..7ed3474 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -142,6 +142,17 @@ send an `account.balance.get` request and submit a simple signal for the current chart symbol. `trade.open` is available behind an input flag so the example does not open a direct trade accidentally. +When the caller does not pass an explicit `operation_key`, the MQL header +generates it after reserving `file_seq` under the command-log lock: + +```text +mql::: +``` + +This makes automatically generated JSON-RPC `id`, `context.idempotency_key` and +`identity.unique_hash` deterministic for the exact transport record and avoids +depending on MetaTrader's process-local `MathRand()` sequence. + The MQL header opens text files with `CP_UTF8` and shared read/write flags. It also repairs an incomplete tail before appending the first new command after a restart. If `commands.checkpoint.json` exists but is unreadable or malformed, or diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 7dea133..6f99fc3 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -136,6 +136,18 @@ Examples пишут через `Print` resolved client root и command log path, `trade.open` доступен через input flag, чтобы example случайно не открывал direct trade. +Если caller не передал explicit `operation_key`, MQL header генерирует его +после резервирования `file_seq` под command-log lock: + +```text +mql::: +``` + +Это делает automatically generated JSON-RPC `id`, +`context.idempotency_key` и `identity.unique_hash` детерминированными для +конкретной transport record и не зависит от MetaTrader process-local +`MathRand()` sequence. + MQL header открывает text files с `CP_UTF8` и shared read/write flags. Он также ремонтирует incomplete tail перед первым новым append после restart. Если `commands.checkpoint.json` существует, но не читается или malformed, либо если diff --git a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh index 9998ed5..7497be1 100644 --- a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -13,7 +13,6 @@ private: int m_max_line_bytes; int m_max_command_log_bytes; long m_next_file_seq; - long m_operation_counter; bool m_configured; string NormalizePath(string value) const { @@ -103,15 +102,6 @@ private: return out; } - string RandomBase36(const int length) const { - string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; - string out = ""; - for (int i = 0; i < length; ++i) { - out += StringSubstr(digits, MathRand() % 36, 1); - } - return out; - } - long UnixTimeMs() const { return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); } @@ -525,7 +515,66 @@ private: return params; } - bool AppendRequest(const string id, const string method, const string params) { + string MakeOperationKey(const long file_seq, const string prefix = "mql") const { + return prefix + ":" + + IntegerToString(m_bridge_id) + ":" + + m_client_id + ":" + + Base36Encode(file_seq); + } + + bool RecoverNextFileSeqUnlocked() { + if (!RepairIncompleteTail(CommandsPath())) + return false; + + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) { + Print("OptionX: command sequence recovery failed; command was not written."); + return false; + } + + long recovered_next = MathMax(visible_max, checkpoint) + 1; + if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) + m_next_file_seq = recovered_next; + return true; + } + + bool AppendPreparedRequestUnlocked( + const string id, + const string method, + const string params, + const long visible_max, + const long checkpoint) { + string line = RequestEnvelope(m_next_file_seq, id, method, params); + uchar line_bytes[]; + int line_byte_count = 0; + if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) + return false; + if (line_byte_count > m_max_line_bytes) { + Print("OptionX: command line exceeds configured byte limit: ", + line_byte_count); + return false; + } + + uchar payload[]; + int payload_size = 0; + if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) + return false; + if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) + return false; + if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + return false; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + return true; + } + + bool AppendAccountBalanceGetRequest( + const string account_id, + string operation_key) { if (!m_configured) { Print("OptionX: file bridge is not configured."); return false; @@ -537,45 +586,26 @@ private: bool ok = false; do { - if (!RepairIncompleteTail(CommandsPath())) + if (!RecoverNextFileSeqUnlocked()) break; + if (operation_key == "") + operation_key = MakeOperationKey(m_next_file_seq); long visible_max = 0; long checkpoint = 0; if (!TryMaxFileSeqInCommands(visible_max) || - !TryLastCheckpointSeq(checkpoint)) { - Print("OptionX: command sequence recovery failed; command was not written."); - break; - } - - long recovered_next = MathMax(visible_max, checkpoint) + 1; - if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) - m_next_file_seq = recovered_next; - - string line = RequestEnvelope(m_next_file_seq, id, method, params); - uchar line_bytes[]; - int line_byte_count = 0; - if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) - break; - if (line_byte_count > m_max_line_bytes) { - Print("OptionX: command line exceeds configured byte limit: ", - line_byte_count); - break; - } - - uchar payload[]; - int payload_size = 0; - if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) - break; - if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) - break; - if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + !TryLastCheckpointSeq(checkpoint)) break; - Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, - " id=", id); - ++m_next_file_seq; - ok = true; + string params = "{}"; + if (account_id != "") + params = "{\"account_id\":" + JsonString(account_id) + "}"; + ok = AppendPreparedRequestUnlocked( + operation_key, + "account.balance.get", + params, + visible_max, + checkpoint); } while (false); ReleaseCommandLock(lock_handle); @@ -591,7 +621,6 @@ public: m_max_line_bytes = 65536; m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; - m_operation_counter = 0; m_configured = true; } @@ -657,23 +686,69 @@ public: return JoinPath(ClientRoot(), "commands.checkpoint.json"); } - string MakeOperationKey(const string prefix = "mql") { - string key = prefix + "_" + - Base36Encode(UnixTimeMs()) + "_" + - RandomBase36(16) + "_" + - Base36Encode(m_operation_counter); - ++m_operation_counter; - return key; +private: + bool AppendTradeRequest( + const string method, + const string section_name, + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id, + string operation_key, + const int valid_for_ms) { + if (!m_configured) { + Print("OptionX: file bridge is not configured."); + return false; + } + + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { + if (!RecoverNextFileSeqUnlocked()) + break; + if (operation_key == "") + operation_key = MakeOperationKey(m_next_file_seq); + + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) + break; + + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + section_name, + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + ok = AppendPreparedRequestUnlocked( + operation_key, + method, + params, + visible_max, + checkpoint); + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } +public: bool AccountBalanceGet(const string account_id = "", string operation_key = "") { - if (operation_key == "") - operation_key = MakeOperationKey(); - - string params = "{}"; - if (account_id != "") - params = "{\"account_id\":" + JsonString(account_id) + "}"; - return AppendRequest(operation_key, "account.balance.get", params); + return AppendAccountBalanceGetRequest(account_id, operation_key); } bool SignalSubmit( @@ -686,11 +761,8 @@ public: const string account_id = "", string operation_key = "", const int valid_for_ms = 0) { - if (operation_key == "") - operation_key = MakeOperationKey(); - int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; - long valid_until_ms = UnixTimeMs() + lifetime; - string params = TradeParams( + return AppendTradeRequest( + "signal.submit", "signal", symbol, order_type, @@ -700,8 +772,7 @@ public: signal_name, account_id, operation_key, - valid_until_ms); - return AppendRequest(operation_key, "signal.submit", params); + valid_for_ms); } bool TradeOpen( @@ -714,11 +785,8 @@ public: const string account_id = "", string operation_key = "", const int valid_for_ms = 0) { - if (operation_key == "") - operation_key = MakeOperationKey(); - int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; - long valid_until_ms = UnixTimeMs() + lifetime; - string params = TradeParams( + return AppendTradeRequest( + "trade.open", "trade", symbol, order_type, @@ -728,8 +796,7 @@ public: signal_name, account_id, operation_key, - valid_until_ms); - return AppendRequest(operation_key, "trade.open", params); + valid_for_ms); } bool CleanupCommandsIfCheckpointCaughtUp() { diff --git a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 index 19f093b..5d63e28 100644 --- a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -14,8 +14,6 @@ extern bool InpSendTradeOpen = false; COptionXFileBridge g_optionx; int OnInit() { - MathSrand((int)GetTickCount()); - if (!g_optionx.Configure(InpBridgeId, InpClientId)) return INIT_FAILED; Print("OptionX MT4 file bridge example"); diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh index 9998ed5..7497be1 100644 --- a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -13,7 +13,6 @@ private: int m_max_line_bytes; int m_max_command_log_bytes; long m_next_file_seq; - long m_operation_counter; bool m_configured; string NormalizePath(string value) const { @@ -103,15 +102,6 @@ private: return out; } - string RandomBase36(const int length) const { - string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; - string out = ""; - for (int i = 0; i < length; ++i) { - out += StringSubstr(digits, MathRand() % 36, 1); - } - return out; - } - long UnixTimeMs() const { return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); } @@ -525,7 +515,66 @@ private: return params; } - bool AppendRequest(const string id, const string method, const string params) { + string MakeOperationKey(const long file_seq, const string prefix = "mql") const { + return prefix + ":" + + IntegerToString(m_bridge_id) + ":" + + m_client_id + ":" + + Base36Encode(file_seq); + } + + bool RecoverNextFileSeqUnlocked() { + if (!RepairIncompleteTail(CommandsPath())) + return false; + + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) { + Print("OptionX: command sequence recovery failed; command was not written."); + return false; + } + + long recovered_next = MathMax(visible_max, checkpoint) + 1; + if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) + m_next_file_seq = recovered_next; + return true; + } + + bool AppendPreparedRequestUnlocked( + const string id, + const string method, + const string params, + const long visible_max, + const long checkpoint) { + string line = RequestEnvelope(m_next_file_seq, id, method, params); + uchar line_bytes[]; + int line_byte_count = 0; + if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) + return false; + if (line_byte_count > m_max_line_bytes) { + Print("OptionX: command line exceeds configured byte limit: ", + line_byte_count); + return false; + } + + uchar payload[]; + int payload_size = 0; + if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) + return false; + if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) + return false; + if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + return false; + + Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, + " id=", id); + ++m_next_file_seq; + return true; + } + + bool AppendAccountBalanceGetRequest( + const string account_id, + string operation_key) { if (!m_configured) { Print("OptionX: file bridge is not configured."); return false; @@ -537,45 +586,26 @@ private: bool ok = false; do { - if (!RepairIncompleteTail(CommandsPath())) + if (!RecoverNextFileSeqUnlocked()) break; + if (operation_key == "") + operation_key = MakeOperationKey(m_next_file_seq); long visible_max = 0; long checkpoint = 0; if (!TryMaxFileSeqInCommands(visible_max) || - !TryLastCheckpointSeq(checkpoint)) { - Print("OptionX: command sequence recovery failed; command was not written."); - break; - } - - long recovered_next = MathMax(visible_max, checkpoint) + 1; - if (m_next_file_seq <= 0 || recovered_next > m_next_file_seq) - m_next_file_seq = recovered_next; - - string line = RequestEnvelope(m_next_file_seq, id, method, params); - uchar line_bytes[]; - int line_byte_count = 0; - if (!EncodeUtf8Bytes(line, line_bytes, line_byte_count)) - break; - if (line_byte_count > m_max_line_bytes) { - Print("OptionX: command line exceeds configured byte limit: ", - line_byte_count); - break; - } - - uchar payload[]; - int payload_size = 0; - if (!EncodeUtf8Bytes(line + "\n", payload, payload_size)) - break; - if (!EnsureCommandLogCapacityUnlocked(payload_size, visible_max, checkpoint)) - break; - if (!AppendLineBytesUnlocked(CommandsPath(), payload, payload_size)) + !TryLastCheckpointSeq(checkpoint)) break; - Print("OptionX: wrote ", method, " file_seq=", m_next_file_seq, - " id=", id); - ++m_next_file_seq; - ok = true; + string params = "{}"; + if (account_id != "") + params = "{\"account_id\":" + JsonString(account_id) + "}"; + ok = AppendPreparedRequestUnlocked( + operation_key, + "account.balance.get", + params, + visible_max, + checkpoint); } while (false); ReleaseCommandLock(lock_handle); @@ -591,7 +621,6 @@ public: m_max_line_bytes = 65536; m_max_command_log_bytes = 8 * 1024 * 1024; m_next_file_seq = 0; - m_operation_counter = 0; m_configured = true; } @@ -657,23 +686,69 @@ public: return JoinPath(ClientRoot(), "commands.checkpoint.json"); } - string MakeOperationKey(const string prefix = "mql") { - string key = prefix + "_" + - Base36Encode(UnixTimeMs()) + "_" + - RandomBase36(16) + "_" + - Base36Encode(m_operation_counter); - ++m_operation_counter; - return key; +private: + bool AppendTradeRequest( + const string method, + const string section_name, + const string symbol, + const string order_type, + const double amount, + const string currency, + const int duration_ms, + const string signal_name, + const string account_id, + string operation_key, + const int valid_for_ms) { + if (!m_configured) { + Print("OptionX: file bridge is not configured."); + return false; + } + + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { + if (!RecoverNextFileSeqUnlocked()) + break; + if (operation_key == "") + operation_key = MakeOperationKey(m_next_file_seq); + + long visible_max = 0; + long checkpoint = 0; + if (!TryMaxFileSeqInCommands(visible_max) || + !TryLastCheckpointSeq(checkpoint)) + break; + + int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; + long valid_until_ms = UnixTimeMs() + lifetime; + string params = TradeParams( + section_name, + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_until_ms); + ok = AppendPreparedRequestUnlocked( + operation_key, + method, + params, + visible_max, + checkpoint); + } while (false); + + ReleaseCommandLock(lock_handle); + return ok; } +public: bool AccountBalanceGet(const string account_id = "", string operation_key = "") { - if (operation_key == "") - operation_key = MakeOperationKey(); - - string params = "{}"; - if (account_id != "") - params = "{\"account_id\":" + JsonString(account_id) + "}"; - return AppendRequest(operation_key, "account.balance.get", params); + return AppendAccountBalanceGetRequest(account_id, operation_key); } bool SignalSubmit( @@ -686,11 +761,8 @@ public: const string account_id = "", string operation_key = "", const int valid_for_ms = 0) { - if (operation_key == "") - operation_key = MakeOperationKey(); - int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; - long valid_until_ms = UnixTimeMs() + lifetime; - string params = TradeParams( + return AppendTradeRequest( + "signal.submit", "signal", symbol, order_type, @@ -700,8 +772,7 @@ public: signal_name, account_id, operation_key, - valid_until_ms); - return AppendRequest(operation_key, "signal.submit", params); + valid_for_ms); } bool TradeOpen( @@ -714,11 +785,8 @@ public: const string account_id = "", string operation_key = "", const int valid_for_ms = 0) { - if (operation_key == "") - operation_key = MakeOperationKey(); - int lifetime = valid_for_ms > 0 ? valid_for_ms : m_default_valid_for_ms; - long valid_until_ms = UnixTimeMs() + lifetime; - string params = TradeParams( + return AppendTradeRequest( + "trade.open", "trade", symbol, order_type, @@ -728,8 +796,7 @@ public: signal_name, account_id, operation_key, - valid_until_ms); - return AppendRequest(operation_key, "trade.open", params); + valid_for_ms); } bool CleanupCommandsIfCheckpointCaughtUp() { diff --git a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 index 4076c1b..dfadcaf 100644 --- a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -15,8 +15,6 @@ input bool InpSendTradeOpen = false; COptionXFileBridge g_optionx; int OnInit() { - MathSrand((uint)GetTickCount()); - if (!g_optionx.Configure(InpBridgeId, InpClientId)) return INIT_FAILED; Print("OptionX MT5 file bridge example"); From b67f8c07734c7adc61cfff4760d0e29728236118 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 16:11:56 +0300 Subject: [PATCH 08/11] docs(bridges): clarify metatrader command writer role --- .../file-transport-and-adapters.md | 18 ++++++++++-------- .../file-transport-and-adapters.ru.md | 14 ++++++++------ .../MetaTraderFileCommandWriter.hpp | 13 +++++++------ 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 7ed3474..39cd270 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -67,8 +67,9 @@ file-transport building blocks: - `bridges/metatrader_file.hpp` as the umbrella include; - `MetaTraderFileBridgeConfig` for the MetaQuotes Common Files root, client directory identity, polling interval and NDJSON line limits; -- `MetaTraderFileCommandWriter` for C++/tooling clients that need to append - canonical `signal.submit`, `trade.open` and `account.balance.get` requests; +- `MetaTraderFileCommandWriter` as the C++ client-side counterpart to the MQL + `COptionXFileBridge` header; it appends canonical `signal.submit`, + `trade.open` and `account.balance.get` requests; - `metatrader_file::detail` helpers for path-safe IDs, NDJSON append/read, owner-side log cleanup, JSON-RPC request/response/notification documents, atomic state snapshots and bounded JSON reads; @@ -76,15 +77,16 @@ file-transport building blocks: location, Common Files root and terminal data directories; - small helpers for `balance.updated` and `trade.updated` notification payloads. -The bridge side is implemented by `MetaTraderFileBridge`. The writer side is -kept smaller on purpose: `MetaTraderFileCommandWriter` is a reusable command -generator, not a background transport loop. It should be used by smoke tools, -tests and C++ applications that need to create MetaTrader-compatible command -logs. +The bridge side is implemented by `MetaTraderFileBridge`. The C++ client side is +intentionally smaller: `MetaTraderFileCommandWriter` is a reusable command +writer, analogous to the MQL `COptionXFileBridge` header. It is not a background +transport loop and does not read events or state snapshots. It should be used by +smoke tools, tests and C++ applications that need to create +MetaTrader-compatible command logs. ### Writer Helpers And Smoke Generator -The C++ writer helper appends one compact JSON-RPC request per line to +The C++ client-side writer appends one compact JSON-RPC request per line to `commands.ndjson`. It assigns `file_seq`, generates compact Base36 operation keys when the caller does not provide `id` / `context.idempotency_key`, and adds `context.valid_until_ms` for trade-affecting commands. diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 6f99fc3..e4b26a5 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -64,8 +64,9 @@ Reader хранит свой checkpoint и не переписывает append- - `bridges/metatrader_file.hpp` как umbrella include; - `MetaTraderFileBridgeConfig` для MetaQuotes Common Files root, client directory identity, polling interval и NDJSON line limits; -- `MetaTraderFileCommandWriter` для C++/tooling clients, которым нужно писать - canonical `signal.submit`, `trade.open` и `account.balance.get` requests; +- `MetaTraderFileCommandWriter` как C++ client-side counterpart к MQL header + `COptionXFileBridge`; он пишет canonical `signal.submit`, `trade.open` и + `account.balance.get` requests; - helpers в `metatrader_file::detail` для path-safe IDs, NDJSON append/read, owner-side cleanup, JSON-RPC request/response/notification documents, atomic state snapshots и bounded JSON reads; @@ -73,14 +74,15 @@ Reader хранит свой checkpoint и не переписывает append- Common Files root и terminal data directories; - helpers для `balance.updated`, `trade.updated` и `state.json` payloads. -Bridge-side реализован через `MetaTraderFileBridge`. Writer-side намеренно -меньше: `MetaTraderFileCommandWriter` это reusable command generator, а не -background transport loop. Его можно использовать в smoke tools, tests и C++ +Bridge-side реализован через `MetaTraderFileBridge`. C++ client-side намеренно +меньше: `MetaTraderFileCommandWriter` это reusable command writer, аналог MQL +header `COptionXFileBridge`. Это не background transport loop: он не читает +events и state snapshots. Его можно использовать в smoke tools, tests и C++ applications, которые формируют MetaTrader-compatible command logs. ### Writer Helpers And Smoke Generator -C++ writer helper добавляет в `commands.ndjson` один compact JSON-RPC request +C++ client-side writer добавляет в `commands.ndjson` один compact JSON-RPC request на строку. Он назначает `file_seq`, генерирует compact Base36 operation keys, если caller не передал `id` / `context.idempotency_key`, и добавляет `context.valid_until_ms` для commands, влияющих на торговлю. diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp index 3704cfd..c615097 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -3,7 +3,7 @@ #define OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_COMMAND_WRITER_HPP_INCLUDED /// \file MetaTraderFileCommandWriter.hpp -/// \brief Defines writer-side helpers for MetaTrader Common\Files commands. +/// \brief Defines the C++ client-side command writer for the MetaTrader file bridge. namespace optionx::bridges::metatrader_file { @@ -36,11 +36,12 @@ namespace optionx::bridges::metatrader_file { }; /// \class MetaTraderFileCommandWriter - /// \brief Appends canonical OptionX file-transport commands for MQL/C++ clients. - /// \details This helper is the writer-side counterpart to `MetaTraderFileBridge`. - /// It owns `commands.ndjson`: it assigns monotonic `file_seq`, appends one - /// compact JSON-RPC request per line, and may clear the command log only after - /// the bridge checkpoint confirms that all visible commands were consumed. + /// \brief Writes canonical client commands for `MetaTraderFileBridge`. + /// \details This is the C++ counterpart to the MQL `COptionXFileBridge` + /// header: a lightweight client-side writer, not a bridge runtime. It owns + /// `commands.ndjson`, assigns monotonic `file_seq`, appends one compact + /// JSON-RPC request per line, and may clear the command log only after the + /// bridge checkpoint confirms that all visible commands were consumed. /// Public methods serialize object state with an internal mutex. Mutations /// of `commands.ndjson` additionally take a sibling lock file so several /// writer objects/processes can share the same client root without assigning From 61e10153e0b45d3d41bda1e42cd9ea003d4c79a0 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 16:51:08 +0300 Subject: [PATCH 09/11] fix(mql): require stable trade operation keys --- .../file-transport-and-adapters.md | 30 ++++++++++++------- .../file-transport-and-adapters.ru.md | 27 ++++++++++------- .../MetaTraderFileCommandWriter.hpp | 20 +++++++------ .../Include/OptionX/OptionXFileBridge.mqh | 9 +++--- .../OptionXFileBridgeSignalExample.mq4 | 17 +++++++++-- .../Include/OptionX/OptionXFileBridge.mqh | 11 +++---- .../OptionXFileBridgeSignalExample.mq5 | 17 +++++++++-- tests/metatrader_file_command_writer_test.cpp | 15 ++++++++++ 8 files changed, 102 insertions(+), 44 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 39cd270..995cd1f 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -87,9 +87,13 @@ MetaTrader-compatible command logs. ### Writer Helpers And Smoke Generator The C++ client-side writer appends one compact JSON-RPC request per line to -`commands.ndjson`. It assigns `file_seq`, generates compact Base36 operation -keys when the caller does not provide `id` / `context.idempotency_key`, and -adds `context.valid_until_ms` for trade-affecting commands. +`commands.ndjson`. It assigns `file_seq` and adds `context.valid_until_ms` for +trade-affecting commands. The caller must provide a stable +`context.idempotency_key` for `signal.submit` and `trade.open` and persist it +before retrying the same logical operation; `file_seq` is a transport sequence +and must not be used as the retry identity of a trade-affecting operation. +One-shot query commands such as `account.balance.get` may use a writer-generated +JSON-RPC `id`. Supported convenience methods: @@ -100,8 +104,8 @@ Supported convenience methods: The example `examples/metatrader_file_command_writer_smoke.cpp` writes one `account.balance.get`, one `signal.submit` and one `trade.open` command. In self-test mode it uses a temporary Common Files root and verifies that the -generated log contains `file_seq`, JSON-RPC `id`, `context.idempotency_key` and -`context.valid_until_ms`. +generated log contains `file_seq`, JSON-RPC `id`, caller-provided +`context.idempotency_key` and `context.valid_until_ms`. Owner-side cleanup is explicit: the writer may clear `commands.ndjson` only after `commands.checkpoint.json.last_file_seq` is greater than or equal to the @@ -144,17 +148,21 @@ send an `account.balance.get` request and submit a simple signal for the current chart symbol. `trade.open` is available behind an input flag so the example does not open a direct trade accidentally. -When the caller does not pass an explicit `operation_key`, the MQL header -generates it after reserving `file_seq` under the command-log lock: +`SignalSubmit(...)` and `TradeOpen(...)` require an explicit `operation_key`. +The MQL caller should create that key before appending the command, persist it +as part of its own retry state, and reuse the same value when retrying the same +logical signal or trade. The header uses this key as JSON-RPC `id`, +`context.idempotency_key` and, when no separate domain identity is available, +`identity.unique_hash`. + +`AccountBalanceGet(...)` remains a one-shot query helper. If the caller does not +provide an `operation_key`, the MQL header generates a transport-local request +id after reserving `file_seq` under the command-log lock: ```text mql::: ``` -This makes automatically generated JSON-RPC `id`, `context.idempotency_key` and -`identity.unique_hash` deterministic for the exact transport record and avoids -depending on MetaTrader's process-local `MathRand()` sequence. - The MQL header opens text files with `CP_UTF8` and shared read/write flags. It also repairs an incomplete tail before appending the first new command after a restart. If `commands.checkpoint.json` exists but is unreadable or malformed, or diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index e4b26a5..d3b8b34 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -83,9 +83,12 @@ applications, которые формируют MetaTrader-compatible command lo ### Writer Helpers And Smoke Generator C++ client-side writer добавляет в `commands.ndjson` один compact JSON-RPC request -на строку. Он назначает `file_seq`, генерирует compact Base36 operation keys, -если caller не передал `id` / `context.idempotency_key`, и добавляет -`context.valid_until_ms` для commands, влияющих на торговлю. +на строку. Он назначает `file_seq` и добавляет `context.valid_until_ms` для +commands, влияющих на торговлю. Caller должен передавать стабильный +`context.idempotency_key` для `signal.submit` и `trade.open` и сохранять его до +retry той же логической операции; `file_seq` является transport sequence и не +заменяет retry identity торговой операции. Одноразовые query commands, например +`account.balance.get`, могут использовать JSON-RPC `id`, сгенерированный writer. Supported convenience methods: @@ -96,7 +99,7 @@ Supported convenience methods: Example `examples/metatrader_file_command_writer_smoke.cpp` пишет один `account.balance.get`, один `signal.submit` и один `trade.open` command. В self-test mode используется temporary Common Files root и проверяется, что в log -есть `file_seq`, JSON-RPC `id`, `context.idempotency_key` и +есть `file_seq`, JSON-RPC `id`, caller-provided `context.idempotency_key` и `context.valid_until_ms`. Owner-side cleanup должен быть явным: writer может очищать `commands.ndjson` @@ -138,18 +141,20 @@ Examples пишут через `Print` resolved client root и command log path, `trade.open` доступен через input flag, чтобы example случайно не открывал direct trade. -Если caller не передал explicit `operation_key`, MQL header генерирует его -после резервирования `file_seq` под command-log lock: +`SignalSubmit(...)` и `TradeOpen(...)` требуют explicit `operation_key`. MQL +caller должен создать этот ключ до append, сохранить его в своем retry state и +повторно использовать то же значение при retry того же logical signal или +trade. Header использует этот ключ как JSON-RPC `id`, `context.idempotency_key` +и, когда нет отдельной domain identity, `identity.unique_hash`. + +`AccountBalanceGet(...)` остается one-shot query helper. Если caller не передал +`operation_key`, MQL header генерирует transport-local request id после +резервирования `file_seq` под command-log lock: ```text mql::: ``` -Это делает automatically generated JSON-RPC `id`, -`context.idempotency_key` и `identity.unique_hash` детерминированными для -конкретной transport record и не зависит от MetaTrader process-local -`MathRand()` sequence. - MQL header открывает text files с `CP_UTF8` и shared read/write flags. Он также ремонтирует incomplete tail перед первым новым append после restart. Если `commands.checkpoint.json` существует, но не читается или malformed, либо если diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp index c615097..13e4c74 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -17,10 +17,10 @@ namespace optionx::bridges::metatrader_file { std::string currency = "USD"; ///< Amount currency. std::uint64_t duration_ms = 60000; ///< Expiry duration for binary-option style trades. std::string signal_name; ///< Optional strategy/signal name. - std::string unique_hash; ///< Optional domain dedupe key; generated when empty. + std::string unique_hash; ///< Optional domain dedupe key; defaults to `idempotency_key`. std::string account_id; ///< Optional account routing target. - std::string id; ///< Optional JSON-RPC id; generated when empty. - std::string idempotency_key; ///< Optional operation key; generated when empty. + std::string id; ///< Optional JSON-RPC id; defaults to `idempotency_key`. + std::string idempotency_key; ///< Required caller-persisted retry key for trade commands. std::int64_t valid_until_ms = 0; ///< Absolute Unix deadline; generated from `valid_for_ms` when zero. std::int64_t valid_for_ms = 60000; ///< Relative command lifetime used when `valid_until_ms` is zero. }; @@ -75,9 +75,10 @@ namespace optionx::bridges::metatrader_file { } /// \brief Appends a `signal.submit` command. + /// \throws std::invalid_argument when `command.idempotency_key` is empty. MetaTraderFileWrittenCommand signal_submit(MetaTraderFileTradeCommand command) { std::lock_guard lock(m_mutex); - prepare_trade_command(command, "mql"); + prepare_trade_command(command); return append_request_locked( command.id, "signal.submit", @@ -85,9 +86,10 @@ namespace optionx::bridges::metatrader_file { } /// \brief Appends a `trade.open` command. + /// \throws std::invalid_argument when `command.idempotency_key` is empty. MetaTraderFileWrittenCommand trade_open(MetaTraderFileTradeCommand command) { std::lock_guard lock(m_mutex); - prepare_trade_command(command, "mql"); + prepare_trade_command(command); return append_request_locked( command.id, "trade.open", @@ -215,11 +217,11 @@ namespace optionx::bridges::metatrader_file { "wait for bridge checkpoint or clean up commands.ndjson."); } - static void prepare_trade_command( - MetaTraderFileTradeCommand& command, - const std::string& key_prefix) { + static void prepare_trade_command(MetaTraderFileTradeCommand& command) { if (command.idempotency_key.empty()) { - command.idempotency_key = make_compact_operation_key(key_prefix); + throw std::invalid_argument( + "MetaTrader file trade commands require a caller-provided " + "idempotency_key that can be reused on retry."); } if (command.id.empty()) { command.id = command.idempotency_key; diff --git a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh index 7497be1..5726d17 100644 --- a/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -112,8 +112,6 @@ private: if (FolderCreate(path, FILE_COMMON)) return true; int error = GetLastError(); - // MetaTrader reports an existing directory through the common - // "file/folder already exists" code on recent MT4/MT5 builds. return error == 5019; } @@ -703,6 +701,11 @@ private: Print("OptionX: file bridge is not configured."); return false; } + if (operation_key == "") { + Print("OptionX: trade-affecting commands require an explicit operation_key ", + "that the caller can reuse on retry."); + return false; + } int lock_handle = AcquireCommandLock(); if (lock_handle == INVALID_HANDLE) @@ -712,8 +715,6 @@ private: do { if (!RecoverNextFileSeqUnlocked()) break; - if (operation_key == "") - operation_key = MakeOperationKey(m_next_file_seq); long visible_max = 0; long checkpoint = 0; diff --git a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 index 5d63e28..25a04d4 100644 --- a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -20,6 +20,15 @@ int OnInit() { Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); Print("OptionX command log: ", g_optionx.CommandsPath()); + // Demo keys are visible before append. Production strategies should persist + // their operation key and reuse it when retrying the same logical command. + string operation_suffix = + Symbol() + ":" + + IntegerToString((long)TimeCurrent()) + ":" + + IntegerToString((long)GetTickCount()); + string signal_operation_key = "example:mt4:signal:" + operation_suffix; + string trade_operation_key = "example:mt4:trade:" + operation_suffix; + bool balance_ok = g_optionx.AccountBalanceGet(InpAccountId); bool signal_ok = g_optionx.SignalSubmit( Symbol(), @@ -28,7 +37,8 @@ int OnInit() { "USD", InpDurationMs, "OptionX_MQL4_Example", - InpAccountId); + InpAccountId, + signal_operation_key); bool trade_ok = true; if (InpSendTradeOpen) { @@ -39,9 +49,12 @@ int OnInit() { "USD", InpDurationMs, "OptionX_MQL4_Example", - InpAccountId); + InpAccountId, + trade_operation_key); } + Print("OptionX operation keys: signal=", signal_operation_key, + ", trade=", trade_operation_key); Print("OptionX sent commands: balance=", balance_ok, ", signal=", signal_ok, ", trade=", trade_ok); diff --git a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh index 7497be1..918e273 100644 --- a/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -112,9 +112,7 @@ private: if (FolderCreate(path, FILE_COMMON)) return true; int error = GetLastError(); - // MetaTrader reports an existing directory through the common - // "file/folder already exists" code on recent MT4/MT5 builds. - return error == 5019; + return error == 5018; } bool EnsureClientRoot() const { @@ -703,6 +701,11 @@ private: Print("OptionX: file bridge is not configured."); return false; } + if (operation_key == "") { + Print("OptionX: trade-affecting commands require an explicit operation_key ", + "that the caller can reuse on retry."); + return false; + } int lock_handle = AcquireCommandLock(); if (lock_handle == INVALID_HANDLE) @@ -712,8 +715,6 @@ private: do { if (!RecoverNextFileSeqUnlocked()) break; - if (operation_key == "") - operation_key = MakeOperationKey(m_next_file_seq); long visible_max = 0; long checkpoint = 0; diff --git a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 index dfadcaf..2c85d51 100644 --- a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -21,6 +21,15 @@ int OnInit() { Print("OptionX client root under Common\\Files: ", g_optionx.ClientRoot()); Print("OptionX command log: ", g_optionx.CommandsPath()); + // Demo keys are visible before append. Production strategies should persist + // their operation key and reuse it when retrying the same logical command. + string operation_suffix = + Symbol() + ":" + + IntegerToString((long)TimeCurrent()) + ":" + + IntegerToString((long)GetTickCount()); + string signal_operation_key = "example:mt5:signal:" + operation_suffix; + string trade_operation_key = "example:mt5:trade:" + operation_suffix; + bool balance_ok = g_optionx.AccountBalanceGet(InpAccountId); bool signal_ok = g_optionx.SignalSubmit( Symbol(), @@ -29,7 +38,8 @@ int OnInit() { "USD", InpDurationMs, "OptionX_MQL5_Example", - InpAccountId); + InpAccountId, + signal_operation_key); bool trade_ok = true; if (InpSendTradeOpen) { @@ -40,9 +50,12 @@ int OnInit() { "USD", InpDurationMs, "OptionX_MQL5_Example", - InpAccountId); + InpAccountId, + trade_operation_key); } + Print("OptionX operation keys: signal=", signal_operation_key, + ", trade=", trade_operation_key); Print("OptionX sent commands: balance=", balance_ok, ", signal=", signal_ok, ", trade=", trade_ok); diff --git a/tests/metatrader_file_command_writer_test.cpp b/tests/metatrader_file_command_writer_test.cpp index 1828f05..ed1533a 100644 --- a/tests/metatrader_file_command_writer_test.cpp +++ b/tests/metatrader_file_command_writer_test.cpp @@ -131,6 +131,21 @@ TEST(MetaTraderFileCommandWriter, AppendsCanonicalCommands) { EXPECT_EQ(records[2].document.at("params").at("account_id").get(), "7"); } +TEST(MetaTraderFileCommandWriter, RejectsTradeCommandsWithoutIdempotencyKey) { + namespace mtfile = optionx::bridges::metatrader_file; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + auto writer = mtfile::MetaTraderFileCommandWriter(make_config(root)); + + mtfile::MetaTraderFileTradeCommand command; + command.symbol = "EURUSD"; + + EXPECT_THROW(writer.signal_submit(command), std::invalid_argument); + EXPECT_THROW(writer.trade_open(command), std::invalid_argument); +} + TEST(MetaTraderFileCommandWriter, RecoversSequenceAndCleansAfterCheckpoint) { namespace mtfile = optionx::bridges::metatrader_file; namespace protocol = optionx::bridges::metatrader_file::detail; From 64747d22c407ade97063ab4fc0191af9b8c95f50 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 17:25:26 +0300 Subject: [PATCH 10/11] fix(bridges): compare stable metatrader retry payloads --- .../file-transport-and-adapters.md | 4 + .../file-transport-and-adapters.ru.md | 4 + .../metatrader_file/MetaTraderFileBridge.hpp | 62 +++++++--- .../OptionXFileBridgeSignalExample.mq4 | 1 + .../OptionXFileBridgeSignalExample.mq5 | 1 + tests/metatrader_file_bridge_test.cpp | 116 +++++++++++++++++- 6 files changed, 168 insertions(+), 20 deletions(-) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 995cd1f..7da2ae1 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -393,6 +393,10 @@ seen again within the configured idempotency retention window, the bridge must return or re-emit the original/current operation result instead of creating a second trade. After that retention window, duplicate suppression is no longer guaranteed; stale retained commands should be rejected by `valid_until_ms`. +For idempotency comparison, `context.valid_until_ms` and +`context.client_created_at_ms` are admission/attempt metadata rather than +business payload. They must not make an otherwise identical retry conflict, but +the bridge still validates `valid_until_ms` before accepting a new operation. This draft intentionally does not define log rotation. Production implementations may add owner-side compaction later, but the baseline profile is diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index d3b8b34..218bec4 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -368,6 +368,10 @@ configured idempotency retention window must return or re-emit the original/current operation result, not create a second trade. After that retention window duplicate suppression is no longer guaranteed; stale retained commands should be rejected by `valid_until_ms`. +Для idempotency comparison `context.valid_until_ms` и +`context.client_created_at_ms` считаются admission/attempt metadata, а не +business payload. Они не должны превращать otherwise identical retry в conflict, +но bridge всё равно валидирует `valid_until_ms` перед принятием новой операции. This draft intentionally does not define log rotation. Baseline profile is append, checkpoint and clear. diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridge.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridge.hpp index f56be17..544ee77 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridge.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridge.hpp @@ -738,7 +738,16 @@ namespace optionx::bridges::metatrader_file { } static std::string payload_fingerprint(const nlohmann::json& params) { - return params.dump(-1); + auto canonical = params; + auto context_it = canonical.find("context"); + if (context_it != canonical.end() && context_it->is_object()) { + // Admission metadata may legitimately change between retries. + // The idempotency fingerprint must describe the business + // operation, not the transport attempt envelope. + context_it->erase("valid_until_ms"); + context_it->erase("client_created_at_ms"); + } + return canonical.dump(-1); } static nlohmann::json make_in_doubt_result( @@ -985,23 +994,6 @@ namespace optionx::bridges::metatrader_file { std::vector& pending_signals) { prune_expired_idempotency_tombstones_locked(config); const auto rpc_request_key = request_storage_key(method, id); - if (!rpc_request_key.empty()) { - const auto request_it = m_request_id_index.find(rpc_request_key); - if (request_it != m_request_id_index.end()) { - const auto record_it = m_idempotency_records.find(request_it->second); - if (record_it != m_idempotency_records.end()) { - append_rpc_result_locked(id, record_it->second.result); - return; - } - const auto tombstone_it = - m_idempotency_tombstones.find(request_it->second); - if (tombstone_it != m_idempotency_tombstones.end()) { - append_rpc_result_locked(id, tombstone_it->second.result); - return; - } - m_request_id_index.erase(request_it); - } - } const auto idempotency_key = detail::context_idempotency_key(params); if (idempotency_key.empty()) { @@ -1022,6 +1014,40 @@ namespace optionx::bridges::metatrader_file { const auto storage_key = idempotency_storage_key(method, idempotency_key); const auto fingerprint = payload_fingerprint(params); + if (!rpc_request_key.empty()) { + const auto request_it = m_request_id_index.find(rpc_request_key); + if (request_it != m_request_id_index.end()) { + if (request_it->second != storage_key) { + const nlohmann::json conflict = { + {"status", "rejected"}, + {"final", true}, + {"reason", { + {"code", "idempotency_conflict"}, + {"message", "The same JSON-RPC id was used with a different operation."} + }} + }; + append_rpc_result_locked(id, conflict); + reports.push_back(detail::make_signal_report( + config, + BridgeSignalReportStatus::REJECTED, + "idempotency_conflict", + "MetaTrader file command JSON-RPC id conflicts with an earlier operation.", + record.document, + params, + detail::json_id_to_string(id), + idempotency_key)); + return; + } + + const auto record_it = m_idempotency_records.find(request_it->second); + const auto tombstone_it = + m_idempotency_tombstones.find(request_it->second); + if (record_it == m_idempotency_records.end() && + tombstone_it == m_idempotency_tombstones.end()) { + m_request_id_index.erase(request_it); + } + } + } std::unique_ptr signal; try { diff --git a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 index 25a04d4..6c37076 100644 --- a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -24,6 +24,7 @@ int OnInit() { // their operation key and reuse it when retrying the same logical command. string operation_suffix = Symbol() + ":" + + IntegerToString((long)ChartID()) + ":" + IntegerToString((long)TimeCurrent()) + ":" + IntegerToString((long)GetTickCount()); string signal_operation_key = "example:mt4:signal:" + operation_suffix; diff --git a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 index 2c85d51..7c1206c 100644 --- a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -25,6 +25,7 @@ int OnInit() { // their operation key and reuse it when retrying the same logical command. string operation_suffix = Symbol() + ":" + + IntegerToString((long)ChartID()) + ":" + IntegerToString((long)TimeCurrent()) + ":" + IntegerToString((long)GetTickCount()); string signal_operation_key = "example:mt5:signal:" + operation_suffix; diff --git a/tests/metatrader_file_bridge_test.cpp b/tests/metatrader_file_bridge_test.cpp index 443003f..221efc4 100644 --- a/tests/metatrader_file_bridge_test.cpp +++ b/tests/metatrader_file_bridge_test.cpp @@ -1359,7 +1359,7 @@ TEST(MetaTraderFileBridge, DurableIdempotencySuppressesReplayAfterCheckpointLoss EXPECT_EQ(events[0].document.at("result").at("signal_ref").at("signal_id").get(), "10"); } -TEST(MetaTraderFileBridge, DeduplicatesByJsonRpcId) { +TEST(MetaTraderFileBridge, RejectsJsonRpcIdReuseForDifferentOperation) { namespace mtfile = optionx::bridges::metatrader_file; namespace protocol = optionx::bridges::metatrader_file::detail; @@ -1410,7 +1410,119 @@ TEST(MetaTraderFileBridge, DeduplicatesByJsonRpcId) { config.max_line_bytes); ASSERT_EQ(events.size(), 2u); EXPECT_EQ(events[0].document.at("result").at("signal_ref").at("signal_id").get(), "50"); - EXPECT_EQ(events[1].document.at("result").at("signal_ref").at("signal_id").get(), "50"); + EXPECT_EQ(events[1].document.at("result").at("status").get(), "rejected"); + EXPECT_EQ(events[1].document.at("result").at("reason").at("code").get(), "idempotency_conflict"); +} + +TEST(MetaTraderFileBridge, DeduplicatesRetryWhenOnlyDeadlineChanges) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + mtfile::MetaTraderFileBridgeConfig config; + config.common_files_root = root.u8string(); + config.bridge_id = 40; + config.client_id = "terminal-retry-deadline"; + config.max_line_bytes = 8192; + + const auto layout = protocol::make_layout(config); + protocol::ensure_runtime_directories(layout); + protocol::append_json_line( + layout.commands_log(), + protocol::make_file_jsonrpc_request( + 1, + "request-1", + "signal.submit", + make_signal_submit_params("retry-key", "retry-hash", "EURUSD", "BUY", + protocol::unix_time_ms() + 60000)), + config.max_line_bytes); + protocol::append_json_line( + layout.commands_log(), + protocol::make_file_jsonrpc_request( + 2, + "request-2", + "signal.submit", + make_signal_submit_params("retry-key", "retry-hash", "EURUSD", "BUY", + protocol::unix_time_ms() - 1)), + config.max_line_bytes); + + mtfile::MetaTraderFileBridge bridge; + ASSERT_TRUE(bridge.configure(std::make_unique(config))); + bridge.on_signal_id() = []() { + return optionx::SignalId{51}; + }; + + int signal_count = 0; + bridge.on_trade_signal() = [&](std::unique_ptr) { + ++signal_count; + }; + bridge.process(); + EXPECT_EQ(signal_count, 1); + + const auto events = protocol::read_ndjson_since_file_seq( + layout.events_log(), + 0, + config.max_line_bytes); + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events[0].document.at("result").at("signal_ref").at("signal_id").get(), "51"); + EXPECT_EQ(events[1].document.at("result").at("signal_ref").at("signal_id").get(), "51"); +} + +TEST(MetaTraderFileBridge, RejectsSameIdempotencyKeyWithDifferentBusinessPayload) { + namespace mtfile = optionx::bridges::metatrader_file; + namespace protocol = optionx::bridges::metatrader_file::detail; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + + mtfile::MetaTraderFileBridgeConfig config; + config.common_files_root = root.u8string(); + config.bridge_id = 40; + config.client_id = "terminal-retry-payload"; + config.max_line_bytes = 8192; + + const auto layout = protocol::make_layout(config); + protocol::ensure_runtime_directories(layout); + protocol::append_json_line( + layout.commands_log(), + protocol::make_file_jsonrpc_request( + 1, + "request-1", + "signal.submit", + make_signal_submit_params("retry-key", "retry-hash", "EURUSD")), + config.max_line_bytes); + protocol::append_json_line( + layout.commands_log(), + protocol::make_file_jsonrpc_request( + 2, + "request-2", + "signal.submit", + make_signal_submit_params("retry-key", "retry-hash", "GBPUSD")), + config.max_line_bytes); + + mtfile::MetaTraderFileBridge bridge; + ASSERT_TRUE(bridge.configure(std::make_unique(config))); + bridge.on_signal_id() = []() { + return optionx::SignalId{52}; + }; + + int signal_count = 0; + bridge.on_trade_signal() = [&](std::unique_ptr) { + ++signal_count; + }; + bridge.process(); + EXPECT_EQ(signal_count, 1); + + const auto events = protocol::read_ndjson_since_file_seq( + layout.events_log(), + 0, + config.max_line_bytes); + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events[0].document.at("result").at("signal_ref").at("signal_id").get(), "52"); + EXPECT_EQ(events[1].document.at("result").at("status").get(), "rejected"); + EXPECT_EQ(events[1].document.at("result").at("reason").at("code").get(), "idempotency_conflict"); } TEST(MetaTraderFileBridge, InDoubtIdempotencySuppressesReplayAfterCallbackFailure) { From 451c6da6c61c6be621882f1a2bd14923425c1059 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sat, 18 Jul 2026 21:27:59 +0300 Subject: [PATCH 11/11] docs(bridges): track metatrader file follow-ups --- guides/bridge-protocol-v1/file-transport-and-adapters.md | 8 ++++++++ .../bridge-protocol-v1/file-transport-and-adapters.ru.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index 7da2ae1..2219345 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -407,6 +407,14 @@ Deferred implementation work: - Higher-level MQL helpers for writing and compacting these logs. - A runtime writer object or owner queue that serializes append, repair and owner-side clear operations per log file. +- Canonical idempotency fingerprints for trade commands: validate and normalize + business payloads into a protocol-level canonical JSON form before + deterministic serialization or hashing. This should cover decimal + representations, identifiers, supported enum aliases, defaults, routing, + identity fields and expiry semantics while excluding transport/retry metadata. +- MetaEditor compilation smoke tests for the MQL5 header/example. MQL4 + compilation should be added separately once a reproducible MT4 compiler setup + is available. - Optional `log_generation`/file identity support if persisted byte-offset optimization becomes necessary. - A callback/visitor NDJSON reader if future logs need very large scans without diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 218bec4..6bd1ee7 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -381,6 +381,14 @@ Deferred implementation work: - Higher-level MQL helpers for writing and compacting these logs. - Runtime writer object or owner queue that serializes append, repair and owner-side clear operations per log file. +- Canonical idempotency fingerprints for trade commands: validate and normalize + business payloads into a protocol-level canonical JSON form before + deterministic serialization or hashing. This should cover decimal + representations, identifiers, supported enum aliases, defaults, routing, + identity fields and expiry semantics while excluding transport/retry metadata. +- MetaEditor compilation smoke tests for the MQL5 header/example. MQL4 + compilation should be added separately once a reproducible MT4 compiler setup + is available. - Optional `log_generation`/file identity support if persisted byte-offset optimization becomes necessary. - Callback/visitor NDJSON reader if future logs need very large scans without