Initial commit: Forex Indicator Telegram Bot MVP
Multi-indicator signal engine with RSI, MACD, Bollinger Bands, EMA trend filter, ADX strength gate, Stochastic, ATR, and higher-timeframe trend confirmation. Twelve Data API client with in-memory cache and rate limiting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Forex Indicator Telegram Bot
|
||||||
|
|
||||||
|
Minimal Telegram bot that serves forex prices and technical indicators (RSI, MACD, Bollinger Bands) on demand, powered by [Twelve Data](https://twelvedata.com/).
|
||||||
|
|
||||||
|
## Get tokens
|
||||||
|
|
||||||
|
1. **Telegram bot token** — message [@BotFather](https://t.me/BotFather), `/newbot`, copy the token.
|
||||||
|
2. **Twelve Data API key** — sign up at [twelvedata.com](https://twelvedata.com/), free tier gives 800 req/day, 8 req/min.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd forex_bot
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows: .venv\Scripts\activate macOS/Linux: source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # then edit .env and paste your tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
> `pandas-ta` requires NumPy 1.x; the pinned `numpy==1.26.4` handles that.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open Telegram and message your bot.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Example | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `/start`, `/help` | `/help` | Welcome + command list |
|
||||||
|
| `/pairs` | `/pairs` | List supported pairs and timeframes |
|
||||||
|
| `/price <PAIR>` | `/price EURUSD` | Latest close price |
|
||||||
|
| `/rsi <PAIR> <TF>` | `/rsi EURUSD 1h` | RSI(14) |
|
||||||
|
| `/macd <PAIR> <TF>` | `/macd GBPUSD 4h` | MACD(12,26,9) |
|
||||||
|
| `/bb <PAIR> <TF>` | `/bb USDJPY 1day` | Bollinger Bands(20,2) + price |
|
||||||
|
|
||||||
|
**Supported pairs:** EURUSD, GBPUSD, USDJPY, AUDUSD, USDCAD
|
||||||
|
**Supported timeframes:** 15min, 1h, 4h, 1day
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- In-memory cache per (pair, timeframe) with TTL based on timeframe — keeps you well under the free-tier rate limit.
|
||||||
|
- Simple in-process rate limiter caps to 8 calls/min.
|
||||||
|
- No database, no scheduler, no charts — on-demand text only.
|
||||||
|
|
||||||
|
_Educational only. Not financial advice._
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TELEGRAM_TOKEN: str = os.getenv("TELEGRAM_TOKEN", "")
|
||||||
|
TWELVE_DATA_API_KEY: str = os.getenv("TWELVE_DATA_API_KEY", "")
|
||||||
|
|
||||||
|
SUPPORTED_PAIRS: list[str] = ["EURUSD", "GBPUSD", "USDJPY", "AUDUSD", "USDCAD", "XAUUSD"]
|
||||||
|
SUPPORTED_TIMEFRAMES: list[str] = ["15min", "1h", "4h", "1day"]
|
||||||
|
|
||||||
|
if not TELEGRAM_TOKEN or not TWELVE_DATA_API_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Missing TELEGRAM_TOKEN or TWELVE_DATA_API_KEY. "
|
||||||
|
"Copy .env.example to .env and fill in values."
|
||||||
|
)
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""Technical indicator functions.
|
||||||
|
|
||||||
|
Pure pandas implementations — no third-party TA library. Each public function
|
||||||
|
takes a DataFrame with columns [open, high, low, close] indexed by datetime
|
||||||
|
ascending, and returns either a float or a dict of latest values.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _require(df: pd.DataFrame, n: int) -> None:
|
||||||
|
if len(df) < n:
|
||||||
|
raise ValueError(f"Not enough data: need at least {n} rows, got {len(df)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(series: pd.Series, period: int) -> pd.Series:
|
||||||
|
return series.ewm(span=period, adjust=False).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def _wilder(series: pd.Series, period: int) -> pd.Series:
|
||||||
|
"""Wilder's smoothing (RMA) — same as EMA with alpha = 1/period."""
|
||||||
|
return series.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def rsi(df: pd.DataFrame, period: int = 14) -> float:
|
||||||
|
"""Latest RSI using Wilder's smoothing."""
|
||||||
|
_require(df, period + 1)
|
||||||
|
delta = df["close"].diff()
|
||||||
|
gain = delta.clip(lower=0.0)
|
||||||
|
loss = -delta.clip(upper=0.0)
|
||||||
|
avg_gain = _wilder(gain, period)
|
||||||
|
avg_loss = _wilder(loss, period)
|
||||||
|
rs = avg_gain / avg_loss.replace(0.0, pd.NA)
|
||||||
|
rsi_series = (100.0 - (100.0 / (1.0 + rs))).fillna(100.0)
|
||||||
|
return float(rsi_series.iloc[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def macd(df: pd.DataFrame) -> dict[str, float]:
|
||||||
|
"""Latest MACD line, signal, histogram (12/26/9), plus prior histogram for direction."""
|
||||||
|
_require(df, 35)
|
||||||
|
fast = _ema(df["close"], 12)
|
||||||
|
slow = _ema(df["close"], 26)
|
||||||
|
macd_line = fast - slow
|
||||||
|
signal_line = _ema(macd_line, 9)
|
||||||
|
hist = macd_line - signal_line
|
||||||
|
return {
|
||||||
|
"macd": float(macd_line.iloc[-1]),
|
||||||
|
"signal": float(signal_line.iloc[-1]),
|
||||||
|
"hist": float(hist.iloc[-1]),
|
||||||
|
"hist_prev": float(hist.iloc[-2]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bollinger(df: pd.DataFrame, period: int = 20) -> dict[str, float]:
|
||||||
|
"""Latest Bollinger Bands (upper/mid/lower, 2 stdev) and current price."""
|
||||||
|
_require(df, period + 1)
|
||||||
|
close = df["close"]
|
||||||
|
mid = close.rolling(period).mean()
|
||||||
|
std = close.rolling(period).std(ddof=0)
|
||||||
|
return {
|
||||||
|
"upper": float((mid + 2.0 * std).iloc[-1]),
|
||||||
|
"mid": float(mid.iloc[-1]),
|
||||||
|
"lower": float((mid - 2.0 * std).iloc[-1]),
|
||||||
|
"price": float(close.iloc[-1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ema_pair(df: pd.DataFrame, fast: int = 20, slow: int = 50) -> dict[str, float]:
|
||||||
|
"""Latest fast/slow EMA values + current close."""
|
||||||
|
_require(df, slow + 1)
|
||||||
|
return {
|
||||||
|
"fast": float(_ema(df["close"], fast).iloc[-1]),
|
||||||
|
"slow": float(_ema(df["close"], slow).iloc[-1]),
|
||||||
|
"price": float(df["close"].iloc[-1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def atr(df: pd.DataFrame, period: int = 14) -> float:
|
||||||
|
"""Latest Average True Range (Wilder)."""
|
||||||
|
_require(df, period + 1)
|
||||||
|
high, low, close = df["high"], df["low"], df["close"]
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat(
|
||||||
|
[(high - low), (high - prev_close).abs(), (low - prev_close).abs()],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
return float(_wilder(tr, period).iloc[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def adx(df: pd.DataFrame, period: int = 14) -> float:
|
||||||
|
"""Latest ADX(14) — trend strength, range 0-100."""
|
||||||
|
_require(df, period * 2 + 1)
|
||||||
|
high, low, close = df["high"], df["low"], df["close"]
|
||||||
|
up = high.diff()
|
||||||
|
down = -low.diff()
|
||||||
|
plus_dm = pd.Series(((up > down) & (up > 0)) * up, index=df.index).clip(lower=0.0)
|
||||||
|
minus_dm = pd.Series(((down > up) & (down > 0)) * down, index=df.index).clip(lower=0.0)
|
||||||
|
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat(
|
||||||
|
[(high - low), (high - prev_close).abs(), (low - prev_close).abs()],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
|
||||||
|
atr_s = _wilder(tr, period)
|
||||||
|
plus_di = 100.0 * _wilder(plus_dm, period) / atr_s.replace(0.0, pd.NA)
|
||||||
|
minus_di = 100.0 * _wilder(minus_dm, period) / atr_s.replace(0.0, pd.NA)
|
||||||
|
dx = 100.0 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0.0, pd.NA)
|
||||||
|
adx_series = _wilder(dx.fillna(0.0), period)
|
||||||
|
return float(adx_series.iloc[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def stochastic(df: pd.DataFrame, k_period: int = 14, d_period: int = 3) -> dict[str, float]:
|
||||||
|
"""Latest Stochastic %K (smoothed) and %D, plus prior values for cross detection."""
|
||||||
|
_require(df, k_period + d_period + 1)
|
||||||
|
high, low, close = df["high"], df["low"], df["close"]
|
||||||
|
lowest = low.rolling(k_period).min()
|
||||||
|
highest = high.rolling(k_period).max()
|
||||||
|
raw_k = 100.0 * (close - lowest) / (highest - lowest).replace(0.0, pd.NA)
|
||||||
|
k = raw_k.rolling(d_period).mean()
|
||||||
|
d = k.rolling(d_period).mean()
|
||||||
|
return {
|
||||||
|
"k": float(k.iloc[-1]),
|
||||||
|
"d": float(d.iloc[-1]),
|
||||||
|
"k_prev": float(k.iloc[-2]),
|
||||||
|
"d_prev": float(d.iloc[-2]),
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""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 indicators import bollinger, macd, rsi
|
||||||
|
from signals import HTF_OF, analyze, emoji
|
||||||
|
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 HTF trend filter\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 _format_reading(r) -> str:
|
||||||
|
sign = "+" if r.contribution > 0 else ""
|
||||||
|
return f"• {r.name}: {r.value} ({sign}{r.contribution})"
|
||||||
|
|
||||||
|
|
||||||
|
async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
try:
|
||||||
|
pair, tf = _parse_pair_tf(ctx.args)
|
||||||
|
df = await fetch_ohlc(pair, tf)
|
||||||
|
|
||||||
|
htf = HTF_OF.get(tf)
|
||||||
|
htf_df = await fetch_ohlc(pair, htf) if htf and htf != tf else None
|
||||||
|
|
||||||
|
report = analyze(df, htf_df)
|
||||||
|
|
||||||
|
sl_dist = 1.5 * report["atr"]
|
||||||
|
tp_dist = 2.5 * report["atr"]
|
||||||
|
price = report["price"]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"*Signal* {pair} {tf} (HTF: {htf or 'n/a'})",
|
||||||
|
f"Price: `{price:.5f}` ATR(14): `{report['atr']:.5f}`",
|
||||||
|
"",
|
||||||
|
"*Indicators:*",
|
||||||
|
*[_format_reading(r) for r in report["readings"]],
|
||||||
|
"",
|
||||||
|
f"Score: `{report['score']:+d} / ±{report['max_score']}`",
|
||||||
|
f"*Verdict:* {emoji(report['verdict'])} *{report['verdict']}*"
|
||||||
|
f" (confidence {report['confidence']}%)",
|
||||||
|
]
|
||||||
|
|
||||||
|
if report["verdict"] in ("BUY", "STRONG BUY"):
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_Suggested zones (ATR-based):_",
|
||||||
|
f"Entry near `{price:.5f}`",
|
||||||
|
f"Stop ≈ `{price - sl_dist:.5f}` ({sl_dist:.5f} below)",
|
||||||
|
f"Target ≈ `{price + tp_dist:.5f}` ({tp_dist:.5f} above)",
|
||||||
|
]
|
||||||
|
elif report["verdict"] in ("SELL", "STRONG SELL"):
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_Suggested zones (ATR-based):_",
|
||||||
|
f"Entry near `{price:.5f}`",
|
||||||
|
f"Stop ≈ `{price + sl_dist:.5f}` ({sl_dist:.5f} above)",
|
||||||
|
f"Target ≈ `{price - tp_dist:.5f}` ({tp_dist:.5f} below)",
|
||||||
|
]
|
||||||
|
|
||||||
|
if report["notes"]:
|
||||||
|
lines += ["", *[f"_{n}_" for n in report["notes"]]]
|
||||||
|
|
||||||
|
await _reply(update, "\n".join(lines))
|
||||||
|
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 — /signal 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))
|
||||||
|
logger.info("Bot starting...")
|
||||||
|
app.run_polling()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
python-telegram-bot>=21.6
|
||||||
|
httpx>=0.27.2
|
||||||
|
pandas>=2.2.3
|
||||||
|
python-dotenv>=1.0.1
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
"""Signal scoring engine.
|
||||||
|
|
||||||
|
Each indicator contributes a score in [-2..+2] (sell..buy) with a weight.
|
||||||
|
Trend scores are gated by ADX (weak trend ⇒ halved). The weighted sum is
|
||||||
|
mapped to a verdict and confidence percentage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from indicators import adx, atr, bollinger, ema_pair, macd, rsi, stochastic
|
||||||
|
|
||||||
|
# Weights — tuned so HTF trend dominates and momentum confirms.
|
||||||
|
W_HTF_TREND = 3
|
||||||
|
W_LTF_TREND = 2
|
||||||
|
W_MACD = 2
|
||||||
|
W_RSI = 1
|
||||||
|
W_STOCH = 1
|
||||||
|
W_BB = 1
|
||||||
|
MAX_SCORE = 2 * (W_HTF_TREND + W_LTF_TREND + W_MACD + W_RSI + W_STOCH + W_BB) # 20
|
||||||
|
|
||||||
|
# Higher-timeframe mapping for the trend filter.
|
||||||
|
HTF_OF: dict[str, str] = {
|
||||||
|
"15min": "1h",
|
||||||
|
"1h": "4h",
|
||||||
|
"4h": "1day",
|
||||||
|
"1day": "1day",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Reading:
|
||||||
|
name: str
|
||||||
|
value: str # display string for the user
|
||||||
|
raw: int # -2..+2
|
||||||
|
weight: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contribution(self) -> int:
|
||||||
|
return self.raw * self.weight
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_score(fast: float, slow: float) -> tuple[int, str]:
|
||||||
|
"""Score EMA fast vs slow. ±2 if separation >0.1%, ±1 if just above/below."""
|
||||||
|
if slow == 0:
|
||||||
|
return 0, "flat"
|
||||||
|
sep = (fast - slow) / slow
|
||||||
|
if sep >= 0.001:
|
||||||
|
return 2, "strong bullish"
|
||||||
|
if sep > 0:
|
||||||
|
return 1, "bullish"
|
||||||
|
if sep <= -0.001:
|
||||||
|
return -2, "strong bearish"
|
||||||
|
if sep < 0:
|
||||||
|
return -1, "bearish"
|
||||||
|
return 0, "flat"
|
||||||
|
|
||||||
|
|
||||||
|
def _macd_score(m: dict[str, float]) -> tuple[int, str]:
|
||||||
|
above = m["macd"] > m["signal"]
|
||||||
|
rising = m["hist"] > m["hist_prev"]
|
||||||
|
if above and m["hist"] > 0:
|
||||||
|
return (2, "bullish + rising") if rising else (1, "bullish")
|
||||||
|
if not above and m["hist"] < 0:
|
||||||
|
return (-2, "bearish + falling") if not rising else (-1, "bearish")
|
||||||
|
return 0, "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def _rsi_score(value: float) -> tuple[int, str]:
|
||||||
|
if value >= 80:
|
||||||
|
return -2, "extreme overbought"
|
||||||
|
if value >= 70:
|
||||||
|
return -1, "overbought"
|
||||||
|
if value <= 20:
|
||||||
|
return 2, "extreme oversold"
|
||||||
|
if value <= 30:
|
||||||
|
return 1, "oversold"
|
||||||
|
return 0, "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def _stoch_score(s: dict[str, float]) -> tuple[int, str]:
|
||||||
|
k, d, kp, dp = s["k"], s["d"], s["k_prev"], s["d_prev"]
|
||||||
|
cross_down = kp >= dp and k < d
|
||||||
|
cross_up = kp <= dp and k > d
|
||||||
|
if k >= 80 and cross_down:
|
||||||
|
return -2, "overbought + bearish cross"
|
||||||
|
if k >= 80:
|
||||||
|
return -1, "overbought"
|
||||||
|
if k <= 20 and cross_up:
|
||||||
|
return 2, "oversold + bullish cross"
|
||||||
|
if k <= 20:
|
||||||
|
return 1, "oversold"
|
||||||
|
return 0, "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def _bb_score(b: dict[str, float]) -> tuple[int, str]:
|
||||||
|
if b["price"] >= b["upper"]:
|
||||||
|
return -1, "above upper band"
|
||||||
|
if b["price"] <= b["lower"]:
|
||||||
|
return 1, "below lower band"
|
||||||
|
return 0, "inside bands"
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(score: int) -> str:
|
||||||
|
if score >= 12:
|
||||||
|
return "STRONG BUY"
|
||||||
|
if score >= 6:
|
||||||
|
return "BUY"
|
||||||
|
if score <= -12:
|
||||||
|
return "STRONG SELL"
|
||||||
|
if score <= -6:
|
||||||
|
return "SELL"
|
||||||
|
return "NEUTRAL"
|
||||||
|
|
||||||
|
|
||||||
|
def emoji(verdict: str) -> str:
|
||||||
|
return {
|
||||||
|
"STRONG BUY": "🟢🟢",
|
||||||
|
"BUY": "🟢",
|
||||||
|
"NEUTRAL": "⚪",
|
||||||
|
"SELL": "🔴",
|
||||||
|
"STRONG SELL": "🔴🔴",
|
||||||
|
}.get(verdict, "⚪")
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict:
|
||||||
|
"""Run the full signal engine over a primary DataFrame and optional higher-TF DataFrame.
|
||||||
|
|
||||||
|
Returns a dict with: readings (list[Reading]), score, max_score, confidence,
|
||||||
|
verdict, adx, atr, price, and a notes list (e.g., 'weak trend, halved').
|
||||||
|
"""
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
e = ema_pair(df, 20, 50)
|
||||||
|
ltf_raw, ltf_label = _trend_score(e["fast"], e["slow"])
|
||||||
|
|
||||||
|
if htf_df is not None:
|
||||||
|
eh = ema_pair(htf_df, 20, 50)
|
||||||
|
htf_raw, htf_label = _trend_score(eh["fast"], eh["slow"])
|
||||||
|
else:
|
||||||
|
htf_raw, htf_label = 0, "n/a (no higher TF)"
|
||||||
|
|
||||||
|
adx_value = adx(df)
|
||||||
|
if adx_value < 20:
|
||||||
|
notes.append(f"ADX={adx_value:.1f} <20 → weak trend, trend scores halved")
|
||||||
|
ltf_raw = ltf_raw // 2 # towards zero
|
||||||
|
htf_raw = htf_raw // 2
|
||||||
|
elif adx_value >= 25:
|
||||||
|
notes.append(f"ADX={adx_value:.1f} >=25 → strong trend")
|
||||||
|
|
||||||
|
m = macd(df)
|
||||||
|
macd_raw, macd_label = _macd_score(m)
|
||||||
|
|
||||||
|
rsi_value = rsi(df)
|
||||||
|
rsi_raw, rsi_label = _rsi_score(rsi_value)
|
||||||
|
|
||||||
|
s = stochastic(df)
|
||||||
|
stoch_raw, stoch_label = _stoch_score(s)
|
||||||
|
|
||||||
|
b = bollinger(df)
|
||||||
|
bb_raw, bb_label = _bb_score(b)
|
||||||
|
|
||||||
|
readings = [
|
||||||
|
Reading("HTF Trend (EMA20/50)", htf_label, htf_raw, W_HTF_TREND),
|
||||||
|
Reading("LTF Trend (EMA20/50)", ltf_label, ltf_raw, W_LTF_TREND),
|
||||||
|
Reading("MACD", macd_label, macd_raw, W_MACD),
|
||||||
|
Reading(f"RSI={rsi_value:.1f}", rsi_label, rsi_raw, W_RSI),
|
||||||
|
Reading(f"Stoch %K={s['k']:.1f}", stoch_label, stoch_raw, W_STOCH),
|
||||||
|
Reading("Bollinger", bb_label, bb_raw, W_BB),
|
||||||
|
]
|
||||||
|
|
||||||
|
score = sum(r.contribution for r in readings)
|
||||||
|
confidence = round(abs(score) / MAX_SCORE * 100)
|
||||||
|
verdict = _verdict(score)
|
||||||
|
atr_value = atr(df)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"readings": readings,
|
||||||
|
"score": score,
|
||||||
|
"max_score": MAX_SCORE,
|
||||||
|
"confidence": confidence,
|
||||||
|
"verdict": verdict,
|
||||||
|
"adx": adx_value,
|
||||||
|
"atr": atr_value,
|
||||||
|
"price": b["price"],
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"""Twelve Data API client with in-memory cache and simple rate limiting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TWELVE_DATA_API_KEY
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_API_URL = "https://api.twelvedata.com/time_series"
|
||||||
|
|
||||||
|
_TTL_SECONDS: dict[str, int] = {
|
||||||
|
"15min": 800,
|
||||||
|
"1h": 3500,
|
||||||
|
"4h": 14000,
|
||||||
|
"1day": 43000,
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache: dict[tuple[str, str], tuple[float, pd.DataFrame]] = {}
|
||||||
|
_call_times: list[float] = []
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
_MAX_CALLS_PER_MIN = 8
|
||||||
|
_WINDOW = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _to_api_symbol(pair: str) -> str:
|
||||||
|
"""Convert 'EURUSD' to 'EUR/USD'."""
|
||||||
|
return f"{pair[:3]}/{pair[3:]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _respect_rate_limit() -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
cutoff = now - _WINDOW
|
||||||
|
while _call_times and _call_times[0] < cutoff:
|
||||||
|
_call_times.pop(0)
|
||||||
|
if len(_call_times) >= _MAX_CALLS_PER_MIN:
|
||||||
|
sleep_for = _WINDOW - (now - _call_times[0]) + 0.1
|
||||||
|
logger.info("Rate limit hit, sleeping %.2fs", sleep_for)
|
||||||
|
await asyncio.sleep(sleep_for)
|
||||||
|
_call_times.append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_ohlc(pair: str, timeframe: str, size: int = 200) -> pd.DataFrame:
|
||||||
|
"""Fetch OHLC candles for a forex pair from Twelve Data.
|
||||||
|
|
||||||
|
Returns a DataFrame indexed by datetime (ascending) with columns
|
||||||
|
[open, high, low, close]. Cached per (pair, timeframe) with timeframe-aware TTL.
|
||||||
|
"""
|
||||||
|
pair = pair.upper()
|
||||||
|
if pair not in SUPPORTED_PAIRS:
|
||||||
|
raise ValueError(f"Unsupported pair: {pair}")
|
||||||
|
if timeframe not in SUPPORTED_TIMEFRAMES:
|
||||||
|
raise ValueError(f"Unsupported timeframe: {timeframe}")
|
||||||
|
|
||||||
|
key = (pair, timeframe)
|
||||||
|
ttl = _TTL_SECONDS[timeframe]
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
async with _lock:
|
||||||
|
cached = _cache.get(key)
|
||||||
|
if cached and (now - cached[0]) < ttl:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
await _respect_rate_limit()
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"symbol": _to_api_symbol(pair),
|
||||||
|
"interval": timeframe,
|
||||||
|
"outputsize": size,
|
||||||
|
"apikey": TWELVE_DATA_API_KEY,
|
||||||
|
"format": "JSON",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(_API_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error("Twelve Data HTTP error: %s", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if data.get("status") == "error" or "values" not in data:
|
||||||
|
msg = data.get("message", "Unknown API error")
|
||||||
|
logger.error("Twelve Data API error: %s", msg)
|
||||||
|
raise ValueError(f"API error: {msg}")
|
||||||
|
|
||||||
|
df = pd.DataFrame(data["values"])
|
||||||
|
df["datetime"] = pd.to_datetime(df["datetime"])
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
df[col] = df[col].astype(float)
|
||||||
|
df = df.set_index("datetime")[["open", "high", "low", "close"]].sort_index()
|
||||||
|
|
||||||
|
_cache[key] = (now, df)
|
||||||
|
return df
|
||||||
Reference in New Issue
Block a user