← 返回部落格
·9 min 閱讀·系統架構

股票量化分析 SaaS 架構設計:從 Python 分析引擎到訂閱制服務

分享如何將個人用的股票量化分析工具轉化為 SaaS 服務,涵蓋多投資大師評分模型、Python Worker + BullMQ 任務佇列、NestJS API 設計與 Lemon Squeezy 金流整合。

SaaSPythonNestJSBullMQ股票分析
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

從個人工具到 SaaS 服務

我原本有一個私人使用的 Python 腳本(叫做 buffet),每天自動拉取台股和美股的財報數據,用多位投資大師的選股標準做量化評分,然後產出一份 HTML 報告。

用了半年後發現一個問題:這個工具對我很有用,但要分享給朋友用很困難。他們得自己設定 Python 環境、申請 API key、跑 cron job。於是想法產生了——把它做成一個 Web 服務,讓任何人都能用。

核心價值:多投資大師評分模型

系統的核心邏輯是用多個投資大師的選股框架,對每支股票做量化評分:

巴菲特評分

  • ROE 持續大於 15%
  • 毛利率穩定或成長
  • 負債權益比合理
  • 自由現金流為正
  • 本益比低於產業平均

葛拉漢評分(安全邊際)

  • 股價低於淨值 × 1.5
  • 本益比低於 15
  • 流動比率大於 2
  • 近 5 年未虧損

彼得林區評分(成長股)

  • PEG ratio 小於 1
  • 營收年增率大於 15%
  • 盈餘成長穩定

費雪評分(質化面)

  • 研發費用占比
  • 營收多元化程度
  • 管理階層持股變化

每個面向 0-100 分,加權後得出綜合評分。再搭配護城河分析、新聞風險評估、退出條件判斷。

系統架構

[Browser] → [Next.js Frontend :3000]
                    │
                    ▼
            [NestJS API :4000]
                    │
         ┌──────────┼──────────┐
         │          │          │
         ▼          ▼          ▼
    [PostgreSQL] [Redis]  [Lemon Squeezy]
                    │
                    ▼
            [BullMQ Queue]
                    │
                    ▼
            [Python Worker]
              (財務分析)

為什麼分成 Node.js API + Python Worker?

兩個原因:

  1. 生態系:財務分析用到 pandas、numpy、yfinance 等 Python 生態系的工具,用 Node.js 重寫不划算
  2. 解耦:分析引擎可能跑 30 分鐘(一次分析 70 支股票),不能阻塞 API server

用 BullMQ 做中間的橋樑:NestJS 排程觸發任務 → Redis Queue → Python Worker 消費並執行分析。

BullMQ:跨語言的任務佇列

NestJS 端(發布任務)

// 每日凌晨 2:00 觸發全量分析
@Cron('0 2 * * *')
async triggerDailyAnalysis() {
  const stocks = await this.stockService.getTrackedStocks();

  for (const stock of stocks) {
    await this.analysisQueue.add('analyze-stock', {
      symbol: stock.symbol,
      market: stock.market, // 'tw' | 'us'
    }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
    });
  }

  this.logger.log(`Queued ${stocks.length} stocks for analysis`);
}

Python 端(消費任務)

from bullmq import Worker, Job
import asyncio

async def process_stock(job: Job):
    symbol = job.data['symbol']
    market = job.data['market']

    # 拉取財報數據
    financials = fetch_financials(symbol, market)

    # 多大師評分
    scores = {
        'buffett': calculate_buffett_score(financials),
        'graham': calculate_graham_score(financials),
        'lynch': calculate_lynch_score(financials),
        'fisher': calculate_fisher_score(financials),
    }

    # 護城河分析
    moat = analyze_moat(financials)

    # 寫入資料庫
    save_analysis_result(symbol, scores, moat)

    return {'symbol': symbol, 'overall_score': weighted_average(scores)}

worker = Worker('analysis', process_stock, {
    'connection': {'host': 'redis', 'port': 6379}
})

為什麼用 BullMQ 而不是 Celery?

  • BullMQ 用 Redis(系統已經有 Redis)
  • NestJS 有官方的 @nestjs/bullmq 整合
  • Python 端的 bullmq 套件可以直接消費同一個 queue
  • 不需要額外的 broker(Celery 需要 RabbitMQ 或 Redis + 額外設定)

訂閱制與付費牆

Lemon Squeezy 整合

