Featured image of post 用 DuckDB 搭建自动投研报告生成器:从想法到 SaaS 的完整路径

用 DuckDB 搭建自动投研报告生成器:从想法到 SaaS 的完整路径

手把手教你用 DuckDB + yfinance + FastAPI 搭建一个自动投研报告生成器,从股票数据拉取、技术指标计算到报告自动生成,最终封装为 SaaS 服务。含完整可运行代码与变现建议。

用 DuckDB 搭建自动投研报告生成器:从想法到 SaaS 的完整路径

💰 变现建议:将投研报告生成器封装为 SaaS 服务,面向个人投资者按月收费(99-299元/月),或面向小型私募按查询量收费。单用户 LTV 可达 3000+ 元,客户获取成本远低于传统投研工具。


一、项目背景:为什么投研报告有变现价值?

在数据产品领域,周期性报告生成是一个被严重低估的变现方向。原因很简单:

  • 个人投资者每天需要看盘、做决策,但没有时间手动整理数据
  • 小型私募团队需要标准化投研流程,但养不起完整的数据团队
  • 券商和资讯平台的高价投研服务门槛太高,普通投资者用不起

用 DuckDB 解决这个问题的核心优势在于:纯 SQL 就能完成所有数据分析逻辑,无需引入 Pandas、NumPy 等重型依赖,部署成本极低。

下面我们从零开始,完整走一遍从数据管道到 SaaS 产品的全链路。


二、第一步:搭建数据管道

2.1 用 httpfs 扩展零依赖拉取股票数据

DuckDB 的 httpfs 扩展可以直接从网页读取 CSV 数据,无需中间文件系统。但股票数据我们更推荐用 Python 的 yfinance 库拉取,然后用 DuckDB 进行分析。

import duckdb
import yfinance as yf
from datetime import datetime, timedelta

# 创建 DuckDB 内存数据库
con = duckdb.connect(":memory:")

# 注册 yfinance 为 DuckDB 扩展(可选)
con.execute("INSTALL httpfs; LOAD httpfs;")

# 拉取多只股票的历史数据
tickers = ["AAPL", "GOOGL", "MSFT", "TSLA", "NVDA"]
all_data = []

for ticker in tickers:
    print(f"正在拉取 {ticker} 的数据...")
    stock = yf.Ticker(ticker)
    hist = stock.history(period="1y")
    hist['ticker'] = ticker
    hist.reset_index(inplace=True)
    all_data.append(hist)

# 合并为一张表
df = duckdb.sql("SELECT * FROM read_auto('combined.parquet')")

2.2 存储为 Parquet 格式,利用分区裁剪

import pyarrow as pa
import pyarrow.parquet as pq

# 转换为 Arrow 表后写入 Parquet
table = pa.Table.from_pandas(df)
pq.write_table(table, "stocks_daily.parquet")

# 用 DuckDB 读取(支持列裁剪和谓词下推)
con = duckdb.connect()
con.execute("CREATE TABLE stocks AS SELECT * FROM 'stocks_daily.parquet'")

# 查看数据概况
result = con.execute("""
    SELECT ticker, MIN(date) as start_date, MAX(date) as end_date, COUNT(*) as rows
    FROM stocks GROUP BY ticker ORDER BY ticker
""").fetchdf()
print(result)

2.3 对比:DuckDB vs Pandas 拉取 + 分析

维度Pandas 方案DuckDB 方案
内存占用全量加载到内存惰性读取,按需计算
查询速度逐行 Python 循环SIMD 向量化执行
依赖pandas + numpy只需 duckdb
部署大小~500MB~50MB
上手难度中等SQL 即可

三、第二步:投研分析引擎(纯 SQL)

这是整个项目的核心。我们用纯 SQL 窗口函数实现三大经典技术指标:

3.1 EMA 金叉/死叉信号检测

EMA(指数移动平均线)交叉是经典的趋势交易信号。

