READ-ONLY investment holdings, transactions, and portfolio data from external providers
Overview
The investments module provides read-only access to investment holdings, transactions, securities, and portfolio data from external providers like Plaid and SnapTrade. This module is designed for viewing and analyzing investment data, not for executing trades.
What This Module Provides
- Holdings Data: Current positions, quantities, values, cost basis, and P/L
- Transaction History: Buys, sells, dividends, fees, and other investment transactions
- Account Information: Investment account details, balances, and metadata
- Asset Allocation: Portfolio breakdown by asset class (stocks, bonds, cash, etc.)
- Securities Details: Security identifiers, names, types, and market data
- Real P/L Calculations: Accurate profit/loss using actual cost basis
Critical Distinction: Investments vs Brokerage
┌─────────────────────────────────────────────────────────────┐
│ Investments Module (READ-ONLY) │
│ • View holdings from external accounts (401k, IRA, etc.) │
│ • Track portfolio performance │
│ • Calculate profit/loss │
│ • Providers: Plaid, SnapTrade, Teller (aggregators) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Brokerage Module (TRADING) │
│ • Execute buy/sell orders │
│ • Manage active trading positions │
│ • Place market/limit orders │
│ • Providers: Alpaca, Interactive Brokers (brokers) │
└─────────────────────────────────────────────────────────────┘For trading operations (buy/sell orders, order management), see Brokerage Integration
Supported Providers
| Provider | Status | Coverage | Use Case |
|---|---|---|---|
| Plaid | ✅ Current | 12,000+ institutions (US) | Production-ready, broad coverage |
| SnapTrade | ✅ Current | 5,000+ institutions (US/Canada) | Alternative to Plaid |
| Teller | 🚧 Future | Limited coverage | Privacy-focused alternative |
| MX | 🚧 Future | 16,000+ institutions (US) | Broader coverage for edge cases |
Quick Start
Programmatic Usage
from fin_infra.investments import easy_investments
# Initialize Plaid provider
investments = easy_investments(provider="plaid")
# Fetch holdings for a user
holdings = await investments.get_holdings(
access_token="access-sandbox-123..."
)
# Calculate portfolio metrics
from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
metrics = portfolio_metrics_with_holdings(holdings)
print(f"Total value: ${metrics.total_value:,.2f}")
print(f"Total return: {metrics.total_return_percent:.2f}%")FastAPI Integration
from fastapi import FastAPI
from fin_infra.investments import add_investments
app = FastAPI()
# Mount investment endpoints at /investments
provider = add_investments(app, prefix="/investments")
# Now available:
# GET /investments/holdings
# GET /investments/transactions
# GET /investments/accounts
# GET /investments/allocation
# GET /investments/securitiescURL Examples
# Fetch holdings (Plaid)
curl -X POST http://localhost:8000/investments/holdings \
-H "Content-Type: application/json" \
-d '{"access_token": "access-sandbox-123..."}'
# Fetch holdings (SnapTrade)
curl -X POST http://localhost:8000/investments/holdings \
-H "Content-Type: application/json" \
-d '{
"user_id": "user123",
"user_secret": "secret123"
}'
# Fetch transactions with date range
curl -X POST http://localhost:8000/investments/transactions \
-H "Content-Type: application/json" \
-d '{
"access_token": "access-sandbox-123...",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}'Zero-Config Setup
Set environment variables and the module auto-configures:
# Plaid configuration
export PLAID_CLIENT_ID=your_client_id
export PLAID_SECRET=your_secret
export PLAID_ENV=sandbox # or production
# SnapTrade configuration (alternative)
export SNAPTRADE_CONSUMER_KEY=your_key
export SNAPTRADE_CLIENT_ID=your_client_id# Provider auto-detects from environment
investments = easy_investments() # Uses Plaid if configured
# Or explicitly specify
investments = easy_investments(provider="plaid")API Reference
GET /investments/holdings
Fetch current investment holdings across accounts.
Request:
{
"access_token": "access-sandbox-123...", // Plaid
"account_ids": ["acc1", "acc2"] // Optional filter
}Response:
[
{
"account_id": "acc1",
"security": {
"security_id": "AAPL",
"name": "Apple Inc.",
"ticker_symbol": "AAPL",
"type": "equity"
},
"quantity": 10.0,
"institution_price": 150.00,
"institution_value": 1500.00,
"cost_basis": 1200.00,
"iso_currency_code": "USD",
"unrealized_gain_loss": 300.00,
"unrealized_gain_loss_percent": 25.0
}
]Filtering:
# All holdings
holdings = await investments.get_holdings(access_token=token)
# Specific accounts only
holdings = await investments.get_holdings(
access_token=token,
account_ids=["401k_account", "ira_account"]
)GET /investments/transactions
Fetch investment transactions (buys, sells, dividends, fees).
Request:
{
"access_token": "access-sandbox-123...",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"account_ids": ["acc1"] // Optional
}Response:
[
{
"transaction_id": "txn123",
"account_id": "acc1",
"security": {
"security_id": "AAPL",
"ticker_symbol": "AAPL"
},
"type": "buy",
"date": "2024-06-15",
"quantity": 10.0,
"price": 120.00,
"amount": 1200.00,
"fees": 0.0,
"iso_currency_code": "USD"
}
]Transaction Types:
buy: Purchase of securitysell: Sale of securitydividend: Dividend paymentinterest: Interest paymentfee: Account or transaction feetransfer: Transfer between accountsother: Other transaction types
GET /investments/accounts
Fetch investment account details and balances.
Request:
{
"access_token": "access-sandbox-123..."
}Response:
[
{
"account_id": "401k_account",
"name": "Vanguard 401(k)",
"type": "401k",
"subtype": "retirement",
"balances": {
"current": 50000.00,
"available": 50000.00,
"iso_currency_code": "USD"
}
}
]GET /investments/allocation
Calculate asset allocation breakdown by asset class.
Request:
{
"access_token": "access-sandbox-123...",
"account_ids": ["acc1", "acc2"] // Optional
}Response:
[
{
"asset_class": "Stocks",
"value": 35000.00,
"percentage": 70.0
},
{
"asset_class": "Bonds",
"value": 10000.00,
"percentage": 20.0
},
{
"asset_class": "Cash",
"value": 5000.00,
"percentage": 10.0
}
]Asset Class Mapping:
equity→ Stocksetf→ Stocks (most ETFs)mutual_fund→ Bonds (conservative)bond→ Bondscash→ Cashderivative→ Otherother→ Other
GET /investments/securities
Fetch detailed security information.
Request:
{
"access_token": "access-sandbox-123...",
"security_ids": ["AAPL", "GOOGL", "MSFT"]
}Response:
[
{
"security_id": "AAPL",
"name": "Apple Inc.",
"ticker_symbol": "AAPL",
"type": "equity",
"cusip": "037833100",
"isin": "US0378331005",
"sedol": "2046251"
}
]Provider Comparison
Plaid
Status: ✅ Production-ready
Coverage:
- 12,000+ institutions (US)
- Major brokerages: Vanguard, Fidelity, Charles Schwab, etc.
- 401(k) providers: ADP, Paychex, etc.
Data Quality:
- ✅ Cost basis: Available for most holdings
- ✅ Update frequency: Real-time to daily
- ✅ Transaction history: 2+ years
- ⚠️ Limitations: Some 401(k)s have delayed updates
Pricing:
- Sandbox: Free
- Development: Free
- Production: Pay-per-use (contact Plaid)
Authentication:
- Link flow: User authenticates via Plaid Link
- Token:
access_tokenrequired for API calls
SnapTrade
Status: ✅ Production-ready
Coverage:
- 5,000+ institutions (US/Canada)
- Broader international coverage than Plaid
- Cryptocurrency exchanges
Data Quality:
- ✅ Cost basis: Available
- ✅ Update frequency: Real-time
- ✅ Trading: Supports order execution (brokerage)
Pricing:
- Sandbox: Free
- Production: Tiered pricing
Authentication:
- User ID + Secret: Per-user credentials
- OAuth: Available for some brokers
Teller (Future)
Status: 🚧 Planned
Coverage:
- Limited (privacy-focused)
- Major banks and brokerages
Key Features:
- Privacy-first: No screen scraping
- Direct API connections
- Open banking standards
MX (Future)
Status: 🚧 Planned
Coverage:
- 16,000+ institutions (US)
- Broader coverage for credit unions and regional banks
Use Case:
- Fill coverage gaps from Plaid
- Redundancy for critical accounts
Multi-Provider Usage
Using Multiple Providers
Combine Plaid and SnapTrade for maximum coverage:
from fin_infra.investments import easy_investments
# Initialize both providers
plaid = easy_investments(provider="plaid")
snaptrade = easy_investments(provider="snaptrade")
# Fetch holdings from both
plaid_holdings = await plaid.get_holdings(access_token=plaid_token)
snaptrade_holdings = await snaptrade.get_holdings(
user_id=st_user_id,
user_secret=st_secret
)
# Combine for unified view
all_holdings = plaid_holdings + snaptrade_holdingsProvider Fallback Strategy
async def get_holdings_with_fallback(user_id: str) -> list[Holding]:
"""Fetch holdings with provider fallback."""
# Try primary provider (Plaid)
try:
plaid_token = get_user_plaid_token(user_id)
return await plaid.get_holdings(access_token=plaid_token)
except Exception as e:
logging.warning(f"Plaid failed: {e}")
# Fall back to SnapTrade
try:
st_user_id, st_secret = get_snaptrade_creds(user_id)
return await snaptrade.get_holdings(
user_id=st_user_id,
user_secret=st_secret
)
except Exception as e:
logging.error(f"SnapTrade also failed: {e}")
raiseCredential Management
# Store provider credentials per user
from sqlalchemy import Column, String, JSON
class UserInvestmentCredentials(Base):
__tablename__ = "user_investment_credentials"
user_id = Column(String, primary_key=True)
provider = Column(String, primary_key=True) # "plaid", "snaptrade"
credentials = Column(JSON) # Encrypted tokens/secrets
# Example:
# {
# "plaid": {"access_token": "access-sandbox-123..."},
# "snaptrade": {"user_id": "user123", "user_secret": "secret123"}
# }Integration with Other Modules
Banking Module: Shared Plaid Client
Reuse Plaid credentials across banking and investments:
from fin_infra.banking import easy_banking
from fin_infra.investments import easy_investments
# Both use same Plaid client and credentials
banking = easy_banking(provider="plaid")
investments = easy_investments(provider="plaid")
# Same access_token for both
accounts = await banking.get_accounts(access_token=token)
holdings = await investments.get_holdings(access_token=token)
# Combined view: checking + savings + investments
total_net_worth = (
sum(acc.balance for acc in accounts) +
sum(h.institution_value for h in holdings)
)Analytics Module: Real P/L Calculations
Replace mock portfolio data with real holdings:
from fin_infra.investments import easy_investments
from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
# Fetch real holdings
investments = easy_investments(provider="plaid")
holdings = await investments.get_holdings(access_token=token)
# Calculate real P/L from holdings
metrics = portfolio_metrics_with_holdings(holdings)
print(f"Total value: ${metrics.total_value:,.2f}")
print(f"Total return: ${metrics.total_return:,.2f}")
print(f"Return %: {metrics.total_return_percent:.2f}%")
# Asset allocation
for allocation in metrics.allocation_by_asset_class:
print(f"{allocation.asset_class}: {allocation.percentage:.1f}%")Benefits over mock data:
- Accurate cost basis → real P/L
- Real security types → precise allocation
- Current market values → live tracking
Brokerage Module: Unified Portfolio View
Combine external holdings (Plaid) with active trading positions (Alpaca):
from fin_infra.investments import easy_investments
from fin_infra.brokerage import easy_brokerage
# External holdings (401k, IRA, taxable accounts)
plaid_investments = easy_investments(provider="plaid")
external_holdings = await plaid_investments.get_holdings(plaid_token)
# Active trading positions (brokerage account)
alpaca_broker = easy_brokerage(provider="alpaca")
trading_positions = await alpaca_broker.positions()
# Unified portfolio view
total_portfolio = {
"external_accounts": {
"total_value": sum(h.institution_value for h in external_holdings),
"holdings": external_holdings,
},
"trading_account": {
"total_value": sum(p.market_value for p in trading_positions),
"positions": trading_positions,
},
"net_total": (
sum(h.institution_value for h in external_holdings) +
sum(p.market_value for p in trading_positions)
),
}Market Data Module: Real-Time Portfolio Value
Combine holdings with live quotes:
from fin_infra.investments import easy_investments
from fin_infra.market_data import easy_market_data
# Fetch holdings
investments = easy_investments(provider="plaid")
holdings = await investments.get_holdings(access_token=token)
# Get live quotes for all securities
market_data = easy_market_data(provider="alpha_vantage")
tickers = [h.security.ticker_symbol for h in holdings]
quotes = await market_data.get_quotes(tickers)
# Calculate live portfolio value
live_value = sum(
h.quantity * quotes[h.security.ticker_symbol].price
for h in holdings
if h.security.ticker_symbol in quotes
)
print(f"Live portfolio value: ${live_value:,.2f}")Use Cases
Personal Finance: Track Performance
from fin_infra.investments import easy_investments
from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
async def get_portfolio_summary(user_id: str):
"""Generate portfolio summary for user."""
# Fetch holdings
investments = easy_investments()
token = get_user_plaid_token(user_id)
holdings = await investments.get_holdings(access_token=token)
# Calculate metrics
metrics = portfolio_metrics_with_holdings(holdings)
return {
"total_value": metrics.total_value,
"total_return": metrics.total_return,
"return_percent": metrics.total_return_percent,
"allocation": [
{"asset_class": a.asset_class, "percent": a.percentage}
for a in metrics.allocation_by_asset_class
],
}Robo-Advisor: Portfolio Rebalancing
async def calculate_rebalancing_trades(user_id: str, target_allocation: dict):
"""Calculate trades needed to rebalance portfolio."""
# Fetch current holdings
investments = easy_investments()
holdings = await investments.get_holdings(access_token=token)
# Calculate current allocation
metrics = portfolio_metrics_with_holdings(holdings)
current_allocation = {
a.asset_class: a.percentage
for a in metrics.allocation_by_asset_class
}
# Calculate rebalancing trades
trades = []
for asset_class, target_pct in target_allocation.items():
current_pct = current_allocation.get(asset_class, 0)
diff_pct = target_pct - current_pct
if abs(diff_pct) > 1.0: # Rebalance if >1% off target
trade_value = (diff_pct / 100) * metrics.total_value
trades.append({
"asset_class": asset_class,
"action": "buy" if diff_pct > 0 else "sell",
"amount": abs(trade_value),
})
return tradesWealth Management: Client Reporting
from datetime import datetime, timedelta
async def generate_client_report(client_id: str) -> dict:
"""Generate monthly client report."""
investments = easy_investments()
token = get_client_plaid_token(client_id)
# Current holdings
holdings = await investments.get_holdings(access_token=token)
metrics = portfolio_metrics_with_holdings(holdings)
# Transaction history
end_date = datetime.now().date()
start_date = end_date - timedelta(days=30)
transactions = await investments.get_transactions(
access_token=token,
start_date=start_date,
end_date=end_date,
)
return {
"client_id": client_id,
"report_date": end_date,
"portfolio": {
"total_value": metrics.total_value,
"total_return": metrics.total_return,
"return_percent": metrics.total_return_percent,
},
"allocation": metrics.allocation_by_asset_class,
"activity": {
"buys": [t for t in transactions if t.type == "buy"],
"sells": [t for t in transactions if t.type == "sell"],
"dividends": [t for t in transactions if t.type == "dividend"],
},
}Tax Tools: Cost Basis Reporting
async def generate_tax_report(user_id: str, tax_year: int) -> dict:
"""Generate tax report with cost basis and capital gains."""
investments = easy_investments()
token = get_user_plaid_token(user_id)
# Fetch transactions for tax year
transactions = await investments.get_transactions(
access_token=token,
start_date=f"{tax_year}-01-01",
end_date=f"{tax_year}-12-31",
)
# Calculate realized gains
realized_gains = []
for txn in transactions:
if txn.type == "sell":
# Look up cost basis (simplified - real implementation would track lots)
cost_basis = txn.quantity * txn.price # Placeholder
proceeds = txn.amount
gain = proceeds - cost_basis
realized_gains.append({
"security": txn.security.ticker_symbol,
"date": txn.date,
"proceeds": proceeds,
"cost_basis": cost_basis,
"gain_loss": gain,
})
# Current unrealized gains
holdings = await investments.get_holdings(access_token=token)
unrealized_gains = sum(
h.unrealized_gain_loss for h in holdings
if h.unrealized_gain_loss
)
return {
"tax_year": tax_year,
"realized_gains": realized_gains,
"total_realized": sum(g["gain_loss"] for g in realized_gains),
"unrealized_gains": unrealized_gains,
}Net Worth Tracking: Banking + Investments
async def calculate_net_worth(user_id: str) -> dict:
"""Calculate total net worth from all accounts."""
from fin_infra.banking import easy_banking
from fin_infra.investments import easy_investments
token = get_user_plaid_token(user_id)
# Banking accounts (checking, savings, credit cards)
banking = easy_banking(provider="plaid")
bank_accounts = await banking.get_accounts(access_token=token)
# Investment accounts
investments = easy_investments(provider="plaid")
holdings = await investments.get_holdings(access_token=token)
# Calculate totals
liquid_assets = sum(
acc.balance for acc in bank_accounts
if acc.type in ["depository"]
)
investment_assets = sum(
h.institution_value for h in holdings
)
liabilities = sum(
abs(acc.balance) for acc in bank_accounts
if acc.type == "credit" and acc.balance < 0
)
return {
"liquid_assets": liquid_assets,
"investment_assets": investment_assets,
"total_assets": liquid_assets + investment_assets,
"liabilities": liabilities,
"net_worth": liquid_assets + investment_assets - liabilities,
}Data Persistence Patterns
Optional: Applications Scaffold Models
The investments module returns Pydantic models. Applications can optionally persist data:
from sqlalchemy import Column, String, Float, Date, JSON
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class UserHolding(Base):
"""Persist holdings for historical tracking."""
__tablename__ = "user_holdings"
id = Column(String, primary_key=True)
user_id = Column(String, index=True)
account_id = Column(String)
security_id = Column(String)
snapshot_date = Column(Date, index=True)
# Holdings data
quantity = Column(Float)
institution_price = Column(Float)
institution_value = Column(Float)
cost_basis = Column(Float, nullable=True)
# Metadata
security_data = Column(JSON) # Full Security objectDaily Snapshots for Day Change Tracking
Store daily snapshots to calculate day-over-day changes:
from datetime import datetime, timedelta
async def store_daily_snapshot(user_id: str):
"""Store daily holdings snapshot."""
investments = easy_investments()
token = get_user_plaid_token(user_id)
holdings = await investments.get_holdings(access_token=token)
today = datetime.now().date()
for holding in holdings:
db.add(UserHolding(
id=f"{user_id}_{holding.account_id}_{holding.security.security_id}_{today}",
user_id=user_id,
account_id=holding.account_id,
security_id=holding.security.security_id,
snapshot_date=today,
quantity=float(holding.quantity),
institution_price=float(holding.institution_price),
institution_value=float(holding.institution_value),
cost_basis=float(holding.cost_basis) if holding.cost_basis else None,
security_data=holding.security.dict(),
))
db.commit()
async def calculate_day_change(user_id: str):
"""Calculate day-over-day portfolio change."""
from fin_infra.analytics.portfolio import calculate_day_change_with_snapshot
# Fetch current holdings
investments = easy_investments()
token = get_user_plaid_token(user_id)
current_holdings = await investments.get_holdings(access_token=token)
# Load yesterday's snapshot
yesterday = datetime.now().date() - timedelta(days=1)
previous_snapshot = db.query(UserHolding).filter(
UserHolding.user_id == user_id,
UserHolding.snapshot_date == yesterday,
).all()
# Convert to Holding objects
previous_holdings = [
Holding(
account_id=snap.account_id,
security=Security(**snap.security_data),
quantity=snap.quantity,
institution_price=snap.institution_price,
institution_value=snap.institution_value,
cost_basis=snap.cost_basis,
)
for snap in previous_snapshot
]
# Calculate change
return calculate_day_change_with_snapshot(current_holdings, previous_holdings)Time-Series for YTD/MTD Returns
class PortfolioSnapshot(Base):
"""Time-series portfolio snapshots."""
__tablename__ = "portfolio_snapshots"
id = Column(String, primary_key=True)
user_id = Column(String, index=True)
snapshot_date = Column(Date, index=True)
total_value = Column(Float)
total_cost_basis = Column(Float)
total_return = Column(Float)
total_return_percent = Column(Float)
allocation = Column(JSON) # Asset allocation breakdown
async def calculate_ytd_return(user_id: str):
"""Calculate year-to-date return."""
from datetime import datetime
# Current value
investments = easy_investments()
holdings = await investments.get_holdings(access_token=token)
metrics = portfolio_metrics_with_holdings(holdings)
current_value = metrics.total_value
# Jan 1 value
jan_1 = datetime(datetime.now().year, 1, 1).date()
jan_1_snapshot = db.query(PortfolioSnapshot).filter(
PortfolioSnapshot.user_id == user_id,
PortfolioSnapshot.snapshot_date == jan_1,
).first()
if jan_1_snapshot:
ytd_return = current_value - jan_1_snapshot.total_value
ytd_return_percent = (ytd_return / jan_1_snapshot.total_value) * 100
return {
"ytd_return": ytd_return,
"ytd_return_percent": ytd_return_percent,
}
return NoneTransaction History for Realized Gains
class UserTransaction(Base):
"""Persist investment transactions."""
__tablename__ = "user_transactions"
transaction_id = Column(String, primary_key=True)
user_id = Column(String, index=True)
account_id = Column(String)
security_id = Column(String)
type = Column(String) # buy, sell, dividend, etc.
date = Column(Date, index=True)
quantity = Column(Float)
price = Column(Float)
amount = Column(Float)
fees = Column(Float)
async def calculate_realized_gains(user_id: str, year: int):
"""Calculate realized gains for tax year."""
transactions = db.query(UserTransaction).filter(
UserTransaction.user_id == user_id,
UserTransaction.type.in_(["buy", "sell"]),
UserTransaction.date.between(f"{year}-01-01", f"{year}-12-31"),
).all()
# Calculate realized gains (simplified - real implementation uses tax lots)
realized_gains = 0.0
for txn in transactions:
if txn.type == "sell":
# Look up cost basis from buy transactions
# ... tax lot matching logic ...
pass
return realized_gainsReal P/L Calculations
Current Value vs Cost Basis
The investments module provides actual cost basis from providers:
holdings = await investments.get_holdings(access_token=token)
for holding in holdings:
current_value = holding.institution_value
cost_basis = holding.cost_basis
if cost_basis:
unrealized_gain = current_value - cost_basis
unrealized_gain_percent = (unrealized_gain / cost_basis) * 100
print(f"{holding.security.ticker_symbol}:")
print(f" Current: ${current_value:,.2f}")
print(f" Cost basis: ${cost_basis:,.2f}")
print(f" Gain/Loss: ${unrealized_gain:,.2f} ({unrealized_gain_percent:.2f}%)")Unrealized Gains/Losses
from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
holdings = await investments.get_holdings(access_token=token)
metrics = portfolio_metrics_with_holdings(holdings)
print(f"Total unrealized gain/loss: ${metrics.total_return:,.2f}")
print(f"Return %: {metrics.total_return_percent:.2f}%")Asset Allocation Breakdown
metrics = portfolio_metrics_with_holdings(holdings)
print("Asset Allocation:")
for allocation in metrics.allocation_by_asset_class:
print(f" {allocation.asset_class}: ${allocation.value:,.2f} ({allocation.percentage:.1f}%)")
# Output:
# Asset Allocation:
# Stocks: $35,000.00 (70.0%)
# Bonds: $10,000.00 (20.0%)
# Cash: $5,000.00 (10.0%)What Requires Historical Data
Provided by holdings API:
- ✅ Current value
- ✅ Cost basis
- ✅ Unrealized P/L
- ✅ Asset allocation
Requires persistence (applications must implement):
- ⏳ Day change (requires yesterday's snapshot)
- ⏳ YTD/MTD returns (requires Jan 1 / month start snapshot)
- ⏳ Historical performance (requires time-series data)
- ⏳ Realized gains (requires transaction history)
# Day change - requires yesterday's snapshot
from fin_infra.analytics.portfolio import calculate_day_change_with_snapshot
current = await investments.get_holdings(access_token=token)
previous = load_snapshot_from_db(user_id, date=yesterday)
day_change = calculate_day_change_with_snapshot(current, previous)
print(f"Day change: ${day_change['day_change_dollars']:,.2f}")Troubleshooting
Missing Cost Basis
Issue: Some holdings return cost_basis=None
Causes:
- Provider doesn't have cost basis data
- Account type doesn't track cost basis (e.g., some 401(k)s)
- Recent transfers (cost basis not yet updated)
Solutions:
# Handle missing cost basis
holdings = await investments.get_holdings(access_token=token)
for holding in holdings:
if holding.cost_basis is None:
# Option 1: Skip P/L calculation
print(f"{holding.security.ticker_symbol}: Cost basis unavailable")
# Option 2: Estimate from recent transactions
# ... look up buy transactions ...
# Option 3: Use current value as baseline
estimated_cost = holding.institution_valueStale Data
Issue: Holdings show outdated values
Causes:
- Provider update lag (some 401(k)s update weekly)
- Market closed (values from previous close)
- Cache on provider side
Solutions:
# Force refresh via provider
holdings = await investments.get_holdings(
access_token=token,
force_refresh=True # If provider supports
)
# Check data freshness
for holding in holdings:
if holding.as_of_date:
age = datetime.now().date() - holding.as_of_date
if age.days > 1:
print(f"Warning: {holding.security.ticker_symbol} data is {age.days} days old")Rate Limits
Plaid:
- Development: 100 requests/minute
- Production: 1000 requests/day (default)
- Solution: Cache holdings, use webhooks for updates
SnapTrade:
- Varies by plan
- Solution: Implement exponential backoff
import asyncio
from functools import wraps
def rate_limit_retry(max_retries=3, base_delay=1.0):
"""Retry with exponential backoff on rate limit."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except HTTPException as e:
if e.status_code == 429: # Rate limit
delay = base_delay * (2 ** attempt)
await asyncio.sleep(delay)
else:
raise
raise Exception(f"Rate limit exceeded after {max_retries} retries")
return wrapper
return decorator
@rate_limit_retry()
async def get_holdings_with_retry(token: str):
return await investments.get_holdings(access_token=token)Plaid Sandbox Testing
Sandbox credentials:
# Plaid sandbox environment
investments = easy_investments(
provider="plaid",
plaid_env="sandbox" # or set PLAID_ENV=sandbox
)
# Sandbox test credentials (publicly available)
# Username: user_good
# Password: pass_good
# Institution: Platypus (ins_109508)
# After Plaid Link, use sandbox access token
holdings = await investments.get_holdings(
access_token="access-sandbox-..."
)Data Quality Issues
Issue: Missing securities, incorrect types
Causes:
- Provider mapping issues
- New securities not in provider database
- Institution data quality
Solutions:
# Validate security data
holdings = await investments.get_holdings(access_token=token)
for holding in holdings:
if not holding.security.ticker_symbol:
# Missing ticker - may be mutual fund or custom security
print(f"Warning: No ticker for {holding.security.name}")
if holding.security.type == "other":
# Unknown security type - check security_id/cusip
print(f"Unknown type: {holding.security.name} ({holding.security.security_id})")
# Enrich with market data module
from fin_infra.market_data import easy_market_data
market_data = easy_market_data()
for holding in holdings:
if holding.security.ticker_symbol:
quote = await market_data.get_quote(holding.security.ticker_symbol)
# Use quote for real-time pricingFuture Enhancements
Day Change Tracking
Status: 🚧 Requires persistence layer
Implementation:
# Applications must store daily snapshots
async def enable_day_change_tracking():
"""Store daily snapshots for day change calculations."""
# Cron job: Daily at market close
holdings = await investments.get_holdings(access_token=token)
store_snapshot(user_id, date=today, holdings=holdings)
# Calculate day change
from fin_infra.analytics.portfolio import calculate_day_change_with_snapshot
current = holdings
previous = load_snapshot(user_id, date=yesterday)
day_change = calculate_day_change_with_snapshot(current, previous)YTD/MTD Returns
Status: 🚧 Requires time-series persistence
Implementation:
# Store monthly/yearly snapshots
async def calculate_ytd_return():
"""Calculate year-to-date return."""
current_metrics = portfolio_metrics_with_holdings(current_holdings)
jan_1_snapshot = load_snapshot(user_id, date=f"{year}-01-01")
ytd_return = current_metrics.total_value - jan_1_snapshot.total_value
ytd_return_percent = (ytd_return / jan_1_snapshot.total_value) * 100Historical Performance
Status: 🚧 Requires time-series database
Features:
- Multi-year performance charts
- Rolling returns (1yr, 3yr, 5yr)
- Performance vs benchmark (S&P 500)
Tax Lot Tracking
Status: 🚧 Requires transaction history persistence
Features:
- FIFO/LIFO/Specific ID cost basis methods
- Short-term vs long-term capital gains
- Wash sale detection
- Tax-loss harvesting opportunities
Implementation:
class TaxLot(Base):
"""Track tax lots for capital gains calculations."""
__tablename__ = "tax_lots"
id = Column(String, primary_key=True)
user_id = Column(String)
security_id = Column(String)
acquisition_date = Column(Date)
quantity = Column(Float)
cost_basis_per_share = Column(Float)
total_cost_basis = Column(Float)
disposal_date = Column(Date, nullable=True)
disposal_price = Column(Float, nullable=True)
realized_gain_loss = Column(Float, nullable=True)Dividend Tracking and Reinvestment
Status: 🚧 Requires transaction monitoring
Features:
- Track dividend payments
- Calculate dividend yield
- Monitor reinvestment (DRIP)
- Annual dividend income reporting
Related Documentation
- Brokerage Integration - Execute trades and manage orders
- Banking Module - Bank accounts and transactions
- Analytics Module - Portfolio analytics and insights
- Market Data - Real-time quotes and market data
- Categorization - Transaction categorization
- Persistence - Data storage patterns
Next Steps:
- Set up Plaid sandbox account for testing
- Implement
easy_investments()in your application - Test with sandbox credentials
- Set up daily snapshot persistence (optional)
- Integrate with analytics module for real P/L
- Move to production with real Plaid credentials