選擇 Lemon Squeezy 而非 Stripe 的原因:

  • 台灣開發者不需要自己處理稅務(Lemon Squeezy 是 Merchant of Record)
  • 設定比 Stripe 簡單
  • 支援訂閱制

Webhook 驅動的訂閱狀態

@Post('webhook/lemon-squeezy')
async handleWebhook(@Req() req: Request, @Body() body: any) {
  // 1. 驗證 webhook 簽名
  const signature = req.headers['x-signature'];
  if (!this.verifySignature(body, signature)) {
    throw new UnauthorizedException('Invalid signature');
  }

  // 2. 根據事件類型更新訂閱狀態
  const event = body.meta.event_name;
  switch (event) {
    case 'subscription_created':
      await this.activateSubscription(body.data);
      break;
    case 'subscription_expired':
      await this.deactivateSubscription(body.data);
      break;
    case 'subscription_payment_failed':
      await this.handlePaymentFailure(body.data);
      break;
  }
}

付費牆邏輯

免費用戶可以看到股票列表和綜合評分,但詳細的各大師分項評分、護城河分析、歷史趨勢圖表需要訂閱。

// API Guard
@UseGuards(SubscriptionGuard)
@Get('stocks/:symbol/detailed-analysis')
async getDetailedAnalysis(@Param('symbol') symbol: string) {
  return this.analysisService.getDetailed(symbol);
}

前端 Dashboard 設計

股票列表頁

  • 依評分排序的股票卡片
  • 快速篩選:市場(台股/美股)、產業、評分區間
  • 搜尋功能

個股詳情頁

  • 雷達圖:各大師評分
  • 時間序列:評分歷史趨勢
  • 護城河分析(文字 + 標籤)
  • 新聞風險指標
  • 退出條件檢查清單

資料更新策略

每日批次 vs 即時

我們選擇每日批次更新而非即時串流,原因:

  1. 財報數據本質是低頻的:財報一季才更新一次,每日更新已經足夠
  2. API 成本:即時股價 API 很貴,而我們的評分模型不依賴即時價格
  3. 系統簡單度:批次處理的架構比即時串流簡單一個數量級

增量更新

不是每天都重算所有 70 支股票。只在以下情況才重算:

  • 有新的財報季報出來
  • 股價大幅變動(超過 10%)
  • 使用者新追蹤了某支股票

其餘時間只更新價格相關的指標(本益比、股價淨值比等),不重跑整套分析。

多租戶設計

每個使用者有自己的追蹤清單,但分析結果是共享的(同一支股票的評分對所有使用者相同):

-- 共享的分析結果
stock_analyses (symbol, market, scores, moat, updated_at)

-- 使用者個人的追蹤清單
user_watchlists (user_id, symbol, added_at)

-- 使用者的訂閱狀態
subscriptions (user_id, plan, status, expires_at, lemon_squeezy_id)

這個設計讓分析引擎只需要算一次,結果所有使用者共用。

容器化部署

services:
  stox-frontend:
    build: ./apps/web
    ports: ["3000:3000"]

  stox-api:
    build: ./apps/api
    ports: ["4000:4000"]
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }

  stox-worker:
    build: ./worker
    depends_on:
      redis: { condition: service_healthy }
      postgres: { condition: service_healthy }

  postgres:
    image: postgres:17
    healthcheck:
      test: ["CMD", "pg_isready"]

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

五個容器各司其職,Docker Compose 一鍵啟動整個環境。

學到的經驗

把驗證過的個人工具產品化

先在自己身上驗證需求(用了半年的 buffet 腳本),確認有價值後再投資做成產品。避免了「做了一個沒人要用的東西」的風險。

BullMQ 跨語言的坑

Python 的 bullmq 套件和 Node.js 版本在 job data 的序列化上偶爾有不一致。建議用 JSON-safe 的基本型別(string、number、boolean),不要傳 datetime 物件。

金流整合比想像中複雜

不是串好 webhook 就結束了。還需要處理:

  • 訂閱到期的 grace period
  • 付款失敗的重試通知
  • 使用者取消後的資料保留策略
  • 試用期到期的轉換流程

結語

把個人工具變成 SaaS 服務的核心挑戰不在功能本身——分析引擎已經寫好了——而在「非功能需求」:認證、付費、多租戶、可靠性、部署。這些「無聊」的工程工作往往佔了整個專案 60% 的時間,但它們決定了產品能否真正被其他人使用。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →