DEV Community

Operation Talon
Operation Talon

Posted on

How to Automate Client Reporting for Financial Advisors

How to Automate Client Reporting for Financial Advisors

Every Monday morning, RIAs and family office operators do the same ritual: log into three different custodian portals, download CSV files with inconsistent date formats, spend two hours reformatting data in Excel, apply their firm branding, generate PDFs, and email them to clients.

It's tedious, error-prone, and a complete waste of human intelligence.

I built a reporting pipeline that eliminates this entirely. Here's how it works, and how you can build one too.

The Pain: Manual Reporting Kills Advisors' Time

Let me paint the picture I hear repeatedly from RIAs:

  • Multiple custodians. Schwab, Fidelity, Interactive Brokers, or Pershing. Each has a different API, different data structure, different authentication method.
  • Inconsistent formatting. One custodian returns portfolio data in JSON. Another wants you to download Excel. Another uses FTP. You normalize everything into a single schema just to get started.
  • Client-specific customization. Some clients want performance attribution. Others want tax-loss harvesting opportunities. Some just want the bottom line. You can't build one report; you build twelve.
  • Compliance overhead. You need to track who accessed what, when reports were generated, audit trails for regulatory review.
  • Time sink. A firm with 200 clients spending 30 minutes per report? That's 100 hours a month of busy work. At $200/hour equivalent value, that's $20,000 in labor cost per month.

And here's the worst part: none of this is value-add. It's not growing the business. It's not serving clients better. It's just friction.

The Solution: An Automated Reporting Pipeline

A modern reporting pipeline looks like this:

Custodian APIs → Data Normalization → Analysis → PDF Generation → Delivery → Logging
Enter fullscreen mode Exit fullscreen mode

Let me break down each piece.

Step 1: Connect to Custodian APIs

Most major custodians have APIs. Some are modern REST APIs. Some are legacy SOAP. Some require OAuth. None are standardized, but they all work.

Here's a skeleton for pulling Schwab data:

import requests
from datetime import datetime, timedelta

class SchwabConnector:
    def __init__(self, app_key, app_secret, access_token):
        self.app_key = app_key
        self.app_secret = app_secret
        self.access_token = access_token
        self.base_url = "https://api.schwabapi.com/v1"
        self.headers = {
            "Authorization": f"Bearer {self.access_token}",
            "accept": "application/json"
        }

    def get_account_balances(self, account_number):
        """Fetch current account value and positions"""
        url = f"{self.base_url}/accounts/{account_number}"
        response = requests.get(url, headers=self.headers)
        return response.json()

    def get_account_history(self, account_number, days=30):
        """Fetch transaction history"""
        start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
        url = f"{self.base_url}/accounts/{account_number}/transactions"
        params = {"fromDate": start_date}
        response = requests.get(url, headers=self.headers, params=params)
        return response.json()
Enter fullscreen mode Exit fullscreen mode

The key is that once you abstract each custodian behind a connector class, you can treat them all the same downstream.

Step 2: Normalize Data Into a Single Schema

Each custodian returns different structures. You need a canonical format:

from dataclasses import dataclass
from typing import List

@dataclass
class Position:
    symbol: str
    quantity: float
    price: float
    market_value: float
    cost_basis: float
    unrealized_gain_loss: float

    @property
    def unrealized_gain_pct(self):
        if self.cost_basis == 0:
            return 0
        return (self.unrealized_gain_loss / self.cost_basis) * 100

@dataclass
class AccountSnapshot:
    account_id: str
    account_name: str
    total_value: float
    cash: float
    positions: List[Position]
    as_of_date: str
Enter fullscreen mode Exit fullscreen mode

Then create adapters that transform custodian data into your schema:

