From 69445f6e24fa633334b730858e347b6959447bba Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sun, 9 Aug 2026 02:02:48 +0300 Subject: [PATCH 1/3] feat(telegram): normalize OTC symbols and add live bridge smoke --- CMakeLists.txt | 31 ++ examples/README.md | 4 + examples/telegram_live_bridge_smoke.cpp | 300 +++++++++++++ guides/telegram-bridge-design.md | 24 +- .../telegram/TelegramParsedMessage.hpp | 9 + .../telegram/TelegramSignalBridgeConfig.hpp | 56 +++ .../bridges/telegram/TelegramSignalParser.hpp | 417 +++++++++++++++++- tests/telegram_signal_bridge_test.cpp | 22 + tests/telegram_signal_parser_test.cpp | 163 +++++++ 9 files changed, 1002 insertions(+), 24 deletions(-) create mode 100644 examples/telegram_live_bridge_smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f59e6c3..9770318 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -776,6 +776,37 @@ if(OPTIONX_BUILD_EXAMPLES) endif() target_link_libraries(telegram_archive_parser_smoke PRIVATE ${EXAMPLE_LIBS} optionx_cpp) + if(NOT TARGET tiny-process-library::tiny-process-library) + add_subdirectory( + ${CMAKE_CURRENT_SOURCE_DIR}/external/tg-client-stdio/external/cpp/tiny-process-library + EXCLUDE_FROM_ALL + ) + endif() + + add_executable(telegram_live_bridge_smoke examples/telegram_live_bridge_smoke.cpp) + target_compile_features(telegram_live_bridge_smoke PRIVATE cxx_std_17) + target_include_directories(telegram_live_bridge_smoke PRIVATE + ${EXAMPLE_INCLUDE_DIRS} + ${EXAMPLE_DEPS_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR}/external/tg-client-stdio/include + ) + target_link_directories(telegram_live_bridge_smoke PRIVATE ${EXAMPLE_LIBRARY_DIRS}) + target_compile_definitions( + telegram_live_bridge_smoke PRIVATE + ${EXAMPLE_DEFINES} + LOGIT_BASE_PATH="${LOGIT_BASE_PATH_FWD}" + ) + if(MINGW) + target_compile_options(telegram_live_bridge_smoke PRIVATE -Wa,-mbig-obj) + elseif(MSVC) + target_compile_options(telegram_live_bridge_smoke PRIVATE /bigobj) + endif() + target_link_libraries(telegram_live_bridge_smoke PRIVATE + ${EXAMPLE_LIBS} + optionx_cpp + tiny-process-library::tiny-process-library + ) + add_executable(protocol_v1_bridge_smoke examples/protocol_v1_bridge_smoke.cpp) target_compile_features(protocol_v1_bridge_smoke PRIVATE cxx_std_17) diff --git a/examples/README.md b/examples/README.md index b0650af..3d71cfb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -63,6 +63,10 @@ Currently maintained examples: records through the parser and keeps executable signals, outcomes and diagnostics separate. It uses deterministic fixtures and needs no Telegram credentials. +- `telegram_live_bridge_smoke.cpp` starts tg-client-stdio, listens to a live + Telegram chat, and feeds raw messages through the C++ Telegram signal bridge. + It requires an authorized worker session and accepts settings from `.env` or + command-line options. - `metatrader_file_bridge_smoke.cpp` runs the C++ side of the MetaTrader Common\Files bridge against a temporary command/event layout. - `metatrader_file_command_writer_smoke.cpp` demonstrates the C++ command-writer diff --git a/examples/telegram_live_bridge_smoke.cpp b/examples/telegram_live_bridge_smoke.cpp new file mode 100644 index 0000000..6a91dc6 --- /dev/null +++ b/examples/telegram_live_bridge_smoke.cpp @@ -0,0 +1,300 @@ +/// \file telegram_live_bridge_smoke.cpp +/// \brief Runs the Telegram worker and C++ signal bridge against a live chat. + +#include "example_utils.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct Options { + std::string worker_root; + std::string python; + std::string chat; + std::string marker; + std::string api_id; + std::string api_hash; + std::string session; + std::string proxy; + std::chrono::seconds timeout{300}; +}; + +void load_env_file(const std::filesystem::path& path); +Options parse_options(int argc, char** argv); +bool wait_for_signal( + std::condition_variable& condition, + std::mutex& mutex, + bool& matched, + bool& failed, + std::chrono::seconds timeout); +std::string required_setting(const std::string& value, const char* name); + +} // namespace + +int main(int argc, char** argv) { + try { + std::cout.setf(std::ios::unitbuf); + const auto env_file_argument = optionx::examples::option_value( + argc, argv, "--env-file"); + const auto env_file = env_file_argument.empty() + ? optionx::examples::env_or("TG_CLIENT_STDIO_ENV_FILE") + : env_file_argument; + if (!env_file.empty()) { + load_env_file(env_file); + } + + const auto options = parse_options(argc, argv); + if (options.chat.empty()) { + throw std::invalid_argument( + "chat is required; use --chat or TG_CLIENT_STDIO_TEST_CHAT"); + } + const auto api_id = required_setting( + options.api_id, "TG_CLIENT_STDIO_API_ID"); + const auto api_hash = required_setting( + options.api_hash, "TG_CLIENT_STDIO_API_HASH"); + const auto session = required_setting( + options.session, "TG_CLIENT_STDIO_SESSION"); + const auto worker_root = required_setting( + options.worker_root, "TG_CLIENT_STDIO_WORKER_ROOT"); + + std::vector command{ + options.python, + "-m", + "tg_client_stdio_worker", + "--backend", + "telethon", + "--api-id", + api_id, + "--api-hash", + api_hash, + "--session", + session, + }; + if (!options.proxy.empty()) { + command.insert(command.end(), {"--proxy", options.proxy}); + } + + tg_client_stdio::WorkerProcessConfig worker_config; + worker_config.command = std::move(command); + worker_config.working_directory = worker_root; + worker_config.on_stderr = [](const std::string& text) { + std::cerr << "[tg-worker] " << text; + }; + + tg_client_stdio::WorkerClient worker; + if (!worker.start(std::move(worker_config))) { + std::cerr << "failed to start tg-client-stdio worker\n"; + return 2; + } + + const auto auth = worker.get_auth_status(); + if (!auth.authorized) { + std::cerr << "Telegram session is not authorized\n"; + worker.stop(); + return 2; + } + + auto source = std::make_shared< + optionx::bridges::telegram::TelegramWorkerMessageSource< + tg_client_stdio::WorkerClient>>( + worker, + optionx::bridges::telegram::TelegramWorkerSourceConfig{ + {options.chat}, + {}, + }); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + + auto config = std::make_unique< + optionx::bridges::telegram::TelegramSignalBridgeConfig>(); + config->bridge_id = 9101; + config->fixed_amount = 1.0; + if (!bridge.configure(std::move(config))) { + std::cerr << "failed to configure Telegram signal bridge\n"; + worker.stop(); + return 2; + } + + std::mutex state_mutex; + std::condition_variable state_condition; + bool matched = false; + bool failed = false; + std::int64_t next_signal_id = 0; + + bridge.on_signal_id() = [&next_signal_id]() { + return ++next_signal_id; + }; + bridge.on_status_update() = [&state_condition, &state_mutex, &failed]( + const optionx::BridgeStatusUpdate& update) { + std::cout << "status=" << optionx::to_str(update.status); + if (!update.message.empty()) { + std::cout << " message=" << update.message; + } + std::cout << '\n'; + if (update.status == optionx::BridgeStatus::SERVER_START_FAILED || + update.status == optionx::BridgeStatus::CONNECTION_ERROR) { + std::lock_guard lock(state_mutex); + failed = true; + state_condition.notify_all(); + } + }; + bridge.on_signal_report() = [](const optionx::BridgeSignalReport& report) { + std::cout << "report=" << report.reason_code + << " status=" << optionx::to_str(report.status); + if (!report.message.empty()) { + std::cout << " message=" << report.message; + } + std::cout << '\n'; + }; + bridge.on_trade_signal() = [ + &state_condition, + &state_mutex, + &matched, + &options](std::unique_ptr signal) { + std::cout << "signal=" << signal->symbol + << " direction=" << optionx::to_str(signal->order_type) + << " duration=" << signal->duration + << " name=" << signal->signal_name + << " text=" << signal->comment << '\n'; + if (options.marker.empty() || + signal->comment.find(options.marker) != std::string::npos) { + std::lock_guard lock(state_mutex); + matched = true; + state_condition.notify_all(); + } + }; + + bridge.run(); + const auto success = wait_for_signal( + state_condition, + state_mutex, + matched, + failed, + options.timeout); + bridge.shutdown(); + worker.stop(); + + std::cout << "matched=" << (success ? "true" : "false") << '\n'; + return success ? 0 : 1; + } + catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 2; + } +} + +namespace { + +void load_env_file(const std::filesystem::path& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("could not open env file: " + path.string()); + } + + std::string line; + while (std::getline(input, line)) { + const auto first = line.find_first_not_of(" \t"); + if (first == std::string::npos || line[first] == '#') { + continue; + } + const auto separator = line.find('=', first); + if (separator == std::string::npos) { + continue; + } + auto key = line.substr(first, separator - first); + auto value = line.substr(separator + 1); + const auto key_end = key.find_last_not_of(" \t"); + key.resize(key_end == std::string::npos ? 0 : key_end + 1); + const auto value_first = value.find_first_not_of(" \t"); + value = value_first == std::string::npos + ? std::string() + : value.substr(value_first); + if (value.size() >= 2 && + ((value.front() == '"' && value.back() == '"') || + (value.front() == '\'' && value.back() == '\''))) { + value = value.substr(1, value.size() - 2); + } + if (key.empty() || std::getenv(key.c_str()) != nullptr) { + continue; + } +#ifdef _WIN32 + _putenv_s(key.c_str(), value.c_str()); +#else + setenv(key.c_str(), value.c_str(), 0); +#endif + } +} + +Options parse_options(const int argc, char** argv) { + Options options; + options.worker_root = optionx::examples::env_or("TG_CLIENT_STDIO_WORKER_ROOT"); + options.python = optionx::examples::env_or( + "TG_CLIENT_STDIO_PYTHON", + "python"); + options.chat = optionx::examples::env_or("TG_CLIENT_STDIO_TEST_CHAT"); + options.marker = optionx::examples::env_or("TG_CLIENT_STDIO_BRIDGE_MARKER"); + options.api_id = optionx::examples::env_or("TG_CLIENT_STDIO_API_ID"); + options.api_hash = optionx::examples::env_or("TG_CLIENT_STDIO_API_HASH"); + options.session = optionx::examples::env_or("TG_CLIENT_STDIO_SESSION"); + options.proxy = optionx::examples::env_or("TG_CLIENT_STDIO_PROXY"); + + const auto assign = [&](const char* name, std::string& target) { + const auto value = optionx::examples::option_value(argc, argv, name); + if (!value.empty()) { + target = value; + } + }; + assign("--worker-root", options.worker_root); + assign("--python", options.python); + assign("--chat", options.chat); + assign("--marker", options.marker); + assign("--api-id", options.api_id); + assign("--api-hash", options.api_hash); + assign("--session", options.session); + assign("--proxy", options.proxy); + + const auto timeout = optionx::examples::option_value(argc, argv, "--timeout"); + if (!timeout.empty()) { + const auto seconds = std::stoi(timeout); + if (seconds <= 0) { + throw std::invalid_argument("timeout must be positive"); + } + options.timeout = std::chrono::seconds(seconds); + } + return options; +} + +bool wait_for_signal( + std::condition_variable& condition, + std::mutex& mutex, + bool& matched, + bool& failed, + const std::chrono::seconds timeout) { + std::unique_lock lock(mutex); + condition.wait_for(lock, timeout, [&matched, &failed]() { + return matched || failed; + }); + return matched && !failed; +} + +std::string required_setting( + const std::string& value, + const char* name) { + if (value.empty()) { + throw std::invalid_argument(std::string(name) + " is required"); + } + return value; +} + +} // namespace diff --git a/guides/telegram-bridge-design.md b/guides/telegram-bridge-design.md index bed09d4..23d6ba3 100644 --- a/guides/telegram-bridge-design.md +++ b/guides/telegram-bridge-design.md @@ -256,16 +256,36 @@ empty successful exports. The first parser should be deterministic and testable: - per-source regex rules; -- symbol normalization; +- a universal symbol pattern with source-specific overrides, preserving broker + prefixes and suffixes such as `xEURUSD-OTC`; +- symbol normalization that removes presentation whitespace but does not + silently strip execution-symbol affixes; +- explicit OTC market classification; an unqualified symbol is never assumed + to be a regular-market asset. Common OTC spellings (`EURUSD_OTC`, + `EURUSD-OTC`, `EURUSDOTC`, `EURUSD OTC`) are canonicalized to + `EURUSD_OTC` before execution. The suffix remains configurable for a + source-specific execution alias; - direction aliases (`BUY`, `SELL`, `CALL`, `PUT`, arrows); +- separate direction-token rules so a source can map custom words or emoji; - expiry parsing (`5m`, `M5`, `00:05`, local broker wording); - optional signal name from message, chat title or rule name; - optional amount/sizing only when explicitly configured; - diagnostics for ambiguous or missing fields. +Raw Telegram text remains UTF-8. The parser treats known emoji sequences as +semantic tokens and accepts common variation-selector forms; it does not +require a second UTF-32 parser or normalize away the original text. A result +marker such as `✅`, `❌` or `Profit` has precedence over an overlapping signal +candidate, while a signal on another line of the same message remains +parseable. Result details such as payout, amount and statistics are optional; +missing details must not turn a recognizable result into a new executable +signal. + Martingale parsing should be deferred until the base signal/outcome model is stable. The parser may preserve raw martingale hints in diagnostics or metadata -without turning them into executable sizing decisions. +without turning them into executable sizing decisions. Any future policy that +keeps only the first step or emits martingale steps must be an explicit +opt-in execution policy, not an implicit parser side effect. ## Outcomes diff --git a/include/optionx_cpp/bridges/telegram/TelegramParsedMessage.hpp b/include/optionx_cpp/bridges/telegram/TelegramParsedMessage.hpp index b89bf2a..2966e7e 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramParsedMessage.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramParsedMessage.hpp @@ -14,6 +14,13 @@ namespace optionx::bridges::telegram { + /// \enum TelegramAssetMarket + /// \brief Market qualifier carried by a Telegram asset symbol. + enum class TelegramAssetMarket { + UNKNOWN, + OTC + }; + /// \struct TelegramParsedSignal /// \brief One normalized executable signal extracted from a raw message. struct TelegramParsedSignal { @@ -25,6 +32,7 @@ namespace optionx::bridges::telegram { std::int64_t expiry_time = 0; std::string signal_name; std::string raw_text; + TelegramAssetMarket market = TelegramAssetMarket::UNKNOWN; }; /// \enum TelegramOutcomeResult @@ -47,6 +55,7 @@ namespace optionx::bridges::telegram { std::int32_t step = 0; std::string signal_name; std::string raw_text; + TelegramAssetMarket market = TelegramAssetMarket::UNKNOWN; }; /// \struct TelegramParseDiagnostic diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp index 0578f01..ddaa18c 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp @@ -14,6 +14,35 @@ namespace optionx::bridges::telegram { + inline const char* telegram_outcome_result_name( + const TelegramOutcomeResult result) { + switch (result) { + case TelegramOutcomeResult::WIN: + return "WIN"; + case TelegramOutcomeResult::LOSS: + return "LOSS"; + case TelegramOutcomeResult::REFUND: + return "REFUND"; + case TelegramOutcomeResult::UNKNOWN: + default: + return "UNKNOWN"; + } + } + + inline TelegramOutcomeResult telegram_outcome_result_from_name( + const std::string& value) { + if (value == "WIN") { + return TelegramOutcomeResult::WIN; + } + if (value == "LOSS") { + return TelegramOutcomeResult::LOSS; + } + if (value == "REFUND") { + return TelegramOutcomeResult::REFUND; + } + return TelegramOutcomeResult::UNKNOWN; + } + /// \class TelegramSignalBridgeConfig /// \brief Parser and dispatch settings for Telegram live signal intake. class TelegramSignalBridgeConfig final : public IBridgeConfig { @@ -28,8 +57,11 @@ namespace optionx::bridges::telegram { {"bridge_id", bridge_id}, {"fixed_amount", fixed_amount}, {"dedupe_cache_size", dedupe_cache_size}, + {"symbol_pattern", parser.symbol_pattern}, + {"otc_symbol_suffix", parser.otc_symbol_suffix}, {"use_chat_title_as_signal_name", parser.use_chat_title_as_signal_name}, {"signal_rules", nlohmann::json::array()}, + {"direction_rules", nlohmann::json::array()}, {"outcome_rules", nlohmann::json::array()}, }; for (const auto& rule : parser.signal_rules) { @@ -43,12 +75,21 @@ namespace optionx::bridges::telegram { {"option_type", rule.option_type}, }); } + for (const auto& rule : parser.direction_rules) { + j["direction_rules"].push_back({ + {"name", rule.name}, + {"pattern", rule.pattern}, + {"order_type", rule.order_type}, + }); + } for (const auto& rule : parser.outcome_rules) { j["outcome_rules"].push_back({ {"name", rule.name}, {"pattern", rule.pattern}, {"symbol_group", rule.symbol_group}, {"result_group", rule.result_group}, + {"fixed_result", telegram_outcome_result_name(rule.fixed_result)}, + {"direction_group", rule.direction_group}, }); } } @@ -57,6 +98,8 @@ namespace optionx::bridges::telegram { bridge_id = j.value("bridge_id", bridge_id); fixed_amount = j.value("fixed_amount", fixed_amount); dedupe_cache_size = j.value("dedupe_cache_size", dedupe_cache_size); + parser.symbol_pattern = j.value("symbol_pattern", parser.symbol_pattern); + parser.otc_symbol_suffix = j.value("otc_symbol_suffix", parser.otc_symbol_suffix); parser.use_chat_title_as_signal_name = j.value( "use_chat_title_as_signal_name", parser.use_chat_title_as_signal_name); @@ -75,6 +118,16 @@ namespace optionx::bridges::telegram { parser.signal_rules.push_back(std::move(rule)); } } + if (j.contains("direction_rules")) { + parser.direction_rules.clear(); + for (const auto& item : j.at("direction_rules")) { + TelegramDirectionRule rule; + rule.name = item.value("name", ""); + rule.pattern = item.at("pattern").get(); + rule.order_type = item.value("order_type", OrderType::UNKNOWN); + parser.direction_rules.push_back(std::move(rule)); + } + } if (j.contains("outcome_rules")) { parser.outcome_rules.clear(); for (const auto& item : j.at("outcome_rules")) { @@ -83,6 +136,9 @@ namespace optionx::bridges::telegram { rule.pattern = item.at("pattern").get(); rule.symbol_group = item.value("symbol_group", 1u); rule.result_group = item.value("result_group", 2u); + rule.fixed_result = telegram_outcome_result_from_name( + item.value("fixed_result", std::string("UNKNOWN"))); + rule.direction_group = item.value("direction_group", 0u); parser.outcome_rules.push_back(std::move(rule)); } } diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp index 0d56b66..26c4562 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp @@ -32,19 +32,39 @@ namespace optionx::bridges::telegram { OptionType option_type = OptionType::SPRINT; }; + /// \struct TelegramDirectionRule + /// \brief Maps a textual or UTF-8 emoji direction token to an order type. + struct TelegramDirectionRule { + std::string name; + std::string pattern; + OrderType order_type = OrderType::UNKNOWN; + }; + /// \struct TelegramOutcomeRule /// \brief Capture-group mapping for one outcome regex. + /// + /// A rule may provide a fixed result and use result_group == 0 for + /// outcome text that has no stable result token, such as "Profit". struct TelegramOutcomeRule { std::string name; std::string pattern; std::size_t symbol_group = 1; std::size_t result_group = 2; + TelegramOutcomeResult fixed_result = TelegramOutcomeResult::UNKNOWN; + std::size_t direction_group = 0; }; /// \struct TelegramParserConfig /// \brief Source-independent deterministic parser configuration. struct TelegramParserConfig { + // Inserted as one capture group wherever a rule contains {{SYMBOL}}. + std::string symbol_pattern = + R"((?!(?:BUY|SELL|CALL|PUT|WIN|LOSS|REFUND|DRAW|PROFIT|OTC)\b)[A-Z][A-Z0-9]{2,11}(?:\s*[/_-]\s*[A-Z][A-Z0-9]{1,11})?)"; + // Canonical OTC symbols use the same suffix as UTE and the rest of + // the trading stack. A source-specific alias may override it. + std::string otc_symbol_suffix = "_OTC"; std::vector signal_rules; + std::vector direction_rules; std::vector outcome_rules; bool use_chat_title_as_signal_name = true; }; @@ -56,21 +76,150 @@ namespace optionx::bridges::telegram { /// \brief Returns conservative rules for common binary signal text. static TelegramParserConfig default_config() { TelegramParserConfig config; + const std::string symbol = "{{SYMBOL}}"; + config.direction_rules = { + {"buy-word", R"(\b(?:BUY|CALL)\b)", OrderType::BUY}, + {"sell-word", R"(\b(?:SELL|PUT)\b)", OrderType::SELL}, + // Telegram messages may contain either the emoji with or + // without its optional variation selector. + {"buy-up-emoji", "\xE2\xAC\x86(?:\xEF\xB8\x8F)?", OrderType::BUY}, + {"sell-down-emoji", "\xE2\xAC\x87(?:\xEF\xB8\x8F)?", OrderType::SELL}, + {"buy-up-arrow-emoji", "\xF0\x9F\x94\xBC", OrderType::BUY}, + {"sell-down-arrow-emoji", "\xF0\x9F\x94\xBD", OrderType::SELL}, + }; + config.signal_rules.push_back({ + "pair-prefix-expiry-direction", + "\\b" + symbol + + R"([ \t]+(s|sec|secs|m|min|mins|h|hr|hour|hours)[ \t]*(\d{1,5})[ \t]+(BUY|SELL|CALL|PUT)\b)", + 1, + 4, + 3, + 2, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "pair-expiry-direction", + "\\b" + symbol + + R"([ \t]+(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours)[ \t]+(BUY|SELL|CALL|PUT)\b)", + 1, + 4, + 2, + 3, + OptionType::SPRINT, + }); config.signal_rules.push_back({ "pair-direction-expiry", - R"(\b([A-Z]{3,6}(?:[/_-]?[A-Z]{3,6})?)\s+(BUY|SELL|CALL|PUT)\b(?:\s+(\d{1,5})\s*(s|sec|secs|m|min|mins|h|hr|hour|hours))?)", + "\\b" + symbol + + R"([ \t]+(BUY|SELL|CALL|PUT)\b(?:[ \t]+(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours))?)", 1, 2, 3, 4, OptionType::SPRINT, }); + config.signal_rules.push_back({ + "pair-otc-direction-expiry", + "\\b" + symbol + + R"([ \t]+OTC\b[ \t]+(BUY|SELL|CALL|PUT)\b(?:[ \t]+(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours))?)", + 1, + 2, + 3, + 4, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "otc-pair-direction-expiry", + R"(\bOTC[ \t]+)" + symbol + + R"([ \t]+(BUY|SELL|CALL|PUT)\b(?:[ \t]+(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours))?)", + 1, + 2, + 3, + 4, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "expiry-pair-scheduled-direction", + R"(\b(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours)[ \t]+)" + symbol + + R"((?:[ \t]+\d{1,2}:\d{2})?[ \t]+(BUY|SELL|CALL|PUT)\b)", + 3, + 4, + 1, + 2, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "expiry-pair-scheduled-direction-fallback", + R"(\b(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours)[ \t]+)" + symbol + + R"((?:[ \t]+\d{1,2}:\d{2})?(?![ \t]+(?:BUY|SELL|CALL|PUT)\b))", + 3, + 0, + 1, + 2, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "pair-expiry-direction-fallback", + "\\b" + symbol + + R"([ \t]+(\d{1,5})[ \t]*(s|sec|secs|m|min|mins|h|hr|hour|hours)\b(?![ \t]+(?:BUY|SELL|CALL|PUT)\b))", + 1, + 0, + 2, + 3, + OptionType::SPRINT, + }); + config.signal_rules.push_back({ + "pair-statistics-direction-fallback", + "\\b" + symbol + + R"([^\r\n]{0,160}\b(?:WIN|STATS|PAY|PING|DELAY)\b[^\r\n]{0,160})", + 1, + 0, + 0, + 0, + OptionType::SPRINT, + }); config.outcome_rules.push_back({ "pair-outcome", - R"(\b([A-Z]{3,6}(?:[/_-]?[A-Z]{3,6})?)\s+(WIN|LOSS|REFUND|DRAW)\b)", + "\\b" + symbol + R"([ \t]+(WIN|LOSS|REFUND|DRAW)\b)", 1, 2, }); + config.outcome_rules.push_back({ + "pair-profit", + "\\b" + symbol + R"([^\r\n]{0,40}\bPROFIT\b)", + 1, + 0, + TelegramOutcomeResult::WIN, + }); + config.outcome_rules.push_back({ + "pair-win-emoji-after", + "\\b" + symbol + + R"([^\r\n]{0,160}\xE2\x9C\x85)", + 1, + 0, + TelegramOutcomeResult::WIN, + }); + config.outcome_rules.push_back({ + "pair-win-emoji-before", + "\xE2\x9C\x85[ \t]*" + symbol + R"([^\r\n]{0,160})", + 1, + 0, + TelegramOutcomeResult::WIN, + }); + config.outcome_rules.push_back({ + "pair-loss-emoji-after", + "\\b" + symbol + + R"([^\r\n]{0,160}\xE2\x9D\x8C)", + 1, + 0, + TelegramOutcomeResult::LOSS, + }); + config.outcome_rules.push_back({ + "pair-loss-emoji-before", + "\xE2\x9D\x8C[ \t]*" + symbol + R"([^\r\n]{0,160})", + 1, + 0, + TelegramOutcomeResult::LOSS, + }); return config; } @@ -79,6 +228,13 @@ namespace optionx::bridges::telegram { : m_config(std::move(config)) { for (const auto& rule : m_config.signal_rules) { m_signal_rules.push_back({ + rule, + std::regex(expand_symbol_pattern(rule.pattern), + std::regex::ECMAScript | std::regex::icase), + }); + } + for (const auto& rule : m_config.direction_rules) { + m_direction_rules.push_back({ rule, std::regex(rule.pattern, std::regex::ECMAScript | std::regex::icase), }); @@ -86,7 +242,8 @@ namespace optionx::bridges::telegram { for (const auto& rule : m_config.outcome_rules) { m_outcome_rules.push_back({ rule, - std::regex(rule.pattern, std::regex::ECMAScript | std::regex::icase), + std::regex(expand_symbol_pattern(rule.pattern), + std::regex::ECMAScript | std::regex::icase), }); } } @@ -96,8 +253,8 @@ namespace optionx::bridges::telegram { raw.validate(); TelegramParsedMessage result; result.raw = raw; - parse_signals(raw, result); - parse_outcomes(raw, result); + const auto outcome_spans = parse_outcomes(raw, result); + parse_signals(raw, result, outcome_spans); return result; } @@ -112,12 +269,31 @@ namespace optionx::bridges::telegram { std::regex expression; }; + struct CompiledDirectionRule { + TelegramDirectionRule spec; + std::regex expression; + }; + struct SignalCandidate { std::size_t begin = 0; std::size_t end = 0; TelegramParsedSignal signal; }; + using TextSpan = std::pair; + + std::string expand_symbol_pattern(std::string pattern) const { + static constexpr const char* placeholder = "{{SYMBOL}}"; + const auto token_length = std::char_traits::length(placeholder); + std::size_t position = 0; + while ((position = pattern.find(placeholder, position)) != std::string::npos) { + pattern.replace(position, token_length, "(" + + m_config.symbol_pattern + ")"); + position += m_config.symbol_pattern.size() + 2; + } + return pattern; + } + static std::string upper(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return static_cast(std::toupper(ch)); @@ -125,6 +301,91 @@ namespace optionx::bridges::telegram { return value; } + static std::string normalize_symbol(std::string value) { + value.erase(std::remove_if(value.begin(), value.end(), [](unsigned char ch) { + return std::isspace(ch); + }), value.end()); + return upper(std::move(value)); + } + + static TelegramAssetMarket classify_market( + const std::string& symbol, + const std::string& matched_text) { + static const std::regex otc_suffix( + R"((?:^|[-_/])OTC$)", + std::regex::ECMAScript | std::regex::icase); + static const std::regex otc_token( + R"(\bOTC\b)", + std::regex::ECMAScript | std::regex::icase); + const auto normalized_symbol = normalize_symbol(symbol); + const bool otc_attached_suffix = + normalized_symbol.size() > 3 && + normalized_symbol.compare( + normalized_symbol.size() - 3, 3, "OTC") == 0; + if (otc_attached_suffix || + std::regex_search(symbol, otc_suffix) || + std::regex_search(matched_text, otc_token)) { + return TelegramAssetMarket::OTC; + } + return TelegramAssetMarket::UNKNOWN; + } + + static std::string strip_otc_suffix(std::string symbol) { + symbol = normalize_symbol(std::move(symbol)); + if (symbol.size() > 3 && + symbol.compare(symbol.size() - 3, 3, "OTC") == 0) { + symbol.erase(symbol.size() - 3); + if (!symbol.empty() && + (symbol.back() == '_' || symbol.back() == '-' || + symbol.back() == '/')) { + symbol.pop_back(); + } + } + symbol.erase(std::remove(symbol.begin(), symbol.end(), '/'), symbol.end()); + return symbol; + } + + std::string canonical_symbol( + std::string symbol, + const TelegramAssetMarket market) const { + if (market != TelegramAssetMarket::OTC) { + return symbol; + } + if (m_config.otc_symbol_suffix.empty()) { + return strip_otc_suffix(std::move(symbol)); + } + return strip_otc_suffix(std::move(symbol)) + m_config.otc_symbol_suffix; + } + + static std::string trim_ascii(std::string value) { + const auto first = value.find_first_not_of(" \t"); + if (first == std::string::npos) { + return {}; + } + const auto last = value.find_last_not_of(" \t"); + return value.substr(first, last - first + 1); + } + + static std::string signal_name_from_tail( + const TelegramRawMessage& raw, + const std::smatch& match) { + const auto begin = static_cast(match.position() + match.length()); + if (begin >= raw.text.size()) { + return {}; + } + const auto line_end = raw.text.find_first_of("\r\n", begin); + auto tail = trim_ascii(raw.text.substr(begin, line_end - begin)); + static const std::regex step_suffix( + R"(\s+-\s*\d+\s*$)", + std::regex::ECMAScript); + tail = std::regex_replace(tail, step_suffix, ""); + tail = trim_ascii(std::move(tail)); + if (tail.empty() || !std::isalpha(static_cast(tail.front()))) { + return {}; + } + return tail; + } + static bool has_group( const std::smatch& match, const std::size_t group) { @@ -169,26 +430,85 @@ namespace optionx::bridges::telegram { return static_cast(amount * multiplier); } + static std::string line_text( + const TelegramRawMessage& raw, + const std::size_t begin, + const std::size_t end) { + const auto line_begin = raw.text.rfind('\n', begin == 0 ? 0 : begin - 1); + const auto line_end = raw.text.find('\n', end); + const auto first = line_begin == std::string::npos ? 0 : line_begin + 1; + return raw.text.substr(first, line_end == std::string::npos + ? std::string::npos + : line_end - first); + } + + std::optional resolve_direction( + const std::string& text) const { + std::optional resolved; + for (const auto& compiled : m_direction_rules) { + if (!std::regex_search(text, compiled.expression)) { + continue; + } + if (resolved && *resolved != compiled.spec.order_type) { + throw std::invalid_argument("signal direction is ambiguous"); + } + resolved = compiled.spec.order_type; + } + if (!resolved) { + OrderType standard = OrderType::UNKNOWN; + if (optionx::to_enum(upper(text), standard)) { + resolved = standard; + } + } + return resolved; + } + + std::optional resolve_direction( + const TelegramRawMessage& raw, + const std::size_t begin, + const std::size_t end) const { + return resolve_direction(line_text(raw, begin, end)); + } + TelegramParsedSignal build_signal( const TelegramRawMessage& raw, const TelegramSignalRule& rule, const std::smatch& match, TelegramParsedMessage& result) const { - if (!has_group(match, rule.symbol_group) || - !has_group(match, rule.direction_group)) { - throw std::invalid_argument("signal rule has no symbol or direction"); + if (!has_group(match, rule.symbol_group)) { + throw std::invalid_argument("signal rule has no symbol"); } TelegramParsedSignal signal; signal.source_message_identity = raw.message_identity(); - signal.symbol = upper(match[rule.symbol_group].str()); - if (!optionx::to_enum(upper(match[rule.direction_group].str()), signal.order_type)) { - throw std::invalid_argument("signal direction is unsupported"); + signal.market = classify_market( + match[rule.symbol_group].str(), match.str()); + signal.symbol = canonical_symbol( + normalize_symbol(match[rule.symbol_group].str()), signal.market); + if (has_group(match, rule.direction_group)) { + const auto direction = resolve_direction(match[rule.direction_group].str()); + if (!direction) { + throw std::invalid_argument("signal direction is unsupported"); + } + signal.order_type = *direction; + } + else { + const auto direction = resolve_direction( + raw, + static_cast(match.position()), + static_cast(match.position() + match.length())); + if (!direction) { + throw std::invalid_argument("signal direction is missing"); + } + signal.order_type = *direction; } signal.option_type = rule.option_type; - signal.signal_name = m_config.use_chat_title_as_signal_name - ? raw.chat_title - : rule.name; + signal.signal_name = signal_name_from_tail(raw, match); + if (signal.signal_name.empty()) { + signal.signal_name = m_config.use_chat_title_as_signal_name + ? raw.chat_title + : rule.name; + } signal.raw_text = match.str(); if (has_group(match, rule.expiry_group)) { @@ -215,16 +535,26 @@ namespace optionx::bridges::telegram { void parse_signals( const TelegramRawMessage& raw, - TelegramParsedMessage& result) const { + TelegramParsedMessage& result, + const std::vector& outcome_spans) const { std::vector candidates; for (const auto& compiled : m_signal_rules) { for (std::sregex_iterator it(raw.text.begin(), raw.text.end(), compiled.expression); it != std::sregex_iterator(); ++it) { const auto& match = *it; + const auto begin = static_cast(match.position()); + const auto end = begin + static_cast(match.length()); + const auto overlaps_outcome = std::any_of( + outcome_spans.begin(), outcome_spans.end(), [&](const auto& span) { + return begin < span.second && span.first < end; + }); + if (overlaps_outcome) { + continue; + } try { candidates.push_back({ - static_cast(match.position()), - static_cast(match.position() + match.length()), + begin, + end, build_signal(raw, compiled.spec, match, result), }); } @@ -247,6 +577,14 @@ namespace optionx::bridges::telegram { }); std::vector rejected(candidates.size(), false); + for (std::size_t i = 0; i < candidates.size(); ++i) { + for (const auto& span : outcome_spans) { + if (candidates[i].begin < span.second && span.first < candidates[i].end) { + rejected[i] = true; + break; + } + } + } bool unmatched_expiry = false; static const std::regex trailing_expiry( R"(^\s+(?:\d+\s*)?(?:s|sec|secs|m|min|mins|h|hr|hour|hours|d|day|days|w|week|weeks|tick|ticks)\b)", @@ -331,27 +669,60 @@ namespace optionx::bridges::telegram { return TelegramOutcomeResult::UNKNOWN; } - void parse_outcomes( + std::vector parse_outcomes( const TelegramRawMessage& raw, TelegramParsedMessage& result) const { + std::vector spans; for (const auto& compiled : m_outcome_rules) { for (std::sregex_iterator it(raw.text.begin(), raw.text.end(), compiled.expression); it != std::sregex_iterator(); ++it) { const auto& match = *it; try { - if (!has_group(match, compiled.spec.result_group)) { + if (compiled.spec.fixed_result == TelegramOutcomeResult::UNKNOWN && + !has_group(match, compiled.spec.result_group)) { throw std::invalid_argument("outcome rule has no result"); } TelegramParsedOutcome outcome; outcome.source_message_identity = raw.message_identity(); outcome.reply_to_message_identity = raw.reply_to_message_identity(); if (has_group(match, compiled.spec.symbol_group)) { - outcome.symbol = upper(match[compiled.spec.symbol_group].str()); + outcome.market = classify_market( + match[compiled.spec.symbol_group].str(), match.str()); + outcome.symbol = canonical_symbol( + normalize_symbol(match[compiled.spec.symbol_group].str()), + outcome.market); + } + outcome.result = compiled.spec.fixed_result; + if (has_group(match, compiled.spec.result_group)) { + outcome.result = outcome_from_token( + match[compiled.spec.result_group].str()); + } + if (has_group(match, compiled.spec.direction_group)) { + const auto direction = resolve_direction( + match[compiled.spec.direction_group].str()); + if (direction) { + outcome.order_type = *direction; + } + } + else if (const auto direction = resolve_direction( + raw, + static_cast(match.position()), + static_cast(match.position() + match.length()))) { + outcome.order_type = *direction; } - outcome.result = outcome_from_token( - match[compiled.spec.result_group].str()); outcome.signal_name = raw.chat_title; outcome.raw_text = match.str(); + const TextSpan span{ + static_cast(match.position()), + static_cast(match.position() + match.length())}; + const auto duplicate = std::find_if( + spans.begin(), spans.end(), [&](const auto& existing) { + return existing == span; + }); + if (duplicate != spans.end()) { + continue; + } + spans.push_back(span); result.outcomes.push_back(std::move(outcome)); } catch (const std::exception& error) { @@ -364,10 +735,12 @@ namespace optionx::bridges::telegram { } } } + return spans; } TelegramParserConfig m_config; std::vector m_signal_rules; + std::vector m_direction_rules; std::vector m_outcome_rules; }; diff --git a/tests/telegram_signal_bridge_test.cpp b/tests/telegram_signal_bridge_test.cpp index aa3b2e5..be28379 100644 --- a/tests/telegram_signal_bridge_test.cpp +++ b/tests/telegram_signal_bridge_test.cpp @@ -102,6 +102,28 @@ TEST(TelegramSignalBridge, RejectsInvalidConfigurationBeforeStartingSource) { EXPECT_FALSE(source->started); } +TEST(TelegramSignalBridgeConfig, RoundTripsParserRecognitionSettings) { + auto original = config(); + nlohmann::json serialized; + original->to_json(serialized); + + optionx::bridges::telegram::TelegramSignalBridgeConfig restored; + restored.from_json(serialized); + + EXPECT_EQ(restored.parser.symbol_pattern, original->parser.symbol_pattern); + EXPECT_EQ(restored.parser.otc_symbol_suffix, original->parser.otc_symbol_suffix); + ASSERT_EQ(restored.parser.direction_rules.size(), + original->parser.direction_rules.size()); + ASSERT_EQ(restored.parser.outcome_rules.size(), + original->parser.outcome_rules.size()); + EXPECT_EQ(restored.parser.direction_rules.front().pattern, + original->parser.direction_rules.front().pattern); + EXPECT_EQ(restored.parser.outcome_rules.back().fixed_result, + optionx::bridges::telegram::TelegramOutcomeResult::LOSS); + EXPECT_EQ(restored.parser.outcome_rules.back().direction_group, + original->parser.outcome_rules.back().direction_group); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/tests/telegram_signal_parser_test.cpp b/tests/telegram_signal_parser_test.cpp index 5261a39..79f579e 100644 --- a/tests/telegram_signal_parser_test.cpp +++ b/tests/telegram_signal_parser_test.cpp @@ -29,6 +29,169 @@ TEST(TelegramSignalParser, ParsesExecutableSignalAndNormalizesExpiry) { EXPECT_TRUE(parsed.outcomes.empty()); } +TEST(TelegramSignalParser, ParsesRealWorldPairFormatsAndStrategyName) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text( + "BTC / USD m5 BUY COBRA -5\n" + "5M EURUSD 13:50 SELL\n" + "30M EURCAD 19:00 SELL")); + + ASSERT_EQ(parsed.signals.size(), 3u); + EXPECT_EQ(parsed.signals[0].symbol, "BTC/USD"); + EXPECT_EQ(parsed.signals[0].duration, 300u); + EXPECT_EQ(parsed.signals[0].order_type, optionx::OrderType::BUY); + EXPECT_EQ(parsed.signals[0].signal_name, "COBRA"); + EXPECT_EQ(parsed.signals[1].symbol, "EURUSD"); + EXPECT_EQ(parsed.signals[1].duration, 300u); + EXPECT_EQ(parsed.signals[1].order_type, optionx::OrderType::SELL); + EXPECT_EQ(parsed.signals[2].symbol, "EURCAD"); + EXPECT_EQ(parsed.signals[2].duration, 1800u); +} + +TEST(TelegramSignalParser, ParsesProfitOutcomeFromReplyText) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text("BTCUSD 13:50 SELL\nBTCUSD -> Profit 5586 | 55 =99.0")); + + ASSERT_EQ(parsed.outcomes.size(), 1u); + EXPECT_EQ(parsed.outcomes[0].symbol, "BTCUSD"); + EXPECT_EQ(parsed.outcomes[0].result, + optionx::bridges::telegram::TelegramOutcomeResult::WIN); +} + +TEST(TelegramSignalParser, ParsesEmojiDecoratedScheduledSignal) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(u8"🔊 5M BTCUSD 15:50 BUY ⬆️")); + + ASSERT_EQ(parsed.signals.size(), 1u); + EXPECT_TRUE(parsed.outcomes.empty()); + EXPECT_EQ(parsed.signals[0].symbol, "BTCUSD"); + EXPECT_EQ(parsed.signals[0].order_type, optionx::OrderType::BUY); + EXPECT_EQ(parsed.signals[0].duration, 300u); +} + +TEST(TelegramSignalParser, OutcomeEmojiSuppressesOverlappingSignalCandidate) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(u8"➡️ BTC / USD m5 SELL COBRA -5 ✅ 5612 | 55 =99.0")); + + EXPECT_TRUE(parsed.signals.empty()); + ASSERT_EQ(parsed.outcomes.size(), 1u); + EXPECT_EQ(parsed.outcomes[0].symbol, "BTC/USD"); + EXPECT_EQ(parsed.outcomes[0].order_type, optionx::OrderType::SELL); + EXPECT_EQ(parsed.outcomes[0].result, + optionx::bridges::telegram::TelegramOutcomeResult::WIN); +} + +TEST(TelegramSignalParser, UsesDirectionEmojiForStatisticsSignal) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(u8"🔽 BTCUSD gap-01, win 60%, pay 70%, ping 562 ms, delay 281 ms")); + + ASSERT_EQ(parsed.signals.size(), 1u); + EXPECT_TRUE(parsed.outcomes.empty()); + EXPECT_EQ(parsed.signals[0].symbol, "BTCUSD"); + EXPECT_EQ(parsed.signals[0].order_type, optionx::OrderType::SELL); +} + +TEST(TelegramSignalParser, ClassifiesEmojiResultWithoutPayoutDetails) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(u8"✅ BTCUSD gap-01, stats 55%")); + + EXPECT_TRUE(parsed.signals.empty()); + ASSERT_EQ(parsed.outcomes.size(), 1u); + EXPECT_EQ(parsed.outcomes[0].symbol, "BTCUSD"); + EXPECT_EQ(parsed.outcomes[0].result, + optionx::bridges::telegram::TelegramOutcomeResult::WIN); +} + +TEST(TelegramSignalParser, SupportsCustomSymbolAndDirectionRules) { + auto config = optionx::bridges::telegram::TelegramSignalParser::default_config(); + config.direction_rules = { + {"up", R"(\bUP\b)", optionx::OrderType::BUY}, + {"down", R"(\bDOWN\b)", optionx::OrderType::SELL}, + }; + config.signal_rules = { + {"custom", R"(\b{{SYMBOL}}\s+(UP|DOWN)\s+(\d+)(m)\b)", + 1, 2, 3, 4, optionx::OptionType::SPRINT}, + }; + + const auto parsed = optionx::bridges::telegram::TelegramSignalParser(std::move(config)).parse( + message_with_text("xEURUSD-OTC UP 5m")); + + ASSERT_EQ(parsed.signals.size(), 1u); + EXPECT_EQ(parsed.signals[0].symbol, "XEURUSD_OTC"); + EXPECT_EQ(parsed.signals[0].order_type, optionx::OrderType::BUY); + EXPECT_EQ(parsed.signals[0].duration, 300u); +} + +TEST(TelegramSignalParser, KeepsOtcMarketSeparateFromUnqualifiedSymbol) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text("EURUSD-OTC BUY 5m\nEURUSD SELL 5m\nEURUSD OTC BUY 5m")); + + ASSERT_EQ(parsed.signals.size(), 3u); + EXPECT_EQ(parsed.signals[0].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.signals[0].market, + optionx::bridges::telegram::TelegramAssetMarket::OTC); + EXPECT_EQ(parsed.signals[1].symbol, "EURUSD"); + EXPECT_EQ(parsed.signals[1].market, + optionx::bridges::telegram::TelegramAssetMarket::UNKNOWN); + EXPECT_EQ(parsed.signals[2].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.signals[2].market, + optionx::bridges::telegram::TelegramAssetMarket::OTC); +} + +TEST(TelegramSignalParser, CanonicalizesCommonOtcSymbolSpellings) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text( + "EURUSD_OTC BUY 5m\n" + "EURUSD-OTC SELL 5m\n" + "EURUSDOTC BUY 5m\n" + "EURUSD OTC SELL 5m\n" + "EUR/USD OTC BUY 5m")); + + ASSERT_EQ(parsed.signals.size(), 5u); + for (const auto& signal : parsed.signals) { + EXPECT_EQ(signal.symbol, "EURUSD_OTC"); + EXPECT_EQ(signal.market, + optionx::bridges::telegram::TelegramAssetMarket::OTC); + } +} + +TEST(TelegramSignalParser, CanonicalizesOtcOutcomeSymbol) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text("EURUSDOTC WIN")); + + ASSERT_EQ(parsed.outcomes.size(), 1u); + EXPECT_EQ(parsed.outcomes[0].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.outcomes[0].market, + optionx::bridges::telegram::TelegramAssetMarket::OTC); +} + +TEST(TelegramSignalParser, KeepsSignalsAndOutcomesSeparateInLiveSmokeBatch) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text( + "SMOKE-OTC-BATCH\n" + "EURUSD_OTC BUY 5m\n" + "EURUSD-OTC SELL 5m\n" + "EURUSDOTC BUY 5m\n" + "EURUSD OTC SELL 5m\n" + "EUR/USD OTC BUY 5m\n" + "BTC / USD m5 BUY COBRA -5\n" + "5M EURUSD 15:50 SELL\n" + "30M EURCAD 19:00 SELL\n" + "✅ EURUSDOTC gap-01, stats 55%\n" + "➡️ EUR/USD m5 SELL COBRA -5 ✅ 5612 | 55 =99.0\n" + "EURUSD BUY 5 weeks\n" + "EURUSD UP 5m\n" + "This is EURUSD but not a trade")); + + ASSERT_EQ(parsed.signals.size(), 8u); + EXPECT_EQ(parsed.outcomes.size(), 2u); + for (const auto& signal : parsed.signals) { + if (signal.market == optionx::bridges::telegram::TelegramAssetMarket::OTC) { + EXPECT_EQ(signal.symbol, "EURUSD_OTC"); + } + } +} + TEST(TelegramSignalParser, ParsesMultipleSignalsAndOutcomeSeparately) { const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( message_with_text("EURUSD BUY 5m\nGBPUSD SELL 10m\nEURUSD WIN")); From 242d8f9c89f79ec7753c316ffa05add2fc736576 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sun, 9 Aug 2026 02:20:29 +0300 Subject: [PATCH 2/3] fix(telegram): deduplicate overlapping outcomes --- .../bridges/telegram/TelegramSignalParser.hpp | 116 ++++++++++++++++-- tests/telegram_signal_parser_test.cpp | 34 +++++ 2 files changed, 137 insertions(+), 13 deletions(-) diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp index 26c4562..9f95f7e 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp @@ -183,6 +183,19 @@ namespace optionx::bridges::telegram { 1, 2, }); + config.outcome_rules.push_back({ + "pair-otc-outcome", + "\\b" + symbol + R"([ \t]+OTC[ \t]+(WIN|LOSS|REFUND|DRAW)\b)", + 1, + 2, + }); + config.outcome_rules.push_back({ + "otc-pair-outcome", + R"(\bOTC[ \t]+)" + symbol + + R"([ \t]+(WIN|LOSS|REFUND|DRAW)\b)", + 1, + 2, + }); config.outcome_rules.push_back({ "pair-profit", "\\b" + symbol + R"([^\r\n]{0,40}\bPROFIT\b)", @@ -280,6 +293,12 @@ namespace optionx::bridges::telegram { TelegramParsedSignal signal; }; + struct OutcomeCandidate { + std::size_t begin = 0; + std::size_t end = 0; + TelegramParsedOutcome outcome; + }; + using TextSpan = std::pair; std::string expand_symbol_pattern(std::string pattern) const { @@ -533,6 +552,15 @@ namespace optionx::bridges::telegram { left.signal_name == right.signal_name; } + static bool same_outcome( + const TelegramParsedOutcome& left, + const TelegramParsedOutcome& right) { + return left.symbol == right.symbol && + left.order_type == right.order_type && + left.result == right.result && + left.market == right.market; + } + void parse_signals( const TelegramRawMessage& raw, TelegramParsedMessage& result, @@ -669,10 +697,21 @@ namespace optionx::bridges::telegram { return TelegramOutcomeResult::UNKNOWN; } + static bool has_otc_prefix( + const std::string& text, + const std::size_t begin) { + const auto line_begin = text.rfind('\n', begin == 0 ? 0 : begin - 1); + const auto first = line_begin == std::string::npos ? 0 : line_begin + 1; + auto prefix = trim_ascii(text.substr(first, begin - first)); + const auto separator = prefix.find_last_of(" \t"); + prefix = prefix.substr(separator == std::string::npos ? 0 : separator + 1); + return upper(std::move(prefix)) == "OTC"; + } + std::vector parse_outcomes( const TelegramRawMessage& raw, TelegramParsedMessage& result) const { - std::vector spans; + std::vector candidates; for (const auto& compiled : m_outcome_rules) { for (std::sregex_iterator it(raw.text.begin(), raw.text.end(), compiled.expression); it != std::sregex_iterator(); ++it) { @@ -686,11 +725,22 @@ namespace optionx::bridges::telegram { outcome.source_message_identity = raw.message_identity(); outcome.reply_to_message_identity = raw.reply_to_message_identity(); if (has_group(match, compiled.spec.symbol_group)) { + const auto normalized_symbol = normalize_symbol( + match[compiled.spec.symbol_group].str()); outcome.market = classify_market( match[compiled.spec.symbol_group].str(), match.str()); outcome.symbol = canonical_symbol( - normalize_symbol(match[compiled.spec.symbol_group].str()), + normalized_symbol, outcome.market); + if (outcome.market == TelegramAssetMarket::UNKNOWN && + has_otc_prefix( + raw.text, + static_cast(match.position()))) { + outcome.market = TelegramAssetMarket::OTC; + outcome.symbol = canonical_symbol( + normalized_symbol, + outcome.market); + } } outcome.result = compiled.spec.fixed_result; if (has_group(match, compiled.spec.result_group)) { @@ -712,18 +762,11 @@ namespace optionx::bridges::telegram { } outcome.signal_name = raw.chat_title; outcome.raw_text = match.str(); - const TextSpan span{ + candidates.push_back({ static_cast(match.position()), - static_cast(match.position() + match.length())}; - const auto duplicate = std::find_if( - spans.begin(), spans.end(), [&](const auto& existing) { - return existing == span; - }); - if (duplicate != spans.end()) { - continue; - } - spans.push_back(span); - result.outcomes.push_back(std::move(outcome)); + static_cast(match.position() + match.length()), + std::move(outcome), + }); } catch (const std::exception& error) { result.diagnostics.push_back({ @@ -735,6 +778,53 @@ namespace optionx::bridges::telegram { } } } + + std::sort(candidates.begin(), candidates.end(), []( + const auto& left, + const auto& right) { + if (left.begin != right.begin) { + return left.begin < right.begin; + } + return left.end < right.end; + }); + + std::vector rejected(candidates.size(), false); + bool ambiguous = false; + for (std::size_t i = 0; i < candidates.size(); ++i) { + for (std::size_t j = i + 1; j < candidates.size(); ++j) { + if (candidates[j].begin >= candidates[i].end) { + break; + } + if (same_outcome( + candidates[i].outcome, + candidates[j].outcome)) { + rejected[j] = true; + } + else { + rejected[i] = true; + rejected[j] = true; + ambiguous = true; + } + } + } + + if (ambiguous) { + result.diagnostics.push_back({ + "ambiguous_overlapping_outcome", + "overlapping outcome rules produced different meanings", + 0, + raw.text.size(), + }); + } + + std::vector spans; + for (std::size_t i = 0; i < candidates.size(); ++i) { + if (rejected[i]) { + continue; + } + spans.emplace_back(candidates[i].begin, candidates[i].end); + result.outcomes.push_back(std::move(candidates[i].outcome)); + } return spans; } diff --git a/tests/telegram_signal_parser_test.cpp b/tests/telegram_signal_parser_test.cpp index 79f579e..9736e00 100644 --- a/tests/telegram_signal_parser_test.cpp +++ b/tests/telegram_signal_parser_test.cpp @@ -165,6 +165,40 @@ TEST(TelegramSignalParser, CanonicalizesOtcOutcomeSymbol) { optionx::bridges::telegram::TelegramAssetMarket::OTC); } +TEST(TelegramSignalParser, ParsesSeparatedOtcOutcomeSpellings) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text( + "EURUSD OTC WIN\n" + "EUR/USD OTC LOSS\n" + "OTC EURUSD REFUND")); + + ASSERT_EQ(parsed.outcomes.size(), 3u); + EXPECT_EQ(parsed.outcomes[0].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.outcomes[0].result, + optionx::bridges::telegram::TelegramOutcomeResult::WIN); + EXPECT_EQ(parsed.outcomes[1].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.outcomes[1].result, + optionx::bridges::telegram::TelegramOutcomeResult::LOSS); + EXPECT_EQ(parsed.outcomes[2].symbol, "EURUSD_OTC"); + EXPECT_EQ(parsed.outcomes[2].result, + optionx::bridges::telegram::TelegramOutcomeResult::REFUND); + for (const auto& outcome : parsed.outcomes) { + EXPECT_EQ(outcome.market, + optionx::bridges::telegram::TelegramAssetMarket::OTC); + } +} + +TEST(TelegramSignalParser, DeduplicatesOverlappingOutcomeMatches) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(std::string("EURUSD WIN ") + "\xE2\x9C\x85")); + + EXPECT_TRUE(parsed.signals.empty()); + ASSERT_EQ(parsed.outcomes.size(), 1u); + EXPECT_EQ(parsed.outcomes[0].symbol, "EURUSD"); + EXPECT_EQ(parsed.outcomes[0].result, + optionx::bridges::telegram::TelegramOutcomeResult::WIN); +} + TEST(TelegramSignalParser, KeepsSignalsAndOutcomesSeparateInLiveSmokeBatch) { const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( message_with_text( From bc067114ce0219c02e00db4a33ee5df3e3092b60 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sun, 9 Aug 2026 03:45:26 +0300 Subject: [PATCH 3/3] fix(telegram): block signals on ambiguous outcomes --- .../bridges/telegram/TelegramSignalParser.hpp | 10 +++++++--- tests/telegram_signal_parser_test.cpp | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp index 9f95f7e..6c8a8bd 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalParser.hpp @@ -788,6 +788,12 @@ namespace optionx::bridges::telegram { return left.end < right.end; }); + std::vector blocking_spans; + blocking_spans.reserve(candidates.size()); + for (const auto& candidate : candidates) { + blocking_spans.emplace_back(candidate.begin, candidate.end); + } + std::vector rejected(candidates.size(), false); bool ambiguous = false; for (std::size_t i = 0; i < candidates.size(); ++i) { @@ -817,15 +823,13 @@ namespace optionx::bridges::telegram { }); } - std::vector spans; for (std::size_t i = 0; i < candidates.size(); ++i) { if (rejected[i]) { continue; } - spans.emplace_back(candidates[i].begin, candidates[i].end); result.outcomes.push_back(std::move(candidates[i].outcome)); } - return spans; + return blocking_spans; } TelegramParserConfig m_config; diff --git a/tests/telegram_signal_parser_test.cpp b/tests/telegram_signal_parser_test.cpp index 9736e00..93486d3 100644 --- a/tests/telegram_signal_parser_test.cpp +++ b/tests/telegram_signal_parser_test.cpp @@ -199,6 +199,17 @@ TEST(TelegramSignalParser, DeduplicatesOverlappingOutcomeMatches) { optionx::bridges::telegram::TelegramOutcomeResult::WIN); } +TEST(TelegramSignalParser, BlocksSignalsWhenOutcomeMeaningIsAmbiguous) { + const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( + message_with_text(std::string("EURUSD BUY 5m ") + "\xE2\x9C\x85 \xE2\x9D\x8C")); + + EXPECT_TRUE(parsed.signals.empty()); + EXPECT_TRUE(parsed.outcomes.empty()); + ASSERT_EQ(parsed.diagnostics.size(), 1u); + EXPECT_EQ(parsed.diagnostics.front().code, + "ambiguous_overlapping_outcome"); +} + TEST(TelegramSignalParser, KeepsSignalsAndOutcomesSeparateInLiveSmokeBatch) { const auto parsed = optionx::bridges::telegram::TelegramSignalParser().parse( message_with_text(