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.")