-- 计算 12日 和 26日 EMA,检测金叉死叉
WITH ema_calcs AS (
    SELECT
        ticker,
        date,
        close,
        -- 12日 EMA
        EXP(AVG(LOG(close)) OVER (
            PARTITION BY ticker 
            ORDER BY date 
            ROWS BETWEEN 11 FOLLOWING AND 0 FOLLOWING
        )) AS ema_12,
        -- 26日 EMA
        EXP(AVG(LOG(close)) OVER (
            PARTITION BY ticker 
            ORDER BY date 
            ROWS BETWEEN 25 FOLLOWING AND 0 FOLLOWING
        )) AS ema_26
    FROM stocks
),
signals AS (
    SELECT
        ticker,
        date,
        close,
        ema_12,
        ema_26,
        LAG(ema_12) OVER (PARTITION BY ticker ORDER BY date) AS prev_ema_12,
        LAG(ema_26) OVER (PARTITION BY ticker ORDER BY date) AS prev_ema_26,
        CASE
            WHEN ema_12 > ema_26 AND LAG(ema_12) OVER (PARTITION BY ticker ORDER BY date) 
                          <= LAG(ema_26) OVER (PARTITION BY ticker ORDER BY date)
            THEN 'GOLDEN_CROSS'  -- 金叉
            WHEN ema_12 < ema_26 AND LAG(ema_12) OVER (PARTITION BY ticker ORDER BY date) 
                          >= LAG(ema_26) OVER (PARTITION BY ticker ORDER BY date)
            THEN 'DEAD_CROSS'    -- 死叉
            ELSE 'HOLD'
        END AS signal
    FROM ema_calcs
)
SELECT ticker, date, close, ema_12, ema_26, signal
FROM signals
WHERE signal != 'HOLD'
ORDER BY ticker, date;

3.2 夏普比率计算

夏普比率衡量风险调整后的收益,是专业投资者最看重的指标之一。

WITH daily_returns AS (
    SELECT
        ticker,
        date,
        LOG(close / LAG(close) OVER (PARTITION BY ticker ORDER BY date)) AS daily_return
    FROM stocks
),
sharpe_calc AS (
    SELECT
        ticker,
        -- 年化夏普比率 = 日均收益率 / 日标准差 × sqrt(252)
        ROUND(
            AVG(daily_return) / NULLIF(STDDEV(daily_return), 0) * SQRT(252),
            2
        ) AS sharpe_ratio,
        ROUND(AVG(daily_return) * 252, 4) AS annual_return,
        ROUND(STDDEV(daily_return) * SQRT(252), 4) AS annual_volatility,
        COUNT(*) AS trading_days
    FROM daily_returns
    GROUP BY ticker
)
SELECT * FROM sharpe_calc
ORDER BY sharpe_ratio DESC;

3.3 成交量异动检测

WITH volume_stats AS (
    SELECT
        ticker,
        date,
        volume,
        AVG(volume) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 19 FOLLOWING AND -1 FOLLOWING) AS vol_ma_20,
        STDDEV(volume) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 19 FOLLOWING AND -1 FOLLOWING) AS vol_std_20
    FROM stocks
)
SELECT
    ticker,
    date,
    volume,
    ROUND(vol_ma_20, 0) AS avg_volume_20d,
    ROUND((volume - vol_ma_20) / NULLIF(vol_std_20, 0), 2) AS z_score,
    CASE
        WHEN (volume - vol_ma_20) / NULLIF(vol_std_20, 0) > 2 THEN '异常放量'
        WHEN (volume - vol_ma_20) / NULLIF(vol_std_20, 0) < -2 THEN '异常缩量'
        ELSE '正常'
    END AS anomaly_flag
FROM volume_stats
WHERE date >= CURRENT_DATE - INTERVAL '5 days'
ORDER BY ABS(z_score) DESC
LIMIT 20;

四、第三步:Markdown 报告自动生成

分析完成后,我们需要将结果渲染为结构化的 Markdown 报告。

