iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

30 天打造專屬盯盤機器人:資料爬取、策略指標到 Telegram/LINE 推播系列 第 24 篇

Day 24:全自動化里程碑 — 整合大盤與自選股的全自動盤後推播系統!

  • 分享至 

  • xImage
  •  

今天是第二十四天!昨天我們成功導入了「台股加權/櫃買大盤模組與市場多空溫度計(/market)」,讓我們在操作個股前能先掌握全市場的資金風向。

不過,一個合格的自動化看盤機器人,不應該每次都需要我們手動去敲指令(如 /market 或 /watchlist)。最理想的情境是:每天下午台股收盤、籌碼資料結算後(例如 14:30),機器人自動抓取最新大盤溫度計與你個人資料庫裡的自選股清單,自動組合出一份完整的「盤後日報」推播到你的 Telegram!

今天我們的目標,就是將之前的 APScheduler 定時排程器與 SQLite 資料庫中的自選股清單連結起來,實現「全自動化大盤 + 自選股盤後巡檢推播」。

步驟一:先釐清邏輯
自動化盤後推播的運作流程:

  • 觸發排程(scheduler):每週一至週五下午 14:30,APScheduler 定時觸發 scheduled_job 任務。
  • 產出大盤報告:呼叫 get_market_overview() 抓取最新加權與櫃買指數資料,產出大盤多空溫度計。
  • 讀取自選股資料庫:從 SQLite 資料庫中找出所有設定了自選股的使用者(chat_id)及其追蹤的個股代碼。
  • 生成個股快照報告:對每檔自選股進行「技術 + 籌碼 + 基本 + 消息」四維度檢測,整理出簡潔的關鍵訊號。
  • 分批推播 Telegram 訊息:將大盤總覽與個人自選股檢測日報一次性推播給使用者!
    接下來,知道邏輯後,我們就可以來更新程式碼了!
    步驟二:完整實作程式碼
    我們主要更新 bot.py 中的 scheduled_job 函式,使其具備從資料庫抓取自選股並自動推播的能力。
  1. bot.py-實作全自動盤後推播邏輯
import os
import sqlite3
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes, CommandHandler, CallbackQueryHandler
from fetcher import get_stock_data
from chart import create_stock_chart
from database import add_to_watchlist, remove_from_watchlist, get_watchlist, DB_NAME
from market import get_market_overview

def get_stock_keyboard(symbol):
    keyboard = [
        [
            InlineKeyboardButton("技術線圖", callback_data=f"chart_{symbol}"),
            InlineKeyboardButton("籌碼動向", callback_data=f"chip_{symbol}")
        ],
        [
            InlineKeyboardButton("營收數據", callback_data=f"rev_{symbol}"),
            InlineKeyboardButton("即時新聞", callback_data=f"news_{symbol}")
        ]
    ]
    return InlineKeyboardMarkup(keyboard)

# 取得所有有設定自選股的 Unique Chat ID
def get_all_watchlist_chat_ids():
    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()
    cursor.execute("SELECT DISTINCT chat_id FROM watchlist")
    rows = cursor.fetchall()
    conn.close()
    return [row[0] for row in rows]

