Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions guides/telegram-bridge-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
179 changes: 163 additions & 16 deletions include/optionx_cpp/bridges/telegram/TelegramSignalBridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <algorithm>
#include <chrono>
Expand Down Expand Up @@ -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.
Expand All @@ -59,6 +66,10 @@ namespace optionx::bridges::telegram {
std::unordered_set<std::string> dedupe_keys;
std::unordered_map<std::string, std::int32_t> martingale_steps;
std::unordered_set<std::string> pending_martingale_sequences;
std::unordered_map<std::string, AntiMartingaleGroupState>
anti_martingale_groups;
std::unordered_map<SignalId, std::string> anti_martingale_signal_groups;
std::shared_ptr<const TelegramSignalBridgeConfig> active_config;
bool running = false;
};

Expand Down Expand Up @@ -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<std::mutex> 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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
Expand Down Expand Up @@ -353,27 +426,52 @@ namespace optionx::bridges::telegram {
const std::string& dedupe_key,
const std::string& sequence_key,
const bool martingale_step_recorded,
const std::optional<std::int32_t>& previous_martingale_step) {
const std::optional<std::int32_t>& previous_martingale_step,
const std::string& anti_martingale_key,
const bool anti_martingale_pending) {
std::lock_guard<std::mutex> lock(state->mutex);
state->dedupe_keys.erase(dedupe_key);
const auto dedupe = std::find(
state->dedupe_order.begin(), state->dedupe_order.end(), dedupe_key);
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<RuntimeState>& state,
const std::string& anti_martingale_key,
const SignalId signal_id) {
std::lock_guard<std::mutex> 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<RuntimeState>& state,
const std::string& sequence_key,
const bool martingale_step_recorded) {
Expand Down Expand Up @@ -448,6 +546,8 @@ namespace optionx::bridges::telegram {
std::string sequence_key;
bool martingale_step_recorded = false;
std::optional<std::int32_t> previous_martingale_step;
std::string anti_martingale_key;
bool anti_martingale_pending = false;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->running) {
Expand Down Expand Up @@ -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<std::int32_t>(
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);
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading