Featured image of post Build an Automated Financial Daily Report with DuckDB + GitHub Actions — Zero-Cost Data Product

Build an Automated Financial Daily Report with DuckDB + GitHub Actions — Zero-Cost Data Product

Step-by-step guide to building an automated financial daily report system with DuckDB and GitHub Actions: data fetching, SQL aggregation, Telegram push. Zero infrastructure cost, monetize by selling subscriptions to small investment firms.

Architecture Overview

Introduction: A Zero-Cost Money-Making Data Product

Have you ever thought that automatically sending a financial daily report could become a side business?

Small hedge funds, independent analysts, and financial content creators all need daily market data compilation. Manual work is time-consuming and error-prone. If you can provide an automated solution and charge a monthly subscription (¥500-2000/client), the marginal cost is practically zero.

The most attractive part of this project: zero infrastructure cost. DuckDB is a single-file analytical database, GitHub Actions provides free scheduled execution, and you only need a few lines of Python code to launch a complete automated data product.

Part 1: Architecture — Three-Layer Minimal Design

The entire system consists of three files, deployed in a single GitHub repository:

project/
├── data/
│   └── market.duckdb          # DuckDB database file (persistence)
├── seed_data.py               # Daily data ingestion (replaceable with API fetch)
├── generate_report.py         # Query + Markdown report generation
└── .github/workflows/
    └── daily-report.yml       # GitHub Actions scheduled execution

Data flow: CSV/API → DuckDB → SQL aggregation → Markdown report → Telegram push

Part 2: Why DuckDB Instead of PostgreSQL?

This is a common question. Three core reasons:

DimensionDuckDBPostgreSQLpandas
Deployment complexitySingle .duckdb file, zero serviceRequires database service installation/maintenanceNo persistence, re-computes every run
Aggregation query speedColumnar storage, 10-100x fasterRow-based storage, optimized for OLTPIn-memory computation, slow with large files
Scheduled executionNo dependencies, perfect for serverlessNeeds cron + service runningNeeds Python process startup
Parquet supportNative, extremely fast read/writeRequires extensionNeeds pyarrow
CostZeroCloud server ¥50-200/monthZero

DuckDB is positioned as an analytical database (OLAP). Its columnar storage and vectorized execution engine make SQL aggregation queries far faster than traditional relational databases. For scenarios like “daily report generation” — read-heavy, write-light, aggregation-focused — DuckDB is the optimal choice.

Part 3: Core Code — Data Ingestion and Queries

1. Table Creation and Data Writing (seed_data.py)

import duckdb, os

os.makedirs("data", exist_ok=True)
con = duckdb.connect("data/market.duckdb")

con.execute("""
CREATE TABLE IF NOT EXISTS daily_quotes (
    date   DATE,
    ticker VARCHAR,
    open   DOUBLE,
    high   DOUBLE,
    low    DOUBLE,
    close  DOUBLE,
    volume BIGINT,
    market VARCHAR
)
""")

# Replace with Tushare/Yahoo Finance API in production
sample_data = [
    ("2026-09-11", "AAPL",  189.50, 191.20, 188.80, 190.75, 52_000_000, "US"),
    ("2026-09-11", "GOOGL", 172.30, 174.10, 171.50, 173.55, 28_000_000, "US"),
    ("2026-09-11", "9988.HK", 95.20,  96.80,  94.50,  95.88, 45_000_000, "HK"),
    ("2026-09-11", "0700.HK", 412.00, 418.60, 410.00, 414.60, 12_000_000, "HK"),
]

for row in sample_data:
    con.execute(
        "INSERT INTO daily_quotes VALUES (?, ?, ?, ?, ?, ?, ?, ?)", list(row)
    )
con.commit()
con.close()
print("✅ Data written successfully")

2. Core SQL: Cross-Day Comparison and Movement Ranking

The core value of a daily report lies in cross-day comparison and movement ranking. Here are two key queries:

import duckdb, pandas as pd

con = duckdb.connect("data/market.duckdb")

# Query 1: Cross-day price change comparison (self-join)
report_sql = """
WITH latest_date AS (
    SELECT max(date) AS today FROM daily_quotes
),
compare AS (
    SELECT
        t.ticker, t.market,
        t.close  AS today_close,
        y.close  AS yesterday_close,
        ROUND(
            (t.close - y.close) / nullif(y.close, 0) * 100, 2
        ) AS chg_1d_pct
    FROM daily_quotes t
    JOIN daily_quotes y
      ON t.ticker = y.ticker
     AND y.date = (SELECT today - 1 FROM latest_date)
    WHERE t.date = (SELECT today FROM latest_date)
)
SELECT * FROM compare
ORDER BY abs(chg_1d_pct) DESC
"""
compare_df = con.execute(report_sql).df()
# Query 2: Today's details (with LAG window function)
today_sql = """
SELECT
    date, ticker, market, open, high, low, close, volume,
    ROUND(
        (close - LAG(close) OVER (PARTITION BY ticker ORDER BY date)) /
        nullif(LAG(close) OVER (PARTITION BY ticker ORDER BY date), 0) * 100
    , 2) AS chg_pct
FROM daily_quotes
WHERE date = (SELECT max(date) FROM daily_quotes)
ORDER BY market, ticker
"""
today_df = con.execute(today_sql).df()

Key techniques:

  • LAG() window function is more efficient than self-join, especially with large datasets
  • nullif(..., 0) prevents division-by-zero errors
  • WITH CTEs make complex queries more readable

3. Markdown Report Generation

def generate_markdown(df_compare, df_today):
    lines = []
    lines.append(f"📊 **Financial Daily Report · {df_today['date'].iloc[0]}**")
    lines.append("")

    # Display grouped by market
    for market, label in [("US", "🇺🇸 US Stocks"), ("HK", "🇭🇰 HK Stocks")]:
        market_df = df_today[df_today["market"] == market]
        if len(market_df) == 0:
            continue
        movers = []
        for _, row in market_df.iterrows():
            sign = "+" if row["chg_pct"] >= 0 else ""
            movers.append(f"{row['ticker']} {sign}{row['chg_pct']}%")
        lines.append(f"**{label}**: {', '.join(movers)}")

    # Top 3 movers
    top3 = df_compare.head(3)
    if len(top3) > 0:
        movers_list = " > ".join(
            f"{r['ticker']} ({'+' if r['chg_1d_pct']>=0 else ''}{r['chg_1d_pct']}%)"
            for _, r in top3.iterrows()
        )
        lines.append(f"📈 Top 3 Movers: {movers_list}")

    lines.append("")
    lines.append("---")
    lines.append("🤖 Auto-generated by DuckDB + GitHub Actions | [duckdblab.org](https://duckdblab.org)")
    return "\n".join(lines)

Part 4: GitHub Actions Scheduled Execution

GitHub Actions provides 2000 free minutes per month, more than enough for a daily report running once per day.

name: Daily Market Report
on:
  schedule:
    # UTC 14:00 = Beijing time 22:00
    - cron: '0 14 * * *'
  workflow_dispatch:  # Also supports manual trigger

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install duckdb pandas pyarrow

      - name: Seed data (fetch from API)
        run: python seed_data.py

      - name: Generate report
        run: python generate_report.py

      - name: Push to Telegram
        run: |
          REPORT=$(python generate_report.py 2>/dev/null)
          curl -s -X POST \
            "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
            -d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
            -d "text=${REPORT}" \
            -d "parse_mode=HTML"

Configure TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in GitHub Repository Settings → Secrets for fully automated pushing.

Part 5: Monetization — Three Revenue Models

Model 1: Sell Report Subscriptions Directly

Promote your automated daily report service on V2EX, Jike, or Knowledge Stars.

  • Basic Plan ¥299/month: Morning + evening quick brief (pushed to Telegram)
  • Professional Plan ¥999/month: Includes post-market in-depth analysis PDF + movement alerts
  • Target customers: Individual investors, small investment advisors, financial content creators

Model 2: Sell as a SaaS Backend

Package the DuckDB database + query logic into an API service:

from fastapi import FastAPI
import duckdb

