diff --git a/.github/workflows/ubuntu-smoke.yml b/.github/workflows/ubuntu-smoke.yml index 23b5c402..f27a2e3c 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 9b375bf9..a0ac2f55 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 517387ad..4e1395da 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 00000000..948f2784 --- /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/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index e29680dc..22193458 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 @@ -66,6 +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` 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; @@ -73,9 +77,97 @@ 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 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++ client-side writer appends one compact JSON-RPC request per line to +`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: + +- `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`, 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 +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 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 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 +``` + +Ready-to-run examples are provided as: + +- `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 +chart symbol. `trade.open` is available behind an input flag so the example does +not open a direct trade accidentally. + +`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::: +``` + +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 @@ -213,10 +305,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 @@ -287,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 @@ -297,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 6ceaa32d..6bd1ee72 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: @@ -60,6 +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++ 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; @@ -67,9 +74,92 @@ 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`. 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++ client-side writer добавляет в `commands.ndjson` один compact JSON-RPC request +на строку. Он назначает `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: + +- `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`, caller-provided `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 разложен как реальный terminal data folder. Header лежит в: + +```text +mql/MQL4/Include/OptionX/OptionXFileBridge.mqh +mql/MQL5/Include/OptionX/OptionXFileBridge.mqh +``` + +Он дает небольшой class `COptionXFileBridge` с теми же тремя helpers: + +- `AccountBalanceGet(...)`; +- `SignalSubmit(...)`; +- `TradeOpen(...)`. + +Скопируй подходящее дерево `mql/MQL4` или `mql/MQL5` в terminal data folder +или перенеси только subfolders `OptionX` в существующие `MQL4\Include` / +`MQL4\Indicators` или `MQL5\Include` / `MQL5\Indicators`. Header подключается +так: + +```mql +#include +``` + +Ready-to-run examples: + +- `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. +`trade.open` доступен через input flag, чтобы example случайно не открывал +direct trade. + +`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::: +``` + +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 @@ -191,10 +281,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 @@ -264,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. @@ -273,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 diff --git a/guides/build-and-test.md b/guides/build-and-test.md index 4e6ea3a3..f280e082 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,12 @@ 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` +- `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/include/optionx_cpp/bridges/metatrader_file.hpp b/include/optionx_cpp/bridges/metatrader_file.hpp index dfefb24b..9de1afc3 100644 --- a/include/optionx_cpp/bridges/metatrader_file.hpp +++ b/include/optionx_cpp/bridges/metatrader_file.hpp @@ -22,10 +22,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -49,6 +51,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/MetaTraderFileBridge.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridge.hpp index f56be170..544ee772 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/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp new file mode 100644 index 00000000..13e4c74f --- /dev/null +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileCommandWriter.hpp @@ -0,0 +1,324 @@ +#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 the C++ client-side command writer for the MetaTrader file bridge. + +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; defaults to `idempotency_key`. + std::string account_id; ///< Optional account routing target. + 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. + }; + + /// \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 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 + /// duplicate `file_seq` values or clearing each other's records. + 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() { + std::lock_guard lock(m_mutex); + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); + initialize_locked(); + } + + /// \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() { + 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; + } + + /// \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); + return append_request_locked( + command.id, + "signal.submit", + make_trade_params(command, "signal")); + } + + /// \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); + return append_request_locked( + 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 = {}) { + std::lock_guard lock(m_mutex); + ensure_initialized_locked(); + 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_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() { + std::lock_guard lock(m_mutex); + detail::ScopedLogFileLock log_lock(m_layout.commands_log()); + ensure_initialized_locked(); + 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(); + const auto recovered = detail::next_file_seq_after_checkpoint( + m_layout.commands_log(), + checkpoint, + m_config.max_line_bytes); + if (!m_initialized || recovered > m_next_file_seq) { + m_next_file_seq = recovered; + } + } + + void ensure_initialized_locked() { + if (!m_initialized) { + initialize_locked(); + } + } + + 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."); + } + + 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) { + if (command.idempotency_key.empty()) { + 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; + } + 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_locked( + 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)); + const auto line = document.dump(-1); + ensure_command_log_capacity_locked(line.size() + 1); + detail::append_serialized_json_line( + m_layout.commands_log(), + line, + 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; + mutable std::mutex m_mutex; + }; + +} // namespace optionx::bridges::metatrader_file + +#endif // OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_COMMAND_WRITER_HPP_INCLUDED diff --git a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp index 27fd2a90..00172763 100644 --- a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp @@ -135,6 +135,183 @@ 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 + release_process_lock(); + } + + private: + 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(), + 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()); + release_process_lock(); + 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()); + release_process_lock(); + 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); + 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__) + 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 +630,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 +650,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 +661,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 new file mode 100644 index 00000000..5726d171 --- /dev/null +++ b/mql/MQL4/Include/OptionX/OptionXFileBridge.mqh @@ -0,0 +1,839 @@ +#ifndef OPTIONX_FILE_BRIDGE_MQH +#define OPTIONX_FILE_BRIDGE_MQH + +// Minimal OptionX MetaTrader Common\Files client. +// Place this file under MQL4\Include\OptionX or MQL5\Include\OptionX. + +class COptionXFileBridge { +private: + int m_bridge_id; + string m_client_id; + string m_namespace_subdir; + int m_default_valid_for_ms; + int m_max_line_bytes; + int m_max_command_log_bytes; + long m_next_file_seq; + bool m_configured; + + 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; + } + + 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); + 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; + } + + long UnixTimeMs() const { + return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); + } + + bool EnsureDirectory(const string path) const { + if (path == "") return true; + ResetLastError(); + if (FolderCreate(path, FILE_COMMON)) + return true; + int error = GetLastError(); + return error == 5019; + } + + 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; + } + + int TextReadFlags() const { + return FILE_READ | 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; + } + + 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; + + 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 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) { + 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) { + 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 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; + } + + 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, + FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE); + if (handle == INVALID_HANDLE) { + ResetLastError(); + handle = FileOpen( + relative_path, + BinaryWriteFlags()); + } + if (handle == INVALID_HANDLE) { + Print("OptionX: could not open command log: ", relative_path, + ", error=", GetLastError()); + return false; + } + + FileSeek(handle, 0, SEEK_END); + 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; + } + + bool TryParseLongField(const string text, const string name, long &value) const { + value = 0; + int key = StringFind(text, "\"" + name + "\""); + if (key < 0) return false; + int colon = StringFind(text, ":", key); + if (colon < 0) return false; + + 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 false; + value = (long)StringToInteger(StringSubstr(text, start, end - start)); + return true; + } + + 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; + } + + while (!FileIsEnding(handle)) { + string line = FileReadString(handle); + 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 true; + } + + bool TryLastCheckpointSeq(long &checkpoint) const { + checkpoint = 0; + if (!FileIsExist(CommandsCheckpointPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen( + CommandsCheckpointPath(), + 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); + + 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( + 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; + } + + 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; + } + + 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; + + 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); + return ok; + } + +public: + COptionXFileBridge() { + m_bridge_id = 1; + m_client_id = "default"; + m_namespace_subdir = "OptionX\\Bridge\\v1"; + m_default_valid_for_ms = 60000; + m_max_line_bytes = 65536; + m_max_command_log_bytes = 8 * 1024 * 1024; + m_next_file_seq = 0; + m_configured = true; + } + + bool Configure( + const int bridge_id, + const string client_id, + const string namespace_subdir = "OptionX\\Bridge\\v1", + 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; + 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; + } + 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; + } + + 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"); + } + +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; + } + 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) + return false; + + bool ok = false; + do { + if (!RecoverNextFileSeqUnlocked()) + break; + + 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 = "") { + return AppendAccountBalanceGetRequest(account_id, operation_key); + } + + 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) { + return AppendTradeRequest( + "signal.submit", + "signal", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_for_ms); + } + + 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) { + return AppendTradeRequest( + "trade.open", + "trade", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_for_ms); + } + + bool CleanupCommandsIfCheckpointCaughtUp() { + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { + if (!RepairIncompleteTail(CommandsPath())) + break; + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + break; + if (max_seq <= 0) + break; + + 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; + } +}; + +#endif // OPTIONX_FILE_BRIDGE_MQH diff --git a/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 new file mode 100644 index 00000000..6c37076e --- /dev/null +++ b/mql/MQL4/Indicators/OptionX/OptionXFileBridgeSignalExample.mq4 @@ -0,0 +1,78 @@ +#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() { + 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()); + + // 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)ChartID()) + ":" + + 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(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL4_Example", + InpAccountId, + signal_operation_key); + + bool trade_ok = true; + if (InpSendTradeOpen) { + trade_ok = g_optionx.TradeOpen( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL4_Example", + 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); + 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/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh new file mode 100644 index 00000000..918e273b --- /dev/null +++ b/mql/MQL5/Include/OptionX/OptionXFileBridge.mqh @@ -0,0 +1,839 @@ +#ifndef OPTIONX_FILE_BRIDGE_MQH +#define OPTIONX_FILE_BRIDGE_MQH + +// Minimal OptionX MetaTrader Common\Files client. +// Place this file under MQL4\Include\OptionX or MQL5\Include\OptionX. + +class COptionXFileBridge { +private: + int m_bridge_id; + string m_client_id; + string m_namespace_subdir; + int m_default_valid_for_ms; + int m_max_line_bytes; + int m_max_command_log_bytes; + long m_next_file_seq; + bool m_configured; + + 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; + } + + 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); + 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; + } + + long UnixTimeMs() const { + return (long)TimeGMT() * 1000L + (long)(GetTickCount() % 1000); + } + + bool EnsureDirectory(const string path) const { + if (path == "") return true; + ResetLastError(); + if (FolderCreate(path, FILE_COMMON)) + return true; + int error = GetLastError(); + return error == 5018; + } + + 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; + } + + int TextReadFlags() const { + return FILE_READ | 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; + } + + 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; + + 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 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) { + 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) { + 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 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; + } + + 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, + FILE_READ | FILE_WRITE | FILE_BIN | FILE_COMMON | + FILE_SHARE_READ | FILE_SHARE_WRITE); + if (handle == INVALID_HANDLE) { + ResetLastError(); + handle = FileOpen( + relative_path, + BinaryWriteFlags()); + } + if (handle == INVALID_HANDLE) { + Print("OptionX: could not open command log: ", relative_path, + ", error=", GetLastError()); + return false; + } + + FileSeek(handle, 0, SEEK_END); + 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; + } + + bool TryParseLongField(const string text, const string name, long &value) const { + value = 0; + int key = StringFind(text, "\"" + name + "\""); + if (key < 0) return false; + int colon = StringFind(text, ":", key); + if (colon < 0) return false; + + 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 false; + value = (long)StringToInteger(StringSubstr(text, start, end - start)); + return true; + } + + 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; + } + + while (!FileIsEnding(handle)) { + string line = FileReadString(handle); + 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 true; + } + + bool TryLastCheckpointSeq(long &checkpoint) const { + checkpoint = 0; + if (!FileIsExist(CommandsCheckpointPath(), FILE_COMMON)) + return true; + + ResetLastError(); + int handle = FileOpen( + CommandsCheckpointPath(), + 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); + + 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( + 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; + } + + 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; + } + + 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; + + 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); + return ok; + } + +public: + COptionXFileBridge() { + m_bridge_id = 1; + m_client_id = "default"; + m_namespace_subdir = "OptionX\\Bridge\\v1"; + m_default_valid_for_ms = 60000; + m_max_line_bytes = 65536; + m_max_command_log_bytes = 8 * 1024 * 1024; + m_next_file_seq = 0; + m_configured = true; + } + + bool Configure( + const int bridge_id, + const string client_id, + const string namespace_subdir = "OptionX\\Bridge\\v1", + 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; + 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; + } + 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; + } + + 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"); + } + +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; + } + 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) + return false; + + bool ok = false; + do { + if (!RecoverNextFileSeqUnlocked()) + break; + + 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 = "") { + return AppendAccountBalanceGetRequest(account_id, operation_key); + } + + 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) { + return AppendTradeRequest( + "signal.submit", + "signal", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_for_ms); + } + + 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) { + return AppendTradeRequest( + "trade.open", + "trade", + symbol, + order_type, + amount, + currency, + duration_ms, + signal_name, + account_id, + operation_key, + valid_for_ms); + } + + bool CleanupCommandsIfCheckpointCaughtUp() { + int lock_handle = AcquireCommandLock(); + if (lock_handle == INVALID_HANDLE) + return false; + + bool ok = false; + do { + if (!RepairIncompleteTail(CommandsPath())) + break; + long max_seq = 0; + if (!TryMaxFileSeqInCommands(max_seq)) + break; + if (max_seq <= 0) + break; + + 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; + } +}; + +#endif // OPTIONX_FILE_BRIDGE_MQH diff --git a/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 new file mode 100644 index 00000000..7c1206c5 --- /dev/null +++ b/mql/MQL5/Indicators/OptionX/OptionXFileBridgeSignalExample.mq5 @@ -0,0 +1,79 @@ +#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() { + 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()); + + // 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)ChartID()) + ":" + + 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(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL5_Example", + InpAccountId, + signal_operation_key); + + bool trade_ok = true; + if (InpSendTradeOpen) { + trade_ok = g_optionx.TradeOpen( + Symbol(), + InpOrderType, + InpAmount, + "USD", + InpDurationMs, + "OptionX_MQL5_Example", + 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); + 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/tests/bridge_umbrella_include_test.cpp b/tests/bridge_umbrella_include_test.cpp index 085af0f2..b8a7c344 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_bridge_test.cpp b/tests/metatrader_file_bridge_test.cpp index 443003ff..221efc49 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) { diff --git a/tests/metatrader_file_command_writer_test.cpp b/tests/metatrader_file_command_writer_test.cpp new file mode 100644 index 00000000..ed1533af --- /dev/null +++ b/tests/metatrader_file_command_writer_test.cpp @@ -0,0 +1,377 @@ +#include + +#include + +#include +#include +#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; +} + +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) { + 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, 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; + + 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); +} + +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)); +} + +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(); +}