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:
- Price data —
stock_prices.csv: Daily stock closing prices - Financial data —
financials.csv: PE, ROE, revenue growth rates - Macro data —
macro.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_autoautomatically 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
| Dimension | Traditional Pandas | DuckDB |
|---|---|---|
| Code lines | 80-100 (read + clean + merge + compute) | 30 (one SQL + Jinja2 template) |
| Memory usage | Multiple DataFrames, 1GB+ data易 OOM | Columnar storage, ~200MB for 1GB data |
| Date parsing | Manual parse_dates for mixed formats | read_csv_auto auto-infers |
| Window functions | Manual groupby + transform | Native SQL window functions |
| Execution speed | Million-row aggregation ~5-10s | Million-row aggregation <1s |
| Deployment complexity | Python env + dependency management | Single 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
- File path issues: Cron changes working directory — use absolute paths or
os.chdir()to ensure the script can find CSV files. - Mixed date formats:
read_csv_autois robust, but if a column contains both “2026-01-01” and “2026/01/01”, normalize the format first. - 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.
- NULL handling:
LAG(close, 20)returns NULL when fewer than 20 days of data exist — filter these out in SQL. - Email failures: Sync environment variables when SMTP credentials change. Use
.envfiles 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