app = FastAPI()

@app.get("/report/daily")
def get_daily_report():
    con = duckdb.connect("data/market.duckdb")
    # Execute queries...
    return {"date": "...", "movers": [...]}

Clients call the API to get daily report data without building their own data pipeline. Monthly fee ¥500-2000/client.

Model 3: Productize as Data — Movement Monitoring Subscription

Accumulate daily report data into a historical database, then offer premium features:

  • Movement Stock Monitor: Auto-push when a stock’s daily volatility exceeds a threshold
  • Cross-Market Arbitrage Signals: Monitor price differences for the same stock across A-shares and HK stocks
  • Quant Strategy Backtest Data: Sell Parquet-format historical data to quant teams

This is the key step from “daily report” to “data product”, priced separately at ¥500+/month.

Part 6: Production-Grade Optimization Tips

Why Parquet Instead of CSV for Archiving?

Daily reports aren’t just for human consumption — they’re data products for machines. Parquet has three major advantages:

  1. High compression: Columnar storage + encoding compression, 5-10x smaller than CSV
  2. Embedded schema: No type inference needed on each read, faster access
  3. Downstream friendly: natively supported by Pandas, Polars, ClickHouse — ideal for backtesting and ML feature engineering
# Native DuckDB Parquet read/write
con.execute("COPY daily_quotes TO 'data/market.parquet' (FORMAT PARQUET)")
con.execute("CREATE TABLE latest AS SELECT * FROM 'data/market.parquet'")

Idempotent Writes with Incremental Updates

In production, use conditional inserts to avoid duplicate data:

-- DuckDB alternative to MERGE INTO
INSERT INTO daily_quotes
SELECT * FROM (
    SELECT date, ticker, open, high, low, close, volume, market
    FROM new_data
) t
WHERE NOT EXISTS (
    SELECT 1 FROM daily_quotes d
    WHERE d.ticker = t.ticker AND d.date = t.date
)
ON CONFLICT (ticker, date) DO UPDATE SET
    open   = EXCLUDED.open,
    high   = EXCLUDED.high,
    low    = EXCLUDED.low,
    close  = EXCLUDED.close,
    volume = EXCLUDED.volume;

Connecting Real Data Sources

Replace the sample data in seed_data.py with real APIs:

  • A-shares/HK stocks: Tushare (free registration), AkShare (open source)
  • US stocks: Yahoo Finance (yfinance library), Alpha Vantage
  • Cryptocurrency: CoinGecko API (free)
# Example: Fetch US stock data with yfinance
import yfinance as yf
tickers = ["AAPL", "GOOGL", "MSFT", "TSLA"]
for t in tickers:
    data = yf.download(t, period="1d", progress=False)
    # Write to DuckDB...

Part 7: Real-World Performance

📊 Financial Daily Report · 2026-09-11
🇺🇸 US Stocks: AAPL +0.65%, GOOGL +0.73%, MSFT +0.52%
🇭🇰 HK Stocks: 9988.HK +0.71%, 0700.HK +0.64%
📈 Top 3 Movers: GOOGL (+0.73%) > 9988.HK (+0.71%) > AAPL (+0.65%)
✅ Parquet archived: output/report_2026-09-11.parquet (8 rows)

GitHub Actions logs show the complete pipeline finishes in 45 seconds: data fetch 15s + DuckDB write 3s + query aggregation 2s + Telegram push <1s.

Part 8: Next Steps for Upgrade

  1. Multi-market expansion: Add cryptocurrency and futures data, double your pricing space
  2. Visual enhancement: Generate HTML reports with candlestick charts for higher premium
  3. Email subscription: Support Mailchimp/SendGrid for wider reach
  4. Webhook integration: Connect to Notion, Slack, Feishu to expand distribution channels
  5. AI analysis boost: Use LLM to generate natural language summaries of report data, increasing professional plan value

The complete code repository is uploaded, including seed_data.py, generate_report.py, and GitHub Actions configuration — ready to use out of the box. The full tutorial (including Tushare data source integration and FastAPI service packaging) is published at duckdblab.org with more detailed steps and additional cases.

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