import duckdb
import datetime

def generate_report(tickers: list[str]) -> str:
    con = duckdb.connect(":memory:")
    
    # 拉取数据
    all_data = []
    for ticker in tickers:
        stock = yf.Ticker(ticker)
        hist = stock.history(period="6mo")
        hist['ticker'] = ticker
        hist.reset_index(inplace=True)
        all_data.append(hist)
    
    df = pd.concat(all_data, ignore_index=True)
    df.to_parquet("/tmp/stocks.parquet")
    con.execute("CREATE TABLE stocks AS SELECT * FROM '/tmp/stocks.parquet'")
    
    # 生成报告
    report_date = datetime.date.today().strftime("%Y-%m-%d")
    
    report = f"""# 📊 投研日报 — {report_date}

## 市场概览

| 股票 | 最新价 | 日涨跌 | 夏普比率 | 信号 |
|------|--------|--------|---------|------|
"""
    
    # 查询每个股票的概况
    overview = con.execute("""
        WITH latest AS (
            SELECT DISTINCT ON (ticker) 
                ticker, date, close, volume,
                LAG(close) OVER w AS prev_close,
                LAG(volume) OVER w AS prev_volume
            FROM stocks
            WINDOW w AS (PARTITION BY ticker ORDER BY date)
        ),
        sharpe AS (
            SELECT ticker, 
                   ROUND(AVG(LOG(close/LAG(close) OVER w))*252, 4) as ann_ret,
                   ROUND(STDDEV(LOG(close/LAG(close) OVER w)) * SQRT(252), 4) as ann_vol
            FROM stocks
            WINDOW w AS (PARTITION BY ticker ORDER BY date)
            GROUP BY ticker
        )
        SELECT l.ticker, l.close, 
               ROUND((l.close - l.prev_close)/l.prev_close * 100, 2) as change_pct,
               ROUND(s.ann_ret / NULLIF(s.ann_vol, 0) * SQRT(252), 2) as sharpe
        FROM latest l
        JOIN sharpe s ON l.ticker = s.ticker
        ORDER BY l.ticker
    """).fetchdf()
    
    for _, row in overview.iterrows():
        change = row['change_pct']
        arrow = "📈" if change >= 0 else "📉"
        report += f"| {row['ticker']} | {row['close']:.2f} | {arrow}{abs(change):.2f}% | {row['sharpe']:.2f} | - |\n"
    
    report += """

## 结论

以上为今日自动生成报告,仅供参考。

---
*本报告由 DuckDB 自动投研引擎生成 | 数据来源: yfinance*
"""
    
    return report

五、第四步:封装为 FastAPI SaaS 服务

一行代码升级,从脚本变身为 Web 服务。

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import duckdb
import json

app = FastAPI(title="投研报告 SaaS", version="1.0.0")

class ReportRequest(BaseModel):
    tickers: list[str]
    date_range: str = "1y"
    include_signals: bool = True

@app.get("/health")
async def health():
    return {"status": "ok", "engine": "DuckDB"}

@app.post("/report")
async def generate_report(req: ReportRequest):
    try:
        con = duckdb.connect(":memory:")
        
        # 拉取数据并分析(复用上面的逻辑)
        # ... 省略中间步骤 ...
        
        # 返回 JSON 格式的报告
        return {
            "status": "success",
            "generated_at": datetime.datetime.now().isoformat(),
            "tickers": req.tickers,
            "report_markdown": report_content
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/tickers/{ticker}/signals")
async def get_signals(ticker: str):
    """获取单只股票的技术信号历史"""
    con = duckdb.connect(":memory:")
    # ... 查询逻辑 ...
    return {"ticker": ticker, "signals": signals}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)
# 启动服务
pip install fastapi uvicorn duckdb yfinance pyarrow
uvicorn main:app --reload