# 每日定時自動推播任務
async def scheduled_job(app):
    print("[SCHEDULER] 開始執行每日盤後自動推播任務...")

    # 1. 抓取最新大盤報告
    market_report = get_market_overview()
    if not market_report:
        market_report = "[NOTICE] 無法取得最新大盤數據。"

    # 2. 取得所有使用自選股功能的使用者
    chat_ids = get_all_watchlist_chat_ids()

    if not chat_ids:
        print("[SCHEDULER] 目前資料庫中沒有任何使用者設定自選股。")
        return

    # 3. 針對每個使用者進行個別化推播
    for chat_id in chat_ids:
        stocks = get_watchlist(chat_id)
        if not stocks:
            continue

        # 先推播大盤溫度計
        await app.bot.send_message(
            chat_id=chat_id,
            text=f"-----------------------------------\n[每日盤後自動推播 - 大盤總覽]\n-----------------------------------\n\n{market_report}"
        )

        # 彙整自選股個股簡報
        watchlist_summary = "【今日自選股巡檢簡報】\n-----------------------------------\n"

        for sym in stocks:
            symbol, df, chip_data, revenue_data, news_data = get_stock_data(sym)

            if df is None:
                watchlist_summary += f"{sym}: 資料獲取失敗\n"
                continue

            latest = df.iloc[-1]
            prev = df.iloc[-2]
            change_pct = ((latest['Close'] - prev['Close']) / prev['Close']) * 100

            watchlist_summary += f"{symbol} | 收盤: {latest['Close']:.2f} ({change_pct:+.2f}%)\n"

            # 抓取關鍵訊號
            signals = []
            if revenue_data and revenue_data.get('yoy', 0) >= 30:
                signals.append("營收YoY>30%")
            if chip_data and chip_data.get('foreign', 0) > 0 and chip_data.get('investment', 0) > 0:
                signals.append("外資投信同買")
            if latest['MA5'] > latest['MA20'] and prev['MA5'] <= prev['MA20']:
                signals.append("5MA金叉20MA")

            if signals:
                watchlist_summary += "  訊號: " + ", ".join(signals) + "\n"
            else:
                watchlist_summary += "  訊號: 走勢平穩無特殊訊號\n"
            watchlist_summary += "-----------------------------------\n"

        # 發送自選股簡報
        await app.bot.send_message(
            chat_id=chat_id,
            text=watchlist_summary
        )

    print("[SCHEDULER] 每日盤後自動推播任務發送完成!")

# 指令處理函式
async def market_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("[INFO] 正在分析台股大盤最新數據,請稍後...")
    report = get_market_overview()
    if report:
        await update.message.reply_text(report)
    else:
        await update.message.reply_text("[ERROR] 暫時無法獲取大盤指數數據。")

async def add_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("請輸入股票代碼,例如:/add 2330")
        return

    raw_symbol = context.args[0].upper()
    chat_id = update.effective_chat.id

    if not raw_symbol.endswith('.TW') and not raw_symbol.endswith('.TWO'):
        raw_symbol += '.TW'

    success = add_to_watchlist(chat_id, raw_symbol)
    if success:
        await update.message.reply_text(f"[SUCCESS] 已成功將 {raw_symbol} 加入自選股追蹤清單!")
    else:
        await update.message.reply_text(f"[NOTICE] {raw_symbol} 已經在你的自選股清單中了。")

async def del_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("請輸入股票代碼,例如:/del 2330")
        return

    raw_symbol = context.args[0].upper()
    chat_id = update.effective_chat.id

    if not raw_symbol.endswith('.TW') and not raw_symbol.endswith('.TWO'):
        raw_symbol += '.TW'

    success = remove_from_watchlist(chat_id, raw_symbol)
    if success:
        await update.message.reply_text(f"[SUCCESS] 已將 {raw_symbol} 從自選股清單中移除。")
    else:
        await update.message.reply_text(f"[NOTICE] 在自選股清單中找不到 {raw_symbol}。")

async def watchlist_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    stocks = get_watchlist(chat_id)

    if not stocks:
        await update.message.reply_text("目前自選股清單是空的!請使用 /add <代碼> 新增標的。")
        return

    text = "【我的口袋自選股清單】\n-----------------------------------\n"
    keyboard = []

    for idx, sym in enumerate(stocks, 1):
        text += f"{idx}. {sym}\n"
        keyboard.append([InlineKeyboardButton(f"看盤 {sym}", callback_data=f"view_{sym}")])

    text += "-----------------------------------\n提示:可使用 /del <代碼> 移除標的。"

    await update.message.reply_text(
        text=text,
        reply_markup=InlineKeyboardMarkup(keyboard)
    )

async def stock_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("請輸入股票代碼,例如:/stock 2330")
        return

    raw_symbol = context.args[0]
    await update.message.reply_text(f"[INFO] 正在獲取 {raw_symbol} 完整資料,請稍後...")

    symbol, df, chip_data, revenue_data, news_data = get_stock_data(raw_symbol)

    if df is None:
        await update.message.reply_text(f"[ERROR] 找不到股票代碼:{raw_symbol}")
        return

    latest = df.iloc[-1]
    summary_text = (
        f"【{symbol} 個股儀表板】\n"
        f"日期:{df.index[-1].strftime('%Y-%m-%d')}\n"
        f"最新收盤價:{latest['Close']:.2f} 元\n\n"
        f"請選擇下方按鈕切換詳細看盤維度:"
    )

    await update.message.reply_text(
        text=summary_text,
        reply_markup=get_stock_keyboard(symbol)
    )

