Featured image of post One Person, Eight Stores: How a DuckDB-Powered E-commerce Data Service Runs on Near-Zero Marginal Cost

One Person, Eight Stores: How a DuckDB-Powered E-commerce Data Service Runs on Near-Zero Marginal Cost

A proven monetization model: one analyst serving 8 small e-commerce stores with DuckDB + cron, earning CNY 19k/month with near-zero marginal cost. Complete architecture, fixed SQL, and income breakdown.

One Person, Eight Stores: How a DuckDB-Powered E-commerce Data Service Runs on Near-Zero Marginal Cost

Bottom line first: a data analyst I know (freelance) spent the past six months pivoting to “managed e-commerce data insights” — one person, 8 Tmall/Douyin stores, CNY 32,000/month fixed income, near-zero marginal cost. Tech stack: DuckDB + Python + crontab. This post is the full breakdown, including the SQL fixes you’ll need.

Why This Model Works

Demand side: 20M+ small e-commerce sellers in China, ~80% with no dedicated data team. They know they should track conversion rate, AOV, and repeat purchase — but they can’t build automated reporting. A full-time analyst costs CNY 12k/month (too expensive); manual Excel pulls take 1-2 hours/day and are error-prone.

Supply side: you build the “daily insight” as an automated product with DuckDB, charge per store, and serve 8-15 stores alone.

Service architecture: multi-source ingestion → core KPIs → anomaly detection → daily delivery

Pricing that’s been validated in the market:

TierPriceIncludes
BasicCNY 800/month/storeOne daily automated report (revenue, traffic, conversion)
ProCNY 2,000/month/store5 custom KPIs + anomaly alerts
AnnualCNY 18,000/year/store~25% discount, locks in cash flow

8 pro + 4 basic stores ≈ CNY 19,200/month for one person. Add pre-sale-event “data sprint packs” (CNY 3,000/store/event, 2 weeks before 618/Double 11) and you add CNY 5k-15k in big-promo months.

Cost: one CNY 50/month VPS + 2 hours/day of maintenance. All 8 customer databases total under 1 GB.

Step 1: Multi-Source Ingestion

E-commerce data lives in four places: orders (backend CSV exports), traffic (Excel from platform dashboards), support chats (CRM JSON), inventory (ERP Excel). DuckDB’s key advantage: no data warehouse to ship data into — query the files where they are, with read_csv_auto / read_xlsx / read_json_auto.

import duckdb
from datetime import datetime

DB_PATH = "clients/client_a.duckdb"
RAW_DIR = "./raw_data"   # seller drops export files here

con = duckdb.connect(DB_PATH)

# Ingestion 1: orders CSV
con.execute(f"""
    CREATE OR REPLACE TABLE orders AS
    SELECT * FROM read_csv_auto('{RAW_DIR}/orders_20260928.csv')
""")

# Ingestion 2: traffic Excel (business dashboard export)
con.execute(f"""
    CREATE OR REPLACE TABLE traffic AS
    SELECT * FROM read_xlsx('{RAW_DIR}/traffic_20260928.xlsx',
        header=True, sheet='daily_trend')
""")

# Ingestion 3: support chat JSON
con.execute(f"""
    CREATE OR REPLACE TABLE cs_chats AS
    SELECT * FROM read_json_auto('{RAW_DIR}/cs_chats_20260928.json')
""")

# Ingestion 4: inventory Excel
con.execute(f"""
    CREATE OR REPLACE TABLE inventory AS
    SELECT * FROM read_xlsx('{RAW_DIR}/inventory_20260928.xlsx', header=True)
""")

print("OK - four tables ingested into DuckDB")

The key: sellers just drop 4 files in a folder; your script handles all formats without schema unification.

Step 2: The 5 KPIs Sellers Actually Look At

