fin-infra

Investments Module

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

code
┌─────────────────────────────────────────────────────────────┐
│  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

ProviderStatusCoverageUse Case
Plaid✅ Current12,000+ institutions (US)Production-ready, broad coverage
SnapTrade✅ Current5,000+ institutions (US/Canada)Alternative to Plaid
Teller🚧 FutureLimited coveragePrivacy-focused alternative
MX🚧 Future16,000+ institutions (US)Broader coverage for edge cases

Quick Start

Programmatic Usage

python
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

python
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/securities

cURL Examples

bash
# 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:

bash
# 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
python
# 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:

json
{
  "access_token": "access-sandbox-123...",  // Plaid
  "account_ids": ["acc1", "acc2"]           // Optional filter
}

Response:

json
[
  {
    "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:

python
# 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:

json
{
  "access_token": "access-sandbox-123...",
  "start_date": "2024-01-01",
  "end_date": "2024-12-31",
  "account_ids": ["acc1"]  // Optional
}

Response:

json
[
  {
    "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 security
  • sell: Sale of security
  • dividend: Dividend payment
  • interest: Interest payment
  • fee: Account or transaction fee
  • transfer: Transfer between accounts
  • other: Other transaction types

GET /investments/accounts

Fetch investment account details and balances.

Request:

json
{
  "access_token": "access-sandbox-123..."
}

Response:

json
[
  {
    "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:

json
{
  "access_token": "access-sandbox-123...",
  "account_ids": ["acc1", "acc2"]  // Optional
}

Response:

json
[
  {
    "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 → Stocks
  • etf → Stocks (most ETFs)
  • mutual_fund → Bonds (conservative)
  • bond → Bonds
  • cash → Cash
  • derivative → Other
  • other → Other

GET /investments/securities

Fetch detailed security information.

Request:

json
{
  "access_token": "access-sandbox-123...",
  "security_ids": ["AAPL", "GOOGL", "MSFT"]
}

Response:

json
[
  {
    "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_token required 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:

python
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_holdings

Provider Fallback Strategy

python
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}")
        raise

Credential Management

python
# 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:

python
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:

python
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):

python
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:

python
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

python
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

python
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 trades

Wealth Management: Client Reporting

python
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

python
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

python
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:

python
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 object

Daily Snapshots for Day Change Tracking

Store daily snapshots to calculate day-over-day changes:

python
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

python
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 None

Transaction History for Realized Gains

python
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_gains

Real P/L Calculations

Current Value vs Cost Basis

The investments module provides actual cost basis from providers:

python
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

python
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

python
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)
python
# 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:

python
# 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_value

Stale 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:

python
# 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
python
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:

python
# 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:

python
# 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 pricing

Future Enhancements

Day Change Tracking

Status: 🚧 Requires persistence layer

Implementation:

python
# 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:

python
# 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) * 100

Historical 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:

python
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

Next Steps:

  1. Set up Plaid sandbox account for testing
  2. Implement easy_investments() in your application
  3. Test with sandbox credentials
  4. Set up daily snapshot persistence (optional)
  5. Integrate with analytics module for real P/L
  6. Move to production with real Plaid credentials