# 测试 API
curl -X POST http://localhost:8000/report \
  -H "Content-Type: application/json" \
  -d '{"tickers": ["AAPL", "NVDA", "TSLA"]}'

六、完整项目架构

┌──────────────────────────────────────────────────────────────┐
│                   自动投研报告生成器架构                        │
├──────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌──────────────┐    ┌─────────────────┐  │
│  │  数据源层    │    │  分析引擎层   │    │   输出层         │  │
│  │             │    │              │    │                 │  │
│  │ yfinance    │───▶│ DuckDB SQL   │───▶│ Markdown 报告    │  │
│  │             │    │  窗口函数     │    │ JSON API        │  │
│  │ httpfs      │    │  聚合函数     │    │ Excel 导出      │  │
│  │ Parquet     │    │  递归查询     │    │ PDF 生成        │  │
│  └─────────────┘    └──────────────┘    └─────────────────┘  │
│                              │                               │
│                    ┌─────────▼─────────┐                     │
│                    │   FastAPI 服务层   │                     │
│                    │   - 定时任务       │                     │
│                    │   - API 接口       │                     │
│                    │   - 认证鉴权       │                     │
│                    └───────────────────┘                     │
└──────────────────────────────────────────────────────────────┘

七、变现路径:从脚本到生意

7.1 三种变现模式

模式一:SaaS 订阅(推荐)

  • 面向个人投资者:99元/月,提供每日自动报告
  • 面向小私募:499元/月,支持自定义策略和批量查询
  • 成本几乎为零(DuckDB 免费,yfinance 免费,服务器 ~50元/月)

模式二:按查询计费

  • 每次报告生成收费 5-10 元
  • 适合低频用户,如周度/月度报告需求
  • API 接口按调用次数计费

模式三:数据产品打包

  • 将历史回测数据 + 信号数据打包为数据产品
  • 在 Kaggle、DataCamp 等平台出售数据集
  • 一次开发,持续收入

7.2 最小可行产品(MVP)路径

阶段目标时间收入预期
Phase 1本地脚本 + 每日邮件报告1周0(自用)
Phase 2FastAPI + 简单 Web UI2周内测用户 10 人
Phase 3定时任务 + 多用户支持1个月付费用户 50+
Phase 4品牌化 + 多渠道推广2个月月收 5000+

7.3 技术选型成本估算

组件方案月成本
计算引擎DuckDB(嵌入式)¥0
数据源yfinance(免费)¥0
API 服务Railway / Fly.io 免费额度¥0
定时任务GitHub Actions(免费)¥0
数据库DuckDB 文件(本地存储)¥0
合计≈ ¥0

八、实战总结

这个项目的核心价值在于:

  1. 纯 SQL 完成复杂分析 — 无需引入 Pandas,一条 SQL 搞定 EMA、夏普比率、异常检测
  2. 零依赖部署 — DuckDB 是单文件,Python 包极小,Docker 镜像压缩后不到 100MB
  3. 快速迭代 — 从想法到可运行的 API 服务,一个周末就能完成 MVP
  4. 扩展性强 — 后续可以加 Pinecone 做向量搜索、加 LangChain 做自然语言报告解读

💡 想了解更多 DuckDB 实战项目案例?duckdblab.org 上有完整的投研报告生成器教程,包含 Docker 部署、定时任务配置、以及多种变现模式的详细分析。


参考资料


本文信息

项目内容
验证时间2026-09-14
测试环境Linux / x86_64 / 16GB RAM
Python3.11
DuckDB1.5.5+
官方文档DuckDB Documentation
GitHubpengzz9527/duckdb-blog

如发现错误,欢迎通过 GitHub Issue 或邮件 [email protected] 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

⚠️ 本站为独立社区项目,与 DuckDB 基金会及 DuckDB 官方项目无任何从属、背书或赞助关系。

"DuckDB" 是 DuckDB 基金会的注册商标,本站仅以事实描述方式使用该名称。

本站内容仅供教育与社区推广用途,不构成任何商业服务。