Featured image of post Automated Investment Research Briefing with DuckDB: From Multi-Source Data to Daily Reports

Automated Investment Research Briefing with DuckDB: From Multi-Source Data to Daily Reports

Build a fully automated investment research briefing system with DuckDB: multi-source CSV ingestion, window function valuation screening, momentum calculation, macro regime detection, and Jinja2 template report generation.

Automated Investment Research Briefing with DuckDB: From Multi-Source Data to Daily Reports

Difficulty: ⭐⭐⭐ | Setup time: 2 hours, then 30 seconds per day


Why Is an Investment Briefing System a Profitable Data Product?

In the finance industry, daily research briefings are one of the most frequent demands. Fund managers, financial advisors, and quant teams all need to review market conditions and stock screening signals every day.

The traditional approach: manually open a dozen Excel files, copy-paste data, write VLOOKUP formulas, and assemble a report. It takes 2-3 hours and repeats the same labor every single day.

The monetization potential is significant:

  • Sell the briefing system to financial advisors who pay for daily market summaries
  • Wrap it as an API service for small hedge funds as backend data support
  • Build a paid newsletter with subscription revenue
  • Use it yourself to free up time for deeper data mining

Today, we’ll build a fully automated investment research briefing system with DuckDB — from multi-source data ingestion to report generation, all with a single command.


System Architecture: Three Data Sources, One SQL Query

Our investment briefing relies on three data sources:

  1. Price datastock_prices.csv: Daily stock closing prices
  2. Financial datafinancials.csv: PE, ROE, revenue growth rates
  3. Macro datamacro.csv: CPI, PMI, interest rates

The traditional approach requires reading three CSVs into Pandas separately, cleaning each, then merging. DuckDB’s approach is direct native reading with a single query handling all computation.

import duckdb
import pandas as pd
from pathlib import Path

# One in-memory database, directly reading all CSVs
conn = duckdb.connect(':memory:')

# read_csv_auto auto-infers dates, numeric types — no manual format handling needed
conn.execute("CREATE TABLE stocks AS SELECT * FROM read_csv_auto('data/stock_prices.csv')")
conn.execute("CREATE TABLE financials AS SELECT * FROM read_csv_auto('data/financials.csv')")
conn.execute("CREATE TABLE macro AS SELECT * FROM read_csv_auto('data/macro.csv')")

print(f"✅ stocks: {conn.execute('SELECT COUNT(*) FROM stocks').fetchone()[0]} rows")
print(f"✅ financials: {conn.execute('SELECT COUNT(*) FROM financials').fetchone()[0]} rows")
print(f"✅ macro: {conn.execute('SELECT COUNT(*) FROM macro').fetchone()[0]} rows")

Key Insight: read_csv_auto automatically handles date formats, infers numeric types, and skips empty rows. For CSVs exported from Excel (which often contain mixed-type columns), it’s more robust than Pandas’ read_csv. The time saved on data cleaning is real money.


Core Analysis Logic: Three CTEs, Complete Investment Framework

The core logic of an investment briefing can be broken into three modules:

Module 1: Valuation Screening

Find low-valuation quality stocks with PE < sector average and ROE > 15%:

WITH pe_rank AS (
    SELECT 
        f.symbol,
        f.name,
        f.pe_ttm,
        f.roe,
        f.revenue_yoy,
        f.sector,
        -- Sector PE average (window function, no GROUP BY needed)
        AVG(f.pe_ttm) OVER (PARTITION BY f.sector) AS sector_pe_avg,
        -- PE rank within sector
        RANK() OVER (PARTITION BY f.sector ORDER BY f.pe_ttm) AS pe_rank_in_sector
    FROM financials f
    WHERE f.pe_ttm > 0 AND f.roe > 0
)

Module 2: Momentum Confirmation

Calculate 20-day price momentum on the latest date:

momentum AS (
    SELECT 
        symbol,
        ((close - LAG(close, 20) OVER (PARTITION BY symbol ORDER BY date)) 
         / LAG(close, 20) OVER (PARTITION BY symbol ORDER BY date)) * 100 AS momentum_20d
    FROM stocks
    WHERE date = (SELECT MAX(date) FROM stocks)
)

