Files
Forex_bot/main.py
T

308 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Telegram bot entry point and command handlers."""
from __future__ import annotations
import logging
from telegram import BotCommand, Update
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
)
from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TELEGRAM_TOKEN
from charts import generate as generate_chart
from indicators import bollinger, macd, rsi
from signals import HTF_OF, analyze, ascii_indicator, to_pips
from twelvedata import fetch_ohlc
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
level=logging.INFO,
)
logger = logging.getLogger(__name__)
DISCLAIMER = "\n\n_Educational only. Not financial advice._"
WELCOME = (
"*Forex Indicator Bot*\n\n"
"Commands:\n"
"/pairs - list supported pairs/timeframes\n"
"/price <PAIR> - latest close (e.g. /price EURUSD)\n"
"/rsi <PAIR> <TF> - RSI value\n"
"/macd <PAIR> <TF> - MACD line/signal/histogram\n"
"/bb <PAIR> <TF> - Bollinger Bands\n"
"/signal <PAIR> <TF> - multi-indicator weighted verdict with chart\n"
"/chart <PAIR> <TF> - candlestick chart with EMA/BB/RSI\n"
"/help - this message"
) + DISCLAIMER
def _parse_pair_tf(args: list[str]) -> tuple[str, str]:
if len(args) != 2:
raise ValueError("Usage: <PAIR> <TIMEFRAME>")
pair, tf = args[0].upper(), args[1].lower()
if tf == "1d":
tf = "1day"
if pair not in SUPPORTED_PAIRS:
raise ValueError(f"Unknown pair '{pair}'. Try /pairs.")
if tf not in SUPPORTED_TIMEFRAMES:
raise ValueError(f"Unknown timeframe '{tf}'. Try /pairs.")
return pair, tf
async def _reply(update: Update, text: str) -> None:
await update.message.reply_text(text + DISCLAIMER, parse_mode="Markdown")
async def start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(WELCOME, parse_mode="Markdown")
async def pairs_cmd(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
text = (
"*Pairs:* " + ", ".join(SUPPORTED_PAIRS) +
"\n*Timeframes:* " + ", ".join(SUPPORTED_TIMEFRAMES)
)
await _reply(update, text)
async def price_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
if len(ctx.args) != 1:
raise ValueError("Usage: /price <PAIR> e.g. /price EURUSD")
pair = ctx.args[0].upper()
if pair not in SUPPORTED_PAIRS:
raise ValueError(f"Unknown pair '{pair}'. Try /pairs.")
df = await fetch_ohlc(pair, "1h", size=50)
last = df["close"].iloc[-1]
ts = df.index[-1].strftime("%Y-%m-%d %H:%M UTC")
await _reply(update, f"*{pair}* close: `{last:.5f}`\n_{ts}_")
except Exception as e:
logger.exception("price_cmd failed")
await _reply(update, f"Error: {e}")
async def rsi_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
pair, tf = _parse_pair_tf(ctx.args)
df = await fetch_ohlc(pair, tf)
await _reply(update, f"*RSI(14)* {pair} {tf}: `{rsi(df):.2f}`")
except Exception as e:
logger.exception("rsi_cmd failed")
await _reply(update, f"Error: {e}")
async def macd_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
pair, tf = _parse_pair_tf(ctx.args)
df = await fetch_ohlc(pair, tf)
m = macd(df)
text = (
f"*MACD* {pair} {tf}\n"
f"MACD: `{m['macd']:.5f}`\n"
f"Signal: `{m['signal']:.5f}`\n"
f"Hist: `{m['hist']:.5f}`"
)
await _reply(update, text)
except Exception as e:
logger.exception("macd_cmd failed")
await _reply(update, f"Error: {e}")
async def bb_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
pair, tf = _parse_pair_tf(ctx.args)
df = await fetch_ohlc(pair, tf)
b = bollinger(df)
text = (
f"*Bollinger(20,2)* {pair} {tf}\n"
f"Upper: `{b['upper']:.5f}`\n"
f"Mid: `{b['mid']:.5f}`\n"
f"Lower: `{b['lower']:.5f}`\n"
f"Price: `{b['price']:.5f}`"
)
await _reply(update, text)
except Exception as e:
logger.exception("bb_cmd failed")
await _reply(update, f"Error: {e}")
def _fmt_reading(r) -> str:
"""Format one indicator row.
Shows: signal (raw) × weight → contribution
e.g. +1 × 3 → +3 HTF EMA 20/50: strong bullish
This makes it immediately clear that the number after → is
(raw signal strength) × (importance weight), not a raw count.
"""
sign = "+" if r.raw > 0 else ""
raw_str = f"{sign}{r.raw}"
csign = "+" if r.contribution > 0 else ""
contrib = f"{csign}{r.contribution}"
return (
f" `{raw_str:>3} × {r.weight} → {contrib:>3}`"
f" {r.name}: {r.value}"
)
def _score_bar(score: int, max_score: int, width: int = 10) -> str:
"""Return a filled ASCII bar showing directional strength.
e.g. score=+8, max=20 -> '####------'
We use a single-sided bar: filled squares show absolute strength.
"""
filled = round(abs(score) / max_score * width)
bar = "#" * filled + "-" * (width - filled)
return bar
def _score_label(score: int, max_score: int) -> str:
"""Translate numeric score into a plain-English strength phrase."""
pct = abs(score) / max_score * 100
if pct >= 80:
return "very strong"
if pct >= 50:
return "strong"
if pct >= 25:
return "moderate"
return "weak"
def _fmt_group(readings, group: str, header: str) -> list[str]:
rows = [r for r in readings if r.group == group]
if not rows:
return []
return [header] + [_fmt_reading(r) for r in rows]
def _fmt_price(pair: str, value: float) -> str:
"""5 decimal places for FX, 2 for XAU."""
return f"{value:.2f}" if pair == "XAUUSD" else f"{value:.5f}"
async def chart_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
pair, tf = _parse_pair_tf(ctx.args)
df = await fetch_ohlc(pair, tf)
img = generate_chart(df, pair, tf)
caption = f"*{pair}* {tf} - candlestick | EMA20/50 | BB(20,2) | RSI(14)" + DISCLAIMER
await update.message.reply_photo(img, caption=caption, parse_mode="Markdown")
except Exception as e:
logger.exception("chart_cmd failed")
await _reply(update, f"Error: {e}")
async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
try:
pair, tf = _parse_pair_tf(ctx.args)
htf = HTF_OF.get(tf)
# Fetch both timeframes concurrently to double the network speed
import asyncio
if htf and htf != tf:
df, htf_df = await asyncio.gather(
fetch_ohlc(pair, tf),
fetch_ohlc(pair, htf)
)
else:
df = await fetch_ohlc(pair, tf)
htf_df = None
report = analyze(df, htf_df)
price = report["price"]
atr_val = report["atr"]
verdict = report["verdict"]
ts = report["timestamp"].strftime("%Y-%m-%d %H:%M UTC")
pips = to_pips(pair, atr_val)
lines: list[str] = [
f"[SIGNAL] *{pair} {tf}* | HTF: {htf or 'n/a'}",
f"`{_fmt_price(pair, price)}` | "
f"ATR `{_fmt_price(pair, atr_val)}` ({pips} pips) | _{ts}_",
"",
*_fmt_group(report["readings"], "trend",
f"*-- TREND* (ADX {report['adx_label']}"
+ (" (!) scores halved" if report["adx_weak"] else "") + ")"),
"",
*_fmt_group(report["readings"], "momentum", "*-- MOMENTUM*"),
"",
*_fmt_group(report["readings"], "volatility", "*-- VOLATILITY*"),
"",
"*-- FINAL VERDICT --*",
f"`[{_score_bar(report['score'], report['max_score'])}]` "
f"{ascii_indicator(verdict)} *{verdict}*",
f"Score `{report['score']:+d}` of maximum +/-`{report['max_score']}`",
f"_{report['confidence']}% overall strength ({_score_label(report['score'], report['max_score'])} signal)_",
"> _The bar above visualizes the signal strength. The more # symbols, the stronger the conviction. +20 is maximum Buy, -20 is maximum Sell._",
]
if report["context"]:
lines += ["", f"> _{report['context']}_"]
sl_dist = 1.5 * atr_val
tp_dist = 2.5 * atr_val
if verdict in ("BUY", "STRONG BUY"):
sl = _fmt_price(pair, price - sl_dist)
tp = _fmt_price(pair, price + tp_dist)
lines += [
"",
f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
]
elif verdict in ("SELL", "STRONG SELL"):
sl = _fmt_price(pair, price + sl_dist)
tp = _fmt_price(pair, price - tp_dist)
lines += [
"",
f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
]
text = "\n".join(lines) + DISCLAIMER
await update.message.reply_text(text, parse_mode="Markdown")
except Exception as e:
logger.exception("signal_cmd failed")
await _reply(update, f"Error: {e}")
BOT_COMMANDS: list[BotCommand] = [
BotCommand("start", "Show welcome and command list"),
BotCommand("help", "Show welcome and command list"),
BotCommand("pairs", "List supported pairs and timeframes"),
BotCommand("price", "Latest close price - /price EURUSD"),
BotCommand("rsi", "RSI(14) - /rsi EURUSD 1h"),
BotCommand("macd", "MACD(12,26,9) - /macd EURUSD 1h"),
BotCommand("bb", "Bollinger Bands(20,2) - /bb EURUSD 1h"),
BotCommand("signal", "Multi-indicator verdict + chart - /signal EURUSD 1h"),
BotCommand("chart", "Candlestick chart with EMA/BB/RSI - /chart EURUSD 1h"),
]
async def _post_init(app: Application) -> None:
await app.bot.set_my_commands(BOT_COMMANDS)
logger.info("Registered %d bot commands with Telegram", len(BOT_COMMANDS))
def main() -> None:
"""Run the bot."""
app = (
Application.builder()
.token(TELEGRAM_TOKEN)
.post_init(_post_init)
.build()
)
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", start))
app.add_handler(CommandHandler("pairs", pairs_cmd))
app.add_handler(CommandHandler("price", price_cmd))
app.add_handler(CommandHandler("rsi", rsi_cmd))
app.add_handler(CommandHandler("macd", macd_cmd))
app.add_handler(CommandHandler("bb", bb_cmd))
app.add_handler(CommandHandler("signal", signal_cmd))
app.add_handler(CommandHandler("chart", chart_cmd))
logger.info("Bot starting...")
app.run_polling()
if __name__ == "__main__":
main()