Featured image of post Building an Automated Investment Research Report System with DuckDB

Building an Automated Investment Research Report System with DuckDB

Step-by-step guide to building a fully automated investment research report system with DuckDB — from data collection to report delivery, all in a single Python script.

Building an Automated Investment Research Report System with DuckDB

Many data analysts spend hours every day on repetitive research reports. But the real money is in building “productizable” analysis. Today, I’ll show you how to build an automated investment research report system with DuckDB — from data collection to report generation, all automated. You can use this architecture to offer paid research services.

Architecture


Why DuckDB?

The traditional research report workflow looks like this: Python scrapes data → pandas cleans it → Excel formats it → manual distribution. It’s extremely inefficient and error-prone.

DuckDB’s core advantages shine here:

  • Embedded database, zero deployment cost, runs from a single file
  • SQL directly analyzes CSV/Parquet/JSON, no ETL pipeline needed
  • Multi-language bindings (Python/R/Node.js), seamless integration into existing workflows
  • Vectorized execution, million-row queries in seconds

The entire system we’re building today requires no database server — just one Python script.


System Architecture

The system has three layers:

  1. Data Layer: Read market data (CSV/Parquet) using DuckDB
  2. Analysis Layer: Calculate key metrics (PE, ROE, momentum) with SQL
  3. Output Layer: Generate structured reports and push to subscribers

Let’s start with data preparation. Assume you have a stock history file stocks.csv with fields including date, ticker, close price, volume, and fundamental data.

Step 1: Build the Data Pipeline

import duckdb
import pandas as pd
from datetime import datetime, timedelta

# Connect to in-memory database, zero configuration
con = duckdb.connect(':memory:')

# Register CSV as virtual table, no need to load into memory
con.execute("""
    CREATE TABLE stocks AS
    SELECT * FROM read_csv_auto('stocks.csv')
""")

# Read and merge multiple files
con.execute("""
    CREATE TABLE fundamentals AS
    SELECT * FROM read_csv_auto('fundamentals/*.csv')
""")

The key point here is that read_csv_auto automatically infers column types. And it only reads data when actually needed — DuckDB uses predicate pushdown optimization to read only the columns and rows you need.

Step 2: Write Research Analysis SQL

The real core value lies in the analysis logic. We’ll calculate all metrics in one go with SQL:

-- Create analysis view: calculate PE percentile, momentum, ROE trends
CREATE OR REPLACE VIEW daily_analysis AS
WITH price_changes AS (
    SELECT 
        date,
        code,
        close,
        close / LAG(close, 20) OVER (PARTITION BY code ORDER BY date) - 1 AS momentum_20d,
        close / LAG(close, 60) OVER (PARTITION BY code ORDER BY date) - 1 AS momentum_60d,
        close / LAG(close, 120) OVER (PARTITION BY code ORDER BY date) - 1 AS momentum_120d
    FROM stocks
),
ranked_pe AS (
    SELECT 
        code,
        date,
        pe_ratio,
        PERCENT_RANK() OVER (PARTITION BY code ORDER BY pe_ratio) AS pe_percentile,
        AVG(pe_ratio) OVER (
            PARTITION BY code 
            ORDER BY date 
            ROWS BETWEEN 250 PRECEDING AND CURRENT ROW
        ) AS pe_ma5y
    FROM fundamentals
),
combined AS (
    SELECT 
        p.date, p.code, p.close,
        p.momentum_20d, p.momentum_60d,
        r.pe_ratio, r.pe_percentile, r.pe_ma5y,
        f.roe, f.revenue_growth
    FROM price_changes p
    JOIN ranked_pe r ON p.code = r.code AND p.date = r.date
    JOIN fundamentals f ON p.code = f.code AND p.date = f.date
)
SELECT * FROM combined;

Notice which DuckDB native capabilities are used here:

  • Window functions LAG / PERCENT_RANK / AVG OVER: Directly calculate momentum and PE percentiles, no need for complex groupby + join patterns
  • Time-range aggregation: ROWS BETWEEN 250 PRECEDING AND CURRENT ROW naturally expresses “last 5 years”
  • View materialization: CREATE OR REPLACE VIEW lets subsequent queries reference cleanly, logical layering is clear

Step 3: Strategy Screening Logic

With the analysis view in place, here’s the core strategy logic:

-- Generate daily picks list
CREATE OR REPLACE VIEW daily_picks AS
SELECT 
    date, code, name, close,
    ROUND(pe_ratio, 2) AS pe,
    ROUND(pe_percentile * 100, 1) AS pe_percentile,
    ROUND(momentum_60d * 100, 2) AS momentum_60d_pct,
    ROUND(roe * 100, 2) AS roe_pct,
    ROUND(revenue_growth * 100, 2) AS rev_growth_pct,
    CASE 
        WHEN pe_percentile < 0.3 AND roe > 0.15 
             AND momentum_60d > 0 AND revenue_growth > 0.1 
        THEN 'BUY'
        WHEN pe_percentile > 0.7 AND momentum_60d < -0.1
        THEN 'SELL'
        ELSE 'HOLD'
    END AS signal