Module 3: Macro Regime Detection

Determine current market regime based on PMI and interest rate changes:

macro_filter AS (
    SELECT 
        date,
        cpi,
        pmi,
        rate,
        CASE 
            WHEN pmi > 50 AND rate <= LAG(rate) OVER (ORDER BY date) THEN 'risk_on'
            WHEN pmi < 49 AND rate >= LAG(rate) OVER (ORDER BY date) THEN 'risk_off'
            ELSE 'neutral'
        END AS market_regime
    FROM macro
    ORDER BY date DESC
    LIMIT 1
)

Combined Query

Combine all three modules into the final screening query:

SELECT 
    p.symbol,
    p.name,
    p.sector,
    ROUND(p.pe_ttm, 2) AS pe,
    ROUND(p.sector_pe_avg, 2) AS sector_pe_avg,
    ROUND(p.roe, 2) AS roe_pct,
    ROUND(m.momentum_20d, 2) AS momentum_20d,
    ROUND(r.cpi, 3) AS cpi,
    r.pmi,
    r.market_regime
FROM pe_rank p
LEFT JOIN momentum m ON p.symbol = m.symbol
CROSS JOIN macro_filter r
WHERE p.pe_ttm < p.sector_pe_avg
  AND p.roe >= 15
  AND p.pe_rank_in_sector <= 5
ORDER BY p.pe_ttm ASC
LIMIT 20

Performance comparison: The same logic in Pandas requires 50+ lines of code and hits memory limits with large files. DuckDB completes all computation in a single SQL query, processing million-row datasets in seconds.


Report Generation: Jinja2 Template for Telegram-Readable Output

The analysis results need to be rendered into a briefing format ready for distribution. We use a Jinja2 template to output text suitable for Telegram/email:

from jinja2 import Template
from datetime import datetime

template_text = """
📊 Investment Research Briefing | {{ date }}

━━━━━━━━━━━━━━━━━━
【Macro Environment】
CPI: {{ cpi }} | PMI: {{ pmi }} | Regime: {{ regime }}

━━━━━━━━━━━━━━━━━━
【Low Valuation, High ROE Picks】(PE < Sector Avg & ROE ≥ 15%)

{% for row in stocks %}
{{ loop.index }}. {{ row.name }}({{ row.symbol }})
   Sector: {{ row.sector }} | PE: {{ row.pe }} (Sector Avg: {{ row.sector_pe_avg }})
   ROE: {{ row.roe_pct }}% | 20-Day Momentum: {{ row.momentum_20d }}%
{% endfor %}

━━━━━━━━━━━━━━━━━━
⚠️ Data as of {{ date }}, for reference only, not investment advice.
Generated by DuckDB Auto-Report
"""

template = Template(template_text)
report = template.render(
    date=datetime.now().strftime('%Y-%m-%d'),
    cpi=float(screener['cpi'].iloc[0]) if len(screener) > 0 else None,
    pmi=float(screener['pmi'].iloc[0]) if len(screener) > 0 else None,
    regime=str(screener['market_regime'].iloc[0]) if len(screener) > 0 else 'unknown',
    stocks=[
        {
            'name': r['name'],
            'symbol': r['symbol'],
            'sector': r['sector'],
            'pe': r['pe'],
            'sector_pe_avg': r['sector_pe_avg'],
            'roe_pct': r['roe_pct'],
            'momentum_20d': r['momentum_20d'] if pd.notna(r['momentum_20d']) else '--',
        }
        for _, r in screener.iterrows()
    ]
)

print(report)

Complete Automation Script (Cron-Scheduled Execution)

Wrap the entire pipeline into an entry script:

# auto_report.py — complete entry point
import duckdb
from jinja2 import Template
from datetime import datetime
import smtplib
from email.mime.text import MIMEText
import os