async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()

    data = query.data
    action, symbol = data.split("_", 1)

    if action == "view":
        _, df, chip_data, revenue_data, news_data = get_stock_data(symbol)
        latest = df.iloc[-1]
        summary_text = (
            f"【{symbol} 個股儀表板】\n"
            f"日期:{df.index[-1].strftime('%Y-%m-%d')}\n"
            f"最新收盤價:{latest['Close']:.2f} 元\n\n"
            f"請選擇下方按鈕切換詳細看盤維度:"
        )
        await query.message.reply_text(
            text=summary_text,
            reply_markup=get_stock_keyboard(symbol)
        )
        return

    _, df, chip_data, revenue_data, news_data = get_stock_data(symbol)

    if action == "chart":
        img_path = create_stock_chart(symbol, df)
        with open(img_path, 'rb') as photo:
            await query.message.reply_photo(
                photo=photo,
                caption=f"【{symbol} K線與技術指標線圖】",
                reply_markup=get_stock_keyboard(symbol)
            )
        if os.path.exists(img_path):
            os.remove(img_path)

    elif action == "chip":
        text = (
            f"【{symbol} 三大法人籌碼動向】(單位: 張)\n"
            f"-----------------------------------\n"
            f"外資:{chip_data['foreign']:+d}\n"
            f"投信:{chip_data['investment']:+d}\n"
            f"自營商:{chip_data['dealer']:+d}\n"
            f"-----------------------------------\n"
            f"法人合計:{chip_data['total']:+d}\n"
        )
        await query.message.reply_text(text=text, reply_markup=get_stock_keyboard(symbol))

    elif action == "rev":
        text = (
            f"【{symbol} 最新月營收報告】({revenue_data['month']})\n"
            f"-----------------------------------\n"
            f"單月營收:{revenue_data['revenue'] / 100000:.2f} 億元\n"
            f"MoM (月增率):{revenue_data['mom']:+.1f}%\n"
            f"YoY (年增率):{revenue_data['yoy']:+.1f}%\n"
        )
        await query.message.reply_text(text=text, reply_markup=get_stock_keyboard(symbol))

    elif action == "news":
        text = f"【{symbol} 即時消息面分析】({news_data['sentiment']})\n-----------------------------------\n"
        for idx, item in enumerate(news_data["list"], 1):
            text += f"{idx}. {item['title']}\n"
        await query.message.reply_text(text=text, reply_markup=get_stock_keyboard(symbol))

def setup_handlers(app):
    app.add_handler(CommandHandler("start", lambda u, c: u.message.reply_text("歡迎使用台股看盤小幫手!")))
    app.add_handler(CommandHandler("stock", stock_command))
    app.add_handler(CommandHandler("add", add_command))
    app.add_handler(CommandHandler("del", del_command))
    app.add_handler(CommandHandler("watchlist", watchlist_command))
    app.add_handler(CommandHandler("market", market_command))
    app.add_handler(CallbackQueryHandler(button_callback))
  1. main.py-無需修改

解釋一下程式碼吧!
動態多使用者廣播支援:
透由 SELECT DISTINCT chat_id FROM watchlist,無論有多少位使用者使用了你的 Bot 並新增自選股,排程器都會在 14:30 依次發送個人專屬的自選股簡報,擴充性很好。

濃縮式訊號簡報:
自動推播不採用單檔大量圖文洗版的形式,而是將所有自選股的「最新漲跌幅」與「關鍵訊號」精簡濃縮在同一封訊息中,讓使用者 5 秒內就能完成口袋名單的盤後掃描。

步驟三:測試推播效果
若想要立刻測試排程發送效果(不用等到 14:30),可以在 main.py 的 if name == "main": 下方臨時加入測試代碼,或是直接在手動呼叫時觸發。

小結一下:
告別了手動查詢,現在每天收盤後,Telegram 就會自動跳出今日大盤溫度計與你口袋名單的健康檢查報告。
今天我們成功整合了定時排程、SQLite 自選股清單與大盤模組,完成了「全自動盤後推播系統」!


上一篇
Day 23:大盤全局觀 — 導入台股加權指數/櫃買指數與全市場多空溫度計!
下一篇
Day 25:邁向雲端 24 小時不間斷運作 — Docker 容器化打包實戰!
系列文
30 天打造專屬盯盤機器人:資料爬取、策略指標到 Telegram/LINE 推播 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言