diff --git a/CMakeLists.txt b/CMakeLists.txt index 767464c..8247af7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -889,6 +889,7 @@ if(OPTIONX_BUILD_TESTS) metatrader_paths_test telegram_dto_test telegram_signal_parser_test + telegram_signal_bridge_test ) if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) list(APPEND OPTIONX_LIGHTWEIGHT_TESTS diff --git a/include/optionx_cpp/bridges/telegram.hpp b/include/optionx_cpp/bridges/telegram.hpp index 8b304f2..d00b914 100644 --- a/include/optionx_cpp/bridges/telegram.hpp +++ b/include/optionx_cpp/bridges/telegram.hpp @@ -8,5 +8,7 @@ #include "bridges/telegram/TelegramParsedMessage.hpp" #include "bridges/telegram/TelegramRawMessage.hpp" #include "bridges/telegram/TelegramSignalParser.hpp" +#include "bridges/telegram/TelegramSignalBridgeConfig.hpp" +#include "bridges/telegram/TelegramSignalBridge.hpp" #endif // OPTIONX_HEADER_BRIDGES_TELEGRAM_HPP_INCLUDED diff --git a/include/optionx_cpp/bridges/telegram/TelegramRawMessage.hpp b/include/optionx_cpp/bridges/telegram/TelegramRawMessage.hpp index 706a19c..d30c303 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramRawMessage.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramRawMessage.hpp @@ -73,7 +73,6 @@ namespace optionx::bridges::telegram { /// \brief Serializes the DTO using worker-compatible field names. nlohmann::json to_json() const { - validate(); return nlohmann::json{ {"chat_id", chat_id}, {"chat_title", chat_title}, diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp new file mode 100644 index 0000000..ae3bc09 --- /dev/null +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp @@ -0,0 +1,439 @@ +#pragma once +#ifndef OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_HPP_INCLUDED +#define OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_HPP_INCLUDED + +/// \file TelegramSignalBridge.hpp +/// \brief Converts Telegram message events into OptionX TradeSignal objects. + +#include "bridges/BaseBridge.hpp" +#include "bridges/detail/BridgeTradeSignalValidation.hpp" +#include "bridges/telegram/TelegramSignalBridgeConfig.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace optionx::bridges::telegram { + + /// \class TelegramMessageSource + /// \brief Minimal live-message source boundary used by the Telegram bridge. + /// + /// A concrete adapter may be backed by tg-client-stdio, a test fixture, or + /// another Telegram client. The source must stop invoking callbacks before + /// `stop()` returns. + class TelegramMessageSource { + public: + using message_callback_t = std::function; + using error_callback_t = std::function; + + virtual ~TelegramMessageSource() = default; + virtual bool start(message_callback_t on_message, + error_callback_t on_error) = 0; + virtual void stop() noexcept = 0; + }; + + /// \class TelegramSignalBridge + /// \brief Publishes executable signals parsed from live Telegram messages. + class TelegramSignalBridge final : public BaseBridge { + private: + struct RuntimeState { + std::mutex mutex; + bridge_status_callback_t status_callback; + BaseBridge::trade_signal_callback_t trade_signal_callback; + BaseBridge::signal_report_callback_t signal_report_callback; + BaseBridge::signal_id_allocator_t signal_id_allocator; + std::shared_ptr source; + std::deque dedupe_order; + std::unordered_set dedupe_keys; + bool running = false; + }; + + public: + explicit TelegramSignalBridge( + std::shared_ptr source = {}) + : m_state(std::make_shared()), + m_source(std::move(source)) {} + + ~TelegramSignalBridge() override { + shutdown(); + } + + /// \brief Replaces the live source while the bridge is stopped. + bool set_message_source(std::shared_ptr source) { + std::lock_guard lock(m_state->mutex); + if (m_state->running) { + return false; + } + m_source = std::move(source); + return true; + } + + bool configure(std::unique_ptr config) override { + if (!config) { + return false; + } + const auto* typed = dynamic_cast(config.get()); + if (!typed) { + config->dispatch_callbacks(false, "Invalid Telegram signal bridge config type."); + return false; + } + auto next_config = std::make_shared(*typed); + const auto validation = next_config->validate(); + config->dispatch_callbacks(validation.first, validation.second); + if (!validation.first) { + return false; + } + std::lock_guard lock(m_config_mutex); + m_config = std::move(next_config); + return true; + } + + bridge_status_callback_t& on_status_update() override { + return m_state->status_callback; + } + + trade_signal_callback_t& on_trade_signal() override { + return m_state->trade_signal_callback; + } + + signal_report_callback_t& on_signal_report() override { + return m_state->signal_report_callback; + } + + signal_id_allocator_t& on_signal_id() override { + return m_state->signal_id_allocator; + } + + void update_account_info(const AccountInfoUpdate& info) override { + (void)info; + } + + void run() override { + const auto config = get_config(); + if (!config) { + notify_status(BridgeStatus::SERVER_START_FAILED, + "Telegram bridge is not configured."); + return; + } + auto source = get_source(); + if (!source) { + notify_status(BridgeStatus::SERVER_START_FAILED, + "Telegram bridge has no message source."); + return; + } + if (!get_signal_id_allocator()) { + notify_status(BridgeStatus::SERVER_START_FAILED, + "Telegram bridge requires a signal ID allocator."); + return; + } + + { + std::lock_guard lock(m_state->mutex); + if (m_state->running) { + return; + } + m_state->running = true; + m_state->source = source; + m_state->dedupe_keys.clear(); + m_state->dedupe_order.clear(); + } + + try { + const auto parser = TelegramSignalParser(config->parser); + const bool started = source->start( + [state = m_state, config, parser](const TelegramRawMessage& raw) { + process_message(state, *config, parser, raw); + }, + [state = m_state](const std::string& message) { + notify_status(state, BridgeStatus::CONNECTION_ERROR, message); + }); + if (!started) { + set_running(false); + notify_status(BridgeStatus::SERVER_START_FAILED, + "Telegram message source failed to start."); + return; + } + notify_status(BridgeStatus::SERVER_STARTED, {}); + } + catch (const std::exception& error) { + set_running(false); + notify_status(BridgeStatus::SERVER_START_FAILED, error.what()); + } + catch (...) { + set_running(false); + notify_status(BridgeStatus::SERVER_START_FAILED, + "Telegram message source threw an unknown exception."); + } + } + + void shutdown() override { + std::shared_ptr source; + bool was_running = false; + { + std::lock_guard lock(m_state->mutex); + was_running = m_state->running; + m_state->running = false; + source = m_state->source; + m_state->source.reset(); + } + if (source) { + try { + source->stop(); + } + catch (...) { + notify_status(BridgeStatus::CONNECTION_ERROR, + "Telegram message source threw during stop."); + } + } + if (was_running) { + notify_status(BridgeStatus::SERVER_STOPPED, {}); + } + } + + private: + std::shared_ptr get_config() const { + std::lock_guard lock(m_config_mutex); + return m_config; + } + + std::shared_ptr get_source() const { + std::lock_guard lock(m_state->mutex); + return m_source; + } + + BaseBridge::signal_id_allocator_t get_signal_id_allocator() const { + std::lock_guard lock(m_state->mutex); + return m_state->signal_id_allocator; + } + + void set_running(const bool running) { + std::lock_guard lock(m_state->mutex); + m_state->running = running; + if (!running) { + m_state->source.reset(); + } + } + + static void notify_status( + const std::shared_ptr& state, + const BridgeStatus status, + const std::string& message) { + bridge_status_callback_t callback; + { + std::lock_guard lock(state->mutex); + callback = state->status_callback; + } + if (callback) { + try { + callback({status, {}, message}); + } + catch (...) { + } + } + } + + void notify_status(const BridgeStatus status, const std::string& message) const { + notify_status(m_state, status, message); + } + + static void emit_report( + const std::shared_ptr& state, + BridgeSignalReport report) { + signal_report_callback_t callback; + { + std::lock_guard lock(state->mutex); + callback = state->signal_report_callback; + } + if (callback) { + try { + callback(report); + } + catch (...) { + } + } + } + + static std::string make_dedupe_key( + const TelegramRawMessage& raw, + const TelegramParsedSignal& parsed, + const std::size_t index) { + return raw.message_identity() + "|" + std::to_string(index) + "|" + + parsed.symbol + "|" + optionx::to_str(parsed.order_type) + "|" + + optionx::to_str(parsed.option_type) + "|" + + std::to_string(parsed.duration) + "|" + + std::to_string(parsed.expiry_time); + } + + static void process_message( + const std::shared_ptr& state, + const TelegramSignalBridgeConfig& config, + const TelegramSignalParser& parser, + const TelegramRawMessage& raw) { + try { + raw.validate(); + const auto parsed = parser.parse(raw); + for (const auto& diagnostic : parsed.diagnostics) { + BridgeSignalReport report; + report.bridge_id = config.bridge_id; + report.bridge_type = BridgeType::TELEGRAM_SIGNAL; + report.status = BridgeSignalReportStatus::INVALID; + report.reason_code = diagnostic.code; + report.message = diagnostic.message; + report.event_id = raw.message_identity(); + report.raw_payload = raw.to_json(); + report.context = { + {"offset", diagnostic.offset}, + {"length", diagnostic.length}, + }; + emit_report(state, std::move(report)); + } + + for (std::size_t index = 0; index < parsed.signals.size(); ++index) { + const auto& parsed_signal = parsed.signals[index]; + auto signal = std::make_unique(); + signal->bridge_id = config.bridge_id; + signal->symbol = parsed_signal.symbol; + signal->order_type = parsed_signal.order_type; + signal->option_type = parsed_signal.option_type; + signal->duration = parsed_signal.duration; + signal->expiry_time = parsed_signal.expiry_time; + signal->signal_name = parsed_signal.signal_name; + signal->comment = raw.text; + signal->amount = config.fixed_amount; + const auto dedupe_key = make_dedupe_key(raw, parsed_signal, index); + signal->unique_hash = dedupe_key; + + detail::validate_executable_trade_signal( + *signal, "Telegram signal", true); + + BaseBridge::signal_id_allocator_t allocator; + BaseBridge::trade_signal_callback_t callback; + bool duplicate = false; + { + std::lock_guard lock(state->mutex); + if (!state->running) { + return; + } + if (state->dedupe_keys.find(dedupe_key) != state->dedupe_keys.end()) { + duplicate = true; + } + else { + state->dedupe_keys.insert(dedupe_key); + state->dedupe_order.push_back(dedupe_key); + while (state->dedupe_order.size() > config.dedupe_cache_size) { + state->dedupe_keys.erase(state->dedupe_order.front()); + state->dedupe_order.pop_front(); + } + allocator = state->signal_id_allocator; + callback = state->trade_signal_callback; + } + } + if (duplicate) { + BridgeSignalReport report; + report.bridge_id = config.bridge_id; + report.bridge_type = BridgeType::TELEGRAM_SIGNAL; + report.status = BridgeSignalReportStatus::DUPLICATE; + report.reason_code = "duplicate_message"; + report.message = "Telegram signal was already dispatched."; + report.event_id = raw.message_identity(); + report.dedupe_key = dedupe_key; + report.symbol = parsed_signal.symbol; + report.signal_name = parsed_signal.signal_name; + report.raw_payload = raw.to_json(); + emit_report(state, std::move(report)); + continue; + } + + try { + signal->signal_id = allocator(); + if (signal->signal_id == 0) { + throw std::runtime_error("Telegram signal ID allocator returned zero."); + } + } + catch (const std::exception& error) { + { + std::lock_guard lock(state->mutex); + state->dedupe_keys.erase(dedupe_key); + } + emit_report(state, BridgeSignalReport{ + config.bridge_id, + BridgeType::TELEGRAM_SIGNAL, + BridgeSignalReportStatus::INTAKE_ERROR, + "signal_id_allocation_failed", + error.what(), + {}, + raw.message_identity(), + dedupe_key, + parsed_signal.symbol, + parsed_signal.signal_name, + {}, + raw.to_json(), + {}, + {}, + 0, + raw.date_ms, + }); + continue; + } + if (callback) { + try { + callback(std::move(signal)); + } + catch (...) { + emit_report(state, BridgeSignalReport{ + config.bridge_id, + BridgeType::TELEGRAM_SIGNAL, + BridgeSignalReportStatus::INTAKE_ERROR, + "trade_signal_callback_failed", + "Telegram trade signal callback threw.", + {}, + raw.message_identity(), + dedupe_key, + parsed_signal.symbol, + parsed_signal.signal_name, + {}, + raw.to_json(), + {}, + {}, + 0, + raw.date_ms, + }); + } + } + } + } + catch (const std::exception& error) { + emit_report(state, BridgeSignalReport{ + config.bridge_id, + BridgeType::TELEGRAM_SIGNAL, + BridgeSignalReportStatus::INVALID, + "telegram_message_parse_failed", + error.what(), + {}, + raw.message_identity(), + {}, + {}, + {}, + {}, + raw.to_json(), + {}, + {}, + 0, + raw.date_ms, + }); + } + } + + std::shared_ptr m_state; + mutable std::mutex m_config_mutex; + std::shared_ptr m_config; + std::shared_ptr m_source; + }; + +} // namespace optionx::bridges::telegram + +#endif // OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_HPP_INCLUDED diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp new file mode 100644 index 0000000..0578f01 --- /dev/null +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp @@ -0,0 +1,122 @@ +#pragma once +#ifndef OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_CONFIG_HPP_INCLUDED +#define OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_CONFIG_HPP_INCLUDED + +/// \file TelegramSignalBridgeConfig.hpp +/// \brief Configuration for the Telegram signal bridge. + +#include "data/bridge.hpp" +#include "bridges/telegram/TelegramSignalParser.hpp" + +#include +#include +#include + +namespace optionx::bridges::telegram { + + /// \class TelegramSignalBridgeConfig + /// \brief Parser and dispatch settings for Telegram live signal intake. + class TelegramSignalBridgeConfig final : public IBridgeConfig { + public: + BridgeId bridge_id = 0; + double fixed_amount = 0.0; + std::size_t dedupe_cache_size = 4096; + TelegramParserConfig parser = TelegramSignalParser::default_config(); + + void to_json(nlohmann::json& j) const override { + j = nlohmann::json{ + {"bridge_id", bridge_id}, + {"fixed_amount", fixed_amount}, + {"dedupe_cache_size", dedupe_cache_size}, + {"use_chat_title_as_signal_name", parser.use_chat_title_as_signal_name}, + {"signal_rules", nlohmann::json::array()}, + {"outcome_rules", nlohmann::json::array()}, + }; + for (const auto& rule : parser.signal_rules) { + j["signal_rules"].push_back({ + {"name", rule.name}, + {"pattern", rule.pattern}, + {"symbol_group", rule.symbol_group}, + {"direction_group", rule.direction_group}, + {"expiry_group", rule.expiry_group}, + {"unit_group", rule.unit_group}, + {"option_type", rule.option_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}, + }); + } + } + + void from_json(const nlohmann::json& j) override { + 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.use_chat_title_as_signal_name = j.value( + "use_chat_title_as_signal_name", + parser.use_chat_title_as_signal_name); + + if (j.contains("signal_rules")) { + parser.signal_rules.clear(); + for (const auto& item : j.at("signal_rules")) { + TelegramSignalRule rule; + rule.name = item.value("name", ""); + rule.pattern = item.at("pattern").get(); + rule.symbol_group = item.value("symbol_group", 1u); + rule.direction_group = item.value("direction_group", 2u); + rule.expiry_group = item.value("expiry_group", 3u); + rule.unit_group = item.value("unit_group", 4u); + rule.option_type = item.value("option_type", OptionType::SPRINT); + parser.signal_rules.push_back(std::move(rule)); + } + } + if (j.contains("outcome_rules")) { + parser.outcome_rules.clear(); + for (const auto& item : j.at("outcome_rules")) { + TelegramOutcomeRule rule; + rule.name = item.value("name", ""); + rule.pattern = item.at("pattern").get(); + rule.symbol_group = item.value("symbol_group", 1u); + rule.result_group = item.value("result_group", 2u); + parser.outcome_rules.push_back(std::move(rule)); + } + } + } + + std::pair validate() const override { + if (!std::isfinite(fixed_amount) || fixed_amount <= 0.0) { + return {false, "Telegram fixed_amount must be positive and finite."}; + } + if (dedupe_cache_size == 0) { + return {false, "Telegram dedupe_cache_size must be positive."}; + } + try { + (void)TelegramSignalParser(parser); + } + catch (const std::exception& error) { + return {false, std::string("Invalid Telegram parser rules: ") + error.what()}; + } + return {true, {}}; + } + + std::unique_ptr clone_unique() const override { + return std::make_unique(*this); + } + + std::shared_ptr clone_shared() const override { + return std::make_shared(*this); + } + + BridgeType bridge_type() const override { + return BridgeType::TELEGRAM_SIGNAL; + } + }; + +} // namespace optionx::bridges::telegram + +#endif // OPTIONX_HEADER_BRIDGES_TELEGRAM_TELEGRAM_SIGNAL_BRIDGE_CONFIG_HPP_INCLUDED diff --git a/include/optionx_cpp/data/trading/enums.hpp b/include/optionx_cpp/data/trading/enums.hpp index 04a27c7..fa5b51c 100644 --- a/include/optionx_cpp/data/trading/enums.hpp +++ b/include/optionx_cpp/data/trading/enums.hpp @@ -100,7 +100,8 @@ namespace optionx { METATRADER_FILE_TRANSPORT, ///< MetaTrader common-files JSON-RPC bridge transport. BRIDGE_PROTOCOL_V1_HTTP_WEBSOCKET, ///< Bridge Protocol v1 HTTP/WebSocket server. BRIDGE_PROTOCOL_V1_NAMED_PIPE, ///< Bridge Protocol v1 named-pipe server. - BOT_BINARY ///< BotBinary/BinaryBot compatibility bridge. + BOT_BINARY, ///< BotBinary/BinaryBot compatibility bridge. + TELEGRAM_SIGNAL ///< Telegram user-client signal bridge. }; /// \brief Converts BridgeType to its string representation. @@ -115,7 +116,8 @@ namespace optionx { "METATRADER_FILE_TRANSPORT", "BRIDGE_PROTOCOL_V1_HTTP_WEBSOCKET", "BRIDGE_PROTOCOL_V1_NAMED_PIPE", - "BOT_BINARY" + "BOT_BINARY", + "TELEGRAM_SIGNAL" }; return utils::enum_string_or_unknown(str_data, static_cast(value)); } @@ -133,6 +135,7 @@ namespace optionx { {"BRIDGE_PROTOCOL_V1_HTTP_WEBSOCKET", BridgeType::BRIDGE_PROTOCOL_V1_HTTP_WEBSOCKET}, {"BRIDGE_PROTOCOL_V1_NAMED_PIPE", BridgeType::BRIDGE_PROTOCOL_V1_NAMED_PIPE}, {"BOT_BINARY", BridgeType::BOT_BINARY}, + {"TELEGRAM_SIGNAL", BridgeType::TELEGRAM_SIGNAL}, {"BINARYBOT", BridgeType::BOT_BINARY}, {"BOTBINARY", BridgeType::BOT_BINARY} }; diff --git a/tests/telegram_signal_bridge_test.cpp b/tests/telegram_signal_bridge_test.cpp new file mode 100644 index 0000000..aa3b2e5 --- /dev/null +++ b/tests/telegram_signal_bridge_test.cpp @@ -0,0 +1,108 @@ +#include + +#include "optionx_cpp/bridges/telegram.hpp" + +#include +#include +#include + +namespace { + +class FakeMessageSource final + : public optionx::bridges::telegram::TelegramMessageSource { +public: + bool start(message_callback_t on_message, error_callback_t on_error) override { + (void)on_error; + m_on_message = std::move(on_message); + started = true; + return true; + } + + void stop() noexcept override { + stopped = true; + m_on_message = {}; + } + + void emit(optionx::bridges::telegram::TelegramRawMessage message) { + if (m_on_message) { + m_on_message(message); + } + } + + bool started = false; + bool stopped = false; + +private: + message_callback_t m_on_message; +}; + +optionx::bridges::telegram::TelegramRawMessage make_message() { + optionx::bridges::telegram::TelegramRawMessage message; + message.chat_id = "-10042"; + message.chat_title = "Signals"; + message.message_id = 123; + message.date_ms = 1800000000000; + message.text = "EURUSD BUY 5m"; + return message; +} + +std::unique_ptr config() { + auto value = std::make_unique< + optionx::bridges::telegram::TelegramSignalBridgeConfig>(); + value->bridge_id = 17; + value->fixed_amount = 1.0; + return value; +} + +} // namespace + +TEST(TelegramSignalBridge, PublishesParsedSignalAndReportsDuplicate) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + ASSERT_TRUE(bridge.configure(config())); + + std::vector> signals; + std::vector reports; + std::int64_t next_signal_id = 100; + bridge.on_signal_id() = [&] { return ++next_signal_id; }; + bridge.on_trade_signal() = [&](std::unique_ptr signal) { + signals.push_back(std::move(signal)); + }; + bridge.on_signal_report() = [&](const auto& report) { + reports.push_back(report); + }; + + bridge.run(); + ASSERT_TRUE(source->started); + source->emit(make_message()); + source->emit(make_message()); + + ASSERT_EQ(signals.size(), 1u); + EXPECT_EQ(signals.front()->signal_id, 101); + EXPECT_EQ(signals.front()->bridge_id, 17); + EXPECT_EQ(signals.front()->symbol, "EURUSD"); + EXPECT_EQ(signals.front()->duration, 300u); + ASSERT_EQ(reports.size(), 1u); + EXPECT_EQ(reports.front().status, + optionx::BridgeSignalReportStatus::DUPLICATE); + EXPECT_EQ(reports.front().reason_code, "duplicate_message"); + + bridge.shutdown(); + EXPECT_TRUE(source->stopped); +} + +TEST(TelegramSignalBridge, RejectsInvalidConfigurationBeforeStartingSource) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + auto invalid = config(); + invalid->fixed_amount = 0.0; + + EXPECT_FALSE(bridge.configure(std::move(invalid))); + bridge.run(); + EXPECT_FALSE(source->started); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}