EMAIL_CONFIG = {
    'smtp_host': os.environ.get('SMTP_HOST'),
    'smtp_port': int(os.environ.get('SMTP_PORT', '587')),
    'sender': os.environ.get('EMAIL_USER'),
    'password': os.environ.get('EMAIL_PASS'),
    'recipients': os.environ.get('EMAIL_RECIPIENTS', '').split(','),
}

def generate_report() -> str:
    conn = duckdb.connect(':memory:')
    
    # Read data
    conn.execute("CREATE TABLE stocks AS SELECT * FROM read_csv_auto('data/stock_prices.csv')")
    conn.execute("CREATE TABLE financials AS SELECT * FROM read_csv_auto('data/financials.csv')")
    conn.execute("CREATE TABLE macro AS SELECT * FROM read_csv_auto('data/macro.csv')")
    
    # Execute analysis query
    query = """
    WITH pe_rank AS (
        SELECT symbol, name, pe_ttm, roe, revenue_yoy, sector,
               AVG(pe_ttm) OVER (PARTITION BY sector) AS sector_pe_avg,
               RANK() OVER (PARTITION BY sector ORDER BY pe_ttm) AS pe_rank_in_sector
        FROM financials WHERE pe_ttm > 0 AND roe > 0
    ),
    momentum AS (
        SELECT symbol,
               ((close - LAG(close, 20) OVER (PARTITION BY symbol ORDER BY date)) 
                / LAG(close, 20) OVER (PARTITION BY symbol ORDER BY date)) * 100 AS momentum_20d
        FROM stocks
        WHERE date = (SELECT MAX(date) FROM stocks)
    ),
    macro_filter AS (
        SELECT date, cpi, pmi, rate,
               CASE 
                   WHEN pmi > 50 AND rate <= LAG(rate) OVER (ORDER BY date) THEN 'risk_on'
                   WHEN pmi < 49 AND rate >= LAG(rate) OVER (ORDER BY date) THEN 'risk_off'
                   ELSE 'neutral'
               END AS market_regime
        FROM macro ORDER BY date DESC LIMIT 1
    )
    SELECT p.symbol, p.name, p.sector, ROUND(p.pe_ttm,2) AS pe,
           ROUND(p.sector_pe_avg,2) AS sector_pe_avg, ROUND(p.roe,2) AS roe_pct,
           ROUND(m.momentum_20d,2) AS momentum_20d,
           ROUND(r.cpi,3) AS cpi, r.pmi, r.market_regime
    FROM pe_rank p
    LEFT JOIN momentum m ON p.symbol = m.symbol
    CROSS JOIN macro_filter r
    WHERE p.pe_ttm < p.sector_pe_avg AND p.roe >= 15 AND p.pe_rank_in_sector <= 5
    ORDER BY p.pe_ttm ASC LIMIT 20
    """
    
    screener = conn.execute(query).fetchdf()
    conn.close()
    
    # Render report
    template = Template("""
📊 Investment Research Briefing | {{ date }}
━━━━━━━━━━━━━━━━━━
【Macro】CPI: {{ cpi }} | PMI: {{ pmi }} | Regime: {{ regime }}
━━━━━━━━━━━━━━━━━━
【Low Valuation, High ROE Picks】(PE < Sector Avg & ROE ≥ 15%)
{% for row in stocks %}
{{ loop.index }}. {{ row.name }}({{ row.symbol }})
   Sector: {{ row.sector }} | PE: {{ row.pe }} (Avg: {{ row.sector_pe_avg }})
   ROE: {{ row.roe_pct }}% | 20d Momentum: {{ row.momentum_20d }}%
{% endfor %}
━━━━━━━━━━━━━━━━━━
⚠️ Data as of {{ date }}, for reference only.
Generated by DuckDB Auto-Report
    """)
    
    return template.render(
        date=datetime.now().strftime('%Y-%m-%d'),
        cpi=float(screener['cpi'].iloc[0]) if len(screener) > 0 else None,
        pmi=float(screener['pmi'].iloc[0]) if len(screener) > 0 else None,
        regime=str(screener['market_regime'].iloc[0]) if len(screener) > 0 else 'unknown',
        stocks=[
            {'name': r['name'], 'symbol': r['symbol'], 'sector': r['sector'],
             'pe': r['pe'], 'sector_pe_avg': r['sector_pe_avg'],
             'roe_pct': r['roe_pct'],
             'momentum_20d': r['momentum_20d'] if pd.notna(r['momentum_20d']) else '--'}
            for _, r in screener.iterrows()
        ]
    )

