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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-11 15:30:11 -04:00
parent 037dec643e
commit b3e0da7455
4 changed files with 315 additions and 51 deletions
Regular → Executable
+13 -1
View File
@@ -38,10 +38,14 @@ class MarketSession(Enum):
# ── Supported pairs ────────────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
USDJPY = "USDJPY=X" USDJPY = "USDJPY=X"
EURUSD = "EURUSD=X" EURUSD = "EURUSD=X"
GBPUSD = "GBPUSD=X"
XAUUSD = "XAUUSD=X"
PAIR_INFO = { PAIR_INFO = {
USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False}, 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) # Defaults (kept for backward compat)
@@ -117,7 +121,7 @@ class SignalConfig:
@dataclass @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """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" min_impact: str = "medium"
pre_event_buffer_minutes: int = 30 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
@@ -163,4 +167,12 @@ class PlatformConfig:
refresh_interval: int = 300 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" log_level: str = "INFO"
Regular → Executable
+8 -1
View File
@@ -43,6 +43,12 @@ class ForexDataProvider:
Timeframe.D1: 1, Timeframe.W1: 0.143, 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_LIMIT = 8
RATE_WINDOW = 60.0 RATE_WINDOW = 60.0
@@ -79,9 +85,10 @@ class ForexDataProvider:
cache_key = f"{symbol}_{timeframe.value}" cache_key = f"{symbol}_{timeframe.value}"
now = time.time() now = time.time()
cache_ttl = self.CANDLE_SECONDS.get(timeframe, self.config.refresh_interval)
if ( if (
cache_key in self._cache_timestamps 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 and cache_key in self._cache
): ):
cached = self._cache.get(cache_key) cached = self._cache.get(cache_key)
Regular → Executable
+4 -6
View File
@@ -297,15 +297,13 @@ class SignalAggregator:
report.news_sentiment = 0.0 report.news_sentiment = 0.0
report.news_description = "" report.news_description = ""
# ── 5. Spread estimate (only meaningful in live mode) ────── # ── 5. Spread estimate from primary data (avoids extra API call) ─
if use_news:
try: try:
report.spread_estimate = self.data_provider.get_spread_estimate(symbol) report.spread_estimate = float(
(primary_df["High"] - primary_df["Low"]).median()
)
except Exception: except Exception:
report.spread_estimate = 0.0 report.spread_estimate = 0.0
else:
# Backtest sets spread externally via BacktestConfig
report.spread_estimate = 0.0
# ── 6. Final signal ──────────────────────────────────────── # ── 6. Final signal ────────────────────────────────────────
self._compute_final_signal(report) self._compute_final_signal(report)
Regular → Executable
+287 -40
View File
@@ -2,14 +2,23 @@
""" """
ForexBot2 — Telegram Bot ForexBot2 — Telegram Bot
======================== ========================
Async Telegram interface for USD/JPY signal intelligence. Async Telegram interface with candle-boundary auto-scanner.
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 Manual scan: 4 pairs × 2 timeframes via inline buttons.
engine produces a STRONG_BUY or STRONG_SELL signal. 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: Usage:
python telegram_bot.py python telegram_bot.py
TELEGRAM_TOKEN=<token> python telegram_bot.py TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
""" """
import asyncio import asyncio
@@ -18,6 +27,7 @@ import os
import sys import sys
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode from telegram.constants import ParseMode
@@ -28,7 +38,11 @@ from telegram.ext import (
ContextTypes, 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 from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ──────────────────────────────────────────── # ── Force UTF-8 on Windows ────────────────────────────────────────────
@@ -45,19 +59,52 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot") logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE") 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 = [ SCAN_BUTTONS = [
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"}, {"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_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_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_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} 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) _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 formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = { _SIGNAL_EMOJI = {
@@ -72,6 +119,14 @@ _QUALITY_EMOJI = {
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌", "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: def _signal_label(sig: SignalStrength) -> str:
return sig.name.replace("_", " ") return sig.name.replace("_", " ")
@@ -87,15 +142,6 @@ def _mtf_line(report: SignalReport) -> str:
return " | ".join(parts) return " | ".join(parts)
_ARROW = {
SignalStrength.STRONG_BUY: "⬆️",
SignalStrength.BUY: "↗️",
SignalStrength.NEUTRAL: "➡️",
SignalStrength.SELL: "↘️",
SignalStrength.STRONG_SELL: "⬇️",
}
def _indicator_line(name: str, ind) -> str: def _indicator_line(name: str, ind) -> str:
arrow = _ARROW.get(ind.signal, "➡️") arrow = _ARROW.get(ind.signal, "➡️")
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description 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] = [] lines: list[str] = []
# Header
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines += [ lines += [
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})", 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}", f"{mtf_tag}",
] ]
# MTF breakdown
mtf = _mtf_line(report) mtf = _mtf_line(report)
if mtf: if mtf:
lines += ["", f"📈 *MTF*: {mtf}"] lines += ["", f"📈 *MTF*: {mtf}"]
# Indicator breakdown with arrows
if report.primary_analysis: if report.primary_analysis:
lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"] lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
for name, ind in report.primary_analysis.indicators.items(): for name, ind in report.primary_analysis.indicators.items():
@@ -139,13 +182,11 @@ def format_signal_message(report: SignalReport) -> str:
continue continue
lines.append(_indicator_line(name, ind)) lines.append(_indicator_line(name, ind))
# Warnings
if report.warnings: if report.warnings:
lines.append("") lines.append("")
for w in report.warnings: for w in report.warnings:
lines.append(f"⚠️ {w}") lines.append(f"⚠️ {w}")
# Trade setup — only for strong signals
if report.trade_setup and sig in (SignalStrength.STRONG_BUY, SignalStrength.STRONG_SELL): if report.trade_setup and sig in (SignalStrength.STRONG_BUY, SignalStrength.STRONG_SELL):
ts = report.trade_setup ts = report.trade_setup
dir_emoji = "📈" if ts.direction == "BUY" else "📉" dir_emoji = "📈" if ts.direction == "BUY" else "📉"
@@ -168,48 +209,190 @@ def format_signal_message(report: SignalReport) -> str:
return "\n".join(lines) 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 ─────────────────────────────────────────────────── # ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([ rows = []
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"]) for i in range(0, len(SCAN_BUTTONS), 2):
for b in SCAN_BUTTONS[:2]], rows.append([
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"]) InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
for b in SCAN_BUTTONS[2:]], 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 = { def _run_signal_sync(
Timeframe.M15: [Timeframe.M5, Timeframe.H1], symbol: str,
Timeframe.H1: [Timeframe.M15, Timeframe.H4], timeframe: Timeframe,
} confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
if confirmation_tfs is None:
def _run_signal_sync(symbol: str, timeframe: Timeframe) -> SignalReport: confirmation_tfs = TF_CONFIRMATIONS.get(
config = PlatformConfig()
config.symbol = symbol
config.primary_timeframe = timeframe
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4] timeframe, [Timeframe.M15, Timeframe.H4]
) )
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
aggregator = SignalAggregator(config) aggregator = SignalAggregator(config)
aggregator.data_provider = _get_shared_dp()
return aggregator.analyze_pair(symbol=symbol) 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() 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 ────────────────────────────────────────────────────────── # ── Handlers ──────────────────────────────────────────────────────────
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = ( text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n" "👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\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( await update.message.reply_text(
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: async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query query = update.callback_query
await query.answer() 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 ─────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────
def main() -> None: def main() -> None:
@@ -257,10 +502,12 @@ def main() -> None:
app = ( app = (
Application.builder() Application.builder()
.token(TOKEN) .token(TOKEN)
.post_init(_post_init)
.build() .build()
) )
app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_")) app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
logger.info("Bot running — press Ctrl+C to stop") logger.info("Bot running — press Ctrl+C to stop")