Principle: the report is at most 1 page, at most 5 metrics. Sellers want “understand the business in 3 minutes,” not a 30-page PPT.

  1. Yesterday’s revenue & day-over-day change
  2. Yesterday’s visitors & conversion rate
  3. Inventory alerts (SKUs below safety stock)
  4. Negative support tickets / complaints
  5. Competitor price monitoring (optional add-on)

KPI 1: revenue with day-over-day (LAG window):

WITH daily AS (
    SELECT DATE(order_time) AS dt,
           SUM(amount) AS revenue,
           COUNT(*) AS order_cnt
    FROM orders
    GROUP BY DATE(order_time)
),
with_lag AS (
    SELECT dt, revenue, order_cnt,
           LAG(revenue, 1) OVER (ORDER BY dt) AS prev_revenue
    FROM daily
)
SELECT dt, revenue, order_cnt, prev_revenue,
       ROUND((revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0), 1)
           AS revenue_change_pct,
       CASE
           WHEN (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0) < -10 THEN 'DOWN >10%'
           WHEN (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0) >  10 THEN 'UP >10%'
           ELSE 'normal'
       END AS alert
FROM with_lag
WHERE dt = (SELECT MAX(dt) FROM daily);

Sample run (demo data):

┌─────────────┬─────────┬───────────┬──────────────┬────────────────────┐
│     dt      │ revenue │ order_cnt │ prev_revenue │ revenue_change_pct │
├─────────────┼─────────┼───────────┼──────────────┼────────────────────┤
│ 2026-09-28  │   388.0 │         2 │        318.0 │               22.0 │
└─────────────┴─────────┴───────────┴──────────────┴────────────────────┘
→ alert: UP >10%

KPI 2: 7-day visitors & conversion rate:

SELECT
    DATE(t.stat_date) AS dt,
    t.visitor_cnt,
    COALESCE(o.order_cnt, 0) AS order_cnt,
    ROUND(COALESCE(o.order_cnt, 0) * 100.0 / NULLIF(t.visitor_cnt, 0), 2)
        AS conversion_rate
FROM (SELECT DATE(stat_date) AS stat_date, SUM(visitor_cnt) AS visitor_cnt
      FROM traffic GROUP BY DATE(stat_date)) t
LEFT JOIN (SELECT DATE(order_time) AS dt, COUNT(*) AS order_cnt
           FROM orders GROUP BY DATE(order_time)) o
    ON t.stat_date = o.dt
ORDER BY t.stat_date DESC
LIMIT 7;

KPI 3: inventory alerts:

SELECT sku, product_name, stock, safety_stock,
    CASE
        WHEN stock < safety_stock        THEN 'ALERT: below safety stock'
        WHEN stock < safety_stock * 1.5 THEN 'WARNING: running low'
        ELSE 'OK'
    END AS status
FROM inventory
WHERE stock < safety_stock * 2
ORDER BY (stock - safety_stock) ASC;

Step 3: Anomaly Detection — The “Worth It” Feature

Three rules cover nearly every “something’s wrong with the business” scenario: 3 consecutive down days, single-day drop >30%, or below 7-day mean by 2 standard deviations.

The original channel SQL had two bugs (a mis-referenced LAG and MAX(dt) - INTERVAL applied to a date column directly). Here is the corrected, tested version:

WITH daily AS (
    SELECT DATE(order_time) AS dt, SUM(amount) AS revenue
    FROM orders
    GROUP BY DATE(order_time)
),
trend AS (
    SELECT dt, revenue,
        AVG(revenue)    OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND 1 PRECEDING) AS avg7,
        STDDEV(revenue) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND 1 PRECEDING) AS std7,
        LAG(revenue, 1) OVER (ORDER BY dt) AS prev_revenue,
        LAG(revenue, 2) OVER (ORDER BY dt) AS prev2
    FROM daily
)
SELECT dt, revenue,
    CASE
        -- 3 consecutive down days
        WHEN prev2 IS NOT NULL
             AND prev_revenue < prev2
             AND revenue < prev_revenue
        THEN 'DOWN_3_CONSECUTIVE'
        -- single-day drop > 30%
        WHEN prev_revenue > 0
             AND (revenue - prev_revenue) * 100.0 / prev_revenue < -30
        THEN 'SHARP_DROP_30PCT'
        -- below 7-day mean by 2 std dev
        WHEN std7 IS NOT NULL AND revenue < avg7 - 2 * std7
        THEN 'BELOW_MEAN_2SD'
        ELSE 'NORMAL'
    END AS anomaly_type
