TradingAgents/docs/portfolio/04_repository_api.md

13 KiB

Repository Layer API

This document is the authoritative API reference for all classes in tradingagents/portfolio/.


Exception Hierarchy

Defined in tradingagents/portfolio/exceptions.py.

PortfolioError                  # Base exception for all portfolio errors
├── PortfolioNotFoundError      # Requested portfolio_id does not exist
├── HoldingNotFoundError        # Requested holding (portfolio_id, ticker) does not exist
├── DuplicatePortfolioError     # Portfolio name or ID already exists
├── InsufficientCashError       # Not enough cash for a BUY trade
├── InsufficientSharesError     # Not enough shares for a SELL trade
├── ConstraintViolationError    # PM constraint breached (position size, sector, cash)
└── ReportStoreError            # Filesystem read/write failure

Usage

from tradingagents.portfolio.exceptions import (
    PortfolioError,
    PortfolioNotFoundError,
    InsufficientCashError,
    InsufficientSharesError,
)

try:
    repo.add_holding(portfolio_id, "AAPL", shares=100, price=195.50)
except InsufficientCashError as e:
    print(f"Cannot buy: {e}")

SupabaseClient

Location: tradingagents/portfolio/supabase_client.py

Thin wrapper around the supabase-py client that:

  • Manages a singleton connection
  • Translates HTTP / Supabase errors into domain exceptions
  • Converts raw DB rows into model instances

Constructor / Singleton

client = SupabaseClient.get_instance()
# or
client = SupabaseClient(url=SUPABASE_URL, key=SUPABASE_KEY)

Portfolio Methods

def create_portfolio(self, portfolio: Portfolio) -> Portfolio:
    """Insert a new portfolio row.

    Raises:
        DuplicatePortfolioError: If portfolio_id already exists.
    """

def get_portfolio(self, portfolio_id: str) -> Portfolio:
    """Fetch a portfolio by ID.

    Raises:
        PortfolioNotFoundError: If no portfolio has that ID.
    """

def list_portfolios(self) -> list[Portfolio]:
    """Return all portfolios ordered by created_at DESC."""

def update_portfolio(self, portfolio: Portfolio) -> Portfolio:
    """Update mutable fields (cash, report_path, metadata, updated_at).

    Raises:
        PortfolioNotFoundError: If portfolio_id does not exist.
    """

def delete_portfolio(self, portfolio_id: str) -> None:
    """Delete a portfolio and all its holdings, trades, and snapshots (CASCADE).

    Raises:
        PortfolioNotFoundError: If portfolio_id does not exist.
    """

Holdings Methods

def upsert_holding(self, holding: Holding) -> Holding:
    """Insert or update a holding row (upsert on portfolio_id + ticker).

    Returns the holding with updated DB-assigned fields (updated_at).
    """

def get_holding(self, portfolio_id: str, ticker: str) -> Holding | None:
    """Return the holding for (portfolio_id, ticker), or None if not found."""

def list_holdings(self, portfolio_id: str) -> list[Holding]:
    """Return all holdings for a portfolio ordered by cost_basis DESC."""

def delete_holding(self, portfolio_id: str, ticker: str) -> None:
    """Delete the holding for (portfolio_id, ticker).

    Raises:
        HoldingNotFoundError: If no such holding exists.
    """

Trades Methods

def record_trade(self, trade: Trade) -> Trade:
    """Insert a new trade record. Immutable — no update method.

    Returns the trade with DB-assigned trade_id and trade_date.
    """

def list_trades(
    self,
    portfolio_id: str,
    ticker: str | None = None,
    limit: int = 100,
) -> list[Trade]:
    """Return recent trades for a portfolio, newest first.

    Args:
        portfolio_id: Filter by portfolio.
        ticker: Optional additional filter by ticker symbol.
        limit: Maximum number of rows to return.
    """

Snapshots Methods

def save_snapshot(self, snapshot: PortfolioSnapshot) -> PortfolioSnapshot:
    """Insert a new snapshot. Immutable — no update method."""

def get_latest_snapshot(self, portfolio_id: str) -> PortfolioSnapshot | None:
    """Return the most recent snapshot, or None if none exist."""

def list_snapshots(
    self,
    portfolio_id: str,
    limit: int = 30,
) -> list[PortfolioSnapshot]:
    """Return snapshots newest-first up to limit."""

ReportStore

Location: tradingagents/portfolio/report_store.py

Filesystem document store for all non-transactional portfolio artifacts. Integrates with the existing tradingagents/report_paths.py path conventions.

Constructor

store = ReportStore(base_dir: str | Path = "reports")

base_dir defaults to "reports" (relative to CWD). Override via PORTFOLIO_DATA_DIR env var or config.

Scan Methods

def save_scan(self, date: str, data: dict) -> Path:
    """Save macro scan summary JSON.

    Path: {base_dir}/daily/{date}/market/macro_scan_summary.json

    Returns the path written.
    """

def load_scan(self, date: str) -> dict | None:
    """Load macro scan summary. Returns None if file doesn't exist."""

Analysis Methods

def save_analysis(self, date: str, ticker: str, data: dict) -> Path:
    """Save per-ticker analysis report as JSON.

    Path: {base_dir}/daily/{date}/{TICKER}/complete_report.json
    """

def load_analysis(self, date: str, ticker: str) -> dict | None:
    """Load per-ticker analysis JSON. Returns None if file doesn't exist."""

Holding Review Methods

def save_holding_review(self, date: str, ticker: str, data: dict) -> Path:
    """Save holding reviewer output for one ticker.

    Path: {base_dir}/daily/{date}/portfolio/{TICKER}_holding_review.json
    """

def load_holding_review(self, date: str, ticker: str) -> dict | None:
    """Load holding review. Returns None if file doesn't exist."""

Risk Metrics Methods

def save_risk_metrics(
    self,
    date: str,
    portfolio_id: str,
    data: dict,
) -> Path:
    """Save risk computation results.

    Path: {base_dir}/daily/{date}/portfolio/{portfolio_id}_risk_metrics.json
    """

def load_risk_metrics(self, date: str, portfolio_id: str) -> dict | None:
    """Load risk metrics. Returns None if file doesn't exist."""

PM Decision Methods

def save_pm_decision(
    self,
    date: str,
    portfolio_id: str,
    data: dict,
    markdown: str | None = None,
) -> Path:
    """Save PM agent decision.

    JSON path: {base_dir}/daily/{date}/portfolio/{portfolio_id}_pm_decision.json
    MD path:   {base_dir}/daily/{date}/portfolio/{portfolio_id}_pm_decision.md
               (written only when markdown is not None)

    Returns JSON path.
    """

def load_pm_decision(self, date: str, portfolio_id: str) -> dict | None:
    """Load PM decision JSON. Returns None if file doesn't exist."""

def list_pm_decisions(self, portfolio_id: str) -> list[Path]:
    """Return all saved PM decision JSON paths for portfolio_id, newest first.

    Scans {base_dir}/daily/*/portfolio/{portfolio_id}_pm_decision.json
    """

PortfolioRepository

Location: tradingagents/portfolio/repository.py

Unified façade that combines SupabaseClient and ReportStore. This is the primary interface for all portfolio operations — callers should not interact with SupabaseClient or ReportStore directly.

Constructor

repo = PortfolioRepository(
    client: SupabaseClient | None = None,   # uses singleton if None
    store: ReportStore | None = None,       # uses default if None
    config: dict | None = None,             # uses get_portfolio_config() if None
)

Portfolio Lifecycle

def create_portfolio(
    self,
    name: str,
    initial_cash: float,
    currency: str = "USD",
) -> Portfolio:
    """Create a new portfolio with the given starting capital.

    Generates a UUID for portfolio_id. Sets cash = initial_cash.

    Raises:
        DuplicatePortfolioError: If name is already in use.
        ValueError: If initial_cash <= 0.
    """

def get_portfolio(self, portfolio_id: str) -> Portfolio:
    """Fetch portfolio by ID.

    Raises:
        PortfolioNotFoundError: If not found.
    """

def get_portfolio_with_holdings(
    self,
    portfolio_id: str,
    prices: dict[str, float] | None = None,
) -> tuple[Portfolio, list[Holding]]:
    """Fetch portfolio + all holdings, optionally enriched with current prices.

    Args:
        portfolio_id: Target portfolio.
        prices: Optional dict of {ticker: current_price}. When provided,
                holdings are enriched and portfolio.total_value is computed.

    Returns:
        (Portfolio, list[Holding]) — Portfolio.enrich() called if prices given.
    """

Holdings Management

def add_holding(
    self,
    portfolio_id: str,
    ticker: str,
    shares: float,
    price: float,
    sector: str | None = None,
    industry: str | None = None,
) -> Holding:
    """Buy shares and update portfolio cash and holdings.

    Business logic:
    - Raises InsufficientCashError if portfolio.cash < shares * price
    - If holding already exists: updates avg_cost = weighted average
    - portfolio.cash -= shares * price
    - Records a BUY trade automatically

    Returns the updated/created Holding.
    """

def remove_holding(
    self,
    portfolio_id: str,
    ticker: str,
    shares: float,
    price: float,
) -> Holding | None:
    """Sell shares and update portfolio cash and holdings.

    Business logic:
    - Raises HoldingNotFoundError if no holding exists for ticker
    - Raises InsufficientSharesError if holding.shares < shares
    - If shares == holding.shares: deletes the holding row, returns None
    - Otherwise: decrements holding.shares (avg_cost unchanged on sell)
    - portfolio.cash += shares * price
    - Records a SELL trade automatically

    Returns the updated Holding (or None if fully sold).
    """

Snapshot Management

def take_snapshot(self, portfolio_id: str, prices: dict[str, float]) -> PortfolioSnapshot:
    """Take an immutable snapshot of the current portfolio state.

    Enriches all holdings with current prices, computes total_value,
    then persists to Supabase via SupabaseClient.save_snapshot().

    Returns the saved PortfolioSnapshot.
    """

Report Convenience Methods

def save_pm_decision(
    self,
    portfolio_id: str,
    date: str,
    decision: dict,
    markdown: str | None = None,
) -> Path:
    """Delegate to ReportStore.save_pm_decision and update portfolio.report_path."""

def load_pm_decision(self, portfolio_id: str, date: str) -> dict | None:
    """Delegate to ReportStore.load_pm_decision."""

def save_risk_metrics(
    self,
    portfolio_id: str,
    date: str,
    metrics: dict,
) -> Path:
    """Delegate to ReportStore.save_risk_metrics."""

def load_risk_metrics(self, portfolio_id: str, date: str) -> dict | None:
    """Delegate to ReportStore.load_risk_metrics."""

Avg Cost Basis Calculation

When buying more shares of an existing holding, the average cost basis is updated using the weighted average formula:

new_avg_cost = (old_shares * old_avg_cost + new_shares * new_price)
               / (old_shares + new_shares)

When selling shares, the average cost basis is not changed — only shares is decremented. This follows the FIFO approximation used by most brokerages for tax-reporting purposes.


Cash Management Rules

Operation Effect on portfolio.cash
BUY n shares at p cash -= n * p
SELL n shares at p cash += n * p
Snapshot Read-only
Portfolio creation cash = initial_cash

Cash can never go below 0 after a trade. add_holding raises InsufficientCashError if the trade would exceed available cash.


Example Usage

from tradingagents.portfolio import PortfolioRepository

repo = PortfolioRepository()

# Create a portfolio
portfolio = repo.create_portfolio("Main Portfolio", initial_cash=100_000.0)

# Buy some shares
holding = repo.add_holding(
    portfolio.portfolio_id,
    ticker="AAPL",
    shares=50,
    price=195.50,
    sector="Technology",
)
# portfolio.cash is now 100_000 - 50 * 195.50 = 90_225.00
# holding.avg_cost = 195.50

# Buy more (avg cost update)
holding = repo.add_holding(
    portfolio.portfolio_id,
    ticker="AAPL",
    shares=25,
    price=200.00,
)
# holding.avg_cost = (50*195.50 + 25*200.00) / 75 = 197.00

# Sell half
holding = repo.remove_holding(
    portfolio.portfolio_id,
    ticker="AAPL",
    shares=37,
    price=205.00,
)
# portfolio.cash += 37 * 205.00 = 7_585.00

# Take snapshot
prices = {"AAPL": 205.00}
snapshot = repo.take_snapshot(portfolio.portfolio_id, prices)

# Save PM decision
repo.save_pm_decision(
    portfolio.portfolio_id,
    date="2026-03-20",
    decision={"sells": [], "buys": [...], "rationale": "..."},
)