Add 4-pair candle-boundary auto-scanner (free tier safe)

Expand from 2 to 4 pairs (EUR/USD, USD/JPY, GBP/USD, XAU/USD) with
automatic scanning at M15 and H1 candle close boundaries. Scheduler
staggers H1 by 60s to stay within 8 credits/min; daily budget is
480 of 800 allowed. Cache TTL is now candle-duration-aware so cross-TF
confirmation data is reused without extra API calls. Spread estimate
uses primary-TF data instead of fetching M5 separately.
This commit is contained in:
harwinder
2026-05-11 15:30:11 -04:00
parent a708609f8b
commit 873cf5487e
4 changed files with 315 additions and 51 deletions
Regular → Executable
+13 -1
View File
@@ -38,10 +38,14 @@ class MarketSession(Enum):
# ── Supported pairs ──────────────────────────────────────────────
USDJPY = "USDJPY=X"
EURUSD = "EURUSD=X"
GBPUSD = "GBPUSD=X"
XAUUSD = "XAUUSD=X"
PAIR_INFO = {
USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False},
GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False},
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
}
# Defaults (kept for backward compat)
@@ -117,7 +121,7 @@ class SignalConfig:
@dataclass
class NewsConfig:
"""Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR"])
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR", "GBP"])
min_impact: str = "medium"
pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15
@@ -163,4 +167,12 @@ class PlatformConfig:
refresh_interval: int = 300
scan_pairs: List[str] = field(
default_factory=lambda: [EURUSD, USDJPY, GBPUSD, XAUUSD]
)
scan_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M15, Timeframe.H1]
)
candle_buffer_seconds: int = 10
log_level: str = "INFO"
Regular → Executable
+8 -1
View File
@@ -43,6 +43,12 @@ class ForexDataProvider:
Timeframe.D1: 1, Timeframe.W1: 0.143,
}
CANDLE_SECONDS = {
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400, Timeframe.W1: 604800,
}
RATE_LIMIT = 8
RATE_WINDOW = 60.0
@@ -79,9 +85,10 @@ class ForexDataProvider:
cache_key = f"{symbol}_{timeframe.value}"
now = time.time()
cache_ttl = self.CANDLE_SECONDS.get(timeframe, self.config.refresh_interval)
if (
cache_key in self._cache_timestamps
and (now - self._cache_timestamps[cache_key]) < self.config.refresh_interval
and (now - self._cache_timestamps[cache_key]) < cache_ttl
and cache_key in self._cache
):
cached = self._cache.get(cache_key)
Regular → Executable
+6 -8
View File
@@ -297,14 +297,12 @@ class SignalAggregator:
report.news_sentiment = 0.0
report.news_description = ""
# ── 5. Spread estimate (only meaningful in live mode) ──────
if use_news:
try:
report.spread_estimate = self.data_provider.get_spread_estimate(symbol)
except Exception:
report.spread_estimate = 0.0
else:
# Backtest sets spread externally via BacktestConfig
# ── 5. Spread estimate from primary data (avoids extra API call) ─
try:
report.spread_estimate = float(
(primary_df["High"] - primary_df["Low"]).median()
)
except Exception:
report.spread_estimate = 0.0
# ── 6. Final signal ────────────────────────────────────────
Regular → Executable
+288 -41
View File
@@ -2,14 +2,23 @@
"""
ForexBot2 — Telegram Bot
========================
Async Telegram interface for USD/JPY signal intelligence.
Presents a persistent Scan button; on press runs a live signal analysis
and formats the result. Trade setup (entry / SL / TP) is shown when the
engine produces a STRONG_BUY or STRONG_SELL signal.
Async Telegram interface with candle-boundary auto-scanner.
Manual scan: 4 pairs × 2 timeframes via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop.
M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 70s buffer (staggered to respect 8 credits/min).
Credit budget (free tier):
M15 boundary: 4 pairs × 1 credit = 4 credits
H1 boundary: 4 pairs × 1 credit = 4 credits (staggered to next minute)
Top of hour: 4 + 4 = 8 across two minutes → ≤ 8 / min
Daily total: 384 (M15) + 96 (H1) = 480 / day (limit 800)
Usage:
python telegram_bot.py
TELEGRAM_TOKEN=<token> python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
"""
import asyncio
@@ -18,6 +27,7 @@ import os
import sys
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import List, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
@@ -28,7 +38,11 @@ from telegram.ext import (
ContextTypes,
)
from config import PlatformConfig, SignalStrength, Timeframe, EURUSD, USDJPY, PAIR_INFO
from config import (
PlatformConfig, SignalStrength, Timeframe,
EURUSD, USDJPY, GBPUSD, XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ────────────────────────────────────────────
@@ -45,19 +59,52 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, USDJPY, GBPUSD, XAUUSD]
SCAN_BUTTONS = [
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"},
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"},
{"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"},
{"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"},
{"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"},
]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
# Manual scans use richer confirmations (may spend extra cached credits)
TF_CONFIRMATIONS = {
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
}
# Auto-scan uses only cross-TF (already cached, zero extra credits)
AUTOSCAN_CONFIRMATIONS = {
Timeframe.M15: [Timeframe.H1],
Timeframe.H1: [Timeframe.M15],
}
_executor = ThreadPoolExecutor(max_workers=2)
# ── Shared data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None
def _get_shared_dp() -> ForexDataProvider:
global _shared_dp
if _shared_dp is None:
_shared_dp = ForexDataProvider(PlatformConfig())
return _shared_dp
# ── Signal formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = {
@@ -72,6 +119,14 @@ _QUALITY_EMOJI = {
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌",
}
_ARROW = {
SignalStrength.STRONG_BUY: "⬆️",
SignalStrength.BUY: "↗️",
SignalStrength.NEUTRAL: "➡️",
SignalStrength.SELL: "↘️",
SignalStrength.STRONG_SELL: "⬇️",
}
def _signal_label(sig: SignalStrength) -> str:
return sig.name.replace("_", " ")
@@ -87,15 +142,6 @@ def _mtf_line(report: SignalReport) -> str:
return " | ".join(parts)
_ARROW = {
SignalStrength.STRONG_BUY: "⬆️",
SignalStrength.BUY: "↗️",
SignalStrength.NEUTRAL: "➡️",
SignalStrength.SELL: "↘️",
SignalStrength.STRONG_SELL: "⬇️",
}
def _indicator_line(name: str, ind) -> str:
arrow = _ARROW.get(ind.signal, "➡️")
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description
@@ -111,7 +157,6 @@ def format_signal_message(report: SignalReport) -> str:
lines: list[str] = []
# Header
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines += [
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
@@ -126,12 +171,10 @@ def format_signal_message(report: SignalReport) -> str:
f"{mtf_tag}",
]
# MTF breakdown
mtf = _mtf_line(report)
if mtf:
lines += ["", f"📈 *MTF*: {mtf}"]
# Indicator breakdown with arrows
if report.primary_analysis:
lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
for name, ind in report.primary_analysis.indicators.items():
@@ -139,13 +182,11 @@ def format_signal_message(report: SignalReport) -> str:
continue
lines.append(_indicator_line(name, ind))
# Warnings
if report.warnings:
lines.append("")
for w in report.warnings:
lines.append(f"⚠️ {w}")
# Trade setup — only for strong signals
if report.trade_setup and sig in (SignalStrength.STRONG_BUY, SignalStrength.STRONG_SELL):
ts = report.trade_setup
dir_emoji = "📈" if ts.direction == "BUY" else "📉"
@@ -168,48 +209,190 @@ def format_signal_message(report: SignalReport) -> str:
return "\n".join(lines)
# ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
lines = [
f"📊 *{tf.value} Auto-Scan* | {now}",
"━━━━━━━━━━━━━━━━━━━━",
]
for r in reports:
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
sig = _signal_label(r.final_signal)
mtf = "✅" if r.mtf_agreement else "❌"
lines.append(
f"{emoji} *{r.display_name}* {sig} "
f"{_QUALITY_EMOJI.get(r.signal_quality, '')}{r.signal_quality} "
f"{r.final_score:+.1f} {r.final_confidence:.0f}% {mtf}MTF"
)
lines.append("━━━━━━━━━━━━━━━━━━━━")
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable:
names = ", ".join(r.display_name for r in actionable)
lines.append(f"🔔 Actionable: {names}")
else:
lines.append("No actionable signals this cycle.")
return "\n".join(lines)
# ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
for b in SCAN_BUTTONS[:2]],
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
for b in SCAN_BUTTONS[2:]],
])
rows = []
for i in range(0, len(SCAN_BUTTONS), 2):
rows.append([
InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 2]
])
return InlineKeyboardMarkup(rows)
# ── Async signal runner (offloaded to thread pool) ────────────────────
# ── Signal runner (shared data provider for cache reuse) ──────────────
TF_CONFIRMATIONS = {
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
}
def _run_signal_sync(symbol: str, timeframe: Timeframe) -> SignalReport:
def _run_signal_sync(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
if confirmation_tfs is None:
confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
config = PlatformConfig()
config.symbol = symbol
config.primary_timeframe = timeframe
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
config.confirmation_timeframes = confirmation_tfs
aggregator = SignalAggregator(config)
aggregator.data_provider = _get_shared_dp()
return aggregator.analyze_pair(symbol=symbol)
async def run_signal(symbol: str, timeframe: Timeframe) -> SignalReport:
async def run_signal(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, _run_signal_sync, symbol, timeframe)
return await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
)
# ── Candle-boundary scheduler ────────────────────────────────────────
_autoscan_chats: set[int] = set()
_autoscan_tasks: list[asyncio.Task] = []
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
"""Seconds until the next candle close + buffer."""
now = datetime.now(timezone.utc)
elapsed_minutes = now.minute % interval_minutes
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
interval_seconds = interval_minutes * 60
remaining = interval_seconds - elapsed_seconds + buffer
if remaining <= buffer:
remaining += interval_seconds
return remaining
async def _scan_timeframe(
bot,
tf: Timeframe,
confirmation_tfs: List[Timeframe],
) -> None:
"""Scan all pairs for one timeframe and broadcast results."""
reports: List[SignalReport] = []
for symbol in SCAN_PAIRS:
try:
report = await run_signal(symbol, tf, confirmation_tfs)
reports.append(report)
except Exception:
logger.exception(f"Auto-scan failed for {symbol} {tf.value}")
if not reports:
return
summary = _format_scan_summary(reports, tf)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, summary, parse_mode=ParseMode.MARKDOWN)
for report in actionable:
detail = format_signal_message(report)
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send to chat {chat_id}")
async def _m15_loop(bot) -> None:
"""Sleep until each M15 candle close, then scan all pairs."""
try:
while _autoscan_chats:
wait = _seconds_until_boundary(15, buffer=10)
logger.info(f"M15 auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait)
if not _autoscan_chats:
break
logger.info("M15 auto-scan: firing")
await _scan_timeframe(
bot, Timeframe.M15, AUTOSCAN_CONFIRMATIONS[Timeframe.M15]
)
except asyncio.CancelledError:
pass
finally:
logger.info("M15 auto-scan loop stopped")
async def _h1_loop(bot) -> None:
"""Sleep until each H1 candle close + 60s stagger, then scan all pairs."""
try:
while _autoscan_chats:
wait = _seconds_until_boundary(60, buffer=70)
logger.info(f"H1 auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait)
if not _autoscan_chats:
break
logger.info("H1 auto-scan: firing")
await _scan_timeframe(
bot, Timeframe.H1, AUTOSCAN_CONFIRMATIONS[Timeframe.H1]
)
except asyncio.CancelledError:
pass
finally:
logger.info("H1 auto-scan loop stopped")
def _start_scheduler(bot) -> None:
"""Start the M15 and H1 scanning loops if not already running."""
alive = [t for t in _autoscan_tasks if not t.done()]
if not alive:
_autoscan_tasks.clear()
_autoscan_tasks.append(asyncio.create_task(_m15_loop(bot)))
_autoscan_tasks.append(asyncio.create_task(_h1_loop(bot)))
logger.info("Candle-boundary scheduler started")
def _stop_scheduler() -> None:
"""Cancel all scanning loops."""
for t in _autoscan_tasks:
t.cancel()
_autoscan_tasks.clear()
logger.info("Candle-boundary scheduler stopped")
# ── Handlers ──────────────────────────────────────────────────────────
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\n"
"Strong signals include a full trade setup with entry, SL and TP."
"Strong signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle"
)
await update.message.reply_text(
text,
@@ -218,6 +401,34 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
)
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
_stop_scheduler()
await update.message.reply_text(
"⏹ Auto-scan stopped.",
parse_mode=ParseMode.MARKDOWN,
)
return
_autoscan_chats.add(chat_id)
_start_scheduler(context.bot)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=70)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M15 + H1 at candle close.\n"
f"Next M15 scan in {next_m15 / 60:.0f} min\n"
f"Next H1 scan in {next_h1 / 60:.0f} min\n\n"
"Use /autoscan again to stop.",
parse_mode=ParseMode.MARKDOWN,
)
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
@@ -246,6 +457,40 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
)
# ── Boot-time auto-start ──────────────────────────────────────────────
async def _post_init(app: Application) -> None:
"""Start the candle-boundary scheduler on boot if CHAT_ID is set."""
if not CHAT_ID:
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
"Use /autoscan in chat to enable.")
return
chat_id = int(CHAT_ID)
_autoscan_chats.add(chat_id)
_start_scheduler(app.bot)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=70)
logger.info(
f"Auto-scan started for chat {chat_id} — "
f"M15 in {next_m15:.0f}s, H1 in {next_h1:.0f}s"
)
try:
await app.bot.send_message(
chat_id,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M15 + H1.\n"
f"Next M15 in {next_m15 / 60:.0f} min | "
f"Next H1 in {next_h1 / 60:.0f} min\n\n"
f"/autoscan to stop | Manual buttons via /start",
parse_mode=ParseMode.MARKDOWN,
)
except Exception:
logger.exception("Failed to send boot message")
# ── Entry point ───────────────────────────────────────────────────────
def main() -> None:
@@ -257,10 +502,12 @@ def main() -> None:
app = (
Application.builder()
.token(TOKEN)
.post_init(_post_init)
.build()
)
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
logger.info("Bot running — press Ctrl+C to stop")