Simplify Telegram bot — mirror main.py output exactly

- Remove /all, /watch, /unwatch, /credits, /status commands
- Keep only /scan, /pair, /tf, /help
- Output exact same format as main.py terminal output (box-drawing
  characters, score bars, table, detail report) inside <pre> blocks
- /help explains all timeframes with recommended use cases
- /scan TF and /pair EURUSD TF accept inline timeframe override
- Auto-split messages at newlines to stay under Telegram 4096-char limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 15:50:49 -04:00
parent 0e95a34d82
commit da6fc5d283
+267 -448
View File
@@ -2,22 +2,17 @@
""" """
Forex Signal Intelligence Platform - Telegram Bot Forex Signal Intelligence Platform - Telegram Bot
=================================================== ===================================================
Exposes all platform functionality through a Telegram bot interface. Mirrors main.py output exactly via Telegram.
Usage: Setup:
export TELEGRAM_BOT_TOKEN="your_bot_token_here" export TELEGRAM_BOT_TOKEN="your_token_here"
python telegram_bot.py python telegram_bot.py
Commands: Commands:
/scan [TF] — Scan all major pairs /scan [TF] — Full signal scan (all major pairs)
/signals [TF] — Only actionable signals (grade B+) /pair EURUSD [TF] — Detailed analysis for one pair
/pair EURUSD [TF] — Detailed analysis of one pair
/all [TF] — Scan major + cross pairs
/tf H1 — Set default timeframe /tf H1 — Set default timeframe
/watch [minutes] — Enable periodic scan alerts /help — Commands + timeframe guide
/unwatch — Disable alerts
/status — Current settings
/help — Full command reference
""" """
import asyncio import asyncio
@@ -25,21 +20,21 @@ import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional from typing import Dict, List, Optional
from telegram import BotCommand, Update from telegram import BotCommand, Update
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.ext import Application, CallbackContext, CommandHandler, ContextTypes from telegram.ext import Application, CommandHandler, ContextTypes
from config import ( from config import (
CROSS_PAIRS, MAJOR_PAIRS, SYMBOL_DISPLAY, MAJOR_PAIRS, SYMBOL_DISPLAY,
PlatformConfig, SignalStrength, Timeframe, PlatformConfig, SignalStrength, Timeframe,
) )
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────── # ── Timeframe config ───────────────────────────────────────────────
TF_MAP = { TF_MAP = {
"M5": Timeframe.M5, "M5": Timeframe.M5,
@@ -59,34 +54,162 @@ TF_CONFIRMATIONS = {
"D1": [Timeframe.H4, Timeframe.W1], "D1": [Timeframe.H4, Timeframe.W1],
} }
SIGNAL_EMOJI = { # ── Formatting (mirrors main.py, no ANSI colours) ──────────────────
SignalStrength.STRONG_BUY: "🟢⬆⬆",
SignalStrength.BUY: "🟢⬆",
SignalStrength.NEUTRAL: "⬜↔",
SignalStrength.SELL: "🔴⬇",
SignalStrength.STRONG_SELL: "🔴⬇⬇",
}
SIGNAL_NAME = { WIDTH = 80
SignalStrength.STRONG_BUY: "STRONG BUY",
SignalStrength.BUY: "BUY",
SignalStrength.NEUTRAL: "NEUTRAL",
SignalStrength.SELL: "SELL",
SignalStrength.STRONG_SELL: "STRONG SELL",
}
GRADE_EMOJI = {
"A+": "⭐⭐",
"A": "⭐",
"B": "🔵",
"C": "🟡",
"D": "🟠",
"F": "⚫",
}
WATCH_MIN_INTERVAL = 5 # minimum watch interval in minutes def _score_bar(score: float, width: int = 30) -> str:
WATCH_MAX_INTERVAL = 120 # maximum watch interval in minutes normalized = (score + 100) / 200
WATCH_DEFAULT = 5 filled = int(normalized * width)
center = width // 2
bar = ""
for i in range(width):
if i == center:
bar += "│"
elif (score >= 0 and center < i <= filled) or \
(score < 0 and filled <= i < center):
bar += "█"
else:
bar += "░"
return bar
def _format_header(timeframe: str) -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
lines = [
"",
"═" * WIDTH,
" ╔═══════════════════════════════════════════════════════╗",
" ║ FOREX SIGNAL INTELLIGENCE PLATFORM ║",
" ║ Multi-Layer Signal Analysis Engine ║",
" ╚═══════════════════════════════════════════════════════╝",
f" Scan Time: {now} TF: {timeframe}",
"═" * WIDTH,
"",
]
return "\n".join(lines)
def _format_table(reports: List[SignalReport]) -> str:
lines = [
" ┌─────────────┬───────────┬──────────────────────────────────┬────────┬────────────┐",
" │ Pair │ Signal │ Score │ Grade │ Confidence │",
" ├─────────────┼───────────┼──────────────────────────────────┼────────┼────────────┤",
]
for r in reports:
sig = r.final_signal.name.replace("_", " ")
bar = _score_bar(r.final_score, 30)
pair = f"{r.display_name:<11}"
signal_str = f"{sig:<9}"
grade_str = f"{r.signal_quality:<6}"
conf_str = f"{r.final_confidence:5.1f}%"
warn = " ⚠" if r.event_risk == "danger" else (" △" if r.event_risk == "caution" else " ")
lines.append(f" │ {pair} │ {signal_str} │ {bar} │ {grade_str} │ {conf_str:>8}{warn} │")
lines.append(
" └─────────────┴───────────┴──────────────────────────────────┴────────┴────────────┘"
)
lines += [
"",
" Legend: ██ Bullish | ██ Bearish | │ Center (0) | ⚠ Event Risk | △ Caution",
]
return "\n".join(lines)
def _format_calendar(reports: List[SignalReport]) -> str:
events_shown = set()
lines = []
for r in reports:
if r.event_risk != "clear" and r.event_description not in events_shown:
events_shown.add(r.event_description)
lines.append(f" ● {r.event_description} → affects {r.display_name}")
if lines:
return "\n 📅 ECONOMIC CALENDAR ALERTS\n" + "\n".join(lines)
return "\n 📅 No high-impact events in the near term"
def _format_detail(r: SignalReport) -> List[str]:
"""
Returns a list of message strings for one pair's detail report.
Split into two messages so each stays under Telegram's 4096-char limit.
"""
sig_name = r.final_signal.name.replace("_", " ")
# ── Part 1: header, signal, MTF, indicators ────────────────────
p1 = ["─" * WIDTH]
p1.append(
f" {r.display_name} │ Price: {r.current_price:.5f}"
f" │ Regime: {r.market_regime} │ Vol: {r.volatility_regime}"
)
p1.append(
f"\n SIGNAL: {sig_name} │ Score: {r.final_score:+.1f}"
f" │ Grade: {r.signal_quality} │ Confidence: {r.final_confidence:.0f}%"
)
p1.append(f" Action: {r.action_recommendation}")
if r.mtf_signals:
agree = "✓ ALIGNED" if r.mtf_agreement else "✗ DIVERGENT"
p1.append(f"\n Multi-Timeframe: {agree}")
for tf, sig in r.mtf_signals.items():
score = r.mtf_scores.get(tf, 0)
p1.append(f" {tf:>5}: {sig.name.replace('_', ' '):<12} ({score:+.1f})")
if r.primary_analysis:
p1.append(f"\n Indicators ({r.primary_analysis.timeframe}):")
for name, ind in r.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
ind_name = f"{ind.name:<18}"
sig_str = f"{ind.signal.name.replace('_', ' '):<12}"
desc = ind.description[:60]
p1.append(f" {ind_name} {sig_str} ({ind.confidence:.0f}%) │ {desc}")
# ── Part 2: reasons, warnings, trade setup, key levels ─────────
p2 = []
if r.bullish_reasons:
p2.append(" ▲ Bullish Factors:")
for reason in r.bullish_reasons[:5]:
p2.append(f" + {reason}")
if r.bearish_reasons:
p2.append("\n ▼ Bearish Factors:")
for reason in r.bearish_reasons[:5]:
p2.append(f" - {reason}")
if r.warnings:
p2.append("\n ⚠ Warnings:")
for w in r.warnings:
p2.append(f" ! {w}")
if r.news_description and r.news_description != "Neutral news sentiment":
p2.append(f"\n 📰 News: {r.news_description}")
if r.event_description and r.event_risk != "clear":
p2.append(f" 📅 Event: {r.event_description}")
if r.trade_setup:
ts = r.trade_setup
p2 += [
"\n Trade Setup:",
f" Direction: {ts.direction}",
f" Entry: {ts.entry_price:.5f}",
f" Stop Loss: {ts.stop_loss:.5f} ({ts.risk_pips:.1f} pips)",
f" Take Profit 1: {ts.take_profit_1:.5f} ({ts.reward_pips:.1f} pips)",
f" Take Profit 2: {ts.take_profit_2:.5f}",
f" Risk:Reward: 1:{ts.risk_reward:.1f}",
]
if r.primary_analysis and r.primary_analysis.key_levels:
lvls = r.primary_analysis.key_levels
p2.append("\n Key Levels:")
for k in ("r3", "r2", "r1", "pivot", "s1", "s2", "s3"):
if k in lvls:
p2.append(f" {k.upper():>6}: {lvls[k]:.5f}")
result = ["\n".join(p1)]
if p2:
result.append("\n".join(p2))
return result
# ── Per-chat state ───────────────────────────────────────────────── # ── Per-chat state ─────────────────────────────────────────────────
@@ -94,274 +217,88 @@ WATCH_DEFAULT = 5
class ChatState: class ChatState:
def __init__(self): def __init__(self):
self.timeframe: str = "H1" self.timeframe: str = "H1"
self.pairs: list = list(MAJOR_PAIRS)
self.watch_enabled: bool = False
# ── HTML helpers ───────────────────────────────────────────────────
def _esc(text: str) -> str:
"""Escape HTML special characters for Telegram HTML parse mode."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _split_messages(lines: list[str], limit: int = 4000) -> list[str]:
"""Join lines into messages that stay under Telegram's 4096-char limit."""
messages, current, current_len = [], [], 0
for line in lines:
line_len = len(line) + 1 # +1 for newline
if current and current_len + line_len > limit:
messages.append("\n".join(current))
current, current_len = [], 0
current.append(line)
current_len += line_len
if current:
messages.append("\n".join(current))
return messages
# ── Message formatters ─────────────────────────────────────────────
def _format_scan(reports: list[SignalReport], timeframe: str, title: str = "FOREX SIGNAL SCAN") -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines = [
f"<b>📊 {title} — {timeframe}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<code>🕐 {now}</code>\n",
]
for r in reports:
sig_em = SIGNAL_EMOJI.get(r.final_signal, "❔")
grade_em = GRADE_EMOJI.get(r.signal_quality, "")
sig_name = SIGNAL_NAME.get(r.final_signal, "?")
event_flag = " ⚠️" if r.event_risk == "danger" else (" ⚡" if r.event_risk == "caution" else "")
lines.append(
f"{sig_em} <b>{_esc(r.display_name)}</b> "
f"{grade_em}<code>{r.signal_quality}</code> "
f"<code>{_esc(sig_name):<11}</code> "
f"<code>{r.final_confidence:.0f}%</code>{event_flag}"
)
lines += ["", "<i>Use /pair EURUSD for detailed analysis</i>"]
return "\n".join(lines)
def _format_pair_detail(r: SignalReport) -> list[str]:
"""
Return a list of message strings for a single pair.
Split across messages so each stays under Telegram's 4096-char limit.
"""
sig_em = SIGNAL_EMOJI.get(r.final_signal, "❔")
grade_em = GRADE_EMOJI.get(r.signal_quality, "")
sig_name = SIGNAL_NAME.get(r.final_signal, "?")
# ── Message 1: Header, MTF, Trade Setup, Key Levels ───────────
m1 = [
f"{sig_em} <b>{_esc(r.display_name)}</b> — <b>{_esc(sig_name)}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<b>Price:</b> <code>{r.current_price:.5f}</code>",
f"<b>Regime:</b> {_esc(r.market_regime)} <b>Vol:</b> {_esc(r.volatility_regime)}",
"",
f"<b>Score:</b> <code>{r.final_score:+.1f}</code> "
f"<b>Grade:</b> {grade_em}<code>{r.signal_quality}</code> "
f"<b>Conf:</b> <code>{r.final_confidence:.0f}%</code>",
f"<b>Action:</b> {_esc(r.action_recommendation)}",
]
if r.event_risk != "clear":
icon = "⚠️" if r.event_risk == "danger" else "⚡"
m1.append(f"{icon} <b>Event:</b> {_esc(r.event_description)}")
if r.news_description and r.news_description != "Neutral news sentiment":
m1.append(f"📰 <b>News:</b> {_esc(r.news_description)}")
# Multi-timeframe
if r.mtf_signals:
agree = "✅ ALIGNED" if r.mtf_agreement else "⚡ DIVERGENT"
m1 += ["", f"<b>📊 Multi-Timeframe:</b> {agree}"]
for tf, sig in r.mtf_signals.items():
score = r.mtf_scores.get(tf, 0)
m1.append(
f" <code>{tf:>5}</code> {SIGNAL_EMOJI.get(sig, '?')} "
f"<code>{score:+.1f}</code>"
)
# Trade setup
if r.trade_setup:
ts = r.trade_setup
dir_em = "🟢" if ts.direction == "BUY" else "🔴"
m1 += [
"",
"<b>💼 Trade Setup</b>",
f" <b>Direction:</b> {dir_em} {ts.direction}",
f" <b>Entry:</b> <code>{ts.entry_price:.5f}</code>",
f" <b>Stop Loss:</b> <code>{ts.stop_loss:.5f}</code> ({ts.risk_pips:.1f} pips)",
f" <b>Take Profit 1:</b> <code>{ts.take_profit_1:.5f}</code> ({ts.reward_pips:.1f} pips)",
f" <b>Take Profit 2:</b> <code>{ts.take_profit_2:.5f}</code>",
f" <b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}",
]
# Key levels
if r.primary_analysis and r.primary_analysis.key_levels:
lvls = r.primary_analysis.key_levels
shown = [(k, lvls[k]) for k in ("r3", "r2", "r1", "pivot", "s1", "s2", "s3") if k in lvls]
if shown:
m1 += ["", "<b>📍 Key Levels:</b>"]
for k, v in shown:
arrow = "🔴" if v > r.current_price else "🟢"
m1.append(f" {arrow} <code>{k.upper():>6}:</code> <code>{v:.5f}</code>")
# ── Message 2: Indicators + Reasons ───────────────────────────
m2 = []
if r.primary_analysis:
ind_lines = []
for name, ind in r.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
em = SIGNAL_EMOJI.get(ind.signal, "↔")
# Trim long descriptions to keep message readable
desc = _esc(ind.description[:90])
ind_lines.append(
f" {em} <b>{_esc(ind.name)}</b> <code>{ind.confidence:.0f}%</code>"
f"\n <i>{desc}</i>"
)
if ind_lines:
m2 += ["<b>📉 Indicators:</b>"] + ind_lines
if r.bullish_reasons:
if m2:
m2.append("")
m2.append("<b>🟢 Bullish Factors:</b>")
for reason in r.bullish_reasons[:5]:
m2.append(f" ▲ {_esc(reason)}")
if r.bearish_reasons:
if m2:
m2.append("")
m2.append("<b>🔴 Bearish Factors:</b>")
for reason in r.bearish_reasons[:5]:
m2.append(f" ▼ {_esc(reason)}")
if r.warnings:
if m2:
m2.append("")
m2.append("<b>⚠️ Warnings:</b>")
for w in r.warnings:
m2.append(f" ! {_esc(w)}")
result = _split_messages(m1)
if m2:
result += _split_messages(m2)
return result
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════
# BOT CLASS # BOT
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════
class ForexTelegramBot: class ForexTelegramBot:
def __init__(self): def __init__(self):
self._chat_states: Dict[int, ChatState] = {} self._states: Dict[int, ChatState] = {}
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex") self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
def _state(self, chat_id: int) -> ChatState: def _state(self, chat_id: int) -> ChatState:
if chat_id not in self._chat_states: if chat_id not in self._states:
self._chat_states[chat_id] = ChatState() self._states[chat_id] = ChatState()
return self._chat_states[chat_id] return self._states[chat_id]
def _build_config(self, state: ChatState) -> PlatformConfig: def _build_config(self, tf: str, pairs: list = None) -> PlatformConfig:
config = PlatformConfig() config = PlatformConfig()
config.primary_timeframe = TF_MAP[state.timeframe] config.primary_timeframe = TF_MAP[tf]
config.confirmation_timeframes = TF_CONFIRMATIONS.get( config.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
state.timeframe, [Timeframe.M15, Timeframe.H4] config.watch_pairs = list(pairs or MAJOR_PAIRS)
)
config.watch_pairs = list(state.pairs)
return config return config
# ── Blocking analysis (run in thread pool) ───────────────────── def _run_scan(self, tf: str, pairs: list) -> List[SignalReport]:
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
def _run_scan(self, state: ChatState) -> list[SignalReport]:
config = self._build_config(state)
return SignalAggregator(config).analyze_all_pairs()
def _run_pair(self, symbol: str, state: ChatState) -> Optional[SignalReport]:
config = self._build_config(state)
config.watch_pairs = [symbol]
reports = SignalAggregator(config).analyze_all_pairs()
return reports[0] if reports else None
async def _analyze(self, func, *args): async def _analyze(self, func, *args):
"""Run a blocking analysis function in the thread pool."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, func, *args) return await loop.run_in_executor(self._executor, func, *args)
# ── Parse helpers ──────────────────────────────────────────────
@staticmethod @staticmethod
def _parse_tf(args: list[str]) -> Optional[str]: def _parse_tf(args: list) -> Optional[str]:
"""Return the first arg that is a valid timeframe key, or None."""
for a in args: for a in args:
if a.upper() in TF_MAP: if a.upper() in TF_MAP:
return a.upper() return a.upper()
return None return None
@staticmethod
async def _send_pre(update: Update, text: str):
"""Send text as a monospace pre block, splitting if over 4096 chars."""
LIMIT = 3800 # leave room for <pre> tags
chunks = []
while text:
if len(text) <= LIMIT:
chunks.append(text)
break
# Split at last newline before limit
split_at = text.rfind("\n", 0, LIMIT)
if split_at == -1:
split_at = LIMIT
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
for chunk in chunks:
await update.message.reply_text(
f"<pre>{chunk}</pre>", parse_mode=ParseMode.HTML
)
# ── Commands ─────────────────────────────────────────────────── # ── Commands ───────────────────────────────────────────────────
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = ( await self.cmd_help(update, context)
"<b>🌐 Forex Signal Intelligence Platform</b>\n"
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>\n\n"
"Multi-layer technical + fundamental signal analysis across major Forex pairs.\n\n"
"<b>Commands:</b>\n"
" /scan — Scan all major pairs\n"
" /signals — Actionable signals only (grade B+)\n"
" /pair EURUSD — Detailed single-pair analysis\n"
" /all — Include cross pairs in scan\n"
" /tf H1 — Set default timeframe\n"
" /watch — Periodic scan alerts (every 5 min)\n"
" /unwatch — Disable alerts\n"
" /status — Current settings\n"
" /help — Full command reference\n\n"
"<i>⚠️ Educational/research use only. Not financial advice.</i>"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = (
"<b>📖 Command Reference</b>\n\n"
"<b>/scan</b> — Scan all major pairs (uses default TF)\n"
"<b>/scan H4</b> — Scan with a specific timeframe\n\n"
"<b>/signals</b> — Grade B+ signals only\n"
"<b>/signals H4</b> — With a specific timeframe\n\n"
"<b>/pair EURUSD</b> — Detailed analysis (uses default TF)\n"
"<b>/pair EURUSD H4</b> — With a specific timeframe\n\n"
"<b>/all</b> — Scan major + cross pairs\n"
"<b>/all H4</b> — With a specific timeframe\n\n"
"<b>/tf H1</b> — Set your default timeframe\n"
" Options: <code>M5 M15 M30 H1 H4 D1</code>\n\n"
"<b>/watch</b> — Alert every 5 min (actionable signals only)\n"
"<b>/watch 15</b> — Alert every 15 min (5–120 min range)\n"
"<b>/unwatch</b> — Stop alerts\n\n"
"<b>/status</b> — View current settings\n\n"
"<b>Signal key:</b>\n"
" 🟢⬆⬆ Strong Buy 🟢⬆ Buy ⬜↔ Neutral\n"
" 🔴⬇ Sell 🔴⬇⬇ Strong Sell\n\n"
"<b>Grade key:</b>\n"
" A+ ⭐⭐ A ⭐ B 🔵 C 🟡 D 🟠 F ⚫"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
state = self._state(update.effective_chat.id) state = self._state(update.effective_chat.id)
pairs_display = ", ".join(SYMBOL_DISPLAY.get(p, p) for p in state.pairs)
watch_str = "✅ enabled" if state.watch_enabled else "❌ disabled"
text = ( text = (
"<b>⚙️ Current Settings</b>\n\n" "<b>🌐 Forex Signal Intelligence Platform</b>\n\n"
f"<b>Timeframe:</b> {state.timeframe}\n" "<b>Commands:</b>\n"
f"<b>Pairs:</b> {_esc(pairs_display)}\n" " /scan — Scan all major pairs\n"
f"<b>Watch alerts:</b> {watch_str}" " /scan H4 — Scan with specific timeframe\n"
" /pair EURUSD — Detailed analysis\n"
" /pair EURUSD H4 — With specific timeframe\n"
" /tf H1 — Set default timeframe\n"
" /help — This message\n\n"
"<b>Timeframes:</b>\n"
" <code>M5 </code> — 5 minutes\n"
" <code>M15</code> — 15 minutes\n"
" <code>M30</code> — 30 minutes\n"
" <code>H1 </code> — 1 hour ← recommended for intraday\n"
" <code>H4 </code> — 4 hours ← recommended for swing trading\n"
" <code>D1 </code> — Daily ← recommended for position trading\n\n"
f"Current default: <b>{state.timeframe}</b>\n\n"
"<i>⚠️ Educational use only. Not financial advice.</i>"
) )
await update.message.reply_text(text, parse_mode=ParseMode.HTML) await update.message.reply_text(text, parse_mode=ParseMode.HTML)
@@ -370,7 +307,8 @@ class ForexTelegramBot:
if not tf: if not tf:
opts = " ".join(TF_MAP.keys()) opts = " ".join(TF_MAP.keys())
await update.message.reply_text( await update.message.reply_text(
f"Usage: <b>/tf &lt;timeframe&gt;</b>\nOptions: <code>{opts}</code>", f"Usage: <b>/tf &lt;timeframe&gt;</b>\n"
f"Options: <code>{opts}</code>",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
return return
@@ -382,45 +320,42 @@ class ForexTelegramBot:
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
state = self._state(chat_id) state = self._state(chat_id)
tf = self._parse_tf(context.args or []) or state.timeframe
# Allow inline TF override: /scan H4 msg = await update.message.reply_text(f"🔄 Scanning markets ({tf})…")
scan_state = ChatState()
scan_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
scan_state.pairs = list(state.pairs)
msg = await update.message.reply_text("🔄 Scanning markets, please wait…")
try: try:
reports = await self._analyze(self._run_scan, scan_state) reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
text = _format_scan(reports, scan_state.timeframe)
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Scan error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_signals(self, update: Update, context: ContextTypes.DEFAULT_TYPE): # ── summary block (header + table + calendar) ──────────
chat_id = update.effective_chat.id summary = (
state = self._state(chat_id) _format_header(tf)
+ _format_table(reports)
+ _format_calendar(reports)
)
await msg.edit_text(f"<pre>{summary}</pre>", parse_mode=ParseMode.HTML)
scan_state = ChatState() # ── detailed block for each actionable signal ──────────
scan_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
scan_state.pairs = list(state.pairs)
msg = await update.message.reply_text("🔄 Scanning for signals…")
try:
reports = await self._analyze(self._run_scan, scan_state)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")] actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable: if actionable:
text = _format_scan(actionable, scan_state.timeframe, title="🎯 ACTIONABLE SIGNALS") header_line = "\n ═══ ACTIONABLE SIGNALS (Grade B or above) ═══\n"
first = True
for r in actionable:
parts = _format_detail(r)
if first:
parts[0] = header_line + parts[0]
first = False
for part in parts:
await self._send_pre(update, part)
else: else:
text = ( await self._send_pre(
f"<b>🎯 ACTIONABLE SIGNALS — {scan_state.timeframe}</b>\n\n" update,
"No grade B+ signals right now.\n" "\n No high-grade signals detected.\n"
"Use /scan to see all pairs." " Use /scan with a different timeframe to see more.\n",
) )
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e: except Exception as e:
logger.error(f"Signals error: {e}") logger.error(f"Scan error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML) await msg.edit_text(f"❌ Scan failed: {e}")
async def cmd_pair(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_pair(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
@@ -438,127 +373,26 @@ class ForexTelegramBot:
raw = args[0].upper().replace("/", "").replace("_", "") raw = args[0].upper().replace("/", "").replace("_", "")
if not raw.endswith("=X"): if not raw.endswith("=X"):
raw += "=X" raw += "=X"
tf = self._parse_tf(args[1:]) or state.timeframe
pair_state = ChatState()
pair_state.timeframe = self._parse_tf(args[1:]) or state.timeframe
display = SYMBOL_DISPLAY.get(raw, raw) display = SYMBOL_DISPLAY.get(raw, raw)
msg = await update.message.reply_text(f"🔄 Analyzing {_esc(display)}…")
msg = await update.message.reply_text(f"🔄 Analyzing {display} ({tf})…")
try: try:
report = await self._analyze(self._run_pair, raw, pair_state) reports = await self._analyze(self._run_scan, tf, [raw])
if report is None: if not reports:
await msg.edit_text( await msg.edit_text(f"❌ No data returned for {raw}")
f"❌ No data returned for <code>{_esc(raw)}</code>.",
parse_mode=ParseMode.HTML,
)
return return
messages = _format_pair_detail(report)
await msg.edit_text(messages[0], parse_mode=ParseMode.HTML) r = reports[0]
for m in messages[1:]: summary = _format_header(tf) + _format_table([r])
await update.message.reply_text(m, parse_mode=ParseMode.HTML) await msg.edit_text(f"<pre>{summary}</pre>", parse_mode=ParseMode.HTML)
for part in _format_detail(r):
await self._send_pre(update, part)
except Exception as e: except Exception as e:
logger.error(f"Pair analysis error for {raw}: {e}") logger.error(f"Pair error for {raw}: {e}")
await msg.edit_text(f"❌ Analysis failed: {_esc(str(e))}", parse_mode=ParseMode.HTML) await msg.edit_text(f"❌ Analysis failed: {e}")
async def cmd_all(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
all_state = ChatState()
all_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
all_state.pairs = list(MAJOR_PAIRS) + list(CROSS_PAIRS)
msg = await update.message.reply_text("🔄 Scanning all pairs (major + cross)…")
try:
reports = await self._analyze(self._run_scan, all_state)
text = _format_scan(reports, all_state.timeframe, title="FULL PAIR SCAN")
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"All-pairs scan error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_watch(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
interval_minutes = WATCH_DEFAULT
if context.args:
try:
interval_minutes = int(context.args[0])
interval_minutes = max(WATCH_MIN_INTERVAL, min(WATCH_MAX_INTERVAL, interval_minutes))
except ValueError:
pass
# Remove any existing watch job for this chat
for job in context.job_queue.get_jobs_by_name(f"watch_{chat_id}"):
job.schedule_removal()
state.watch_enabled = True
context.job_queue.run_repeating(
self._watch_job,
interval=interval_minutes * 60,
first=5,
chat_id=chat_id,
name=f"watch_{chat_id}",
)
await update.message.reply_text(
f"✅ Watch alerts enabled — scanning every <b>{interval_minutes} min</b>.\n"
"Alerts fire when grade B+ signals appear. Use /unwatch to stop.",
parse_mode=ParseMode.HTML,
)
async def cmd_unwatch(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
self._state(chat_id).watch_enabled = False
for job in context.job_queue.get_jobs_by_name(f"watch_{chat_id}"):
job.schedule_removal()
await update.message.reply_text("❌ Watch alerts disabled.")
async def cmd_credits(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show API credit usage against the free-tier daily limit."""
from data_provider import ForexDataProvider
config = self._build_config(self._state(update.effective_chat.id))
provider = ForexDataProvider(config)
used = provider._daily_credits
limit = provider.DAILY_LIMIT
remaining = limit - used
pct = (used / limit) * 100 if limit else 0
bar_filled = int(pct / 5)
bar = "█" * bar_filled + "░" * (20 - bar_filled)
status = "🟢" if pct < 60 else ("🟡" if pct < 80 else "🔴")
text = (
"<b>📊 API Credit Usage</b>\n\n"
f"<code>{bar}</code> {status}\n"
f"<b>Used:</b> {used} / {limit} credits\n"
f"<b>Remaining:</b> {remaining} credits\n\n"
"<i>Free tier: 8 credits/min, 800 credits/day\n"
"Each full scan uses ~21 credits (7 pairs × 3 timeframes)\n"
"≈ 38 scans available per day</i>"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
# ── Watch job ──────────────────────────────────────────────────
async def _watch_job(self, context: CallbackContext):
chat_id = context.job.chat_id
state = self._state(chat_id)
if not state.watch_enabled:
return
try:
reports = await self._analyze(self._run_scan, state)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable:
text = (
"🔔 <b>SIGNAL ALERT</b>\n\n"
+ _format_scan(actionable, state.timeframe, title="ACTIONABLE SIGNALS")
)
else:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
text = f"📊 <b>Watch scan {now}</b> — No grade B+ signals detected."
await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Watch job error for chat {chat_id}: {e}")
# ── Application builder ──────────────────────────────────────── # ── Application builder ────────────────────────────────────────
@@ -572,18 +406,11 @@ class ForexTelegramBot:
) )
async def _register_commands(app: Application) -> None: async def _register_commands(app: Application) -> None:
"""Register commands with Telegram so they appear in the / menu."""
await app.bot.set_my_commands([ await app.bot.set_my_commands([
BotCommand("scan", "Scan all major pairs"), BotCommand("scan", "Scan all major pairs"),
BotCommand("signals", "Actionable signals only (grade B+)"), BotCommand("pair", "Detailed pair analysis — /pair EURUSD"),
BotCommand("pair", "Detailed pair analysis — /pair EURUSD"), BotCommand("tf", "Set default timeframe — /tf H4"),
BotCommand("all", "Scan major + cross pairs"), BotCommand("help", "Commands and timeframe guide"),
BotCommand("tf", "Set timeframe — /tf H4"),
BotCommand("watch", "Enable periodic alerts"),
BotCommand("unwatch", "Disable alerts"),
BotCommand("credits", "View API credit usage"),
BotCommand("status", "View current settings"),
BotCommand("help", "Full command reference"),
]) ])
app = ( app = (
@@ -593,17 +420,11 @@ class ForexTelegramBot:
.build() .build()
) )
app.add_handler(CommandHandler("start", self.cmd_start)) app.add_handler(CommandHandler("start", self.cmd_start))
app.add_handler(CommandHandler("help", self.cmd_help)) app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("status", self.cmd_status)) app.add_handler(CommandHandler("tf", self.cmd_tf))
app.add_handler(CommandHandler("tf", self.cmd_tf)) app.add_handler(CommandHandler("scan", self.cmd_scan))
app.add_handler(CommandHandler("scan", self.cmd_scan)) app.add_handler(CommandHandler("pair", self.cmd_pair))
app.add_handler(CommandHandler("signals", self.cmd_signals))
app.add_handler(CommandHandler("pair", self.cmd_pair))
app.add_handler(CommandHandler("all", self.cmd_all))
app.add_handler(CommandHandler("watch", self.cmd_watch))
app.add_handler(CommandHandler("unwatch", self.cmd_unwatch))
app.add_handler(CommandHandler("credits", self.cmd_credits))
return app return app
@@ -616,10 +437,8 @@ def main():
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
bot = ForexTelegramBot() bot = ForexTelegramBot()
app = bot.build_application() app = bot.build_application()
print("🤖 Forex Signal Bot started. Press Ctrl+C to stop.") print("🤖 Forex Signal Bot started. Press Ctrl+C to stop.")
app.run_polling(drop_pending_updates=True) app.run_polling(drop_pending_updates=True)