Featured image of post Building an Automated Investment Research Report Generator with DuckDB: From Idea to SaaS

Building an Automated Investment Research Report Generator with DuckDB: From Idea to SaaS

A complete walkthrough of building an automated investment research report generator using DuckDB + yfinance + FastAPI — from data pipeline to SaaS deployment. Includes runnable code and monetization strategies.

Building an Automated Investment Research Report Generator with DuckDB: From Idea to SaaS

💰 Monetization Tip: Package the report generator as a SaaS product targeting retail investors at $99-299/month, or small hedge funds by query volume. Single-user LTV can exceed $3,000, with customer acquisition costs far below traditional research tools.


1. Project Background: Why Investment Reports Are an Undervalued Opportunity

In the data product space, periodic report generation is a severely underserved monetization channel. Here’s why:

  • Individual investors need daily market analysis but lack the time to manually compile data
  • Small fund teams need standardized research workflows but can’t afford a full data team
  • Premium research services from brokerages are prohibitively expensive for most retail investors

The core advantage of using DuckDB to solve this problem is clear: pure SQL handles all analytical logic without introducing heavy dependencies like Pandas or NumPy, resulting in extremely low deployment costs.

Below, we walk through the entire pipeline from data ingestion to SaaS product deployment.


2. Step One: Building the Data Pipeline

2.1 Zero-Dependency Stock Data Retrieval with httpfs

DuckDB’s httpfs extension can read CSV data directly from URLs without any intermediate file system. However, for stock data, we recommend using Python’s yfinance library for retrieval, then analyzing with DuckDB.

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

# Create an in-memory DuckDB database
con = duckdb.connect(":memory:")

# Register httpfs extension
con.execute("INSTALL httpfs; LOAD httpfs;")

# Fetch historical data for multiple tickers
tickers = ["AAPL", "GOOGL", "MSFT", "TSLA", "NVDA"]
all_data = []

for ticker in tickers:
    print(f"Fetching data for {ticker}...")
    stock = yf.Ticker(ticker)
    hist = stock.history(period="1y")
    hist['ticker'] = ticker
    hist.reset_index(inplace=True)
    all_data.append(hist)

2.2 Store as Parquet, Leverage Partition Pruning

import pyarrow as pa
import pyarrow.parquet as pq

# Convert to Arrow table and write as Parquet
table = pa.Table.from_pandas(df)
pq.write_table(table, "stocks_daily.parquet")

# Read with DuckDB (supports column pruning and predicate pushdown)
con = duckdb.connect()
con.execute("CREATE TABLE stocks AS SELECT * FROM 'stocks_daily.parquet'")

# Data overview
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 Comparison: DuckDB vs Pandas

DimensionPandas ApproachDuckDB Approach
Memory usageFull load into RAMLazy reading, on-demand computation
Query speedIterative Python loopsSIMD vectorized execution
Dependenciespandas + numpyJust duckdb
Deploy size~500MB~50MB
Learning curveMediumSQL only

3. Step Two: The Analysis Engine (Pure SQL)

This is the core of the project. We implement three classic technical indicators using pure SQL window functions:

3.1 EMA Crossover Signal Detection

EMA (Exponential Moving Average) crossover is a classic trend-following signal.

