25 KiB
Portfolio Manager Agent — Design Overview
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:
- Initiates market research — triggers the existing
ScannerGraphto produce a macro watchlist of top candidate tickers. - Initiates per-ticker analysis — feeds scan results into the existing
MacroBridge/TradingAgentsGraphpipeline for high-conviction candidates. - Loads current holdings — queries the Supabase database for the active portfolio state (positions, cash balance, sector weights).
- 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. - 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.
- 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.
- 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-binaryis 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_STRINGenv 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:
{
"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:
{
"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 | 3–5 | ~3,000–5,000 | ~1,500–3,000 |
| Social Media Analyst | quick_think | get_news | 1–2 | ~2,000–3,000 | ~1,000–2,000 |
| News Analyst | quick_think | get_news, get_global_news | 2–3 | ~2,000–4,000 | ~1,000–2,500 |
| Fundamentals Analyst | quick_think | get_ttm_analysis, get_fundamentals, etc. | 4–6 | ~4,000–8,000 | ~2,000–4,000 |
| Bull Researcher | mid_think | — | 1–2 per round | ~4,000–8,000 | ~1,500–3,000 |
| Bear Researcher | mid_think | — | 1–2 per round | ~4,000–8,000 | ~1,500–3,000 |
| Research Manager (Judge) | deep_think | — | 1 | ~6,000–12,000 | ~2,000–4,000 |
| Trader | mid_think | — | 1 | ~3,000–5,000 | ~1,000–2,000 |
| Aggressive Risk Analyst | quick_think | — | 1–2 per round | ~3,000–6,000 | ~1,000–2,000 |
| Neutral Risk Analyst | quick_think | — | 1–2 per round | ~3,000–6,000 | ~1,000–2,000 |
| Conservative Risk Analyst | quick_think | — | 1–2 per round | ~3,000–6,000 | ~1,000–2,000 |
| Risk Judge | deep_think | — | 1 | ~6,000–12,000 | ~2,000–4,000 |
Trading workflow totals (with max_debate_rounds=2):
- LLM calls: ~19–27
- Tool calls: ~15–25
- quick_think tokens: ~35,000–55,000 input, ~12,000–20,000 output
- deep_think tokens: ~12,000–24,000 input, ~4,000–8,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 | 2–3 | ~2,000–4,000 | ~1,000–2,500 |
| Market Movers Scanner | quick_think | get_market_movers, get_market_indices | 2–3 | ~2,000–4,000 | ~1,000–2,500 |
| Sector Scanner | quick_think | get_sector_performance | 1–2 | ~1,500–3,000 | ~800–2,000 |
| Industry Deep Dive | mid_think | get_industry_performance, get_topic_news | 5–7 | ~6,000–10,000 | ~3,000–5,000 |
| Macro Synthesis | deep_think | — | 1 | ~8,000–15,000 | ~3,000–5,000 |
Scanner workflow totals:
- LLM calls: ~9–13
- Tool calls: ~11–16
- quick_think tokens: ~5,500–11,000 input, ~2,800–7,000 output
- deep_think tokens: ~8,000–15,000 input, ~3,000–5,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,000–6,000 | ~1,500–3,000 |
| prioritize_candidates | — | — | 0 | 0 | 0 |
| pm_decision | deep_think | — | 1 | ~6,000–12,000 | ~2,000–4,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,000–6,000 input, ~1,500–3,000 output
- deep_think tokens: ~6,000–12,000 input, ~2,000–4,000 output
Full Auto Mode (scan → pipeline → portfolio)
| Workflow | quick_think | deep_think |
|---|---|---|
| Scanner | ~5K–11K in / ~3K–7K out | ~8K–15K in / ~3K–5K out |
| Trading (× 3 tickers — aggregate) | ~105K–165K in / ~36K–60K out | ~36K–72K in / ~12K–24K out |
| Portfolio | — | ~6K–12K in / ~2K–4K out |
| Totals | ~110K–176K in / ~39K–67K out | ~50K–99K in / ~17K–33K out |
Note:
mid_thinkdefaults toquick_think_llmwhen 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 orchestratortradingagents/graph/portfolio_setup.py— 6-node sequential workflow setuptradingagents/portfolio/portfolio_states.py— PortfolioManagerState definitiontradingagents/agents/portfolio/holding_reviewer.py— Holding reviewer LLM agenttradingagents/agents/portfolio/pm_decision_agent.py— PM decision LLM agenttradingagents/portfolio/risk_evaluator.py— Pure-Python risk metricstradingagents/portfolio/candidate_prioritizer.py— Candidate scoring/rankingtradingagents/portfolio/trade_executor.py— Trade execution with constraint checkstradingagents/portfolio/models.py— Portfolio, Holding, Trade, PortfolioSnapshottradingagents/portfolio/repository.py— Unified data-access façadetradingagents/portfolio/report_store.py— Filesystem document storagetradingagents/pipeline/macro_bridge.py— Existing scan → per-ticker bridgetradingagents/report_paths.py— Filesystem path conventionstradingagents/default_config.py— Config pattern and LLM tier defaultstradingagents/graph/scanner_graph.py— Scanner pipeline (runs before portfolio)cli/main.py— CLI commands:portfolio,check-portfolio,auto