Spaces:
Configuration error
Configuration error
File size: 6,541 Bytes
83bcbe3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | """
main.py β Entry point for the Multi-Asset Price Alert Bot.
Orchestrates four concurrent async tasks:
1. Stock/commodity poller (yfinance)
2. Crypto WebSocket stream (Binance)
3. Telegram bot (commands + notifications)
4. Alert dispatcher (queue β Telegram sender)
Usage:
python main.py # normal mode
python main.py --dry-run # no Telegram sends, console alerts only
"""
import argparse
import asyncio
import signal
import sys
import config
import persistence
from alert_engine import AlertEngine, AlertEvent
from fetchers.crypto_fetcher import stream_crypto
from fetchers.stock_fetcher import poll_stocks
from logger import get_logger
from telegram_bot import TelegramBot
log = get_logger("main")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Multi-Asset Price Alert Bot")
parser.add_argument(
"--dry-run",
action="store_true",
help="Run without sending Telegram messages (prints alerts to console)",
)
parser.add_argument(
"--no-restore",
action="store_true",
help="Skip restoring saved state on startup (use default ranges only)",
)
return parser.parse_args()
async def main() -> None:
args = parse_args()
log.info("=" * 50)
log.info(" Multi-Asset Price Alert Bot")
log.info(" Mode: %s", "DRY-RUN" if args.dry_run else "LIVE")
log.info("=" * 50)
# ββ Validate config ββββββββββββββββββββββββββββββββββββββββββββββββββ
if not args.dry_run:
if not config.TELEGRAM_BOT_TOKEN:
log.error("TELEGRAM_BOT_TOKEN not set. See .env.example")
sys.exit(1)
if not config.TELEGRAM_CHAT_ID:
log.warning("TELEGRAM_CHAT_ID not set β alerts won't be sent until configured")
# ββ Initialise alert queue & engine βββββββββββββββββββββββββββββββββ
alert_queue: asyncio.Queue[AlertEvent] = asyncio.Queue(maxsize=500)
def on_alert(event: AlertEvent) -> None:
"""Thread-safe callback: push alert events into the async queue."""
try:
alert_queue.put_nowait(event)
except asyncio.QueueFull:
log.warning("Alert queue full, dropping alert for %s", event.asset_name)
engine = AlertEngine(on_alert=on_alert)
bot = TelegramBot(alert_engine=engine, dry_run=args.dry_run)
# ββ Load state: try persistence file first, then fall back to defaults β
if not args.no_restore:
restored = persistence.load_state(engine)
if restored == 0:
# Nothing restored β load built-in defaults
for name, (min_p, max_p) in config.DEFAULT_RANGES.items():
engine.add_asset(name, min_p, max_p)
log.info("Loaded %d default price ranges", len(config.DEFAULT_RANGES))
else:
for name, (min_p, max_p) in config.DEFAULT_RANGES.items():
engine.add_asset(name, min_p, max_p)
log.info("--no-restore: loaded %d default price ranges", len(config.DEFAULT_RANGES))
# ββ Build Telegram application βββββββββββββββββββββββββββββββββββββββ
app = bot.build_app() if not args.dry_run else None
# ββ Alert dispatcher βββββββββββββββββββββββββββββββββββββββββββββββββ
async def dispatch_alerts() -> None:
"""Consume alert events from the queue and send Telegram notifications."""
while True:
event = await alert_queue.get()
try:
await bot.send_alert(event)
except Exception as exc:
log.error("Failed to dispatch alert: %s", exc)
finally:
alert_queue.task_done()
# ββ Graceful shutdown ββββββββββββββββββββββββββββββββββββββββββββββββ
shutdown_event = asyncio.Event()
def _signal_handler(sig, frame):
log.info("Shutdown signal received (%s), stoppingβ¦", sig)
shutdown_event.set()
signal.signal(signal.SIGINT, _signal_handler)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, _signal_handler)
# ββ Launch all tasks βββββββββββββββββββββββββββββββββββββββββββββββββ
log.info("Starting all subsystemsβ¦")
tasks = [
asyncio.create_task(
poll_stocks(engine.on_price_update, engine.get_tracked_assets),
name="stock_poller",
),
asyncio.create_task(
stream_crypto(engine.on_price_update, engine.get_tracked_assets),
name="crypto_streamer",
),
asyncio.create_task(dispatch_alerts(), name="alert_dispatcher"),
]
if app:
async def run_telegram():
"""Run the Telegram updater alongside our tasks."""
await app.initialize()
await app.start()
await app.updater.start_polling(drop_pending_updates=True)
log.info("Telegram bot started β")
await shutdown_event.wait()
log.info("Stopping Telegram botβ¦")
await app.updater.stop()
await app.stop()
await app.shutdown()
tasks.append(asyncio.create_task(run_telegram(), name="telegram_bot"))
else:
log.info("[DRY-RUN] Telegram bot not started")
# ββ Wait for shutdown ββββββββββββββββββββββββββββββββββββββββββββββββ
try:
if app:
await shutdown_event.wait()
else:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
pass
finally:
log.info("Saving state before shutdownβ¦")
persistence.save_state(engine)
log.info("Cancelling tasksβ¦")
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
log.info("Bot stopped. Goodbye! π")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nBot stopped by user.")
|