-- Calculate 12-day and 26-day EMA, detect golden/dead crosses
WITH ema_calcs AS (
    SELECT
        ticker,
        date,
        close,
        -- 12-day EMA
        EXP(AVG(LOG(close)) OVER (
            PARTITION BY ticker 
            ORDER BY date 
            ROWS BETWEEN 11 FOLLOWING AND 0 FOLLOWING
        )) AS ema_12,
        -- 26-day 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 Sharpe Ratio Calculation

The Sharpe ratio measures risk-adjusted returns — the single most important metric for professional investors.

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,
        -- Annualized Sharpe = mean daily return / std daily return * 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 Volume Anomaly Detection

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 'High Volume Anomaly'
        WHEN (volume - vol_ma_20) / NULLIF(vol_std_20, 0) < -2 THEN 'Low Volume Anomaly'
        ELSE 'Normal'
    END AS anomaly_flag
FROM volume_stats
WHERE date >= CURRENT_DATE - INTERVAL '5 days'
ORDER BY ABS(z_score) DESC
LIMIT 20;

4. Step Three: Markdown Report Generation

Once analysis is complete, render results into structured Markdown reports.

import duckdb
import datetime
import pandas as pd

def generate_report(tickers: list[str]) -> str:
    con = duckdb.connect(":memory:")
    
    # Fetch and process data
    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"""# 📊 Daily Research Report — {report_date}

## Market Overview

"""
    
    # Query latest snapshot
    overview = con.execute("""
        WITH latest AS (
            SELECT DISTINCT ON (ticker) 
                ticker, date, close,
                LAG(close) OVER w AS prev_close
            FROM stocks
            WINDOW w AS (PARTITION BY ticker ORDER BY date)
        )
        SELECT ticker, close, 
               ROUND((close - prev_close)/prev_close * 100, 2) as change_pct
        FROM latest
        ORDER BY 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}%)\n"
    
    report += f"""

## Conclusion

Report generated automatically by DuckDB Investment Research Engine.

---
*Generated by DuckDB Auto-Research Engine | Data: yfinance*
"""
    
    return report

5. Step Four: Packaging as a FastAPI SaaS Service

One line of code upgrades from script to web service.

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

app = FastAPI(title="Research Report 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:")
        # ... data fetching and analysis ...
        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):
    """Get technical signals history for a single ticker"""
    con = duckdb.connect(":memory:")
    # ... query logic ...
    return {"ticker": ticker, "signals": signals}

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

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

6. Complete Project Architecture

┌──────────────────────────────────────────────────────────────┐
│            Automated Research Report Generator Architecture   │
├──────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌──────────────┐    ┌─────────────────┐  │
│  │  Data Layer  │    │  Analysis    │    │   Output Layer  │  │
│  │             │    │  Engine      │    │                 │  │
│  │ yfinance    │───▶│ DuckDB SQL   │───▶│ Markdown Reports│  │
│  │             │    │  Window Fns  │    │ JSON API        │  │
│  │ httpfs      │    │  Aggregations│    │ Excel Export    │  │
│  │ Parquet     │    │  Recursive   │    │ PDF Generation  │  │
│  └─────────────┘    └──────────────┘    └─────────────────┘  │
│                              │                               │
│                    ┌─────────▼─────────┐                     │
│                    │   FastAPI Service  │                     │
│                    │   - Cron jobs      │                     │
│                    │   - API endpoints  │                     │
│                    │   - Auth           │                     │
│                    └───────────────────┘                     │
└──────────────────────────────────────────────────────────────┘

7. Monetization Paths: From Script to Business

7.1 Three Monetization Models

Model 1: SaaS Subscription (Recommended)

  • Retail investors: $99/month for daily automated reports
  • Small funds: $499/month with custom strategies and batch queries
  • Near-zero marginal cost (DuckDB free, yfinance free, server ~$50/month)

Model 2: Pay-per-Query

  • $5-10 per report generation
  • Suitable for low-frequency users (weekly/monthly reports)
  • API charged per call

Model 3: Data Product Bundling

  • Package backtest data + signal data as sellable datasets
  • Sell on Kaggle, DataCamp, or specialized data marketplaces
  • Build once, earn repeatedly

7.2 Minimum Viable Product (MVP) Roadmap

PhaseGoalTimelineRevenue Expectation
Phase 1Local script + daily email report1 week$0 (personal use)
Phase 2FastAPI + simple web UI2 weeks10 beta users
Phase 3Scheduled tasks + multi-user1 month50+ paying users
Phase 4Branding + multi-channel promotion2 months$5,000+/month

7.3 Technology Cost Estimate

ComponentSolutionMonthly Cost
Compute EngineDuckDB (embedded)$0
Data Sourceyfinance (free)$0
API ServiceRailway / Fly.io free tier$0
Scheduled TasksGitHub Actions (free)$0
DatabaseDuckDB file (local storage)$0
Total≈ $0

8. Practical Summary

The core value of this project lies in four areas:

  1. Complex analysis in pure SQL — No Pandas needed; one SQL query handles EMA, Sharpe ratio, and anomaly detection
  2. Zero-dependency deployment — DuckDB is a single file, minimal Python packages, Docker image under 100MB compressed
  3. Rapid iteration — From idea to running API service in one weekend
  4. Strong extensibility — Add Pinecone for vector search, LangChain for natural language report interpretation

💡 Want to learn more DuckDB实战经验? duckdblab.org has a complete tutorial series covering the investment report generator, including Docker deployment, scheduled task configuration, and detailed analysis of multiple monetization models.


References


Article Info

ItemContent
Verification Date2026-09-14
Test EnvironmentLinux / x86_64 / 16GB RAM
Python3.11
DuckDB1.5.5+
Official DocsDuckDB Documentation
GitHubpengzz9527/duckdb-blog

If you find any errors, please report via GitHub Issue or email [email protected].

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy

⚠️ This site is an independent community project, not affiliated with, endorsed by, or sponsored by the DuckDB Foundation or official DuckDB project.

"DuckDB" is a registered trademark of the DuckDB Foundation. This site uses the name solely for factual description purposes.

All content is for educational and community promotion purposes only and does not constitute any commercial service.