def normalize_schwab_account(raw_data):
    """Convert Schwab API response into AccountSnapshot"""
    positions = [
        Position(
            symbol=p['symbol'],
            quantity=p['quantity'],
            price=p['price'],
            market_value=p['marketValue'],
            cost_basis=p['costBasis'],
            unrealized_gain_loss=p['marketValue'] - p['costBasis']
        )
        for p in raw_data['positions']
    ]

    return AccountSnapshot(
        account_id=raw_data['accountNumber'],
        account_name=raw_data['accountName'],
        total_value=raw_data['accountValue'],
        cash=raw_data['cash'],
        positions=positions,
        as_of_date=raw_data['asOf']
    )
Enter fullscreen mode Exit fullscreen mode

This pattern scales. Add Fidelity? Write a normalize_fidelity_account(). Add Interactive Brokers? Another adapter. The downstream pipeline doesn't care.

Step 3: Calculate Performance and Add Context

This is where you add intelligence. Raw data is useless. Context is valuable.

def calculate_performance_metrics(current_snapshot, prior_snapshot):
    """Calculate period return, contributions, withdrawals"""

    # Simple time-weighted return calculation
    beginning_value = prior_snapshot.total_value
    contributions = current_snapshot.total_value - prior_snapshot.total_value - (
        current_snapshot.total_value - prior_snapshot.total_value  # simplified
    )
    ending_value = current_snapshot.total_value

    period_return = (ending_value - beginning_value - contributions) / beginning_value

    return {
        'period_return': period_return,
        'beginning_value': beginning_value,
        'ending_value': ending_value,
        'contributions': contributions,
        'top_gainers': sorted(
            current_snapshot.positions, 
            key=lambda x: x.unrealized_gain_pct, 
            reverse=True
        )[:5],
        'top_losers': sorted(
            current_snapshot.positions, 
            key=lambda x: x.unrealized_gain_pct
        )[:5]
    }
Enter fullscreen mode Exit fullscreen mode

Add tax-loss harvesting opportunities, rebalancing suggestions, benchmark comparisons — whatever your clients value.

Step 4: Generate Branded PDFs

Use reportlab or weasyprint to generate client-branded PDFs:

from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib import colors
from datetime import datetime

def generate_client_report(account_snapshot, metrics, client_name, firm_logo):
    """Generate a branded PDF report"""

    filename = f"reports/{client_name}_{datetime.now().strftime('%Y%m%d')}.pdf"
    c = canvas.Canvas(filename, pagesize=letter)

    # Header with firm branding
    c.drawImage(firm_logo, 50, 750, width=100, height=50)
    c.setFont("Helvetica-Bold", 24)
    c.drawString(200, 770, "Portfolio Report")

    # Account summary
    c.setFont("Helvetica", 10)
    c.drawString(50, 700, f"Account: {account_snapshot.account_name}")
    c.drawString(50, 685, f"As of: {account_snapshot.as_of_date}")
    c.drawString(50, 670, f"Total Value: ${account_snapshot.total_value:,.2f}")

    # Performance metrics
    c.setFont("Helvetica-Bold", 12)
    c.drawString(50, 630, "Performance")
    c.setFont("Helvetica", 10)
    c.drawString(50, 615, f"Period Return: {metrics['period_return']*100:.2f}%")

    # Top positions
    c.setFont("Helvetica-Bold", 12)
    c.drawString(50, 570, "Top Holdings")
    y = 555
    for pos in account_snapshot.positions[:10]:
        c.drawString(50, y, f"{pos.symbol}: ${pos.market_value:,.2f} ({pos.unrealized_gain_pct:.1f}%)")
        y -= 15

    c.save()
    return filename
Enter fullscreen mode Exit fullscreen mode

For more complex layouts, use weasyprint which lets you write HTML/CSS and converts to PDF.

Step 5: Automate Delivery

Schedule the whole pipeline to run nightly or weekly:

import schedule
import time
from datetime import datetime

