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
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()
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
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']
)
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]
}
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
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)
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()
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)