def send_report(report: str):
    msg = MIMEText(report, 'plain', 'utf-8')
    msg['Subject'] = f'Investment Research Briefing | {datetime.now().strftime("%Y-%m-%d")}'
    msg['From'] = EMAIL_CONFIG['sender']
    msg['To'] = ', '.join(EMAIL_CONFIG['recipients'])
    with smtplib.SMTP(EMAIL_CONFIG['smtp_host'], EMAIL_CONFIG['smtp_port']) as s:
        s.starttls()
        s.login(EMAIL_CONFIG['sender'], EMAIL_CONFIG['password'])
        s.sendmail(EMAIL_CONFIG['sender'], EMAIL_CONFIG['recipients'], msg.as_string())

if __name__ == '__main__':
    report = generate_report()
    send_report(report)
    print('✅ Report sent')

Cron Configuration

# Execute at 16:00 on trading days (after HK/A-share market close)
0 16 * * 1-5 cd /opt/report && python3 auto_report.py

Performance Comparison: Traditional vs. DuckDB

DimensionTraditional PandasDuckDB
Code lines80-100 (read + clean + merge + compute)30 (one SQL + Jinja2 template)
Memory usageMultiple DataFrames, 1GB+ data易 OOMColumnar storage, ~200MB for 1GB data
Date parsingManual parse_dates for mixed formatsread_csv_auto auto-infers
Window functionsManual groupby + transformNative SQL window functions
Execution speedMillion-row aggregation ~5-10sMillion-row aggregation <1s
Deployment complexityPython env + dependency managementSingle script, Python + DuckDB only

Monetization Paths: How Much Can This System Earn?

Path 1: Data Service Subscription (¥2,000-8,000/month)

Send daily briefings to paying subscribers (financial advisors, individual investors). Charge ¥99-299/month per user. 100 subscribers = ¥10,000-30,000/month.

Path 2: API Service (¥5,000-20,000/month)

Wrap as a REST API, providing daily stock screening signals to small hedge funds and investment advisory teams. Charge per API call; marginal cost approaches zero after initial build.

Path 3: SaaS Data Product (¥100,000+/year)

Add a web interface and user management, turn it into a SaaS product. Price at ¥999/year per user, 100 customers = ¥100,000/year.

Path 4: Custom Development (¥2,000-10,000 per project)

Many small institutions have similar needs but don’t know how to build them. You can take on这类 projects. A complete system from data ingestion to report generation typically commands ¥5,000-20,000.


Pitfalls to Avoid

  1. File path issues: Cron changes working directory — use absolute paths or os.chdir() to ensure the script can find CSV files.
  2. Mixed date formats: read_csv_auto is robust, but if a column contains both “2026-01-01” and “2026/01/01”, normalize the format first.
  3. Timestamp alignment: Macro data (PMI, CPI) is typically monthly or quarterly. Ensure the latest data date aligns properly to avoid using stale data for decisions.
  4. NULL handling: LAG(close, 20) returns NULL when fewer than 20 days of data exist — filter these out in SQL.
  5. Email failures: Sync environment variables when SMTP credentials change. Use .env files to manage sensitive configurations.

Summary

The core approach to building an investment briefing system with DuckDB is: direct data ingestion + SQL expression + template rendering. Three steps, and a process that used to take 2 hours manually is compressed to 30 seconds. The real value of this system isn’t the technology itself — it’s that it transforms repetitive labor into a replicable, monetizable data product.

学习更多 DuckDB 实战经验 → duckdblab.org

📺 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.