FROM daily_analysis
WHERE date = (SELECT MAX(date) FROM daily_analysis)
ORDER BY pe_percentile ASC;

This query’s output is the core table of your daily research report — sorted by PE percentile, stocks with low PE + high ROE + positive momentum + high growth are marked as BUY.

Step 4: Generate Reports and Push

The final step is turning SQL results into readable reports:

# Get today's picks
today = datetime.now().strftime('%Y-%m-%d')
picks = con.execute("""
    SELECT code, name, close, pe, pe_percentile, 
           momentum_60d_pct, roe_pct, signal
    FROM daily_picks
""").fetchdf()

# Generate report text
report = f"""
📊 Investment Research Report {today}
{'='*20}

🔥 Top Picks Today

{chr(10).join([
    f"• {row['name']}({row['code']}) | Price:${row['close']:.2f} | PE:{row['pe']}({row['pe_percentile']}% percentile) | ROE:{row['roe_pct']}% | 60d Momentum:{row['momentum_60d_pct']}%"
    for _, row in picks[picks['signal'] == 'BUY'].iterrows()
])}

📈 Market Overview
Total Symbols: {len(picks)}
Buy Signals: {len(picks[picks['signal']=='BUY'])}
Sell Signals: {len(picks[picks['signal']=='SELL'])}

⚠️ Disclaimer: Analysis is for reference only, not investment advice.
"""

# Send to Telegram
import requests
bot_token = "YOUR_BOT_TOKEN"
chat_id = "YOUR_CHAT_ID"
requests.post(
    f"https://api.telegram.org/bot{bot_token}/sendMessage",
    json={"chat_id": chat_id, "text": report, "parse_mode": "HTML"}
)

Step 5: Scheduled Automation

Use cron or Python’s schedule library for nightly execution:

import schedule
import time

def daily_report():
    con = duckdb.connect(':memory:')
    # ... full logic above ...
    print(f"[{datetime.now()}] Report sent")

schedule.every().day.at("22:00").do(daily_report)

while True:
    schedule.run_pending()
    time.sleep(60)

Comparison with Traditional Approaches

DimensionTraditional ApproachDuckDB Approach
Deployment CostRequires MySQL/PostgreSQL serverZero deployment, in-memory DB
Data PrepETL pipeline + scheduled extractionRead CSV/Parquet directly
Query PerformanceSlow, depends on indexes and partitioningVectorized execution, predicate pushdown
Development SpeedMulti-language stitching (Python + SQL)Pure SQL for all logic
Maintenance CostHigh, need database monitoringLow, single-file execution

Key Optimization Tips

If you’re handling millions of rows, these techniques can boost performance 10x+:

1. Use Parquet Instead of CSV

con.execute("COPY stocks TO 'stocks.parquet' (FORMAT PARQUET)")
# 5-10x faster queries after
con.execute("SELECT * FROM 'stocks.parquet' WHERE date > '2024-01-01'")

2. Set Memory Limits to Prevent OOM

con.execute("SET memory_limit='4GB'")
con.execute("SET threads TO 4")

3. Leverage Partition Pruning

# Only read needed date ranges, DuckDB auto-pushes predicates
con.execute("SELECT * FROM 'parquet_files/*.parquet' WHERE date > '2024-06-01'")

4. Materialize Intermediate Results

con.execute("CREATE TABLE analysis_cache AS SELECT ...")
# Next time, read cache directly, avoid recomputation
con.execute("SELECT * FROM analysis_cache WHERE date = '2024-09-19'")

Monetization Strategies

This system itself can become a product:

  1. Paid Research Subscription: Weekly/monthly access to recommendation lists, priced at $10-30/month
  2. SaaS Model: Wrap the system as an API for other analysts to integrate with, charge per call
  3. Data Products: Sell cleaned Parquet datasets to quant teams — one dataset, repeated sales
  4. Training Revenue: Teach others to build similar systems, offer DuckDB practical courses

DuckDB’s value is clear: no database server maintenance needed, runs locally, deployment cost approaches zero. This means you can focus more on analysis logic and monetization, rather than infrastructure.


Want to dive deeper into DuckDB’s full application in financial data scenarios? duckdblab.org has a complete tutorial series from data collection to report delivery, with real stock data files and runnable code templates to help you quickly build your own research system. Learn more DuckDB practical experience → duckdblab.org

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy