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:
+267
-448
@@ -2,22 +2,17 @@
|
||||
"""
|
||||
Forex Signal Intelligence Platform - Telegram Bot
|
||||
===================================================
|
||||
Exposes all platform functionality through a Telegram bot interface.
|
||||
Mirrors main.py output exactly via Telegram.
|
||||
|
||||
Usage:
|
||||
export TELEGRAM_BOT_TOKEN="your_bot_token_here"
|
||||
Setup:
|
||||
export TELEGRAM_BOT_TOKEN="your_token_here"
|
||||
python telegram_bot.py
|
||||
|
||||
Commands:
|
||||
/scan [TF] — Scan all major pairs
|
||||
/signals [TF] — Only actionable signals (grade B+)
|
||||
/pair EURUSD [TF] — Detailed analysis of one pair
|
||||
/all [TF] — Scan major + cross pairs
|
||||
/scan [TF] — Full signal scan (all major pairs)
|
||||
/pair EURUSD [TF] — Detailed analysis for one pair
|
||||
/tf H1 — Set default timeframe
|
||||
/watch [minutes] — Enable periodic scan alerts
|
||||
/unwatch — Disable alerts
|
||||
/status — Current settings
|
||||
/help — Full command reference
|
||||
/help — Commands + timeframe guide
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -25,21 +20,21 @@ import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import Application, CallbackContext, CommandHandler, ContextTypes
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||
|
||||
from config import (
|
||||
CROSS_PAIRS, MAJOR_PAIRS, SYMBOL_DISPLAY,
|
||||
MAJOR_PAIRS, SYMBOL_DISPLAY,
|
||||
PlatformConfig, SignalStrength, Timeframe,
|
||||
)
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
# ── Timeframe config ───────────────────────────────────────────────
|
||||
|
||||
TF_MAP = {
|
||||
"M5": Timeframe.M5,
|
||||
@@ -59,34 +54,162 @@ TF_CONFIRMATIONS = {
|
||||
"D1": [Timeframe.H4, Timeframe.W1],
|
||||
}
|
||||
|
||||
SIGNAL_EMOJI = {
|
||||
SignalStrength.STRONG_BUY: "🟢⬆⬆",
|
||||
SignalStrength.BUY: "🟢⬆",
|
||||
SignalStrength.NEUTRAL: "⬜↔",
|
||||
SignalStrength.SELL: "🔴⬇",
|
||||
SignalStrength.STRONG_SELL: "🔴⬇⬇",
|
||||
}
|
||||
# ── Formatting (mirrors main.py, no ANSI colours) ──────────────────
|
||||
|
||||
SIGNAL_NAME = {
|
||||
SignalStrength.STRONG_BUY: "STRONG BUY",
|
||||
SignalStrength.BUY: "BUY",
|
||||
SignalStrength.NEUTRAL: "NEUTRAL",
|
||||
SignalStrength.SELL: "SELL",
|
||||
SignalStrength.STRONG_SELL: "STRONG SELL",
|
||||
}
|
||||
WIDTH = 80
|
||||
|
||||
GRADE_EMOJI = {
|
||||
"A+": "⭐⭐",
|
||||
"A": "⭐",
|
||||
"B": "🔵",
|
||||
"C": "🟡",
|
||||
"D": "🟠",
|
||||
"F": "⚫",
|
||||
}
|
||||
|
||||
WATCH_MIN_INTERVAL = 5 # minimum watch interval in minutes
|
||||
WATCH_MAX_INTERVAL = 120 # maximum watch interval in minutes
|
||||
WATCH_DEFAULT = 5
|
||||
def _score_bar(score: float, width: int = 30) -> str:
|
||||
normalized = (score + 100) / 200
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -94,274 +217,88 @@ WATCH_DEFAULT = 5
|
||||
class ChatState:
|
||||
def __init__(self):
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self):
|
||||
self._chat_states: Dict[int, ChatState] = {}
|
||||
self._states: Dict[int, ChatState] = {}
|
||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
|
||||
|
||||
def _state(self, chat_id: int) -> ChatState:
|
||||
if chat_id not in self._chat_states:
|
||||
self._chat_states[chat_id] = ChatState()
|
||||
return self._chat_states[chat_id]
|
||||
if chat_id not in self._states:
|
||||
self._states[chat_id] = ChatState()
|
||||
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.primary_timeframe = TF_MAP[state.timeframe]
|
||||
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
|
||||
state.timeframe, [Timeframe.M15, Timeframe.H4]
|
||||
)
|
||||
config.watch_pairs = list(state.pairs)
|
||||
config.primary_timeframe = TF_MAP[tf]
|
||||
config.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
|
||||
config.watch_pairs = list(pairs or MAJOR_PAIRS)
|
||||
return config
|
||||
|
||||
# ── Blocking analysis (run in thread pool) ─────────────────────
|
||||
|
||||
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
|
||||
def _run_scan(self, tf: str, pairs: list) -> List[SignalReport]:
|
||||
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
|
||||
|
||||
async def _analyze(self, func, *args):
|
||||
"""Run a blocking analysis function in the thread pool."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(self._executor, func, *args)
|
||||
|
||||
# ── Parse helpers ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_tf(args: list[str]) -> Optional[str]:
|
||||
"""Return the first arg that is a valid timeframe key, or None."""
|
||||
def _parse_tf(args: list) -> Optional[str]:
|
||||
for a in args:
|
||||
if a.upper() in TF_MAP:
|
||||
return a.upper()
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
text = (
|
||||
"<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)
|
||||
await self.cmd_help(update, context)
|
||||
|
||||
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)
|
||||
pairs_display = ", ".join(SYMBOL_DISPLAY.get(p, p) for p in state.pairs)
|
||||
watch_str = "✅ enabled" if state.watch_enabled else "❌ disabled"
|
||||
text = (
|
||||
"<b>⚙️ Current Settings</b>\n\n"
|
||||
f"<b>Timeframe:</b> {state.timeframe}\n"
|
||||
f"<b>Pairs:</b> {_esc(pairs_display)}\n"
|
||||
f"<b>Watch alerts:</b> {watch_str}"
|
||||
"<b>🌐 Forex Signal Intelligence Platform</b>\n\n"
|
||||
"<b>Commands:</b>\n"
|
||||
" /scan — Scan all major pairs\n"
|
||||
" /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)
|
||||
|
||||
@@ -370,7 +307,8 @@ class ForexTelegramBot:
|
||||
if not tf:
|
||||
opts = " ".join(TF_MAP.keys())
|
||||
await update.message.reply_text(
|
||||
f"Usage: <b>/tf <timeframe></b>\nOptions: <code>{opts}</code>",
|
||||
f"Usage: <b>/tf <timeframe></b>\n"
|
||||
f"Options: <code>{opts}</code>",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
return
|
||||
@@ -382,45 +320,42 @@ class ForexTelegramBot:
|
||||
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
state = self._state(chat_id)
|
||||
tf = self._parse_tf(context.args or []) or state.timeframe
|
||||
|
||||
# Allow inline TF override: /scan H4
|
||||
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…")
|
||||
msg = await update.message.reply_text(f"🔄 Scanning markets ({tf})…")
|
||||
try:
|
||||
reports = await self._analyze(self._run_scan, scan_state)
|
||||
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)
|
||||
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
|
||||
|
||||
async def cmd_signals(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
state = self._state(chat_id)
|
||||
# ── summary block (header + table + calendar) ──────────
|
||||
summary = (
|
||||
_format_header(tf)
|
||||
+ _format_table(reports)
|
||||
+ _format_calendar(reports)
|
||||
)
|
||||
await msg.edit_text(f"<pre>{summary}</pre>", parse_mode=ParseMode.HTML)
|
||||
|
||||
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 for signals…")
|
||||
try:
|
||||
reports = await self._analyze(self._run_scan, scan_state)
|
||||
# ── detailed block for each actionable signal ──────────
|
||||
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
|
||||
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:
|
||||
text = (
|
||||
f"<b>🎯 ACTIONABLE SIGNALS — {scan_state.timeframe}</b>\n\n"
|
||||
"No grade B+ signals right now.\n"
|
||||
"Use /scan to see all pairs."
|
||||
await self._send_pre(
|
||||
update,
|
||||
"\n No high-grade signals detected.\n"
|
||||
" Use /scan with a different timeframe to see more.\n",
|
||||
)
|
||||
await msg.edit_text(text, parse_mode=ParseMode.HTML)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Signals error: {e}")
|
||||
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
|
||||
logger.error(f"Scan error: {e}")
|
||||
await msg.edit_text(f"❌ Scan failed: {e}")
|
||||
|
||||
async def cmd_pair(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
@@ -438,127 +373,26 @@ class ForexTelegramBot:
|
||||
raw = args[0].upper().replace("/", "").replace("_", "")
|
||||
if not raw.endswith("=X"):
|
||||
raw += "=X"
|
||||
|
||||
pair_state = ChatState()
|
||||
pair_state.timeframe = self._parse_tf(args[1:]) or state.timeframe
|
||||
|
||||
tf = self._parse_tf(args[1:]) or state.timeframe
|
||||
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:
|
||||
report = await self._analyze(self._run_pair, raw, pair_state)
|
||||
if report is None:
|
||||
await msg.edit_text(
|
||||
f"❌ No data returned for <code>{_esc(raw)}</code>.",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
reports = await self._analyze(self._run_scan, tf, [raw])
|
||||
if not reports:
|
||||
await msg.edit_text(f"❌ No data returned for {raw}")
|
||||
return
|
||||
messages = _format_pair_detail(report)
|
||||
await msg.edit_text(messages[0], parse_mode=ParseMode.HTML)
|
||||
for m in messages[1:]:
|
||||
await update.message.reply_text(m, parse_mode=ParseMode.HTML)
|
||||
|
||||
r = reports[0]
|
||||
summary = _format_header(tf) + _format_table([r])
|
||||
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:
|
||||
logger.error(f"Pair analysis error for {raw}: {e}")
|
||||
await msg.edit_text(f"❌ Analysis failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
|
||||
|
||||
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}")
|
||||
logger.error(f"Pair error for {raw}: {e}")
|
||||
await msg.edit_text(f"❌ Analysis failed: {e}")
|
||||
|
||||
# ── Application builder ────────────────────────────────────────
|
||||
|
||||
@@ -572,18 +406,11 @@ class ForexTelegramBot:
|
||||
)
|
||||
|
||||
async def _register_commands(app: Application) -> None:
|
||||
"""Register commands with Telegram so they appear in the / menu."""
|
||||
await app.bot.set_my_commands([
|
||||
BotCommand("scan", "Scan all major pairs"),
|
||||
BotCommand("signals", "Actionable signals only (grade B+)"),
|
||||
BotCommand("pair", "Detailed pair analysis — /pair EURUSD"),
|
||||
BotCommand("all", "Scan major + cross pairs"),
|
||||
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"),
|
||||
BotCommand("scan", "Scan all major pairs"),
|
||||
BotCommand("pair", "Detailed pair analysis — /pair EURUSD"),
|
||||
BotCommand("tf", "Set default timeframe — /tf H4"),
|
||||
BotCommand("help", "Commands and timeframe guide"),
|
||||
])
|
||||
|
||||
app = (
|
||||
@@ -593,17 +420,11 @@ class ForexTelegramBot:
|
||||
.build()
|
||||
)
|
||||
|
||||
app.add_handler(CommandHandler("start", self.cmd_start))
|
||||
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("scan", self.cmd_scan))
|
||||
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))
|
||||
app.add_handler(CommandHandler("start", self.cmd_start))
|
||||
app.add_handler(CommandHandler("help", self.cmd_help))
|
||||
app.add_handler(CommandHandler("tf", self.cmd_tf))
|
||||
app.add_handler(CommandHandler("scan", self.cmd_scan))
|
||||
app.add_handler(CommandHandler("pair", self.cmd_pair))
|
||||
|
||||
return app
|
||||
|
||||
@@ -616,10 +437,8 @@ def main():
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
bot = ForexTelegramBot()
|
||||
app = bot.build_application()
|
||||
|
||||
print("🤖 Forex Signal Bot started. Press Ctrl+C to stop.")
|
||||
app.run_polling(drop_pending_updates=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user