diff --git a/guides/telegram-bridge-design.md b/guides/telegram-bridge-design.md index bddd89f..d072d26 100644 --- a/guides/telegram-bridge-design.md +++ b/guides/telegram-bridge-design.md @@ -323,9 +323,61 @@ Unknown `martingale_policy` values are rejected during configuration validation; they never fall back to `ALL_SIGNALS`. The policies only decide whether a source signal reaches the trade pipeline. -They do not implement stake sizing; a future execution policy may use -`TradeSignal::mm_step` and the source group once its money-management contract -is defined. +They do not infer a source-side stake multiplier from channel text. + +The bridge also has a separate, opt-in local anti-martingale policy. It is an +execution-side sizing policy, not a parser feature and not an interpretation of +Telegram-reported statistics or outcomes. Its configuration is deliberately +explicit: + +- `anti_martingale_enabled` is false by default; +- `anti_martingale_multiplier` must be finite and greater than one; +- `anti_martingale_max_steps` bounds consecutive winning increases; +- `anti_martingale_max_amount` is a required absolute amount cap and must be + at least `fixed_amount`. + +Each Telegram bridge instance keeps an independent series for a +chat/topic/symbol/direction/strategy group. The initial signal uses +`fixed_amount` at anti-martingale step `0`. A confirmed broker `WIN` advances +the next signal by one step and applies the multiplier, capped by +`anti_martingale_max_amount`. A `WIN` at the configured maximum step resets the +next signal to the base amount. Every other terminal broker result (`LOSS`, +`REFUND`, `STANDOFF`, cancellation, or execution/check error) also resets the +series to step `0`. + +At most one anti-martingale-managed signal may be outstanding in a group. The +bridge marks a group pending while the signal callback is running and keeps it +pending until the execution pipeline reports a terminal `TradeResult` for the +same `signal_id`. A later source message for that group is rejected while the +result is pending, avoiding two trades that both assume the same next stake. +Repeated or non-terminal result updates do not advance the series. + +The bridge reserves both its dedupe key and any source/local money-management +state before it transfers the signal to the callback. This makes a terminal +result delivered synchronously from inside the callback safe: its `signal_id` +is already registered. An allocator failure rolls the reservation back. A +callback exception is an ambiguous execution outcome, because the callback may +already have queued or submitted the trade before throwing. The bridge reports +`ambiguous_dispatch_failure` but deliberately keeps the dedupe and pending +state fail-closed rather than risking a duplicate order. + +Only the actual broker/execution `TradeResult` delivered through +`BaseBridge::update_trade_result()` changes local anti-martingale state. +Telegram outcome messages remain parser/archive data and must not advance, +reset, or otherwise size a live trade. The local anti-martingale policy cannot +be combined with source-side `CONTIGUOUS_STEPS`, because that mode has a +different requirement to dispatch every explicit source chain step. It may be +used with `ALL_SIGNALS` or `FIRST_SIGNAL_ONLY` according to the desired source +filtering behavior. + +A future optional source-chain watchdog may use correlated Telegram outcomes +to report that an expected explicit source martingale step did not appear +within a configured timeout (for example, 15 seconds), and may eventually +offer a separately enabled assumed-signal action. It must first establish a +source `signal -> outcome` correlation contract and an explicit synthetic +signal identity/diagnostic model. Broker `TradeResult` alone is not evidence +that the Telegram source intended another step, and the current bridge must +not invent an executable source signal without that contract. ## Outcomes @@ -516,8 +568,8 @@ Next steps: anonymized or synthetic regression fixtures. 2. Correlate source outcomes with signals for archive statistics and replay; reported channel statistics remain non-authoritative. -3. Define an execution-side money-management contract before adding automatic - stake sizing to the explicit martingale step metadata. +3. Extend result-driven local money management only after replay statistics and + execution contracts establish the required grouping and risk semantics. 4. Implement the bounded worker media contract before starting OCR work. 5. Revisit the deferred OCR provider after collecting representative image fixtures. OCR/vision must remain optional and must not block the text diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp index 63ee532..becd328 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp @@ -8,6 +8,7 @@ #include "bridges/BaseBridge.hpp" #include "bridges/detail/BridgeTradeSignalValidation.hpp" #include "bridges/telegram/TelegramSignalBridgeConfig.hpp" +#include "data/trading/trade_state_traits.hpp" #include #include @@ -45,6 +46,12 @@ namespace optionx::bridges::telegram { class TelegramSignalBridge final : public BaseBridge { private: struct RuntimeState { + struct AntiMartingaleGroupState { + std::uint32_t next_step = 0; + bool pending_trade = false; + SignalId pending_signal_id = 0; + }; + // Source adapters may invoke messages concurrently. Serialize the // full intake path and hold contiguous sequence steps as pending // until their allocator and callback complete. @@ -59,6 +66,10 @@ namespace optionx::bridges::telegram { std::unordered_set dedupe_keys; std::unordered_map martingale_steps; std::unordered_set pending_martingale_sequences; + std::unordered_map + anti_martingale_groups; + std::unordered_map anti_martingale_signal_groups; + std::shared_ptr active_config; bool running = false; }; @@ -122,6 +133,43 @@ namespace optionx::bridges::telegram { (void)info; } + void update_trade_result( + const TradeRequest& request, + const TradeResult& result) override { + if (!is_terminal_trade_state(result.trade_state) || request.signal_id == 0) { + return; + } + + std::lock_guard lock(m_state->mutex); + const auto config = m_state->active_config; + if (!config || !config->anti_martingale_enabled) { + return; + } + const auto signal_group = m_state->anti_martingale_signal_groups.find( + request.signal_id); + if (signal_group == m_state->anti_martingale_signal_groups.end()) { + return; + } + const auto group = m_state->anti_martingale_groups.find(signal_group->second); + if (group == m_state->anti_martingale_groups.end() || + !group->second.pending_trade || + group->second.pending_signal_id != request.signal_id) { + return; + } + + m_state->anti_martingale_signal_groups.erase(signal_group); + auto& state = group->second; + state.pending_trade = false; + state.pending_signal_id = 0; + if (is_win(result.trade_state) && + state.next_step < config->anti_martingale_max_steps) { + ++state.next_step; + } + else { + state.next_step = 0; + } + } + void run() override { const auto config = get_config(); if (!config) { @@ -152,6 +200,9 @@ namespace optionx::bridges::telegram { m_state->dedupe_order.clear(); m_state->martingale_steps.clear(); m_state->pending_martingale_sequences.clear(); + m_state->anti_martingale_groups.clear(); + m_state->anti_martingale_signal_groups.clear(); + m_state->active_config = config; } try { @@ -191,6 +242,9 @@ namespace optionx::bridges::telegram { m_state->running = false; source = m_state->source; m_state->source.reset(); + m_state->anti_martingale_groups.clear(); + m_state->anti_martingale_signal_groups.clear(); + m_state->active_config.reset(); } if (source) { try { @@ -227,6 +281,9 @@ namespace optionx::bridges::telegram { m_state->running = running; if (!running) { m_state->source.reset(); + m_state->anti_martingale_groups.clear(); + m_state->anti_martingale_signal_groups.clear(); + m_state->active_config.reset(); } } @@ -287,6 +344,22 @@ namespace optionx::bridges::telegram { optionx::to_str(parsed.order_type) + "|" + parsed.signal_name; } + static double anti_martingale_amount( + const TelegramSignalBridgeConfig& config, + const std::uint32_t step) { + auto amount = config.fixed_amount; + for (std::uint32_t index = 0; index < step; ++index) { + const auto maximum_before_multiplier = + config.anti_martingale_max_amount / + config.anti_martingale_multiplier; + if (amount >= maximum_before_multiplier) { + return config.anti_martingale_max_amount; + } + amount *= config.anti_martingale_multiplier; + } + return std::min(amount, config.anti_martingale_max_amount); + } + static std::int64_t current_time_ms() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -353,7 +426,9 @@ namespace optionx::bridges::telegram { const std::string& dedupe_key, const std::string& sequence_key, const bool martingale_step_recorded, - const std::optional& previous_martingale_step) { + const std::optional& previous_martingale_step, + const std::string& anti_martingale_key, + const bool anti_martingale_pending) { std::lock_guard lock(state->mutex); state->dedupe_keys.erase(dedupe_key); const auto dedupe = std::find( @@ -361,19 +436,42 @@ namespace optionx::bridges::telegram { if (dedupe != state->dedupe_order.end()) { state->dedupe_order.erase(dedupe); } - if (!martingale_step_recorded) { - return; + if (martingale_step_recorded) { + if (previous_martingale_step) { + state->martingale_steps[sequence_key] = *previous_martingale_step; + } + else { + state->martingale_steps.erase(sequence_key); + } + state->pending_martingale_sequences.erase(sequence_key); } - if (previous_martingale_step) { - state->martingale_steps[sequence_key] = *previous_martingale_step; + if (anti_martingale_pending) { + const auto group = state->anti_martingale_groups.find(anti_martingale_key); + if (group != state->anti_martingale_groups.end()) { + group->second.pending_trade = false; + group->second.pending_signal_id = 0; + } } - else { - state->martingale_steps.erase(sequence_key); + } + + static bool register_anti_martingale_dispatch( + const std::shared_ptr& state, + const std::string& anti_martingale_key, + const SignalId signal_id) { + std::lock_guard lock(state->mutex); + const auto group = state->anti_martingale_groups.find(anti_martingale_key); + if (group == state->anti_martingale_groups.end() || + !group->second.pending_trade || group->second.pending_signal_id != 0 || + state->anti_martingale_signal_groups.find(signal_id) != + state->anti_martingale_signal_groups.end()) { + return false; } - state->pending_martingale_sequences.erase(sequence_key); + group->second.pending_signal_id = signal_id; + state->anti_martingale_signal_groups.emplace(signal_id, anti_martingale_key); + return true; } - static void commit_dispatch_state( + static void commit_martingale_dispatch_state( const std::shared_ptr& state, const std::string& sequence_key, const bool martingale_step_recorded) { @@ -448,6 +546,8 @@ namespace optionx::bridges::telegram { std::string sequence_key; bool martingale_step_recorded = false; std::optional previous_martingale_step; + std::string anti_martingale_key; + bool anti_martingale_pending = false; { std::lock_guard lock(state->mutex); if (!state->running) { @@ -517,6 +617,29 @@ namespace optionx::bridges::telegram { } } } + if (!duplicate && !policy_report && config.anti_martingale_enabled) { + anti_martingale_key = martingale_key(raw, parsed_signal); + auto& anti_martingale = state->anti_martingale_groups[ + anti_martingale_key]; + if (anti_martingale.pending_trade) { + policy_report = make_signal_report( + config, raw, parsed_signal, dedupe_key, received_time_ms, + BridgeSignalReportStatus::REJECTED, + "anti_martingale_pending_result", + "Telegram anti-martingale awaits the broker result for this group."); + } + else { + signal->amount = anti_martingale_amount( + config, anti_martingale.next_step); + signal->mm_type = MmSystemType::ANTI_MARTINGALE_SIGNAL; + signal->mm_step = static_cast( + anti_martingale.next_step); + signal->mm_group_hash = anti_martingale_key; + signal->mm_group_name = parsed_signal.signal_name; + anti_martingale.pending_trade = true; + anti_martingale_pending = true; + } + } if (!duplicate && !policy_report) { state->dedupe_keys.insert(dedupe_key); state->dedupe_order.push_back(dedupe_key); @@ -549,29 +672,53 @@ namespace optionx::bridges::telegram { catch (const std::exception& error) { rollback_dispatch_state( state, dedupe_key, sequence_key, martingale_step_recorded, - previous_martingale_step); + previous_martingale_step, anti_martingale_key, + anti_martingale_pending); emit_report(state, make_signal_report( config, raw, parsed_signal, dedupe_key, received_time_ms, BridgeSignalReportStatus::INTAKE_ERROR, "signal_id_allocation_failed", error.what())); continue; } + if (anti_martingale_pending && !callback) { + rollback_dispatch_state( + state, dedupe_key, sequence_key, martingale_step_recorded, + previous_martingale_step, anti_martingale_key, + anti_martingale_pending); + emit_report(state, make_signal_report( + config, raw, parsed_signal, dedupe_key, received_time_ms, + BridgeSignalReportStatus::INTAKE_ERROR, + "trade_signal_callback_missing", + "Telegram anti-martingale requires a trade signal callback.")); + continue; + } + if (anti_martingale_pending && !register_anti_martingale_dispatch( + state, anti_martingale_key, signal->signal_id)) { + rollback_dispatch_state( + state, dedupe_key, sequence_key, martingale_step_recorded, + previous_martingale_step, anti_martingale_key, + anti_martingale_pending); + emit_report(state, make_signal_report( + config, raw, parsed_signal, dedupe_key, received_time_ms, + BridgeSignalReportStatus::INTAKE_ERROR, + "signal_id_collision", + "Telegram anti-martingale requires unique pending signal IDs.")); + continue; + } if (callback) { try { callback(std::move(signal)); } catch (...) { - rollback_dispatch_state( - state, dedupe_key, sequence_key, martingale_step_recorded, - previous_martingale_step); emit_report(state, make_signal_report( config, raw, parsed_signal, dedupe_key, received_time_ms, BridgeSignalReportStatus::INTAKE_ERROR, - "trade_signal_callback_failed", - "Telegram trade signal callback threw.")); + "ambiguous_dispatch_failure", + "Telegram trade signal callback threw after dispatch reservation.")); + continue; } } - commit_dispatch_state( + commit_martingale_dispatch_state( state, sequence_key, martingale_step_recorded); } } diff --git a/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp index 1cea283..58e446a 100644 --- a/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp +++ b/include/optionx_cpp/bridges/telegram/TelegramSignalBridgeConfig.hpp @@ -120,6 +120,14 @@ namespace optionx::bridges::telegram { std::size_t dedupe_cache_size = 4096; std::uint32_t max_signal_age_seconds = 0; TelegramMartingalePolicy martingale_policy = TelegramMartingalePolicy::ALL_SIGNALS; + /// \brief Enables broker-result-driven anti-martingale stake sizing. + bool anti_martingale_enabled = false; + /// \brief Stake multiplier applied after each confirmed broker WIN. + double anti_martingale_multiplier = 2.0; + /// \brief Maximum number of consecutive winning step increases. + std::uint32_t anti_martingale_max_steps = 1; + /// \brief Absolute amount cap required when anti-martingale is enabled. + double anti_martingale_max_amount = 0.0; TelegramParserConfig parser = TelegramSignalParser::default_config(); void to_json(nlohmann::json& j) const override { @@ -129,6 +137,10 @@ namespace optionx::bridges::telegram { {"dedupe_cache_size", dedupe_cache_size}, {"max_signal_age_seconds", max_signal_age_seconds}, {"martingale_policy", telegram_martingale_policy_name(martingale_policy)}, + {"anti_martingale_enabled", anti_martingale_enabled}, + {"anti_martingale_multiplier", anti_martingale_multiplier}, + {"anti_martingale_max_steps", anti_martingale_max_steps}, + {"anti_martingale_max_amount", anti_martingale_max_amount}, {"symbol_pattern", parser.symbol_pattern}, {"otc_symbol_suffix", parser.otc_symbol_suffix}, {"expiry_mode", telegram_expiry_mode_name(parser.expiry_policy.mode)}, @@ -186,6 +198,14 @@ namespace optionx::bridges::telegram { martingale_policy = telegram_martingale_policy_from_name( j.at("martingale_policy").get()); } + anti_martingale_enabled = j.value( + "anti_martingale_enabled", anti_martingale_enabled); + anti_martingale_multiplier = j.value( + "anti_martingale_multiplier", anti_martingale_multiplier); + anti_martingale_max_steps = j.value( + "anti_martingale_max_steps", anti_martingale_max_steps); + anti_martingale_max_amount = j.value( + "anti_martingale_max_amount", anti_martingale_max_amount); parser.symbol_pattern = j.value("symbol_pattern", parser.symbol_pattern); parser.otc_symbol_suffix = j.value("otc_symbol_suffix", parser.otc_symbol_suffix); if (j.contains("expiry_mode")) { @@ -259,6 +279,26 @@ namespace optionx::bridges::telegram { if (martingale_policy == TelegramMartingalePolicy::UNKNOWN) { return {false, "Telegram martingale_policy is unsupported."}; } + if (anti_martingale_enabled && + martingale_policy == TelegramMartingalePolicy::CONTIGUOUS_STEPS) { + return {false, + "Telegram anti-martingale cannot use CONTIGUOUS_STEPS martingale_policy."}; + } + if (anti_martingale_enabled && + (!std::isfinite(anti_martingale_multiplier) || + anti_martingale_multiplier <= 1.0)) { + return {false, + "Telegram anti_martingale_multiplier must be finite and greater than one."}; + } + if (anti_martingale_enabled && anti_martingale_max_steps == 0) { + return {false, "Telegram anti_martingale_max_steps must be positive."}; + } + if (anti_martingale_enabled && + (!std::isfinite(anti_martingale_max_amount) || + anti_martingale_max_amount < fixed_amount)) { + return {false, + "Telegram anti_martingale_max_amount must be finite and at least fixed_amount."}; + } if (parser.expiry_policy.mode == TelegramExpiryMode::UNKNOWN) { return {false, "Telegram expiry_mode is unsupported."}; } diff --git a/tests/telegram_signal_bridge_test.cpp b/tests/telegram_signal_bridge_test.cpp index c3fa761..a3ef1bc 100644 --- a/tests/telegram_signal_bridge_test.cpp +++ b/tests/telegram_signal_bridge_test.cpp @@ -387,6 +387,186 @@ TEST(TelegramSignalBridge, RollsBackMartingaleStepWhenSignalIdAllocationFails) { bridge.shutdown(); } +TEST(TelegramSignalBridge, AntiMartingaleUsesConfirmedBrokerResults) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + auto bridge_config = config(); + bridge_config->anti_martingale_enabled = true; + bridge_config->anti_martingale_multiplier = 2.0; + bridge_config->anti_martingale_max_steps = 2; + bridge_config->anti_martingale_max_amount = 4.0; + ASSERT_TRUE(bridge.configure(std::move(bridge_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(); + source->emit(make_message(124, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(signals.size(), 1u); + EXPECT_EQ(signals[0]->amount, 1.0); + EXPECT_EQ(signals[0]->mm_type, optionx::MmSystemType::ANTI_MARTINGALE_SIGNAL); + EXPECT_EQ(signals[0]->mm_step, 0); + + source->emit(make_message(125, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(reports.size(), 1u); + EXPECT_EQ(reports.back().reason_code, "anti_martingale_pending_result"); + + auto first_request = signals[0]->to_trade_request(); + optionx::TradeResult first_result; + first_result.trade_state = optionx::TradeState::OPEN_SUCCESS; + bridge.update_trade_result(first_request, first_result); + source->emit(make_message(126, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(reports.size(), 2u); + EXPECT_EQ(reports.back().reason_code, "anti_martingale_pending_result"); + + first_result.trade_state = optionx::TradeState::WIN; + bridge.update_trade_result(first_request, first_result); + source->emit(make_message(127, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(signals.size(), 2u); + EXPECT_EQ(signals[1]->amount, 2.0); + EXPECT_EQ(signals[1]->mm_step, 1); + + bridge.update_trade_result(first_request, first_result); + auto second_request = signals[1]->to_trade_request(); + optionx::TradeResult second_result; + second_result.trade_state = optionx::TradeState::WIN; + bridge.update_trade_result(second_request, second_result); + source->emit(make_message(128, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(signals.size(), 3u); + EXPECT_EQ(signals[2]->amount, 4.0); + EXPECT_EQ(signals[2]->mm_step, 2); + + auto third_request = signals[2]->to_trade_request(); + optionx::TradeResult third_result; + third_result.trade_state = optionx::TradeState::WIN; + bridge.update_trade_result(third_request, third_result); + source->emit(make_message(129, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(signals.size(), 4u); + EXPECT_EQ(signals[3]->amount, 1.0); + EXPECT_EQ(signals[3]->mm_step, 0); + + auto fourth_request = signals[3]->to_trade_request(); + optionx::TradeResult fourth_result; + fourth_result.trade_state = optionx::TradeState::LOSS; + bridge.update_trade_result(fourth_request, fourth_result); + source->emit(make_message(130, "EURUSD BUY 5m COBRA")); + ASSERT_EQ(signals.size(), 5u); + EXPECT_EQ(signals[4]->amount, 1.0); + EXPECT_EQ(signals[4]->mm_step, 0); + + bridge.shutdown(); +} + +TEST(TelegramSignalBridge, AntiMartingaleHandlesReentrantTerminalResultDuringDispatch) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + auto bridge_config = config(); + bridge_config->anti_martingale_enabled = true; + bridge_config->anti_martingale_max_amount = 2.0; + ASSERT_TRUE(bridge.configure(std::move(bridge_config))); + + std::vector> signals; + std::int64_t next_signal_id = 100; + bridge.on_signal_id() = [&] { return ++next_signal_id; }; + bridge.on_trade_signal() = [&](std::unique_ptr signal) { + const auto request = signal->to_trade_request(); + optionx::TradeResult result; + result.trade_state = optionx::TradeState::WIN; + bridge.update_trade_result(request, result); + signals.push_back(std::move(signal)); + }; + + bridge.run(); + source->emit(make_message(124, "EURUSD BUY 5m COBRA")); + source->emit(make_message(125, "EURUSD BUY 5m COBRA")); + + ASSERT_EQ(signals.size(), 2u); + EXPECT_EQ(signals[0]->amount, 1.0); + EXPECT_EQ(signals[0]->mm_step, 0); + EXPECT_EQ(signals[1]->amount, 2.0); + EXPECT_EQ(signals[1]->mm_step, 1); + + bridge.shutdown(); +} + +TEST(TelegramSignalBridge, KeepsAntiMartingaleStateFailClosedWhenCallbackThrows) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + auto bridge_config = config(); + bridge_config->anti_martingale_enabled = true; + bridge_config->anti_martingale_max_amount = 2.0; + ASSERT_TRUE(bridge.configure(std::move(bridge_config))); + + std::vector> signals; + std::vector reports; + bool fail_first_callback = true; + bridge.on_signal_id() = [] { return 101; }; + bridge.on_trade_signal() = [&](std::unique_ptr signal) { + if (fail_first_callback) { + fail_first_callback = false; + throw std::runtime_error("expected test failure"); + } + signals.push_back(std::move(signal)); + }; + bridge.on_signal_report() = [&](const auto& report) { + reports.push_back(report); + }; + + bridge.run(); + source->emit(make_message(124, "EURUSD BUY 5m COBRA")); + source->emit(make_message(124, "EURUSD BUY 5m COBRA")); + source->emit(make_message(125, "EURUSD BUY 5m COBRA")); + + EXPECT_TRUE(signals.empty()); + ASSERT_EQ(reports.size(), 3u); + EXPECT_EQ(reports[0].reason_code, "ambiguous_dispatch_failure"); + EXPECT_EQ(reports[1].reason_code, "duplicate_message"); + EXPECT_EQ(reports[2].reason_code, "anti_martingale_pending_result"); + + bridge.shutdown(); +} + +TEST(TelegramSignalBridge, KeepsMartingaleReservationFailClosedWhenCallbackThrows) { + auto source = std::make_shared(); + optionx::bridges::telegram::TelegramSignalBridge bridge(source); + auto bridge_config = config(); + bridge_config->martingale_policy = + optionx::bridges::telegram::TelegramMartingalePolicy::CONTIGUOUS_STEPS; + bridge_config->parser.martingale_rules = { + {"explicit-mg", R"(\bMG[ -]?(\d+)\b)", 1}, + }; + ASSERT_TRUE(bridge.configure(std::move(bridge_config))); + + std::vector reports; + bridge.on_signal_id() = [] { return 101; }; + bridge.on_trade_signal() = [](std::unique_ptr) { + throw std::runtime_error("expected test failure"); + }; + bridge.on_signal_report() = [&](const auto& report) { + reports.push_back(report); + }; + + bridge.run(); + source->emit(make_message(124, "EURUSD BUY 5m COBRA MG-0")); + source->emit(make_message(124, "EURUSD BUY 5m COBRA MG-0")); + source->emit(make_message(125, "EURUSD BUY 5m COBRA MG-1")); + + ASSERT_EQ(reports.size(), 3u); + EXPECT_EQ(reports[0].reason_code, "ambiguous_dispatch_failure"); + EXPECT_EQ(reports[1].reason_code, "duplicate_message"); + EXPECT_EQ(reports[2].reason_code, "martingale_step_pending"); + + bridge.shutdown(); +} + TEST(TelegramSignalBridge, RejectsInvalidConfigurationBeforeStartingSource) { auto source = std::make_shared(); optionx::bridges::telegram::TelegramSignalBridge bridge(source); @@ -446,6 +626,39 @@ TEST(TelegramSignalBridgeConfig, RoundTripsTimingAndMartingaleSettings) { EXPECT_EQ(restored.parser.martingale_rules[0].step_group, 1u); } +TEST(TelegramSignalBridgeConfig, RoundTripsAntiMartingaleSettings) { + auto original = config(); + original->anti_martingale_enabled = true; + original->anti_martingale_multiplier = 1.5; + original->anti_martingale_max_steps = 3; + original->anti_martingale_max_amount = 12.0; + nlohmann::json serialized; + original->to_json(serialized); + + optionx::bridges::telegram::TelegramSignalBridgeConfig restored; + restored.from_json(serialized); + + EXPECT_TRUE(restored.anti_martingale_enabled); + EXPECT_EQ(restored.anti_martingale_multiplier, 1.5); + EXPECT_EQ(restored.anti_martingale_max_steps, 3u); + EXPECT_EQ(restored.anti_martingale_max_amount, 12.0); + EXPECT_TRUE(restored.validate().first); +} + +TEST(TelegramSignalBridgeConfig, RejectsInvalidAntiMartingaleSettings) { + auto invalid = config(); + invalid->anti_martingale_enabled = true; + EXPECT_FALSE(invalid->validate().first); + + invalid->anti_martingale_max_amount = 2.0; + invalid->martingale_policy = + optionx::bridges::telegram::TelegramMartingalePolicy::CONTIGUOUS_STEPS; + const auto validation = invalid->validate(); + EXPECT_FALSE(validation.first); + EXPECT_EQ(validation.second, + "Telegram anti-martingale cannot use CONTIGUOUS_STEPS martingale_policy."); +} + TEST(TelegramSignalBridgeConfig, RejectsUnknownMartingalePolicy) { auto original = config(); nlohmann::json serialized;