FROM trend
WHERE dt >= (SELECT MAX(dt) FROM daily) - INTERVAL 7 DAY
ORDER BY dt DESC;

Stock-out risk (sales velocity vs. stock coverage):

The original query used WHERE i.stock < COALESCE(s.sales_7d, 0) after a LEFT JOIN — when a SKU has zero sales in 7 days, sales_7d is NULL, COALESCE(...,0) makes the condition always false, and you silently miss slow-moving overstock. Practical fix:

SELECT i.sku, i.product_name, i.stock,
    COALESCE(s.sales_7d, 0) AS sales_7d,
    CASE
        WHEN i.stock < COALESCE(s.sales_7d, 0) * 0.3 THEN 'RISK: covers <3 days'
        WHEN i.stock < COALESCE(s.sales_7d, 0) * 0.7 THEN 'RISK: covers <1 week'
        ELSE 'OK'
    END AS risk
FROM inventory i
LEFT JOIN (SELECT sku, SUM(quantity) AS sales_7d
           FROM orders
           WHERE order_time >= CURRENT_DATE - INTERVAL 7 DAY
           GROUP BY sku) s
    ON i.sku = s.sku
WHERE s.sales_7d IS NULL              -- slow mover: zero sales in 7d
   OR i.stock < COALESCE(s.sales_7d, 0); -- stock can't cover recent sales

Two WHERE branches together: “selling too fast, stock runs out” and “not selling, stock rotting” — the latter is exactly what sellers don’t want to see but will pay to be warned about.

Step 4: Generate and Deliver the Daily Report

Reports stay under 6-8 lines, sized for WeCom/WeChat/Telegram:

def generate_daily_insight_report(con, client_name="Client A", today=None):
    """today is a 'YYYY-MM-DD' string — pass it explicitly to avoid timezone issues."""
    core = con.execute("""
        SELECT
            SUM(CASE WHEN DATE(order_time) = ? THEN amount END) AS today_revenue,
            SUM(CASE WHEN DATE(order_time) = ? - INTERVAL 1 DAY THEN amount END) AS yest_revenue
        FROM orders
    """, [today, today]).fetchone()
    today_rev, yest_rev = (core[0] or 0), (core[1] or 0)
    rev_change = (today_rev - yest_rev) * 100.0 / max(yest_rev, 1)

    inv_alerts = con.execute("""
        SELECT product_name, stock, safety_stock
        FROM inventory WHERE stock < safety_stock
        ORDER BY stock ASC LIMIT 3
    """).fetchdf()

    lines = [
        f"📊 {client_name} Daily Insight · {today[5:10]}",
        f"💰 Today: CNY {today_rev:,.0f} ({abs(rev_change):.1f}% {'up' if rev_change>=0 else 'down'} vs yesterday)",
    ]
    if rev_change < -15:
        lines += ["", "🚨 Revenue anomaly",
                  "   Check: traffic drop / conversion anomaly / hot SKU out of stock"]
    if len(inv_alerts) > 0:
        lines += ["", "📦 Inventory alerts"]
        for _, row in inv_alerts.iterrows():
            lines.append(f"   • {row['product_name']}: {int(row['stock'])} left (safety line {int(row['safety_stock'])})")
    lines += ["", "✅ Sources: orders / traffic / support / inventory auto-aggregated"]
    return "\n".join(lines)

Sample output (demo data):

📊 Client A Daily Insight · 09-28
💰 Today: CNY 388 (22.0% up vs yesterday)