def run_reporting_pipeline():
    """Main orchestrator"""

    # 1. Connect to custodians and fetch data
    schwab_data = schwab_connector.get_account_balances(account_id)
    fidelity_data = fidelity_connector.get_account_balances(account_id)

    # 2. Normalize
    schwab_snapshot = normalize_schwab_account(schwab_data)
    fidelity_snapshot = normalize_fidelity_account(fidelity_data)

    # 3. Calculate metrics
    metrics = calculate_performance_metrics(
        current_snapshot=schwab_snapshot,
        prior_snapshot=get_prior_snapshot(schwab_snapshot.account_id)
    )

    # 4. Generate PDF
    pdf_path = generate_client_report(
        account_snapshot=schwab_snapshot,
        metrics=metrics,
        client_name="John Doe",
        firm_logo="path/to/logo.png"
    )

    # 5. Email to client
    send_email(
        to="client@example.com",
        subject="Your Monthly Portfolio Report",
        body="Please see attached report.",
        attachment=pdf_path
    )

    # 6. Log for compliance
    log_report_sent(
        account_id=schwab_snapshot.account_id,
        client_email="client@example.com",
        timestamp=datetime.now(),
        pdf_path=pdf_path
    )

# Schedule to run every Monday at 6 AM
schedule.every().monday.at("06:00").do(run_reporting_pipeline)

while True:
    schedule.run_pending()
    time.sleep(60)
Enter fullscreen mode Exit fullscreen mode

Or use a more robust scheduler like APScheduler for production:

from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()
scheduler.add_job(run_reporting_pipeline, 'cron', day_of_week='mon', hour=6)
scheduler.start()
Enter fullscreen mode Exit fullscreen mode

Implementation Considerations

Authentication: Use OAuth for security. Store credentials in a secrets manager (HashiCorp Vault, AWS Secrets Manager), not hardcoded.

Error handling: Custodian APIs go down. Email servers fail. Wrap everything in try/except and log failures. Alert on critical breaks.

Data validation: Validate that data looks reasonable before generating reports. If an account shows 10x growth overnight, flag it before sending to the client.

Compliance: Keep audit logs of every report generated, who accessed it, when. Some RIAs need this for SEC exams.

Testing: Test your pipeline weekly with sample data. You don't want to discover bugs on the morning clients expect their reports.

The Math

Let's say you're an RIA with 150 clients:

  • Manual reporting: 30 minutes per client = 75 hours/month
  • At your loaded cost ($150/hour): $11,250/month

Automated pipeline:

  • Development: 40 hours
  • Maintenance: 2 hours/month
  • One-time cost amortized: $4,000
  • Monthly cost: $300

Payback period: 4 weeks. Lifetime savings: $132,000/year (assuming 5-year timeframe).

Plus, your clients get reports at 6 AM Monday instead of whenever you finish. They're happier. You're less stressed.

What to Build vs. What to Buy

You could use tools like Tamarac, Black Diamond, or Orion for this. They're turnkey solutions that already handle multiple custodians and compliance.

Build if:

  • You have unique client requirements not covered by off-the-shelf solutions
  • You want to own the code and customize it indefinitely
  • Your tech team exists and has spare capacity
  • You want full control over client data and infrastructure

Buy if:

  • You want it working in weeks, not months
  • Your requirements are standard (most RIAs' requirements are)
  • You need compliance certifications built-in
  • You don't want to maintain it yourself

For most RIAs, buying makes sense. But understanding how it works underneath helps you evaluate vendors and troubleshoot problems.

Next Steps

If you run an RIA or family office and you're tired of the Monday morning ritual, let's talk about how to automate this for your firm. Reach out: matt@hcip.health

I've built versions of this pipeline for multiple advisory firms. I can help you connect your custodians, normalize your data, and have client reports generating automatically.

You can also check out ready-made templates and frameworks at https://talon8575.gumroad.com — some are off-the-shelf Python packages that handle specific custodian integrations.

Your Mondays will thank you.

Top comments (0)