feat: Telegram notifications for all 6 SSE events + hourly PnL summary

- Handle position_opened/closed/modified and order_placed/cancelled/modified
- Unified SsePayload struct (Option fields) for both position and order events
- Hourly PnL summary task (send_pnl_summary every 3600s, skip first tick)
- Add sim binary for testing TG notification flow without MT5
- Export telegram + helpers via lib.rs for bin access
- Update README with Telegram setup and event table
- Update .env.example with Telegram comment block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-11 01:52:53 +07:00
parent a49aee6ae0
commit e285ab3ee0
5 changed files with 230 additions and 54 deletions
+3 -1
View File
@@ -42,6 +42,8 @@ BACKTEST_CANDLES=50000
# LIVE_POLL_SECS=30
# ── Telegram Notifications ───────────────────────────────────────────────────
# Get token from @BotFather, chat_id from @userinfobot or Telegram API.
# All three are optional — omit to disable notifications.
# TELEGRAM_BOT_TOKEN=123456:ABC-xxxx
# TELEGRAM_CHAT_ID=-100xxxxxxxxxx
# TELEGRAM_THREAD_ID=123 # optional, for forum topics/threads
# TELEGRAM_THREAD_ID=123 # forum topic/thread ID (optional)
+24 -2
View File
@@ -43,6 +43,22 @@ Polls MT5 every `LIVE_POLL_SECS` seconds. Places a pending limit order when a va
State for each symbol is persisted in `.ares_state_{symbol}.json` so the bot survives restarts without orphaning orders.
### Telegram notifications
Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` to receive real-time trade updates. Each trade lifecycle is tracked as a single edited message:
| Event | Message |
|---|---|
| Pending order placed | 🟡 PENDING — entry / SL / TP / lot |
| Limit order filled | ⚡ FILLED |
| Position SL/TP modified | 🔄 MODIFIED |
| TP hit | ✅ TP HIT — P&L + balance |
| SL hit | ❌ SL HIT — P&L + balance |
| Order expired | ⏱ EXPIRED |
| Order cancelled externally | 🚫 CANCELLED |
A 📊 PnL summary (today + all-time) is sent automatically every hour.
### Multi-pair
```bash
@@ -80,6 +96,9 @@ Copy `.env.example` to `.env` and adjust. All fields are optional except `MT5_BA
| `TIMEOUT_CANDLES` | `0` | Force-close open trade after N bars (0 = disabled) |
| `LIVE` | `false` | Set to `true` to enable live mode |
| `LIVE_POLL_SECS` | `30` | Poll interval for live mode |
| `TELEGRAM_BOT_TOKEN` | — | Bot token from @BotFather (optional) |
| `TELEGRAM_CHAT_ID` | — | Target chat/group ID |
| `TELEGRAM_THREAD_ID` | — | Forum thread ID (optional) |
## Backtest results
@@ -102,9 +121,12 @@ ares/
├── src/
│ ├── main.rs # entry point, env parsing, mode dispatch
│ ├── backtest.rs # walk-forward simulation engine
│ ├── live.rs # live trading loop
│ ├── live.rs # live trading loop + SSE position listener
│ ├── detector.rs # momentum FVG detection
── helpers.rs # shared math utilities
── telegram.rs # Telegram Bot API client
│ ├── helpers.rs # shared math utilities
│ └── bin/
│ └── sim.rs # Telegram notification simulator (no MT5 needed)
└── crates/
├── domain/ # shared types (Candle, Symbol, Timeframe, Side…)
└── mt5-client/ # async HTTP client for the MT5 bridge
+51
View File
@@ -0,0 +1,51 @@
/// Simulate: pending → filled → TP/SL result (single trade lifecycle)
use anyhow::Result;
use ares::telegram::TelegramConfig;
use reqwest::Client;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").expect("TELEGRAM_BOT_TOKEN");
let chat_id = std::env::var("TELEGRAM_CHAT_ID").expect("TELEGRAM_CHAT_ID").parse::<i64>()?;
let thread_id = std::env::var("TELEGRAM_THREAD_ID").ok().and_then(|s| s.parse::<i64>().ok());
let tg = TelegramConfig { token, chat_id, thread_id };
let http = Client::new();
// ── 1. Pending order placed ───────────────────────────────────────────────
tracing::info!("step 1: PENDING");
let msg_id = tg.send(&http,
"🟡 <b>PENDING</b>\nXAUUSDm Long\nEntry: 3325.50\nSL: 3320.00 TP: 3333.75\nVol: 0.03 lot RR: 1.5",
).await?;
tracing::info!(msg_id, "sent");
sleep(Duration::from_secs(3)).await;
// ── 2. Limit order triggered / filled ────────────────────────────────────
tracing::info!("step 2: FILLED");
tg.edit(&http, msg_id,
"⚡ <b>FILLED</b>\nXAUUSDm Long\nEntry: 3325.50\nSL: 3320.00 TP: 3333.75\nVol: 0.03 lot",
).await?;
tracing::info!(msg_id, "edited");
sleep(Duration::from_secs(3)).await;
// ── 3a. TP hit ────────────────────────────────────────────────────────────
tracing::info!("step 3: TP HIT");
tg.edit(&http, msg_id,
"✅ <b>TP HIT +12.68</b>\nXAUUSDm Long\n3325.50 → 3333.75\nVol: 0.03 lot\nBal: $5012.68",
).await?;
tracing::info!(msg_id, "edited TP HIT");
// uncomment to test SL hit instead:
// tg.edit(&http, msg_id,
// "❌ <b>SL HIT -7.50</b>\nXAUUSDm Long\n3325.50 → 3320.00\nVol: 0.03 lot\nBal: $4992.50",
// ).await?;
println!("\nDone ✅ check Telegram");
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
pub mod helpers;
pub mod telegram;
+150 -51
View File
@@ -95,21 +95,22 @@ impl PosState {
fn clear(symbol: &str) { let _ = std::fs::remove_file(Self::path(symbol)); }
}
// ── SSE position event ────────────────────────────────────────────────────────
// ── SSE event payload (unified for positions + orders) ────────────────────────
#[derive(Debug, Deserialize)]
struct SsePosition {
struct SsePayload {
ticket: u64,
symbol: String,
#[serde(rename = "type")]
pos_type: u32,
volume: f64,
price_open: f64,
price_current: Option<f64>,
sl: f64,
tp: f64,
profit: f64,
magic: u64,
#[serde(rename = "type")]
kind: Option<u32>,
volume: Option<f64>,
price_open: Option<f64>, // position open price
price: Option<f64>, // order limit price
price_current: Option<f64>,
sl: Option<f64>,
tp: Option<f64>,
profit: Option<f64>,
}
// ── entry point ───────────────────────────────────────────────────────────────
@@ -132,16 +133,39 @@ pub async fn run(mt5: &mt5_client::Mt5Client, cfg: &LiveConfig) -> Result<()> {
let tf_mins = timeframe_minutes(cfg.timeframe);
let expiry_dur = chrono::Duration::minutes(tf_mins * cfg.fvg_expiry_candles as i64);
// spawn SSE position listener
if cfg.telegram.is_some() {
let mt5_arc = Arc::new(mt5_client::Mt5Client::new(cfg.mt5_base_url.clone()));
let http_arc = Arc::new(reqwest::Client::new());
let symbol = cfg.symbol.clone();
let tg = cfg.telegram.clone();
let base_url = cfg.mt5_base_url.clone();
tokio::spawn(async move {
sse_task(mt5_arc, http_arc, base_url, symbol, tg).await;
});
// SSE position listener
{
let mt5_c = Arc::clone(&mt5_arc);
let http_c = Arc::clone(&http_arc);
let symbol = cfg.symbol.clone();
let tg = cfg.telegram.clone();
let base_url = cfg.mt5_base_url.clone();
tokio::spawn(async move {
sse_task(mt5_c, http_c, base_url, symbol, tg).await;
});
}
// hourly PnL summary
{
let mt5_c = Arc::clone(&mt5_arc);
let http_c = Arc::clone(&http_arc);
let symbol = cfg.symbol.clone();
let tg = cfg.telegram.clone().unwrap();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(3600));
ticker.tick().await; // skip first immediate tick
loop {
ticker.tick().await;
if let Err(e) = send_pnl_summary(&mt5_c, &http_c, &symbol, &tg).await {
tracing::warn!(symbol = %symbol, "PnL summary error: {e:#}");
}
}
});
}
}
let http = reqwest::Client::new();
@@ -424,62 +448,67 @@ async fn handle_sse_event(
data: &str,
tg: &Option<TelegramConfig>,
) {
let pos: SsePosition = match serde_json::from_str(data) {
let payload: SsePayload = match serde_json::from_str(data) {
Ok(p) => p,
Err(e) => { tracing::warn!("SSE parse error: {e:#}"); return; }
Err(e) => { tracing::warn!(%symbol, event_type, "SSE parse error: {e:#}"); return; }
};
if pos.magic != MAGIC || pos.symbol != symbol { return; }
if payload.magic != MAGIC || payload.symbol != symbol { return; }
match event_type {
"position_opened" => on_position_opened(http, symbol, &pos, tg).await,
"position_closed" => on_position_closed(mt5, http, symbol, &pos, tg).await,
_ => {}
"position_opened" => on_position_opened(http, symbol, &payload, tg).await,
"position_closed" => on_position_closed(mt5, http, symbol, &payload, tg).await,
"position_modified" => on_position_modified(http, symbol, &payload, tg).await,
"order_placed" => tracing::info!(%symbol, ticket = payload.ticket, "order placed (SSE)"),
"order_cancelled" => on_order_cancelled(http, symbol, &payload, tg).await,
"order_modified" => on_order_modified(http, symbol, &payload, tg).await,
other => tracing::debug!(%symbol, "unknown SSE event: {other}"),
}
}
async fn on_position_opened(
http: &reqwest::Client,
symbol: &str,
pos: &SsePosition,
tg: &Option<TelegramConfig>,
http: &reqwest::Client,
symbol: &str,
payload: &SsePayload,
tg: &Option<TelegramConfig>,
) {
tracing::info!(%symbol, ticket = pos.ticket, "position opened (SSE)");
tracing::info!(%symbol, ticket = payload.ticket, "position opened (SSE)");
// promote pending state → position state
let state = State::load(symbol);
let state = State::load(symbol);
let tg_msg_id = state.as_ref().and_then(|s| s.tg_message_id);
let kind = payload.kind.unwrap_or(0);
let entry = payload.price_open.unwrap_or(0.0);
let sl = payload.sl.unwrap_or(0.0);
let tp = payload.tp.unwrap_or(0.0);
let volume = payload.volume.unwrap_or(0.0);
let ps = PosState {
ticket: pos.ticket,
ticket: payload.ticket,
tg_message_id: tg_msg_id,
side: if pos.pos_type == 0 { "Long".to_string() } else { "Short".to_string() },
entry: pos.price_open,
sl: pos.sl,
tp: pos.tp,
volume: pos.volume,
side: if kind == 0 { "Long".to_string() } else { "Short".to_string() },
entry, sl, tp, volume,
};
let _ = ps.save(symbol);
State::clear(symbol);
if let (Some(tg), Some(msg_id)) = (tg, tg_msg_id) {
let side_str = &ps.side;
let text = format!(
"⚡ <b>FILLED</b>\n{} {}\nEntry: {:.5}\nSL: {:.5} TP: {:.5}\nVol: {:.2} lot",
symbol, side_str, pos.price_open, pos.sl, pos.tp, pos.volume,
symbol, ps.side, entry, sl, tp, volume,
);
let _ = tg.edit(http, msg_id, &text).await;
}
}
async fn on_position_closed(
mt5: &mt5_client::Mt5Client,
http: &reqwest::Client,
symbol: &str,
pos: &SsePosition,
tg: &Option<TelegramConfig>,
mt5: &mt5_client::Mt5Client,
http: &reqwest::Client,
symbol: &str,
payload: &SsePayload,
tg: &Option<TelegramConfig>,
) {
tracing::info!(%symbol, ticket = pos.ticket, profit = pos.profit, "position closed (SSE)");
let profit = payload.profit.unwrap_or(0.0);
tracing::info!(%symbol, ticket = payload.ticket, profit, "position closed (SSE)");
let ps = PosState::load(symbol);
PosState::clear(symbol);
@@ -487,24 +516,94 @@ async fn on_position_closed(
let Some(tg) = tg else { return };
let Some(msg_id) = ps.as_ref().and_then(|s| s.tg_message_id) else { return };
let (icon, label) = if pos.profit >= 0.0 { ("", "TP HIT") } else { ("", "SL HIT") };
let exit = pos.price_current.unwrap_or(0.0);
let entry = ps.as_ref().map(|s| s.entry).unwrap_or(pos.price_open);
let (icon, label) = if profit >= 0.0 { ("", "TP HIT") } else { ("", "SL HIT") };
let exit = payload.price_current.unwrap_or(0.0);
let entry = ps.as_ref().map(|s| s.entry).unwrap_or(payload.price_open.unwrap_or(0.0));
let volume = payload.volume.unwrap_or(ps.as_ref().map(|s| s.volume).unwrap_or(0.0));
let side_str = ps.as_ref().map(|s| s.side.as_str()).unwrap_or("?");
let acct_bal = mt5.account().await.ok().map(|a| a.balance).unwrap_or(Decimal::ZERO);
let text = format!(
"{} <b>{} {:+.2}</b>\n{} {}\n{:.5}{:.5}\nVol: {:.2} lot\nBal: ${}",
icon, label, pos.profit,
icon, label, profit,
symbol, side_str,
entry, exit,
pos.volume, acct_bal,
volume, acct_bal,
);
let _ = tg.edit(http, msg_id, &text).await;
}
// send PnL summary after close
let _ = send_pnl_summary(mt5, http, symbol, tg).await;
async fn on_position_modified(
http: &reqwest::Client,
symbol: &str,
payload: &SsePayload,
tg: &Option<TelegramConfig>,
) {
tracing::info!(%symbol, ticket = payload.ticket, "position modified (SSE)");
if let Some(mut ps) = PosState::load(symbol) {
if let Some(sl) = payload.sl { ps.sl = sl; }
if let Some(tp) = payload.tp { ps.tp = tp; }
if let Some(v) = payload.volume { ps.volume = v; }
let _ = ps.save(symbol);
if let (Some(tg), Some(msg_id)) = (tg, ps.tg_message_id) {
let text = format!(
"🔄 <b>MODIFIED</b>\n{} {}\nEntry: {:.5}\nSL: {:.5} TP: {:.5}\nVol: {:.2} lot",
symbol, ps.side, ps.entry, ps.sl, ps.tp, ps.volume,
);
let _ = tg.edit(http, msg_id, &text).await;
}
}
}
async fn on_order_cancelled(
http: &reqwest::Client,
symbol: &str,
payload: &SsePayload,
tg: &Option<TelegramConfig>,
) {
tracing::info!(%symbol, ticket = payload.ticket, "order cancelled (SSE)");
let state = State::load(symbol);
if state.as_ref().map(|s| s.ticket) != Some(payload.ticket) { return; }
if let (Some(tg), Some(msg_id)) = (tg, state.as_ref().and_then(|s| s.tg_message_id)) {
let side_str = state.as_ref().map(|s| s.side.as_str()).unwrap_or("?");
let entry = state.as_ref().map(|s| s.entry).unwrap_or(0.0);
let text = format!(
"🚫 <b>CANCELLED</b>\n{} {}\nEntry: {:.5}",
symbol, side_str, entry,
);
let _ = tg.edit(http, msg_id, &text).await;
}
State::clear(symbol);
}
async fn on_order_modified(
http: &reqwest::Client,
symbol: &str,
payload: &SsePayload,
tg: &Option<TelegramConfig>,
) {
tracing::info!(%symbol, ticket = payload.ticket, "order modified (SSE)");
if let Some(mut state) = State::load(symbol) {
if state.ticket != payload.ticket { return; }
if let Some(p) = payload.price { state.entry = p; }
if let Some(sl) = payload.sl { state.sl = sl; }
if let Some(tp) = payload.tp { state.tp = tp; }
let _ = state.save(symbol);
if let (Some(tg), Some(msg_id)) = (tg, state.tg_message_id) {
let text = format!(
"🔄 <b>ORDER MODIFIED</b>\n{} {}\nEntry: {:.5}\nSL: {:.5} TP: {:.5}",
symbol, state.side, state.entry, state.sl, state.tp,
);
let _ = tg.edit(http, msg_id, &text).await;
}
}
}
// ── PnL summary ───────────────────────────────────────────────────────────────