Remove TF param — analyze all timeframes automatically
- /signal EURUSD sends one card per timeframe (M15, H1, H4, D1) concurrently - /scan analyzes all pairs across all TFs, shows best-grade card per pair (B+ only) - All TFs run in parallel via ThreadPoolExecutor (one worker per TF) - Remove /tf command and per-chat state — no longer needed - Executor pool sized to len(SCAN_TIMEFRAMES) for true concurrency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+129
-159
@@ -3,16 +3,17 @@
|
||||
Forex Signal Intelligence Platform - Telegram Bot
|
||||
==================================================
|
||||
Trade alert frontend for the signal engine.
|
||||
Analyzes all timeframes automatically — no TF parameter needed.
|
||||
|
||||
Setup:
|
||||
set TELEGRAM_BOT_TOKEN=your_token_here
|
||||
set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
|
||||
export TELEGRAM_BOT_TOKEN=your_token_here (Linux/Mac)
|
||||
python telegram_bot.py
|
||||
|
||||
Commands:
|
||||
/signal EURUSD [TF] — Signal card for one pair
|
||||
/scan [TF] — Scan all pairs, show Grade B+ alerts
|
||||
/tf H1 — Set default timeframe
|
||||
/help — Command guide
|
||||
/signal EURUSD — Signal cards for one pair across all timeframes
|
||||
/scan — Scan all pairs, show Grade B+ alerts
|
||||
/help — Command guide
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -21,44 +22,44 @@ import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||
|
||||
from config import (
|
||||
MAJOR_PAIRS, SYMBOL_DISPLAY,
|
||||
MAJOR_PAIRS, CROSS_PAIRS, SYMBOL_DISPLAY,
|
||||
PlatformConfig, SignalStrength, Timeframe,
|
||||
)
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Timeframe maps ──────────────────────────────────────────────────
|
||||
# ── Timeframes run on every analysis ───────────────────────────────
|
||||
# Each TF is run as the primary; its confirmation TFs are auto-selected.
|
||||
SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"]
|
||||
|
||||
TF_MAP = {
|
||||
"M5": Timeframe.M5,
|
||||
"M15": Timeframe.M15,
|
||||
"M30": Timeframe.M30,
|
||||
"H1": Timeframe.H1,
|
||||
"H4": Timeframe.H4,
|
||||
"D1": Timeframe.D1,
|
||||
_TF_MAP = {
|
||||
"M5": Timeframe.M5, "M15": Timeframe.M15,
|
||||
"M30": Timeframe.M30, "H1": Timeframe.H1,
|
||||
"H4": Timeframe.H4, "D1": Timeframe.D1,
|
||||
}
|
||||
|
||||
TF_CONFIRMATIONS = {
|
||||
"M5": [Timeframe.M1, Timeframe.M15],
|
||||
_TF_CONFIRMATIONS = {
|
||||
"M15": [Timeframe.M5, Timeframe.H1],
|
||||
"M30": [Timeframe.M15, Timeframe.H1],
|
||||
"H1": [Timeframe.M15, Timeframe.H4],
|
||||
"H4": [Timeframe.H1, Timeframe.D1],
|
||||
"D1": [Timeframe.H4, Timeframe.W1],
|
||||
}
|
||||
|
||||
_SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS)
|
||||
|
||||
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}
|
||||
|
||||
# ── Formatting helpers ──────────────────────────────────────────────
|
||||
|
||||
def _grade_dot(quality: str) -> str:
|
||||
"""Colored dot matching confidence grade per spec."""
|
||||
if quality in ("A+", "A"):
|
||||
return "🟢"
|
||||
if quality == "B":
|
||||
@@ -77,22 +78,15 @@ def _signal_action(signal: SignalStrength) -> str:
|
||||
|
||||
|
||||
def _indicator_condition(ind) -> str:
|
||||
"""
|
||||
Extract a short readable condition from the indicator description.
|
||||
Prefer text after ' — ' (e.g. 'RSI 68.2 — Overbought' → 'Overbought').
|
||||
Fall back to the signal enum name.
|
||||
"""
|
||||
desc = ind.description
|
||||
if " — " in desc:
|
||||
return desc.split(" — ", 1)[1].strip()
|
||||
if " | " in desc:
|
||||
# e.g. 'MACD bullish crossover | Hist: 0.00008' — take first clause
|
||||
return desc.split(" | ", 1)[0].strip()
|
||||
return ind.signal.name.replace("_", " ").title()
|
||||
|
||||
|
||||
def _fmt_float(val: float) -> str:
|
||||
"""Human-readable float with appropriate precision."""
|
||||
if val == 0.0:
|
||||
return "0"
|
||||
av = abs(val)
|
||||
@@ -106,33 +100,11 @@ def _fmt_float(val: float) -> str:
|
||||
|
||||
|
||||
def _h(text: str) -> str:
|
||||
"""HTML-escape a string for safe use inside HTML parse mode."""
|
||||
return html.escape(str(text))
|
||||
|
||||
|
||||
def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
"""
|
||||
Build a single trade alert card using the required visual template.
|
||||
Returns an HTML-formatted string ready for Telegram.
|
||||
|
||||
Template:
|
||||
{Dot} **TRADE ALERT: {ACTION} {PAIR}**
|
||||
Grade / confidence / timeframe subtitle
|
||||
|
||||
**Entry:** {price}
|
||||
**Take Profit:** {price} (+pips)
|
||||
**Stop Loss:** {price} (-pips)
|
||||
**Risk:Reward:** 1:2.0
|
||||
|
||||
**Technical Indicators:**
|
||||
• {Name}: {Value} ({Condition})
|
||||
...
|
||||
|
||||
Multi-Timeframe block
|
||||
Warnings / events / news
|
||||
Market context line
|
||||
Disclaimer
|
||||
"""
|
||||
"""Build one trade alert card (HTML) for a given report + timeframe."""
|
||||
dot = _grade_dot(report.signal_quality)
|
||||
action = _signal_action(report.final_signal)
|
||||
pair = _h(report.display_name)
|
||||
@@ -146,7 +118,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
lines.append(f"<i>Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}</i>")
|
||||
lines.append("")
|
||||
|
||||
# ── Trade setup (only when grade B or above) ─────────────────────
|
||||
# ── Trade setup (Grade B and above only) ─────────────────────────
|
||||
if report.trade_setup:
|
||||
ts = report.trade_setup
|
||||
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
|
||||
@@ -162,7 +134,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
)
|
||||
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
|
||||
else:
|
||||
# No actionable setup — show price and recommendation
|
||||
lines.append(f"<b>Current Price:</b> {report.current_price:.5f}")
|
||||
lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}")
|
||||
|
||||
@@ -183,7 +154,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
if report.mtf_signals:
|
||||
lines.append("")
|
||||
agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent"
|
||||
lines.append(f"<b>Multi-Timeframe ({agree_str}):</b>")
|
||||
lines.append(f"<b>Confirmation TFs ({agree_str}):</b>")
|
||||
for tf, sig in report.mtf_signals.items():
|
||||
score = report.mtf_scores.get(tf, 0)
|
||||
sig_name = _h(sig.name.replace("_", " "))
|
||||
@@ -214,53 +185,50 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Per-chat state ──────────────────────────────────────────────────
|
||||
|
||||
class ChatState:
|
||||
def __init__(self):
|
||||
self.timeframe: str = "H1"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# Bot
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ForexTelegramBot:
|
||||
def __init__(self):
|
||||
self._states: Dict[int, ChatState] = {}
|
||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
|
||||
# One worker per timeframe so all TFs run concurrently
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex"
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
# ── Engine helpers ────────────────────────────────────────────────
|
||||
|
||||
def _state(self, chat_id: int) -> ChatState:
|
||||
if chat_id not in self._states:
|
||||
self._states[chat_id] = ChatState()
|
||||
return self._states[chat_id]
|
||||
|
||||
def _build_config(self, tf: str, pairs: Optional[List[str]] = None) -> PlatformConfig:
|
||||
def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig:
|
||||
cfg = PlatformConfig()
|
||||
cfg.primary_timeframe = TF_MAP[tf]
|
||||
cfg.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
|
||||
cfg.watch_pairs = list(pairs or MAJOR_PAIRS)
|
||||
cfg.primary_timeframe = _TF_MAP[tf]
|
||||
cfg.confirmation_timeframes = _TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
|
||||
cfg.watch_pairs = list(pairs)
|
||||
return cfg
|
||||
|
||||
def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]:
|
||||
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
|
||||
|
||||
async def _analyze(self, func, *args):
|
||||
async def _analyze_all_tfs(
|
||||
self, pairs: List[str]
|
||||
) -> Dict[str, List[SignalReport]]:
|
||||
"""Run all SCAN_TIMEFRAMES concurrently, return {tf: [reports]}."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(self._executor, func, *args)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tf(args: List[str]) -> Optional[str]:
|
||||
for a in args:
|
||||
if a.upper() in TF_MAP:
|
||||
return a.upper()
|
||||
return None
|
||||
tasks = {
|
||||
tf: loop.run_in_executor(self._executor, self._run_scan, tf, list(pairs))
|
||||
for tf in SCAN_TIMEFRAMES
|
||||
}
|
||||
results: Dict[str, List[SignalReport]] = {}
|
||||
for tf, task in tasks.items():
|
||||
try:
|
||||
results[tf] = await task
|
||||
except Exception as e:
|
||||
logger.error(f"TF {tf} scan failed: {e}", exc_info=True)
|
||||
results[tf] = []
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
async def _send_html(update: Update, text: str):
|
||||
"""Send HTML message, splitting at newlines if over Telegram's 4096-char limit."""
|
||||
"""Send HTML, splitting into chunks if over 4096 chars."""
|
||||
LIMIT = 4000
|
||||
chunks: List[str] = []
|
||||
while text:
|
||||
@@ -281,60 +249,33 @@ class ForexTelegramBot:
|
||||
await self.cmd_help(update, context)
|
||||
|
||||
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
state = self._state(update.effective_chat.id)
|
||||
tfs = " · ".join(SCAN_TIMEFRAMES)
|
||||
text = (
|
||||
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
|
||||
"<b>Commands:</b>\n"
|
||||
" /signal EURUSD — Signal card for a pair\n"
|
||||
" /signal EURUSD H4 — With specific timeframe\n"
|
||||
" /scan — Scan all pairs (Grade B+ alerts only)\n"
|
||||
" /scan H4 — Scan at a specific timeframe\n"
|
||||
" /tf H1 — Set your 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 (default, intraday)\n"
|
||||
" <code>H4 </code> — 4 hours (swing trading)\n"
|
||||
" <code>D1 </code> — Daily (position trading)\n\n"
|
||||
" /signal EURUSD — Signal cards for all timeframes\n"
|
||||
" /scan — Scan all pairs, Grade B+ alerts\n"
|
||||
" /help — This message\n\n"
|
||||
f"<b>Timeframes analyzed automatically:</b> <code>{tfs}</code>\n\n"
|
||||
"<b>Signal Grades:</b>\n"
|
||||
" 🟢 A / A+ — High confidence, consider entry\n"
|
||||
" 🟡 B — Decent setup, wait for confirmation\n"
|
||||
" 🔴 C / D — Low confidence, watch only\n\n"
|
||||
f"Your default timeframe: <b>{state.timeframe}</b>\n\n"
|
||||
"<i>⚠️ Educational use only. Not financial advice.</i>"
|
||||
)
|
||||
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
|
||||
|
||||
async def cmd_tf(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
tf = self._parse_tf(context.args or [])
|
||||
if not tf:
|
||||
opts = " ".join(TF_MAP.keys())
|
||||
await update.message.reply_text(
|
||||
f"Usage: <b>/tf <timeframe></b>\nOptions: <code>{opts}</code>",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
return
|
||||
self._state(update.effective_chat.id).timeframe = tf
|
||||
await update.message.reply_text(
|
||||
f"✅ Default timeframe set to <b>{tf}</b>",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
|
||||
async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""
|
||||
/signal EURUSD [TF]
|
||||
Analyze a single pair and return the full trade alert card.
|
||||
/signal EURUSD
|
||||
Runs the pair through all timeframes concurrently and sends
|
||||
one trade alert card per timeframe.
|
||||
"""
|
||||
chat_id = update.effective_chat.id
|
||||
state = self._state(chat_id)
|
||||
args = context.args or []
|
||||
|
||||
args = context.args or []
|
||||
if not args:
|
||||
await update.message.reply_text(
|
||||
"Usage: <b>/signal <pair> [timeframe]</b>\n"
|
||||
"Example: <code>/signal EURUSD H4</code>",
|
||||
"Usage: <b>/signal <pair></b>\n"
|
||||
"Example: <code>/signal EURUSD</code>",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
return
|
||||
@@ -342,20 +283,38 @@ class ForexTelegramBot:
|
||||
raw = args[0].upper().replace("/", "").replace("_", "")
|
||||
if not raw.endswith("=X"):
|
||||
raw += "=X"
|
||||
tf = self._parse_tf(args[1:]) or state.timeframe
|
||||
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
|
||||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||||
|
||||
msg = await update.message.reply_text(f"🔄 Analyzing {_h(display)} ({tf})…")
|
||||
synthetic_warning = ""
|
||||
if raw not in _SUPPORTED_PAIRS:
|
||||
synthetic_warning = (
|
||||
"\n\n⚠️ <b>Synthetic data warning:</b> "
|
||||
f"{_h(display)} is not on the Twelve Data free tier. "
|
||||
"Prices and indicators are <b>simulated</b> — not live market data."
|
||||
)
|
||||
|
||||
msg = await update.message.reply_text(
|
||||
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
|
||||
)
|
||||
try:
|
||||
reports = await self._analyze(self._run_scan, tf, [raw])
|
||||
if not reports:
|
||||
await msg.edit_text(f"❌ No data returned for {_h(display)}")
|
||||
return
|
||||
results_by_tf = await self._analyze_all_tfs([raw])
|
||||
|
||||
r = reports[0]
|
||||
alert = _build_alert(r, tf)
|
||||
await msg.delete()
|
||||
await self._send_html(update, alert)
|
||||
|
||||
sent = 0
|
||||
for tf in SCAN_TIMEFRAMES:
|
||||
reports = results_by_tf.get(tf, [])
|
||||
if not reports:
|
||||
continue
|
||||
alert = _build_alert(reports[0], tf) + synthetic_warning
|
||||
await self._send_html(update, alert)
|
||||
sent += 1
|
||||
|
||||
if sent == 0:
|
||||
await update.message.reply_text(
|
||||
f"❌ No data returned for {_h(display)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Signal error for {raw}: {e}", exc_info=True)
|
||||
@@ -363,51 +322,64 @@ class ForexTelegramBot:
|
||||
|
||||
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""
|
||||
/scan [TF]
|
||||
Analyze all major pairs and send a trade alert card for every
|
||||
signal rated Grade B or above.
|
||||
/scan
|
||||
Analyzes all major pairs across all timeframes concurrently.
|
||||
For each pair, picks the highest-grade result across all TFs
|
||||
and sends a card for Grade B and above.
|
||||
"""
|
||||
chat_id = update.effective_chat.id
|
||||
state = self._state(chat_id)
|
||||
tf = self._parse_tf(context.args or []) or state.timeframe
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
msg = await update.message.reply_text(f"🔄 Scanning all pairs ({tf})…")
|
||||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||||
|
||||
msg = await update.message.reply_text(
|
||||
f"🔄 Scanning all pairs across {_h(tfs_str)}…"
|
||||
)
|
||||
try:
|
||||
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
|
||||
results_by_tf = await self._analyze_all_tfs(list(MAJOR_PAIRS))
|
||||
|
||||
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
|
||||
# For each pair pick the best grade across all TFs
|
||||
# best_per_pair: {symbol: (tf, report)}
|
||||
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
|
||||
for tf, reports in results_by_tf.items():
|
||||
for r in reports:
|
||||
current = best_per_pair.get(r.symbol)
|
||||
if current is None or (
|
||||
_GRADE_RANK.get(r.signal_quality, 99)
|
||||
< _GRADE_RANK.get(current[1].signal_quality, 99)
|
||||
):
|
||||
best_per_pair[r.symbol] = (tf, r)
|
||||
|
||||
# Filter to B+ and sort best-first
|
||||
actionable = [
|
||||
(tf, r)
|
||||
for tf, r in best_per_pair.values()
|
||||
if r.signal_quality in ("A+", "A", "B")
|
||||
]
|
||||
actionable.sort(key=lambda x: _GRADE_RANK.get(x[1].signal_quality, 99))
|
||||
|
||||
if not actionable:
|
||||
await msg.edit_text(
|
||||
f"🔍 <b>Scan complete</b> — {now_str}\n\n"
|
||||
"No Grade B+ signals found at this time.\n"
|
||||
"Try a different timeframe with <code>/scan H4</code>.",
|
||||
"No Grade B+ signals found across any timeframe right now.",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
return
|
||||
|
||||
# Summary header
|
||||
grades = {"A+": 0, "A": 0, "B": 0}
|
||||
for r in actionable:
|
||||
grades[r.signal_quality] = grades.get(r.signal_quality, 0) + 1
|
||||
# Tally grades
|
||||
grade_counts: Dict[str, int] = {}
|
||||
for _, r in actionable:
|
||||
grade_counts[r.signal_quality] = grade_counts.get(r.signal_quality, 0) + 1
|
||||
grade_str = " ".join(f"{g}: {n}" for g, n in grade_counts.items())
|
||||
|
||||
grade_str = " ".join(
|
||||
f"{g}: {count}" for g, count in grades.items() if count
|
||||
)
|
||||
summary = (
|
||||
f"📊 <b>Scan complete</b> — {now_str}\n"
|
||||
f"Timeframe: <b>{tf}</b>\n"
|
||||
f"Timeframes: <code>{_h(tfs_str)}</code>\n"
|
||||
f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n"
|
||||
"Trade alerts follow ↓"
|
||||
"Best signal per pair ↓"
|
||||
)
|
||||
await msg.edit_text(summary, parse_mode=ParseMode.HTML)
|
||||
|
||||
# One card per actionable signal
|
||||
for r in actionable:
|
||||
alert = _build_alert(r, tf)
|
||||
await self._send_html(update, alert)
|
||||
for tf, r in actionable:
|
||||
await self._send_html(update, _build_alert(r, tf))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scan error: {e}", exc_info=True)
|
||||
@@ -421,15 +393,14 @@ class ForexTelegramBot:
|
||||
raise ValueError(
|
||||
"TELEGRAM_BOT_TOKEN environment variable is not set.\n"
|
||||
"Get a token from @BotFather on Telegram, then:\n"
|
||||
" Windows: set TELEGRAM_BOT_TOKEN=your_token\n"
|
||||
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
|
||||
" Windows CMD: set TELEGRAM_BOT_TOKEN=your_token\n"
|
||||
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
|
||||
)
|
||||
|
||||
async def _register_commands(app: Application) -> None:
|
||||
await app.bot.set_my_commands([
|
||||
BotCommand("signal", "Trade alert for one pair — /signal EURUSD"),
|
||||
BotCommand("scan", "Scan all pairs, Grade B+ alerts — /scan H4"),
|
||||
BotCommand("tf", "Set default timeframe — /tf H4"),
|
||||
BotCommand("signal", "Signal cards for one pair — /signal EURUSD"),
|
||||
BotCommand("scan", "Scan all pairs, best Grade B+ per pair"),
|
||||
BotCommand("help", "Commands and grade guide"),
|
||||
])
|
||||
|
||||
@@ -442,7 +413,6 @@ class ForexTelegramBot:
|
||||
|
||||
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("signal", self.cmd_signal))
|
||||
app.add_handler(CommandHandler("scan", self.cmd_scan))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user