📦 Inventory alerts
   • Face mask: 12 left (safety line 50)
   • Serum: 40 left (safety line 100)

✅ Sources: orders / traffic / support / inventory auto-aggregated

Step 5: Multi-Tenant Batch Architecture

One .duckdb file per client (typically <100 MB); 8 clients <1 GB total. One CNY 50/month VPS + crontab hosts everything:

import schedule, os, duckdb
from datetime import datetime

CLIENTS = [
    {"id": "client_a", "name": "Client A", "db": "./clients/client_a.duckdb", "tier": "pro"},
    {"id": "client_b", "name": "Client B", "db": "./clients/client_b.duckdb", "tier": "basic"},
]

def process_client(client):
    con = duckdb.connect(client["db"])
    today = datetime.now().strftime('%Y-%m-%d')
    if client["tier"] == "pro":
        report = generate_daily_insight_report(con, client["name"], today)
    else:
        core = con.execute(
            "SELECT COALESCE(SUM(amount),0), COUNT(*) FROM orders WHERE DATE(order_time) = ?",
            [today]).fetchone()
        report = f"📊 {client['name']}\n💰 Today: CNY {core[0]:,.0f}, {core[1]} orders OK"

    os.makedirs("./reports", exist_ok=True)
    with open(f"./reports/{client['id']}_{today.replace('-','')}.txt", 'w') as f:
        f.write(report)
    print(f"OK - {client['name']} report generated")

def daily_batch_run():
    for client in CLIENTS:
        try:
            process_client(client)
        except Exception as e:
            print(f"FAIL {client['name']}: {e}")
    print(f"OK - batch complete, {len(CLIENTS)} clients")

schedule.every().day.at("08:00").do(daily_batch_run)
# Production: 0 8 * * * cd /path/to/project && python daily_batch.py >> run.log 2>&1

DuckDB vs. Traditional Stacks

DimensionManual ExcelCloud DWH + BIDuckDB service
Data ingestion per storeCopy-paste, 1-2h/daySync pipelines, hours to set upFiles land, query in seconds
Anomaly alertsNone (human eyeballs)Extra paid alerting moduleBuilt-in window functions, 0 extra cost
Marginal cost per clientYour time × clientsSeat fees, thousands/monthNear-zero (file = database)
DeploymentOffice on each machineCloud account + IAMSingle binary + crontab
Clients per operator3-5 (drowns in work)5-8 (heavy ops load)8-15
Monthly cost (ref)¥0 tooling + laborCNY 2k-8k/seat/moCNY 50 VPS

The model only works because marginal cost barely grows with client count.

💰 Monetization Playbook

  1. Tonight: run the full data flow in Jupyter with mock data, then offer one acquaintance’s store a free one-week pilot and collect feedback.
  2. Pricing: sell “understand your business in 3 minutes a day.” Start at CNY 800/month basic; 50% off the first 3 clients to build case studies fast.
  3. Income structure (solo, 8 pro + 4 basic):
    • Fixed: 8 × CNY 2,000 + 4 × CNY 800 = CNY 19,200/month
    • Big-promo add-ons: “data sprint pack” CNY 3,000/store in the 2 weeks before 618/Double 11 → +CNY 5k-15k in promo months
    • Annual total: CNY 280k-330k for one person
  4. Scale-up paths:
    • Add-on: custom KPI development at CNY 500/metric (one-time)
    • Upsell: auto-formatted charts (DuckDB CREATE MACRO + simple SVG export) lifts ARPU 20-30%
    • Beyond 15 clients: wrap ingestion into a small CLI (duckdb-ingest) to cut onboarding from 2 hours to 10 minutes

The core logic: you’re not selling “data analysis,” you’re selling “3 minutes a day, the business doesn’t fall over.” Sellers pay for certainty, not for technology. DuckDB makes that certainty cost a CNY 50 VPS.

📖 More DuckDB practical monetization case studies → duckdblab.org

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy