TradingAgents/docs/portfolio/00_overview.md

432 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Portfolio Manager Agent — Design Overview
<!-- Last verified: 2026-03-21 -->
## Feature Description
The Portfolio Manager Agent (PMA) is an autonomous agent that manages a simulated
investment portfolio end-to-end. It performs the following actions in sequence:
1. **Initiates market research** — triggers the existing `ScannerGraph` to produce a
macro watchlist of top candidate tickers.
2. **Initiates per-ticker analysis** — feeds scan results into the existing
`MacroBridge` / `TradingAgentsGraph` pipeline for high-conviction candidates.
3. **Loads current holdings** — queries the Supabase database for the active portfolio
state (positions, cash balance, sector weights).
4. **Requests lightweight holding reviews** — for each existing holding, runs a
quick `HoldingReviewerAgent` (quick_think) that checks price action and recent
news — no full bull/bear debate needed.
5. **Computes portfolio-level risk metrics** — pure Python, no LLM:
Sharpe ratio, Sortino ratio, beta, 95 % VaR, max drawdown, sector concentration,
correlation matrix, and what-if buy/sell scenarios.
6. **Makes allocation decisions** — the Portfolio Manager Agent (deep_think +
memory) reads all inputs and outputs a structured JSON with sells, buys, holds,
target cash %, and detailed rationale.
7. **Executes mock trades** — validates decisions against constraints, records trades
in Supabase, updates holdings, and takes an immutable snapshot.
---
## Architecture Decision: Supabase (PostgreSQL) + Filesystem
```
┌─────────────────────────────────────────────────┐
│ Supabase (PostgreSQL) │
│ │
│ portfolios holdings trades snapshots │
│ │
│ "What do I own right now?" │
│ "What trades did I make?" │
│ "What was my portfolio value on date X?" │
└────────────────────┬────────────────────────────┘
│ report_path column
┌─────────────────────────────────────────────────┐
│ Filesystem (reports/) │
│ │
│ reports/daily/{date}/ │
│ market/ ← scan output │
│ {TICKER}/ ← per-ticker analysis │
│ portfolio/ │
│ holdings_review.json │
│ risk_metrics.json │
│ pm_decision.json │
│ pm_decision.md (human-readable) │
│ │
│ "Why did I decide this?" │
│ "What was the macro context?" │
│ "What did the risk model say?" │
└─────────────────────────────────────────────────┘
```
**Rationale:**
| Concern | Storage | Why |
|---------|---------|-----|
| Transactional integrity (trades) | Supabase | ACID, foreign keys, row-level security |
| Fast portfolio queries (weights, cash) | Supabase | SQL aggregations |
| LLM reports (large text, markdown) | Filesystem | Avoids bloating the DB |
| Agent memory / rationale | Filesystem | Easy to inspect and version |
| Audit trail of decisions | Filesystem | Markdown readable by humans |
The `report_path` column in the `portfolios` table points to the daily portfolio
subdirectory on disk: `reports/daily/{date}/portfolio/`.
### Data Access Layer: raw `psycopg2` (no ORM)
The Python code talks to Supabase PostgreSQL directly via `psycopg2` using the
**pooler connection string** (`SUPABASE_CONNECTION_STRING`). No ORM (Prisma,
SQLAlchemy) and no `supabase-py` REST client is used.
**Why `psycopg2` over `supabase-py`?**
- Direct SQL gives full control — transactions, upserts, `RETURNING *`, CTEs.
- No dependency on Supabase's PostgREST schema cache or API key types.
- `psycopg2-binary` is a single pip install with zero non-Python dependencies.
- 4 tables with straightforward CRUD don't benefit from an ORM or REST wrapper.
**Connection:**
- Uses `SUPABASE_CONNECTION_STRING` env var (pooler URI format).
- Passwords with special characters are auto-URL-encoded by `SupabaseClient._fix_dsn()`.
- Typical pooler URI: `postgresql://postgres.<ref>:<password>@aws-1-<region>.pooler.supabase.com:6543/postgres`
**Why not Prisma / SQLAlchemy?**
- Prisma requires Node.js runtime — extra non-Python dependency.
- SQLAlchemy adds dependency overhead for 4 simple tables.
- Plain SQL migration files are readable, versionable, and Supabase-native.
> Full rationale: `docs/agent/decisions/012-portfolio-no-orm.md`
---
## Implemented Workflow (6-Node Sequential Graph)
The portfolio manager runs as a **sequential LangGraph workflow** inside
`PortfolioGraph`. The scanner and price fetching happen **before** the graph
is invoked (handled by the CLI or calling code). The graph itself processes
6 nodes in strict sequence:
```
┌──────────────────────────────────┐
│ PRE-GRAPH (CLI / caller) │
│ │
│ • ScannerGraph.scan(date) │
│ → scan_summary JSON │
│ • yfinance price fetch │
│ → prices dict {ticker: float} │
└──────────────┬───────────────────┘
┌──────────────▼───────────────────┐
│ PortfolioGraph.run( │
│ portfolio_id, date, │
│ prices, scan_summary) │
└──────────────┬───────────────────┘
┌────────────────────────────────────▼───────────────────────────────────────┐
│ NODE 1: load_portfolio (Python, no LLM) │
│ │
│ • Queries Supabase for portfolio + holdings via PortfolioRepository │
│ • Enriches holdings with current prices and computes weights │
│ → portfolio_data (JSON string with portfolio + holdings dicts) │
└───────────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ NODE 2: compute_risk (Python, no LLM) │
│ │
│ • Computes portfolio risk metrics from enriched holdings │
│ • Sharpe ratio (annualised, rf = 0) │
│ • Sortino ratio (downside deviation) │
│ • Portfolio beta (vs SPY benchmark) │
│ • 95 % VaR (historical simulation, 30-day window) │
│ • Max drawdown (peak-to-trough, 90-day window) │
│ • Sector concentration (weight per GICS sector) │
│ → risk_metrics (JSON string) │
└───────────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ NODE 3: review_holdings (LLM — mid_think) │
│ │
│ HoldingReviewerAgent (create_holding_reviewer) │
│ • Tools: get_stock_data, get_news │
│ • Uses run_tool_loop() for inline tool execution │
│ • Reviews each open position → HOLD or SELL recommendation │
│ → holding_reviews (JSON string — ticker → review mapping) │
└───────────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ NODE 4: prioritize_candidates (Python, no LLM) │
│ │
│ • Scores scan_summary.stocks_to_investigate using: │
│ conviction × thesis × diversification × held_penalty │
│ • Ranks candidates by composite score │
│ → prioritized_candidates (JSON string — sorted list) │
└───────────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ NODE 5: pm_decision (LLM — deep_think) │
│ │
│ PM Decision Agent (create_pm_decision_agent) │
│ • Pure reasoning — no tools │
│ • Reads: portfolio_data, risk_metrics, holding_reviews, │
│ prioritized_candidates, analysis_date │
│ • Outputs structured JSON: │
│ { │
│ "sells": [{"ticker": "X", "shares": 10, "rationale": "..."}], │
│ "buys": [{"ticker": "Y", "shares": 5, "rationale": "..."}], │
│ "holds": [{"ticker": "Z", "rationale": "..."}], │
│ "cash_reserve_pct": 0.10, │
│ "portfolio_thesis": "...", │
│ "risk_summary": "..." │
│ } │
└───────────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ NODE 6: execute_trades (Python, no LLM) │
│ │
│ TradeExecutor │
│ • SELLs first (frees cash), then BUYs │
│ • Constraint pre-flight (position size, sector, cash) │
│ • Records trades in Supabase, updates holdings & cash │
│ • Takes immutable EOD portfolio snapshot │
│ → execution_result (JSON string with executed/failed trades) │
└────────────────────────────────────────────────────────────────────────────┘
```
### State Definition
The graph state is defined in `tradingagents/portfolio/portfolio_states.py`
as `PortfolioManagerState(MessagesState)`:
| Field | Type | Written By |
|-------|------|------------|
| `portfolio_id` | `str` | Caller (initial state) |
| `analysis_date` | `str` | Caller (initial state) |
| `prices` | `dict` | Caller (initial state) |
| `scan_summary` | `dict` | Caller (initial state) |
| `portfolio_data` | `Annotated[str, _last_value]` | Node 1 |
| `risk_metrics` | `Annotated[str, _last_value]` | Node 2 |
| `holding_reviews` | `Annotated[str, _last_value]` | Node 3 |
| `prioritized_candidates` | `Annotated[str, _last_value]` | Node 4 |
| `pm_decision` | `Annotated[str, _last_value]` | Node 5 |
| `execution_result` | `Annotated[str, _last_value]` | Node 6 |
| `sender` | `Annotated[str, _last_value]` | All nodes |
### Comparison: Original Design vs Implementation
| Aspect | Original Design (docs) | Current Implementation |
|--------|----------------------|------------------------|
| Phases 1a/1b | Parallel (scanner + load holdings) | Sequential — scanner runs in pre-graph step |
| Phases 2a/2b | Parallel (MacroBridge + HoldingReviewer) | Sequential — review_holdings then prioritize |
| Candidate analysis | MacroBridge per-ticker full pipeline | Pure Python scoring (no per-ticker LLM analysis) |
| Holding reviewer tier | `quick_think` | `mid_think` |
| Phase 3 risk metrics | Includes correlation matrix, what-if | Sharpe, Sortino, VaR, beta, drawdown, sector |
| Graph topology | Mixed parallel + sequential | Fully sequential (6 nodes) |
---
## Agent Specifications (as implemented)
### Portfolio Manager Decision Agent (`pm_decision_agent.py`)
| Property | Value |
|----------|-------|
| LLM tier | `deep_think` (default: `gpt-5.2`) |
| Pattern | `create_pm_decision_agent(llm)` → closure |
| Memory | Via context injection (portfolio_data, prior decisions) |
| Output format | Structured JSON (validated before trade execution) |
| Tools | None — pure reasoning agent |
| Invocation | Node 5 in the sequential graph, once per run |
**Prompt inputs (injected via state):**
- `portfolio_data` — Current holdings + portfolio state (JSON from Node 1)
- `risk_metrics` — Sharpe, Sortino, VaR, beta, drawdown, sector data (JSON from Node 2)
- `holding_reviews` — Per-ticker HOLD/SELL recommendations (JSON from Node 3)
- `prioritized_candidates` — Ranked candidate list with scores (JSON from Node 4)
- `analysis_date` — Date string for context
**Output schema:**
```json
{
"sells": [{"ticker": "X", "shares": 10.0, "rationale": "..."}],
"buys": [{"ticker": "Y", "shares": 5.0, "price_target": 200.0,
"sector": "Technology", "rationale": "...", "thesis": "..."}],
"holds": [{"ticker": "Z", "rationale": "..."}],
"cash_reserve_pct": 0.10,
"portfolio_thesis": "...",
"risk_summary": "..."
}
```
### Holding Reviewer Agent (`holding_reviewer.py`)
| Property | Value |
|----------|-------|
| LLM tier | `mid_think` (default: falls back to `gpt-5-mini`) |
| Pattern | `create_holding_reviewer(llm)` → closure |
| Memory | Disabled |
| Output format | Structured JSON |
| Tools | `get_stock_data`, `get_news` |
| Tool execution | `run_tool_loop()` inline (up to 5 rounds) |
| Invocation | Node 3 in the sequential graph, once per run |
**Output schema per holding:**
```json
{
"AAPL": {
"ticker": "AAPL",
"recommendation": "HOLD",
"confidence": "high",
"rationale": "Strong momentum, no negative news.",
"key_risks": ["Sector concentration", "Valuation stretch"]
}
}
```
---
## PM Agent Constraints
These constraints are **hard limits** enforced during Phase 5 (trade execution).
The PM Agent is also instructed to respect them in its prompt.
| Constraint | Value |
|------------|-------|
| Max position size | 15 % of portfolio value |
| Max sector exposure | 35 % of portfolio value |
| Min cash reserve | 5 % of portfolio value |
| Max number of positions | 15 |
---
## PM Risk Management Rules
These rules trigger specific actions and are part of the PM Agent's system prompt:
| Trigger | Action |
|---------|--------|
| Portfolio beta > 1.3 | Reduce cyclical / high-beta positions |
| Sector exposure > 35 % | Diversify — sell smallest position in that sector |
| Sharpe ratio < 0.5 | Raise cash reduce overall exposure |
| Max drawdown > 15 % | Go defensive — reduce equity allocation |
| Daily 95 % VaR > 3 % | Reduce position sizes to lower tail risk |
---
## Implementation Roadmap (Status)
| Phase | Deliverable | Status | Source |
|-------|-------------|--------|--------|
| 1 | Data foundation — models, DB, filesystem, repository | ✅ Done (PR #32) | `tradingagents/portfolio/models.py`, `repository.py`, etc. |
| 2 | Holding Reviewer Agent | ✅ Done | `tradingagents/agents/portfolio/holding_reviewer.py` |
| 3 | Risk metrics engine | ✅ Done | `tradingagents/portfolio/risk_evaluator.py`, `risk_metrics.py` |
| 4 | Portfolio Manager Decision Agent (LLM, structured output) | ✅ Done | `tradingagents/agents/portfolio/pm_decision_agent.py` |
| 5 | Trade execution engine | ✅ Done | `tradingagents/portfolio/trade_executor.py` |
| 6 | Full orchestration graph (LangGraph) | ✅ Done | `tradingagents/graph/portfolio_graph.py`, `portfolio_setup.py` |
| 7 | CLI commands (`portfolio`, `check-portfolio`, `auto`) | ✅ Done | `cli/main.py` |
| 8 | Candidate prioritizer | ✅ Done | `tradingagents/portfolio/candidate_prioritizer.py` |
| 9 | Portfolio state for LangGraph | ✅ Done | `tradingagents/portfolio/portfolio_states.py` |
| 10 | Tests (models, report_store, risk, trade, candidates) | ✅ Done | `tests/portfolio/` (588 tests total, 14 skipped) |
---
## Token Estimation per Model
Estimated token usage per model tier across all three workflows. Numbers
assume default models (`quick_think` = gpt-5-mini, `deep_think` = gpt-5.2,
`mid_think` falls back to gpt-5-mini when not configured).
### Trading Workflow (`TradingAgentsGraph.propagate`)
| Agent | LLM Tier | Tools | LLM Calls | Est. Input Tokens | Est. Output Tokens |
|-------|----------|-------|-----------|-------------------|-------------------|
| Market Analyst | quick_think | get_stock_data, get_indicators, get_macro_regime | 35 | ~3,0005,000 | ~1,5003,000 |
| Social Media Analyst | quick_think | get_news | 12 | ~2,0003,000 | ~1,0002,000 |
| News Analyst | quick_think | get_news, get_global_news | 23 | ~2,0004,000 | ~1,0002,500 |
| Fundamentals Analyst | quick_think | get_ttm_analysis, get_fundamentals, etc. | 46 | ~4,0008,000 | ~2,0004,000 |
| Bull Researcher | mid_think | — | 12 per round | ~4,0008,000 | ~1,5003,000 |
| Bear Researcher | mid_think | — | 12 per round | ~4,0008,000 | ~1,5003,000 |
| Research Manager (Judge) | deep_think | — | 1 | ~6,00012,000 | ~2,0004,000 |
| Trader | mid_think | — | 1 | ~3,0005,000 | ~1,0002,000 |
| Aggressive Risk Analyst | quick_think | — | 12 per round | ~3,0006,000 | ~1,0002,000 |
| Neutral Risk Analyst | quick_think | — | 12 per round | ~3,0006,000 | ~1,0002,000 |
| Conservative Risk Analyst | quick_think | — | 12 per round | ~3,0006,000 | ~1,0002,000 |
| Risk Judge | deep_think | — | 1 | ~6,00012,000 | ~2,0004,000 |
**Trading workflow totals** (with `max_debate_rounds=2`):
- **LLM calls**: ~1927
- **Tool calls**: ~1525
- **quick_think tokens**: ~35,00055,000 input, ~12,00020,000 output
- **deep_think tokens**: ~12,00024,000 input, ~4,0008,000 output
### Scanner Workflow (`ScannerGraph.scan`)
| Agent | LLM Tier | Tools | LLM Calls | Est. Input Tokens | Est. Output Tokens |
|-------|----------|-------|-----------|-------------------|-------------------|
| Geopolitical Scanner | quick_think | get_topic_news | 23 | ~2,0004,000 | ~1,0002,500 |
| Market Movers Scanner | quick_think | get_market_movers, get_market_indices | 23 | ~2,0004,000 | ~1,0002,500 |
| Sector Scanner | quick_think | get_sector_performance | 12 | ~1,5003,000 | ~8002,000 |
| Industry Deep Dive | mid_think | get_industry_performance, get_topic_news | 57 | ~6,00010,000 | ~3,0005,000 |
| Macro Synthesis | deep_think | — | 1 | ~8,00015,000 | ~3,0005,000 |
**Scanner workflow totals**:
- **LLM calls**: ~913
- **Tool calls**: ~1116
- **quick_think tokens**: ~5,50011,000 input, ~2,8007,000 output
- **deep_think tokens**: ~8,00015,000 input, ~3,0005,000 output
### Portfolio Workflow (`PortfolioGraph.run`)
| Node | LLM Tier | Tools | LLM Calls | Est. Input Tokens | Est. Output Tokens |
|------|----------|-------|-----------|-------------------|-------------------|
| load_portfolio | — | — | 0 | 0 | 0 |
| compute_risk | — | — | 0 | 0 | 0 |
| review_holdings | mid_think | get_stock_data, get_news | 1 call reviews all holdings (up to 5 tool rounds) | ~3,0006,000 | ~1,5003,000 |
| prioritize_candidates | — | — | 0 | 0 | 0 |
| pm_decision | deep_think | — | 1 | ~6,00012,000 | ~2,0004,000 |
| execute_trades | — | — | 0 | 0 | 0 |
**Portfolio workflow totals** (assuming 5 holdings):
- **LLM calls**: 2 (review + decision)
- **Tool calls**: ~10 (2 per holding)
- **mid_think tokens**: ~3,0006,000 input, ~1,5003,000 output
- **deep_think tokens**: ~6,00012,000 input, ~2,0004,000 output
### Full Auto Mode (`scan → pipeline → portfolio`)
| Workflow | quick_think | deep_think |
|----------|-------------|------------|
| Scanner | ~5K11K in / ~3K7K out | ~8K15K in / ~3K5K out |
| Trading (× 3 tickers — aggregate) | ~105K165K in / ~36K60K out | ~36K72K in / ~12K24K out |
| Portfolio | — | ~6K12K in / ~2K4K out |
| **Totals** | **~110K176K in / ~39K67K out** | **~50K99K in / ~17K33K out** |
> **Note:** `mid_think` defaults to `quick_think_llm` when not configured,
> so mid_think token counts are included under quick_think totals above.
> Actual token usage varies with portfolio size, number of candidates, and
> debate rounds.
---
## References
- `tradingagents/graph/portfolio_graph.py` — PortfolioGraph orchestrator
- `tradingagents/graph/portfolio_setup.py` — 6-node sequential workflow setup
- `tradingagents/portfolio/portfolio_states.py` — PortfolioManagerState definition
- `tradingagents/agents/portfolio/holding_reviewer.py` — Holding reviewer LLM agent
- `tradingagents/agents/portfolio/pm_decision_agent.py` — PM decision LLM agent
- `tradingagents/portfolio/risk_evaluator.py` — Pure-Python risk metrics
- `tradingagents/portfolio/candidate_prioritizer.py` — Candidate scoring/ranking
- `tradingagents/portfolio/trade_executor.py` — Trade execution with constraint checks
- `tradingagents/portfolio/models.py` — Portfolio, Holding, Trade, PortfolioSnapshot
- `tradingagents/portfolio/repository.py` — Unified data-access façade
- `tradingagents/portfolio/report_store.py` — Filesystem document storage
- `tradingagents/pipeline/macro_bridge.py` — Existing scan → per-ticker bridge
- `tradingagents/report_paths.py` — Filesystem path conventions
- `tradingagents/default_config.py` — Config pattern and LLM tier defaults
- `tradingagents/graph/scanner_graph.py` — Scanner pipeline (runs before portfolio)
- `cli/main.py` — CLI commands: `portfolio`, `check-portfolio`, `auto`