Merge pull request #32 from aguzererler/copilot/create-data-foundation-layer

feat: implement Portfolio models, ReportStore, tests; fix SQL constraint & document float decision
This commit is contained in:
ahmet guzererler 2026-03-20 14:07:15 +01:00 committed by GitHub
commit 3e9322ae4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4357 additions and 7 deletions

View File

@ -1,20 +1,21 @@
# Current Milestone
Daily digest consolidation and Google NotebookLM sync shipped (PR open: `feat/daily-digest-notebooklm`). All analyses now append to a single `daily_digest.md` per day and auto-upload to NotebookLM via `nlm` CLI. Next: PR review and merge.
Portfolio Manager Phase 1 (data foundation) complete and merged. All 4 Supabase tables live, 51 tests passing (including integration tests against live DB).
# Recent Progress
- **PR #32 merged**: Portfolio Manager data foundation — models, SQL schema, module scaffolding
- `tradingagents/portfolio/` — full module: models, config, exceptions, supabase_client (psycopg2), report_store, repository
- `migrations/001_initial_schema.sql` — 4 tables (portfolios, holdings, trades, snapshots) with constraints, indexes, triggers
- `tests/portfolio/` — 51 tests: 20 model, 15 report_store, 12 repository unit, 4 integration
- Uses `psycopg2` direct PostgreSQL via Supabase pooler (`aws-1-eu-west-1.pooler.supabase.com:6543`)
- Business logic: avg cost basis, cash accounting, trade recording, snapshots
- **PR #22 merged**: Unified report paths, structured observability logging, memory system update
- **feat/daily-digest-notebooklm** (shipped): Daily digest consolidation + NotebookLM source sync
- `tradingagents/daily_digest.py``append_to_digest()` appends timestamped entries to `reports/daily/{date}/daily_digest.md`
- `tradingagents/notebook_sync.py``sync_to_notebooklm()` deletes existing "Daily Trading Digest" source then uploads new content via `nlm source add --text --wait`.
- `tradingagents/report_paths.py` — added `get_digest_path(date)`
- `cli/main.py``analyze` and `scan` commands both call digest + sync after each run
- `.env.example` — fixed consistency, removed duplicates, aligned with `NOTEBOOKLM_ID`
- **Verification**: 220+ offline tests passing + 5 new unit tests for `notebook_sync.py` + live integration test passed.
# In Progress
- Portfolio Manager Phase 2: Holding Reviewer Agent (next)
- Refinement of macro scan synthesis prompts (ongoing)
# Active Blockers

View File

@ -0,0 +1,92 @@
---
type: decision
status: active
date: 2026-03-20
agent_author: "claude"
tags: [portfolio, database, supabase, orm, prisma]
related_files:
- tradingagents/portfolio/supabase_client.py
- tradingagents/portfolio/repository.py
- tradingagents/portfolio/migrations/001_initial_schema.sql
---
## Context
When designing the Portfolio Manager data layer (Phase 1), the question arose:
should we use an ORM (specifically **Prisma**) or keep the raw `supabase-py`
client that the scaffolding already plans to use?
The options considered were:
| Option | Description |
|--------|-------------|
| **Raw `supabase-py`** (chosen) | Direct Supabase PostgREST client, builder-pattern API |
| **Prisma Python** (`prisma-client-py`) | Code-generated type-safe ORM backed by Node.js |
| **SQLAlchemy** | Full ORM with Core + ORM layers, Alembic migrations |
## The Decision
**Use raw `supabase-py` without an ORM for the portfolio data layer.**
The data access layer (`supabase_client.py`) wraps the Supabase client directly.
Our own `Portfolio`, `Holding`, `Trade`, and `PortfolioSnapshot` dataclasses
provide the type-safety layer; serialisation is handled by `to_dict()` /
`from_dict()` on each model.
## Why Not Prisma
1. **Node.js runtime dependency**`prisma-client-py` uses Prisma's Node.js
engine at code-generation time. This adds a non-Python runtime requirement
to a Python-only project.
2. **Conflicts with Supabase's migration tooling** — the project already uses
Supabase's SQL migration files (`migrations/001_initial_schema.sql`) and the
Supabase dashboard for schema changes. Prisma's `prisma migrate` maintains
its own shadow database and migration state, creating two competing systems.
3. **Code generation build step** — every schema change requires running
`prisma generate` before the Python code works. This complicates CI, local
setup, and agent-driven development.
4. **Overkill for 4 tables** — the portfolio schema has exactly 4 tables with
straightforward CRUD. Prisma's relationship traversal and complex query
features offer no benefit here.
## Why Not SQLAlchemy
1. **Not using a local database** — the database is managed by Supabase (hosted
PostgreSQL). SQLAlchemy's connection-pooling and engine management are
designed for direct database connections, which bypass Supabase's PostgREST
API and Row Level Security.
2. **Extra dependency** — SQLAlchemy + Alembic would be significant new
dependencies for a non-DB-heavy app.
## Why Raw `supabase-py` Is Sufficient
- `supabase-py` provides a clean builder-pattern API:
`client.table("holdings").select("*").eq("portfolio_id", id).execute()`
- Our dataclasses already provide compile-time type safety and lossless
serialisation; the client only handles transport.
- Migrations are plain SQL files — readable, versionable, Supabase-native.
- `SupabaseClient` is a thin singleton wrapper that translates HTTP errors into
domain exceptions — this gives us the ORM-like error-handling benefit without
the complexity.
## Constraints
- **Do not** add an ORM dependency (`prisma-client-py`, `sqlalchemy`, `tortoise-orm`)
to `pyproject.toml` without revisiting this decision.
- **Do not** bypass `SupabaseClient` by importing `supabase` directly in other
modules — always go through `PortfolioRepository`.
- If the schema grows beyond ~10 tables or requires complex multi-table joins,
revisit this decision and consider SQLAlchemy Core (not the ORM layer) with
direct `asyncpg` connections.
## Actionable Rules
- All DB access goes through `PortfolioRepository``SupabaseClient`.
- Migrations are `.sql` files in `tradingagents/portfolio/migrations/`, run via
the Supabase SQL Editor or `supabase db push`.
- Type safety comes from dataclass `to_dict()` / `from_dict()` — not from a
code-generated ORM schema.

View File

@ -0,0 +1,269 @@
# Portfolio Manager Agent — Design Overview
<!-- Last verified: 2026-03-20 -->
## 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`
---
## 5-Phase Workflow
```
┌────────────────────────────────────────────────────────────────────────────┐
│ PHASE 1 (parallel) │
│ │
│ 1a. ScannerGraph.scan(date) 1b. Load Holdings + Fetch Prices │
│ → macro_scan_summary.json → List[Holding] with │
│ watchlist of top candidates current_price, current_value │
└───────────────────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ PHASE 2 (parallel) │
│ │
│ 2a. New Candidate Analysis 2b. Holding Re-evaluation │
│ MacroBridge.run_all_tickers() HoldingReviewerAgent (quick_think)│
│ Full bull/bear pipeline per 7-day price + 3-day news │
│ HIGH/MEDIUM conviction → JSON: signal/confidence/reason │
│ candidates that are NOT urgency per holding │
│ already held │
└───────────────────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ PHASE 3 (Python, no LLM) │
│ │
│ Risk Metrics Computation │
│ • Sharpe ratio (annualised, rf = 0) │
│ • Sortino ratio (downside deviation) │
│ • Portfolio beta (vs SPY) │
│ • 95 % VaR (historical simulation, 30-day window) │
│ • Max drawdown (peak-to-trough, 90-day window) │
│ • Sector concentration (weight per GICS sector) │
│ • Correlation matrix (all holdings) │
│ • What-if scenarios (buy X, sell Y → new weights) │
└───────────────────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ PHASE 4 Portfolio Manager Agent (deep_think + memory) │
│ │
│ Reads: macro context, holdings, candidate signals, re-eval signals, │
│ risk metrics, budget constraint, past decisions (memory) │
│ │
│ Outputs structured JSON: │
│ { │
│ "sells": [{"ticker": "X", "shares": 10, "reason": "..."}], │
│ "buys": [{"ticker": "Y", "shares": 5, "reason": "..."}], │
│ "holds": ["Z"], │
│ "target_cash_pct": 0.08, │
│ "rationale": "...", │
│ "risk_summary": "..." │
│ } │
└───────────────────────────────────┬───────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ PHASE 5 Trade Execution (Mock) │
│ │
│ • Validate decisions against constraints (position size, sector, cash) │
│ • Record each trade in Supabase (trades table) │
│ • Update holdings (avg cost basis, shares) │
│ • Deduct / credit cash balance │
│ • Take immutable portfolio snapshot │
│ • Save PM decision + risk report to filesystem │
└────────────────────────────────────────────────────────────────────────────┘
```
---
## Agent Specifications
### Portfolio Manager Agent (PMA)
| Property | Value |
|----------|-------|
| LLM tier | `deep_think` |
| Memory | Enabled — reads previous PM decision files from filesystem |
| Output format | Structured JSON (validated before trade execution) |
| Invocation | Once per run, after Phases 13 |
**Prompt inputs:**
- Macro scan summary (top candidates + context)
- Current holdings list (ticker, shares, avg cost, current price, weight, sector)
- Candidate analysis signals (BUY/SELL/HOLD per ticker from Phase 2a)
- Holding review signals (signal, confidence, reason, urgency per holding from Phase 2b)
- Risk metrics report (Phase 3 output)
- Budget constraint (available cash)
- Portfolio constraints (see below)
- Previous decision (last PM decision file for memory continuity)
### Holding Reviewer Agent
| Property | Value |
|----------|-------|
| LLM tier | `quick_think` |
| Memory | Disabled |
| Output format | Structured JSON |
| Tools | `get_stock_data` (7-day window), `get_news` (3-day window), RSI, MACD |
| Invocation | Once per existing holding (parallelisable) |
**Output schema per holding:**
```json
{
"ticker": "AAPL",
"signal": "HOLD",
"confidence": 0.72,
"reason": "Price action neutral; no material news. RSI 52, MACD flat.",
"urgency": "LOW"
}
```
---
## 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 |
---
## 10-Phase Implementation Roadmap
| Phase | Deliverable | Effort |
|-------|-------------|--------|
| 1 | Data foundation (this PR) — models, DB, filesystem, repository | ~23 days |
| 2 | Holding Reviewer Agent | ~1 day |
| 3 | Risk metrics engine (Phase 3 of workflow) | ~12 days |
| 4 | Portfolio Manager Agent (LLM, structured output) | ~2 days |
| 5 | Trade execution engine (Phase 5 of workflow) | ~1 day |
| 6 | Full orchestration graph (LangGraph) tying all phases | ~2 days |
| 7 | CLI command `pm run` | ~0.5 days |
| 8 | End-to-end integration tests | ~1 day |
| 9 | Performance tuning + concurrency (Phase 2 parallelism) | ~1 day |
| 10 | Documentation, memory system update, PR review | ~0.5 days |
**Total estimate: ~1522 days**
---
## References
- `tradingagents/pipeline/macro_bridge.py` — existing scan → per-ticker bridge
- `tradingagents/report_paths.py` — filesystem path conventions
- `tradingagents/default_config.py` — config pattern to follow
- `tradingagents/agents/scanners/` — scanner agent examples
- `tradingagents/graph/scanner_setup.py` — parallel graph node patterns

View File

@ -0,0 +1,351 @@
# Phase 1 Implementation Plan — Data Foundation
<!-- Last verified: 2026-03-20 -->
## Goal
Build the data foundation layer for the Portfolio Manager feature.
After Phase 1 you should be able to:
- Create and retrieve portfolios
- Manage holdings (add, update, remove) with correct avg-cost-basis accounting
- Record mock trades
- Take immutable portfolio snapshots
- Save and load all report types (scans, analysis, holding reviews, risk, PM decisions)
- Pass a 90 %+ test coverage gate on all new modules
---
## File Structure
```
tradingagents/portfolio/
├── __init__.py ← public exports
├── models.py ← Portfolio, Holding, Trade, PortfolioSnapshot dataclasses
├── config.py ← PORTFOLIO_CONFIG dict + helpers
├── exceptions.py ← domain exception hierarchy
├── supabase_client.py ← Supabase CRUD wrapper
├── report_store.py ← Filesystem document storage
├── repository.py ← Unified data-access façade (Supabase + filesystem)
└── migrations/
└── 001_initial_schema.sql
tests/portfolio/
├── __init__.py
├── conftest.py ← shared fixtures
├── test_models.py
├── test_report_store.py
└── test_repository.py
```
---
## Task 1 — Data Models (`models.py`)
**Estimated effort:** 23 h
### Deliverables
Four dataclasses fully type-annotated:
- `Portfolio`
- `Holding`
- `Trade`
- `PortfolioSnapshot`
Each class must implement:
- `to_dict() -> dict` — serialise for DB / JSON
- `from_dict(data: dict) -> Self` — deserialise from DB / JSON
- `enrich(**kwargs)` — attach runtime-computed fields (prices, weights, P&L)
### Field Specifications
See `docs/portfolio/02_data_models.md` for full field tables.
### Acceptance Criteria
- All fields have explicit Python type annotations
- `to_dict()``from_dict()` round-trip is lossless for all fields
- `enrich()` correctly computes `current_value`, `unrealized_pnl`, `unrealized_pnl_pct`, `weight`
- 100 % line coverage in `test_models.py`
---
## Task 2 — Portfolio Config (`config.py`)
**Estimated effort:** 1 h
### Deliverables
```python
PORTFOLIO_CONFIG: dict # all tunable parameters
get_portfolio_config() -> dict # returns merged config (defaults + env overrides)
validate_config(cfg: dict) # raises ValueError on invalid values
```
### Environment Variables
All are optional and default to the values shown:
| Env Var | Default | Description |
|---------|---------|-------------|
| `SUPABASE_CONNECTION_STRING` | `""` | PostgreSQL pooler connection URI |
| `TRADINGAGENTS_PORTFOLIO_DATA_DIR` | `"reports"` | Root dir for filesystem reports |
| `TRADINGAGENTS_PM_MAX_POSITIONS` | `15` | Max number of open positions |
| `TRADINGAGENTS_PM_MAX_POSITION_PCT` | `0.15` | Max single-position weight |
| `TRADINGAGENTS_PM_MAX_SECTOR_PCT` | `0.35` | Max sector weight |
| `TRADINGAGENTS_PM_MIN_CASH_PCT` | `0.05` | Minimum cash reserve |
| `TRADINGAGENTS_PM_DEFAULT_BUDGET` | `100000.0` | Default starting cash (USD) |
### Acceptance Criteria
- All env vars read with `os.getenv`, defaulting gracefully when unset
- `validate_config` raises `ValueError` for `max_position_pct > 1.0`,
`min_cash_pct < 0`, `max_positions < 1`, etc.
---
## Task 3 — Supabase Migration (`migrations/001_initial_schema.sql`)
**Estimated effort:** 12 h
### Deliverables
A single idempotent SQL file (`CREATE TABLE IF NOT EXISTS`) that creates:
- `portfolios` table
- `holdings` table
- `trades` table
- `snapshots` table
- All CHECK constraints
- All FOREIGN KEY constraints
- All indexes (PK + query-path indexes)
- `updated_at` trigger function + triggers on portfolios, holdings
See `docs/portfolio/03_database_schema.md` for full schema.
### Acceptance Criteria
- File runs without error on a fresh Supabase PostgreSQL database
- All tables created with correct column types and constraints
- `updated_at` auto-updates on every row modification
---
## Task 4 — Supabase Client (`supabase_client.py`)
**Estimated effort:** 34 h
### Deliverables
`SupabaseClient` class (singleton pattern) with:
**Portfolio CRUD**
- `create_portfolio(portfolio: Portfolio) -> Portfolio`
- `get_portfolio(portfolio_id: str) -> Portfolio`
- `list_portfolios() -> list[Portfolio]`
- `update_portfolio(portfolio: Portfolio) -> Portfolio`
- `delete_portfolio(portfolio_id: str) -> None`
**Holdings CRUD**
- `upsert_holding(holding: Holding) -> Holding`
- `get_holding(portfolio_id: str, ticker: str) -> Holding | None`
- `list_holdings(portfolio_id: str) -> list[Holding]`
- `delete_holding(portfolio_id: str, ticker: str) -> None`
**Trades**
- `record_trade(trade: Trade) -> Trade`
- `list_trades(portfolio_id: str, limit: int = 100) -> list[Trade]`
**Snapshots**
- `save_snapshot(snapshot: PortfolioSnapshot) -> PortfolioSnapshot`
- `get_latest_snapshot(portfolio_id: str) -> PortfolioSnapshot | None`
- `list_snapshots(portfolio_id: str, limit: int = 30) -> list[PortfolioSnapshot]`
### Error Handling
All methods translate Supabase/HTTP errors into domain exceptions (see Task below).
Methods that query a single row raise `PortfolioNotFoundError` when no row is found.
### Acceptance Criteria
- Singleton — only one Supabase connection per process
- All public methods fully type-annotated
- Supabase integration tests auto-skip when `SUPABASE_CONNECTION_STRING` is unset
---
## Task 5 — Report Store (`report_store.py`)
**Estimated effort:** 34 h
### Deliverables
`ReportStore` class with typed save/load methods for each report type:
| Method | Description |
|--------|-------------|
| `save_scan(date, data)` | Save macro scan JSON |
| `load_scan(date)` | Load macro scan JSON |
| `save_analysis(date, ticker, data)` | Save per-ticker analysis report |
| `load_analysis(date, ticker)` | Load per-ticker analysis report |
| `save_holding_review(date, ticker, data)` | Save holding reviewer output |
| `load_holding_review(date, ticker)` | Load holding reviewer output |
| `save_risk_metrics(date, portfolio_id, data)` | Save risk computation output |
| `load_risk_metrics(date, portfolio_id)` | Load risk computation output |
| `save_pm_decision(date, portfolio_id, data)` | Save PM agent decision JSON + MD |
| `load_pm_decision(date, portfolio_id)` | Load PM agent decision JSON |
| `list_pm_decisions(portfolio_id)` | List all saved PM decision paths |
### Directory Convention
```
reports/daily/{date}/
├── market/
│ └── macro_scan_summary.json ← save_scan / load_scan
├── {TICKER}/
│ └── complete_report.md ← save_analysis / load_analysis (existing)
└── portfolio/
├── {TICKER}_holding_review.json ← save_holding_review / load_holding_review
├── {portfolio_id}_risk_metrics.json
├── {portfolio_id}_pm_decision.json
└── {portfolio_id}_pm_decision.md (human-readable version)
```
### Acceptance Criteria
- Directories created automatically on first write
- `load_*` returns `None` when the file doesn't exist (no exception)
- JSON serialisation uses `json.dumps(indent=2)`
---
## Task 6 — Repository (`repository.py`)
**Estimated effort:** 45 h
### Deliverables
`PortfolioRepository` class — unified façade over `SupabaseClient` + `ReportStore`.
**Key business logic:**
```
add_holding(portfolio_id, ticker, shares, price):
existing = client.get_holding(portfolio_id, ticker)
if existing:
new_avg_cost = (existing.avg_cost * existing.shares + price * shares)
/ (existing.shares + shares)
holding.shares += shares
holding.avg_cost = new_avg_cost
else:
holding = Holding(ticker=ticker, shares=shares, avg_cost=price, ...)
portfolio.cash -= shares * price # deduct cash
client.upsert_holding(holding)
client.update_portfolio(portfolio) # persist cash change
remove_holding(portfolio_id, ticker, shares, price):
existing = client.get_holding(portfolio_id, ticker)
if existing.shares < shares:
raise InsufficientSharesError(...)
if shares == existing.shares:
client.delete_holding(portfolio_id, ticker)
else:
existing.shares -= shares
client.upsert_holding(existing)
portfolio.cash += shares * price # credit proceeds
client.update_portfolio(portfolio)
```
All DB operations execute as a logical unit (best-effort; full Supabase transactions
require PG functions — deferred to Phase 3+).
### Acceptance Criteria
- `add_holding` correctly updates avg cost basis on repeated buys
- `remove_holding` raises `InsufficientSharesError` when shares would go negative
- `add_holding` raises `InsufficientCashError` when cash < `shares * price`
- Repository integration tests auto-skip when `SUPABASE_URL` is unset
---
## Task 7 — Package Setup
**Estimated effort:** 1 h
### Deliverables
1. `tradingagents/portfolio/__init__.py` — export public symbols
2. `pyproject.toml` — add `supabase>=2.0.0` to dependencies
3. `.env.example` — add new env vars (`SUPABASE_URL`, `SUPABASE_KEY`, `PM_*`)
4. `tradingagents/default_config.py` — merge `PORTFOLIO_CONFIG` into `DEFAULT_CONFIG`
under a `"portfolio"` key (non-breaking addition)
### Acceptance Criteria
- `from tradingagents.portfolio import PortfolioRepository` works after install
- `pip install -e ".[dev]"` succeeds with the new dependency
---
## Task 8 — Tests
**Estimated effort:** 34 h
### Test List
**`test_models.py`**
- `test_portfolio_to_dict_round_trip`
- `test_holding_to_dict_round_trip`
- `test_trade_to_dict_round_trip`
- `test_snapshot_to_dict_round_trip`
- `test_holding_enrich_computes_current_value`
- `test_holding_enrich_computes_unrealized_pnl`
- `test_holding_enrich_computes_weight`
- `test_holding_enrich_handles_zero_cost`
**`test_report_store.py`**
- `test_save_and_load_scan`
- `test_save_and_load_analysis`
- `test_save_and_load_holding_review`
- `test_save_and_load_risk_metrics`
- `test_save_and_load_pm_decision_json`
- `test_load_returns_none_for_missing_file`
- `test_list_pm_decisions`
- `test_directories_created_on_write`
**`test_repository.py`** (Supabase tests skip when `SUPABASE_URL` unset)
- `test_add_holding_new_position`
- `test_add_holding_updates_avg_cost`
- `test_remove_holding_full_position`
- `test_remove_holding_partial_position`
- `test_remove_holding_raises_insufficient_shares`
- `test_add_holding_raises_insufficient_cash`
- `test_record_and_list_trades`
- `test_save_and_load_snapshot`
### Coverage Target
90 %+ for `models.py` and `report_store.py`.
Integration tests (`test_repository.py`) auto-skip when `SUPABASE_CONNECTION_STRING` is unset.
---
## Execution Order
```
Day 1 (parallel tracks)
Track A: Task 1 (models) → Task 3 (SQL migration)
Track B: Task 2 (config) → Task 7 (package setup partial)
Day 2 (parallel tracks)
Track A: Task 4 (SupabaseClient)
Track B: Task 5 (ReportStore)
Day 3
Task 6 (Repository)
Task 8 (Tests)
Task 7 (package setup final — pyproject.toml, .env.example)
```
**Total estimate: ~1824 hours**

View File

@ -0,0 +1,230 @@
# Data Models — Full Specification
<!-- Last verified: 2026-03-20 -->
All models live in `tradingagents/portfolio/models.py` as Python `dataclass` types.
They must be fully type-annotated and support lossless `to_dict` / `from_dict`
round-trips.
---
## `Portfolio`
Represents a single managed portfolio (one user may eventually have multiple).
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `portfolio_id` | `str` | Yes | UUID, primary key |
| `name` | `str` | Yes | Human-readable name, e.g. "Main Portfolio" |
| `cash` | `float` | Yes | Available cash balance in USD |
| `initial_cash` | `float` | Yes | Starting capital (immutable after creation) |
| `currency` | `str` | Yes | ISO 4217 code, default `"USD"` |
| `created_at` | `str` | Yes | ISO-8601 UTC datetime string |
| `updated_at` | `str` | Yes | ISO-8601 UTC datetime string |
| `report_path` | `str \| None` | No | Filesystem path to today's portfolio report dir |
| `metadata` | `dict` | No | Free-form JSON for agent notes / tags |
### Computed / Derived Fields (not stored in DB)
| Field | Type | Description |
|-------|------|-------------|
| `total_value` | `float` | `cash` + sum of all holding `current_value` |
| `equity_value` | `float` | sum of all holding `current_value` |
| `cash_pct` | `float` | `cash / total_value` |
### Methods
```python
def to_dict(self) -> dict:
"""Serialise all stored fields to a flat dict suitable for JSON / Supabase insert."""
def from_dict(cls, data: dict) -> "Portfolio":
"""Deserialise from a DB row or JSON dict. Missing optional fields default gracefully."""
def enrich(self, holdings: list["Holding"]) -> "Portfolio":
"""Compute total_value, equity_value, cash_pct from the provided holdings list."""
```
---
## `Holding`
Represents a single open position within a portfolio.
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `holding_id` | `str` | Yes | UUID, primary key |
| `portfolio_id` | `str` | Yes | FK → portfolios.portfolio_id |
| `ticker` | `str` | Yes | Stock ticker symbol, e.g. `"AAPL"` |
| `shares` | `float` | Yes | Number of shares held |
| `avg_cost` | `float` | Yes | Average cost basis per share (USD) |
| `sector` | `str \| None` | No | GICS sector name |
| `industry` | `str \| None` | No | GICS industry name |
| `created_at` | `str` | Yes | ISO-8601 UTC datetime string |
| `updated_at` | `str` | Yes | ISO-8601 UTC datetime string |
### Runtime-Computed Fields (not stored in DB)
These are populated by `enrich()` and available for agent/analysis use:
| Field | Type | Description |
|-------|------|-------------|
| `current_price` | `float \| None` | Latest market price per share |
| `current_value` | `float \| None` | `current_price * shares` |
| `cost_basis` | `float` | `avg_cost * shares` |
| `unrealized_pnl` | `float \| None` | `current_value - cost_basis` |
| `unrealized_pnl_pct` | `float \| None` | `unrealized_pnl / cost_basis` (0 if cost_basis == 0) |
| `weight` | `float \| None` | `current_value / portfolio_total_value` |
### Methods
```python
def to_dict(self) -> dict:
"""Serialise stored fields only (not runtime-computed fields)."""
def from_dict(cls, data: dict) -> "Holding":
"""Deserialise from DB row or JSON dict."""
def enrich(self, current_price: float, portfolio_total_value: float) -> "Holding":
"""
Populate runtime-computed fields in-place and return self.
Args:
current_price: Latest market price for this ticker.
portfolio_total_value: Total portfolio value (cash + equity) for weight calc.
"""
```
---
## `Trade`
Immutable record of a single mock trade execution. Never modified after creation.
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `trade_id` | `str` | Yes | UUID, primary key |
| `portfolio_id` | `str` | Yes | FK → portfolios.portfolio_id |
| `ticker` | `str` | Yes | Stock ticker symbol |
| `action` | `str` | Yes | `"BUY"` or `"SELL"` |
| `shares` | `float` | Yes | Number of shares traded |
| `price` | `float` | Yes | Execution price per share (USD) |
| `total_value` | `float` | Yes | `shares * price` |
| `trade_date` | `str` | Yes | ISO-8601 UTC datetime of execution |
| `rationale` | `str \| None` | No | PM agent rationale for this trade |
| `signal_source` | `str \| None` | No | `"scanner"`, `"holding_review"`, `"pm_agent"` |
| `metadata` | `dict` | No | Free-form JSON |
### Methods
```python
def to_dict(self) -> dict:
"""Serialise all fields."""
def from_dict(cls, data: dict) -> "Trade":
"""Deserialise from DB row or JSON dict."""
```
---
## `PortfolioSnapshot`
Point-in-time immutable record of the portfolio state. Taken after every trade
execution session (Phase 5 of the workflow). Used for performance tracking.
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `snapshot_id` | `str` | Yes | UUID, primary key |
| `portfolio_id` | `str` | Yes | FK → portfolios.portfolio_id |
| `snapshot_date` | `str` | Yes | ISO-8601 UTC datetime |
| `total_value` | `float` | Yes | Cash + equity at snapshot time |
| `cash` | `float` | Yes | Cash balance at snapshot time |
| `equity_value` | `float` | Yes | Sum of position values at snapshot time |
| `num_positions` | `int` | Yes | Number of open positions |
| `holdings_snapshot` | `list[dict]` | Yes | Serialised list of holding dicts (as-of) |
| `metadata` | `dict` | No | Free-form JSON (e.g. PM decision path) |
### Methods
```python
def to_dict(self) -> dict:
"""Serialise all fields. `holdings_snapshot` is already a list[dict]."""
def from_dict(cls, data: dict) -> "PortfolioSnapshot":
"""Deserialise. `holdings_snapshot` parsed from JSON string if needed."""
```
---
## Serialisation Contract
### `to_dict()`
- Returns a flat `dict[str, Any]`
- All values must be JSON-serialisable (str, int, float, bool, list, dict, None)
- `datetime` objects → ISO-8601 string (`isoformat()`)
- `Decimal` values → `float`
- Runtime-computed fields (`current_price`, `weight`, etc.) are **excluded**
- Complex nested fields (`metadata`, `holdings_snapshot`) are included as-is
### `from_dict()`
- Class method; must be callable as `Portfolio.from_dict(row)`
- Handles missing optional fields with `data.get("field", default)`
- Does **not** raise on extra keys in `data`
- Does **not** populate runtime-computed fields (call `enrich()` separately)
---
## Enrichment Logic
### `Holding.enrich(current_price, portfolio_total_value)`
```python
self.current_price = current_price
self.current_value = current_price * self.shares
self.cost_basis = self.avg_cost * self.shares
self.unrealized_pnl = self.current_value - self.cost_basis
if self.cost_basis > 0:
self.unrealized_pnl_pct = self.unrealized_pnl / self.cost_basis
else:
self.unrealized_pnl_pct = 0.0
if portfolio_total_value > 0:
self.weight = self.current_value / portfolio_total_value
else:
self.weight = 0.0
return self
```
### `Portfolio.enrich(holdings)`
```python
self.equity_value = sum(h.current_value or 0 for h in holdings)
self.total_value = self.cash + self.equity_value
if self.total_value > 0:
self.cash_pct = self.cash / self.total_value
else:
self.cash_pct = 1.0
return self
```
---
## Type Alias Reference
```python
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
```
All `metadata` fields use `dict[str, Any]` with `field(default_factory=dict)`.
All optional fields default to `None` unless noted otherwise.

View File

@ -0,0 +1,305 @@
# Database & Filesystem Schema
<!-- Last verified: 2026-03-20 -->
## Supabase (PostgreSQL) Schema
All tables are created in the `public` schema (Supabase default).
---
### `portfolios`
Stores one row per managed portfolio.
```sql
CREATE TABLE IF NOT EXISTS portfolios (
portfolio_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
cash NUMERIC(18,4) NOT NULL CHECK (cash >= 0),
initial_cash NUMERIC(18,4) NOT NULL CHECK (initial_cash > 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
report_path TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Constraints:**
- `cash >= 0` — portfolio can be fully invested but never negative
- `initial_cash > 0` — must start with positive capital
- `currency` is 3-char ISO 4217 code
---
### `holdings`
Stores one row per open position per portfolio. Row is deleted when shares reach 0.
```sql
CREATE TABLE IF NOT EXISTS holdings (
holding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
ticker TEXT NOT NULL,
shares NUMERIC(18,6) NOT NULL CHECK (shares > 0),
avg_cost NUMERIC(18,4) NOT NULL CHECK (avg_cost >= 0),
sector TEXT,
industry TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT holdings_portfolio_ticker_unique UNIQUE (portfolio_id, ticker)
);
```
**Constraints:**
- `shares > 0` — zero-share positions are deleted, not stored
- `avg_cost >= 0` — cost basis is non-negative
- `UNIQUE (portfolio_id, ticker)` — one row per ticker per portfolio (upsert pattern)
---
### `trades`
Immutable append-only log of every mock trade execution.
```sql
CREATE TABLE IF NOT EXISTS trades (
trade_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
ticker TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('BUY', 'SELL')),
shares NUMERIC(18,6) NOT NULL CHECK (shares > 0),
price NUMERIC(18,4) NOT NULL CHECK (price > 0),
total_value NUMERIC(18,4) NOT NULL CHECK (total_value > 0),
trade_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
rationale TEXT,
signal_source TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
CONSTRAINT trades_action_values CHECK (action IN ('BUY', 'SELL'))
);
```
**Constraints:**
- `action IN ('BUY', 'SELL')` — only two valid actions
- `shares > 0`, `price > 0` — all quantities positive
- No `updated_at` — trades are immutable
---
### `snapshots`
Point-in-time portfolio state snapshots taken after each trade session.
```sql
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
snapshot_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_value NUMERIC(18,4) NOT NULL,
cash NUMERIC(18,4) NOT NULL,
equity_value NUMERIC(18,4) NOT NULL,
num_positions INTEGER NOT NULL CHECK (num_positions >= 0),
holdings_snapshot JSONB NOT NULL DEFAULT '[]',
metadata JSONB NOT NULL DEFAULT '{}'
);
```
**Constraints:**
- `num_positions >= 0` — can have 0 positions (fully in cash)
- `holdings_snapshot` is a JSONB array of serialised `Holding.to_dict()` objects
- No `updated_at` — snapshots are immutable
---
## Indexes
```sql
-- portfolios: fast lookup by name
CREATE INDEX IF NOT EXISTS idx_portfolios_name
ON portfolios (name);
-- holdings: list all holdings for a portfolio (most common query)
CREATE INDEX IF NOT EXISTS idx_holdings_portfolio_id
ON holdings (portfolio_id);
-- holdings: fast ticker lookup within a portfolio
CREATE INDEX IF NOT EXISTS idx_holdings_portfolio_ticker
ON holdings (portfolio_id, ticker);
-- trades: list recent trades for a portfolio, newest first
CREATE INDEX IF NOT EXISTS idx_trades_portfolio_id_date
ON trades (portfolio_id, trade_date DESC);
-- trades: filter by ticker within a portfolio
CREATE INDEX IF NOT EXISTS idx_trades_portfolio_ticker
ON trades (portfolio_id, ticker);
-- snapshots: get latest snapshot for a portfolio
CREATE INDEX IF NOT EXISTS idx_snapshots_portfolio_id_date
ON snapshots (portfolio_id, snapshot_date DESC);
```
---
## `updated_at` Trigger
Automatically updates `updated_at` on every row modification for `portfolios`
and `holdings` (trades and snapshots are immutable).
```sql
-- Trigger function (shared across tables)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to portfolios
CREATE OR REPLACE TRIGGER trg_portfolios_updated_at
BEFORE UPDATE ON portfolios
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Apply to holdings
CREATE OR REPLACE TRIGGER trg_holdings_updated_at
BEFORE UPDATE ON holdings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
```
---
## Example Queries
### Get active portfolio with cash balance
```sql
SELECT portfolio_id, name, cash, initial_cash, currency
FROM portfolios
WHERE portfolio_id = $1;
```
### Get all holdings with sector summary
```sql
SELECT
ticker,
shares,
avg_cost,
shares * avg_cost AS cost_basis,
sector
FROM holdings
WHERE portfolio_id = $1
ORDER BY shares * avg_cost DESC;
```
### Sector concentration (cost-basis weighted)
```sql
SELECT
COALESCE(sector, 'Unknown') AS sector,
SUM(shares * avg_cost) AS sector_cost_basis
FROM holdings
WHERE portfolio_id = $1
GROUP BY sector
ORDER BY sector_cost_basis DESC;
```
### Recent 20 trades for a portfolio
```sql
SELECT ticker, action, shares, price, total_value, trade_date, rationale
FROM trades
WHERE portfolio_id = $1
ORDER BY trade_date DESC
LIMIT 20;
```
### Latest portfolio snapshot
```sql
SELECT *
FROM snapshots
WHERE portfolio_id = $1
ORDER BY snapshot_date DESC
LIMIT 1;
```
### Portfolio performance over time (snapshot series)
```sql
SELECT snapshot_date, total_value, cash, equity_value, num_positions
FROM snapshots
WHERE portfolio_id = $1
ORDER BY snapshot_date ASC;
```
---
## Filesystem Directory Structure
Reports and documents are stored under the project's `reports/` directory using
the existing convention from `tradingagents/report_paths.py`.
```
reports/
└── daily/
└── {YYYY-MM-DD}/
├── market/
│ ├── geopolitical_report.md
│ ├── market_movers_report.md
│ ├── sector_report.md
│ ├── industry_deep_dive_report.md
│ ├── macro_synthesis_report.md
│ └── macro_scan_summary.json ← ReportStore.save_scan / load_scan
├── {TICKER}/ ← one dir per analysed ticker
│ ├── 1_analysts/
│ ├── 2_research/
│ ├── 3_trader/
│ ├── 4_risk/
│ ├── complete_report.md ← ReportStore.save_analysis / load_analysis
│ └── eval/
│ └── full_states_log.json
├── daily_digest.md
└── portfolio/ ← NEW: portfolio manager artifacts
├── {TICKER}_holding_review.json ← ReportStore.save_holding_review
├── {portfolio_id}_risk_metrics.json
├── {portfolio_id}_pm_decision.json
└── {portfolio_id}_pm_decision.md (human-readable)
```
---
## Supabase ↔ Filesystem Link
The `portfolios.report_path` column stores the **absolute or relative path** to the
daily portfolio subdirectory:
```
report_path = "reports/daily/2026-03-20/portfolio"
```
This allows the Repository layer to load the PM decision, risk metrics, and holding
reviews by constructing:
```python
Path(portfolio.report_path) / f"{portfolio_id}_pm_decision.json"
```
The path is set by the Repository after the first write on each run day.
---
## Schema Version Notes
- Migration file: `tradingagents/portfolio/migrations/001_initial_schema.sql`
- All `CREATE TABLE` and `CREATE INDEX` use `IF NOT EXISTS` — safe to re-run
- `CREATE OR REPLACE TRIGGER` / `CREATE OR REPLACE FUNCTION` — idempotent
- Supabase project dashboard: run via SQL Editor or the Supabase CLI
(`supabase db push`)

View File

@ -0,0 +1,492 @@
# Repository Layer API
<!-- Last verified: 2026-03-20 -->
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
```python
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
```python
client = SupabaseClient.get_instance()
# or
client = SupabaseClient(url=SUPABASE_URL, key=SUPABASE_KEY)
```
### Portfolio Methods
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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
```python
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": "..."},
)
```

View File

@ -0,0 +1 @@
# tests/portfolio package marker

157
tests/portfolio/conftest.py Normal file
View File

@ -0,0 +1,157 @@
"""Shared pytest fixtures for portfolio tests.
Fixtures provided:
- ``tmp_reports`` -- temporary directory used as ReportStore base_dir
- ``sample_portfolio`` -- a Portfolio instance for testing (not persisted)
- ``sample_holding`` -- a Holding instance for testing (not persisted)
- ``sample_trade`` -- a Trade instance for testing (not persisted)
- ``sample_snapshot`` -- a PortfolioSnapshot instance for testing
- ``report_store`` -- a ReportStore instance backed by tmp_reports
- ``mock_supabase_client`` -- MagicMock of SupabaseClient for unit tests
Supabase integration tests use ``pytest.mark.skipif`` to auto-skip when
``SUPABASE_CONNECTION_STRING`` is not set in the environment.
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from tradingagents.portfolio.models import (
Holding,
Portfolio,
PortfolioSnapshot,
Trade,
)
from tradingagents.portfolio.report_store import ReportStore
from tradingagents.portfolio.supabase_client import SupabaseClient
# ---------------------------------------------------------------------------
# Skip marker for Supabase integration tests
# ---------------------------------------------------------------------------
requires_supabase = pytest.mark.skipif(
not os.getenv("SUPABASE_CONNECTION_STRING"),
reason="SUPABASE_CONNECTION_STRING not set -- skipping integration tests",
)
# ---------------------------------------------------------------------------
# Data fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_portfolio_id() -> str:
"""Return a fixed UUID for deterministic testing."""
return "11111111-1111-1111-1111-111111111111"
@pytest.fixture
def sample_holding_id() -> str:
"""Return a fixed UUID for deterministic testing."""
return "22222222-2222-2222-2222-222222222222"
@pytest.fixture
def sample_portfolio(sample_portfolio_id: str) -> Portfolio:
"""Return an unsaved Portfolio instance for testing."""
return Portfolio(
portfolio_id=sample_portfolio_id,
name="Test Portfolio",
cash=50_000.0,
initial_cash=100_000.0,
currency="USD",
created_at="2026-03-20T00:00:00Z",
updated_at="2026-03-20T00:00:00Z",
report_path="reports/daily/2026-03-20/portfolio",
metadata={"strategy": "test"},
)
@pytest.fixture
def sample_holding(sample_portfolio_id: str, sample_holding_id: str) -> Holding:
"""Return an unsaved Holding instance for testing."""
return Holding(
holding_id=sample_holding_id,
portfolio_id=sample_portfolio_id,
ticker="AAPL",
shares=100.0,
avg_cost=150.0,
sector="Technology",
industry="Consumer Electronics",
created_at="2026-03-20T00:00:00Z",
updated_at="2026-03-20T00:00:00Z",
)
@pytest.fixture
def sample_trade(sample_portfolio_id: str) -> Trade:
"""Return an unsaved Trade instance for testing."""
return Trade(
trade_id="33333333-3333-3333-3333-333333333333",
portfolio_id=sample_portfolio_id,
ticker="AAPL",
action="BUY",
shares=100.0,
price=150.0,
total_value=15_000.0,
trade_date="2026-03-20T10:00:00Z",
rationale="Strong momentum signal",
signal_source="scanner",
metadata={"confidence": 0.85},
)
@pytest.fixture
def sample_snapshot(sample_portfolio_id: str) -> PortfolioSnapshot:
"""Return an unsaved PortfolioSnapshot instance for testing."""
return PortfolioSnapshot(
snapshot_id="44444444-4444-4444-4444-444444444444",
portfolio_id=sample_portfolio_id,
snapshot_date="2026-03-20",
total_value=115_000.0,
cash=50_000.0,
equity_value=65_000.0,
num_positions=2,
holdings_snapshot=[
{"ticker": "AAPL", "shares": 100.0, "avg_cost": 150.0},
{"ticker": "MSFT", "shares": 50.0, "avg_cost": 300.0},
],
metadata={"note": "end of day snapshot"},
)
# ---------------------------------------------------------------------------
# Filesystem fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_reports(tmp_path: Path) -> Path:
"""Temporary reports directory, cleaned up after each test."""
reports_dir = tmp_path / "reports"
reports_dir.mkdir()
return reports_dir
@pytest.fixture
def report_store(tmp_reports: Path) -> ReportStore:
"""ReportStore instance backed by a temporary directory."""
return ReportStore(base_dir=tmp_reports)
# ---------------------------------------------------------------------------
# Mock Supabase client fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_supabase_client() -> MagicMock:
"""MagicMock of SupabaseClient for unit tests that don't hit the DB."""
return MagicMock(spec=SupabaseClient)

View File

@ -0,0 +1,249 @@
"""Tests for tradingagents/portfolio/models.py.
Tests the four dataclass models: Portfolio, Holding, Trade, PortfolioSnapshot.
Coverage targets:
- to_dict() / from_dict() round-trips
- enrich() computed-field logic
- Edge cases (zero cost basis, zero portfolio value)
Run::
pytest tests/portfolio/test_models.py -v
"""
from __future__ import annotations
import pytest
from tradingagents.portfolio.models import (
Holding,
Portfolio,
PortfolioSnapshot,
Trade,
)
# ---------------------------------------------------------------------------
# Portfolio round-trip
# ---------------------------------------------------------------------------
def test_portfolio_to_dict_round_trip(sample_portfolio):
"""Portfolio.to_dict() -> Portfolio.from_dict() must be lossless."""
d = sample_portfolio.to_dict()
restored = Portfolio.from_dict(d)
assert restored.portfolio_id == sample_portfolio.portfolio_id
assert restored.name == sample_portfolio.name
assert restored.cash == sample_portfolio.cash
assert restored.initial_cash == sample_portfolio.initial_cash
assert restored.currency == sample_portfolio.currency
assert restored.created_at == sample_portfolio.created_at
assert restored.updated_at == sample_portfolio.updated_at
assert restored.report_path == sample_portfolio.report_path
assert restored.metadata == sample_portfolio.metadata
def test_portfolio_to_dict_excludes_runtime_fields(sample_portfolio):
"""to_dict() must not include computed fields (total_value, equity_value, cash_pct)."""
d = sample_portfolio.to_dict()
assert "total_value" not in d
assert "equity_value" not in d
assert "cash_pct" not in d
def test_portfolio_from_dict_defaults_optional_fields():
"""from_dict() must tolerate missing optional fields."""
minimal = {
"portfolio_id": "pid-1",
"name": "Minimal",
"cash": 1000.0,
"initial_cash": 1000.0,
}
p = Portfolio.from_dict(minimal)
assert p.currency == "USD"
assert p.created_at == ""
assert p.updated_at == ""
assert p.report_path is None
assert p.metadata == {}
# ---------------------------------------------------------------------------
# Holding round-trip
# ---------------------------------------------------------------------------
def test_holding_to_dict_round_trip(sample_holding):
"""Holding.to_dict() -> Holding.from_dict() must be lossless."""
d = sample_holding.to_dict()
restored = Holding.from_dict(d)
assert restored.holding_id == sample_holding.holding_id
assert restored.portfolio_id == sample_holding.portfolio_id
assert restored.ticker == sample_holding.ticker
assert restored.shares == sample_holding.shares
assert restored.avg_cost == sample_holding.avg_cost
assert restored.sector == sample_holding.sector
assert restored.industry == sample_holding.industry
def test_holding_to_dict_excludes_runtime_fields(sample_holding):
"""to_dict() must not include current_price, current_value, weight, etc."""
d = sample_holding.to_dict()
for field in ("current_price", "current_value", "cost_basis",
"unrealized_pnl", "unrealized_pnl_pct", "weight"):
assert field not in d
# ---------------------------------------------------------------------------
# Trade round-trip
# ---------------------------------------------------------------------------
def test_trade_to_dict_round_trip(sample_trade):
"""Trade.to_dict() -> Trade.from_dict() must be lossless."""
d = sample_trade.to_dict()
restored = Trade.from_dict(d)
assert restored.trade_id == sample_trade.trade_id
assert restored.portfolio_id == sample_trade.portfolio_id
assert restored.ticker == sample_trade.ticker
assert restored.action == sample_trade.action
assert restored.shares == sample_trade.shares
assert restored.price == sample_trade.price
assert restored.total_value == sample_trade.total_value
assert restored.trade_date == sample_trade.trade_date
assert restored.rationale == sample_trade.rationale
assert restored.signal_source == sample_trade.signal_source
assert restored.metadata == sample_trade.metadata
# ---------------------------------------------------------------------------
# PortfolioSnapshot round-trip
# ---------------------------------------------------------------------------
def test_snapshot_to_dict_round_trip(sample_snapshot):
"""PortfolioSnapshot.to_dict() -> PortfolioSnapshot.from_dict() round-trip."""
d = sample_snapshot.to_dict()
restored = PortfolioSnapshot.from_dict(d)
assert restored.snapshot_id == sample_snapshot.snapshot_id
assert restored.portfolio_id == sample_snapshot.portfolio_id
assert restored.snapshot_date == sample_snapshot.snapshot_date
assert restored.total_value == sample_snapshot.total_value
assert restored.cash == sample_snapshot.cash
assert restored.equity_value == sample_snapshot.equity_value
assert restored.num_positions == sample_snapshot.num_positions
assert restored.holdings_snapshot == sample_snapshot.holdings_snapshot
assert restored.metadata == sample_snapshot.metadata
def test_snapshot_from_dict_parses_holdings_snapshot_json_string():
"""from_dict() must parse holdings_snapshot when it arrives as a JSON string."""
import json
holdings = [{"ticker": "AAPL", "shares": 10.0}]
data = {
"snapshot_id": "snap-1",
"portfolio_id": "pid-1",
"snapshot_date": "2026-03-20",
"total_value": 110_000.0,
"cash": 10_000.0,
"equity_value": 100_000.0,
"num_positions": 1,
"holdings_snapshot": json.dumps(holdings), # string form as returned by Supabase
}
snap = PortfolioSnapshot.from_dict(data)
assert snap.holdings_snapshot == holdings
# ---------------------------------------------------------------------------
# Holding.enrich()
# ---------------------------------------------------------------------------
def test_holding_enrich_computes_current_value(sample_holding):
"""enrich() must set current_value = current_price * shares."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
assert sample_holding.current_value == 200.0 * sample_holding.shares
def test_holding_enrich_computes_unrealized_pnl(sample_holding):
"""enrich() must set unrealized_pnl = current_value - cost_basis."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
expected_cost_basis = sample_holding.avg_cost * sample_holding.shares
expected_pnl = sample_holding.current_value - expected_cost_basis
assert sample_holding.unrealized_pnl == pytest.approx(expected_pnl)
def test_holding_enrich_computes_unrealized_pnl_pct(sample_holding):
"""enrich() must set unrealized_pnl_pct = unrealized_pnl / cost_basis."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
cost_basis = sample_holding.avg_cost * sample_holding.shares
expected_pct = sample_holding.unrealized_pnl / cost_basis
assert sample_holding.unrealized_pnl_pct == pytest.approx(expected_pct)
def test_holding_enrich_computes_weight(sample_holding):
"""enrich() must set weight = current_value / portfolio_total_value."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
expected_weight = sample_holding.current_value / 100_000.0
assert sample_holding.weight == pytest.approx(expected_weight)
def test_holding_enrich_returns_self(sample_holding):
"""enrich() must return self for chaining."""
result = sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
assert result is sample_holding
def test_holding_enrich_handles_zero_cost(sample_holding):
"""When avg_cost == 0, unrealized_pnl_pct must be 0 (no ZeroDivisionError)."""
sample_holding.avg_cost = 0.0
sample_holding.enrich(current_price=200.0, portfolio_total_value=100_000.0)
assert sample_holding.unrealized_pnl_pct == 0.0
def test_holding_enrich_handles_zero_portfolio_value(sample_holding):
"""When portfolio_total_value == 0, weight must be 0 (no ZeroDivisionError)."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=0.0)
assert sample_holding.weight == 0.0
# ---------------------------------------------------------------------------
# Portfolio.enrich()
# ---------------------------------------------------------------------------
def test_portfolio_enrich_computes_total_value(sample_portfolio, sample_holding):
"""Portfolio.enrich() must compute total_value = cash + sum(holding.current_value)."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=1.0) # sets current_value; dummy total is overwritten by portfolio.enrich()
sample_portfolio.enrich([sample_holding])
expected_equity = 200.0 * sample_holding.shares
assert sample_portfolio.total_value == pytest.approx(sample_portfolio.cash + expected_equity)
def test_portfolio_enrich_computes_equity_value(sample_portfolio, sample_holding):
"""Portfolio.enrich() must set equity_value = sum(holding.current_value)."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=1.0) # sets current_value; dummy total is overwritten by portfolio.enrich()
sample_portfolio.enrich([sample_holding])
assert sample_portfolio.equity_value == pytest.approx(200.0 * sample_holding.shares)
def test_portfolio_enrich_computes_cash_pct(sample_portfolio, sample_holding):
"""Portfolio.enrich() must compute cash_pct = cash / total_value."""
sample_holding.enrich(current_price=200.0, portfolio_total_value=1.0) # sets current_value; dummy total is overwritten by portfolio.enrich()
sample_portfolio.enrich([sample_holding])
expected_pct = sample_portfolio.cash / sample_portfolio.total_value
assert sample_portfolio.cash_pct == pytest.approx(expected_pct)
def test_portfolio_enrich_returns_self(sample_portfolio):
"""enrich() must return self for chaining."""
result = sample_portfolio.enrich([])
assert result is sample_portfolio
def test_portfolio_enrich_no_holdings(sample_portfolio):
"""Portfolio.enrich() with empty holdings: equity_value=0, total_value=cash."""
sample_portfolio.enrich([])
assert sample_portfolio.equity_value == 0.0
assert sample_portfolio.total_value == sample_portfolio.cash
assert sample_portfolio.cash_pct == 1.0

View File

@ -0,0 +1,176 @@
"""Tests for tradingagents/portfolio/report_store.py.
Tests filesystem save/load operations for all report types.
All tests use a temporary directory (``tmp_reports`` fixture) and do not
require Supabase or network access.
Run::
pytest tests/portfolio/test_report_store.py -v
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tradingagents.portfolio.exceptions import ReportStoreError
from tradingagents.portfolio.report_store import ReportStore
# ---------------------------------------------------------------------------
# Macro scan
# ---------------------------------------------------------------------------
def test_save_and_load_scan(report_store, tmp_reports):
"""save_scan() then load_scan() must return the original data."""
data = {"watchlist": ["AAPL", "MSFT"], "date": "2026-03-20"}
path = report_store.save_scan("2026-03-20", data)
assert path.exists()
loaded = report_store.load_scan("2026-03-20")
assert loaded == data
def test_load_scan_returns_none_for_missing_file(report_store):
"""load_scan() must return None when the file does not exist."""
result = report_store.load_scan("1900-01-01")
assert result is None
# ---------------------------------------------------------------------------
# Per-ticker analysis
# ---------------------------------------------------------------------------
def test_save_and_load_analysis(report_store):
"""save_analysis() then load_analysis() must return the original data."""
data = {"ticker": "AAPL", "recommendation": "BUY", "score": 0.92}
report_store.save_analysis("2026-03-20", "AAPL", data)
loaded = report_store.load_analysis("2026-03-20", "AAPL")
assert loaded == data
def test_analysis_ticker_stored_as_uppercase(report_store, tmp_reports):
"""Ticker symbol must be stored as uppercase in the directory path."""
data = {"ticker": "aapl"}
report_store.save_analysis("2026-03-20", "aapl", data)
expected = tmp_reports / "daily" / "2026-03-20" / "AAPL" / "complete_report.json"
assert expected.exists()
# load with lowercase should still work
loaded = report_store.load_analysis("2026-03-20", "aapl")
assert loaded == data
# ---------------------------------------------------------------------------
# Holding reviews
# ---------------------------------------------------------------------------
def test_save_and_load_holding_review(report_store):
"""save_holding_review() then load_holding_review() must round-trip."""
data = {"ticker": "MSFT", "verdict": "HOLD", "price_target": 420.0}
report_store.save_holding_review("2026-03-20", "MSFT", data)
loaded = report_store.load_holding_review("2026-03-20", "MSFT")
assert loaded == data
def test_load_holding_review_returns_none_for_missing(report_store):
"""load_holding_review() must return None when the file does not exist."""
result = report_store.load_holding_review("1900-01-01", "ZZZZ")
assert result is None
# ---------------------------------------------------------------------------
# Risk metrics
# ---------------------------------------------------------------------------
def test_save_and_load_risk_metrics(report_store):
"""save_risk_metrics() then load_risk_metrics() must round-trip."""
data = {"sharpe": 1.35, "sortino": 1.8, "max_drawdown": -0.12}
report_store.save_risk_metrics("2026-03-20", "pid-123", data)
loaded = report_store.load_risk_metrics("2026-03-20", "pid-123")
assert loaded == data
# ---------------------------------------------------------------------------
# PM decisions
# ---------------------------------------------------------------------------
def test_save_and_load_pm_decision_json(report_store):
"""save_pm_decision() then load_pm_decision() must round-trip JSON."""
decision = {"sells": [], "buys": [{"ticker": "AAPL", "shares": 10}]}
report_store.save_pm_decision("2026-03-20", "pid-123", decision)
loaded = report_store.load_pm_decision("2026-03-20", "pid-123")
assert loaded == decision
def test_save_pm_decision_writes_markdown_when_provided(report_store, tmp_reports):
"""When markdown is passed to save_pm_decision(), .md file must be written."""
decision = {"sells": [], "buys": []}
md_text = "# Decision\n\nHold everything."
report_store.save_pm_decision("2026-03-20", "pid-123", decision, markdown=md_text)
md_path = tmp_reports / "daily" / "2026-03-20" / "portfolio" / "pid-123_pm_decision.md"
assert md_path.exists()
assert md_path.read_text(encoding="utf-8") == md_text
def test_save_pm_decision_no_markdown_file_when_not_provided(report_store, tmp_reports):
"""When markdown=None, no .md file should be written."""
decision = {"sells": [], "buys": []}
report_store.save_pm_decision("2026-03-20", "pid-123", decision, markdown=None)
md_path = tmp_reports / "daily" / "2026-03-20" / "portfolio" / "pid-123_pm_decision.md"
assert not md_path.exists()
def test_load_pm_decision_returns_none_for_missing(report_store):
"""load_pm_decision() must return None when the file does not exist."""
result = report_store.load_pm_decision("1900-01-01", "pid-none")
assert result is None
def test_list_pm_decisions(report_store):
"""list_pm_decisions() must return all saved decision paths, newest first."""
dates = ["2026-03-18", "2026-03-19", "2026-03-20"]
for d in dates:
report_store.save_pm_decision(d, "pid-abc", {"date": d})
paths = report_store.list_pm_decisions("pid-abc")
assert len(paths) == 3
# Sorted newest first by ISO date string ordering
date_parts = [p.parent.parent.name for p in paths]
assert date_parts == sorted(dates, reverse=True)
# ---------------------------------------------------------------------------
# Filesystem behaviour
# ---------------------------------------------------------------------------
def test_directories_created_on_write(report_store, tmp_reports):
"""Directories must be created automatically on first write."""
target_dir = tmp_reports / "daily" / "2026-03-20" / "portfolio"
assert not target_dir.exists()
report_store.save_risk_metrics("2026-03-20", "pid-123", {"sharpe": 1.2})
assert target_dir.is_dir()
def test_json_formatted_with_indent(report_store, tmp_reports):
"""Written JSON files must use indent=2 for human readability."""
data = {"key": "value", "nested": {"a": 1}}
path = report_store.save_scan("2026-03-20", data)
raw = path.read_text(encoding="utf-8")
# indent=2 means lines like ' "key": ...'
assert ' "key"' in raw
def test_read_json_raises_on_corrupt_file(report_store, tmp_reports):
"""_read_json must raise ReportStoreError for corrupt JSON."""
corrupt = tmp_reports / "corrupt.json"
corrupt.write_text("not valid json{{{", encoding="utf-8")
with pytest.raises(ReportStoreError):
report_store._read_json(corrupt)

View File

@ -0,0 +1,380 @@
"""Tests for tradingagents/portfolio/repository.py.
Unit tests use ``mock_supabase_client`` to avoid DB access.
Integration tests auto-skip when ``SUPABASE_CONNECTION_STRING`` is unset.
Run (unit tests only)::
pytest tests/portfolio/test_repository.py -v -k "not integration"
"""
from __future__ import annotations
from unittest.mock import MagicMock, call
import pytest
from tradingagents.portfolio.exceptions import (
HoldingNotFoundError,
InsufficientCashError,
InsufficientSharesError,
)
from tradingagents.portfolio.models import Holding, Portfolio, Trade
from tradingagents.portfolio.repository import PortfolioRepository
from tests.portfolio.conftest import requires_supabase
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_repo(mock_client, report_store):
"""Build a PortfolioRepository with mock client and real report store."""
return PortfolioRepository(
client=mock_client,
store=report_store,
config={"data_dir": "reports", "max_positions": 15,
"max_position_pct": 0.15, "max_sector_pct": 0.35,
"min_cash_pct": 0.05, "default_budget": 100_000.0,
"supabase_connection_string": ""},
)
def _mock_portfolio(pid="pid-1", cash=10_000.0):
return Portfolio(
portfolio_id=pid, name="Test", cash=cash,
initial_cash=100_000.0, currency="USD",
)
def _mock_holding(pid="pid-1", ticker="AAPL", shares=50.0, avg_cost=190.0):
return Holding(
holding_id="hid-1", portfolio_id=pid, ticker=ticker,
shares=shares, avg_cost=avg_cost,
)
# ---------------------------------------------------------------------------
# add_holding — new position
# ---------------------------------------------------------------------------
def test_add_holding_new_position(mock_supabase_client, report_store):
"""add_holding() on a ticker not yet held must create a new Holding."""
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=10_000.0)
mock_supabase_client.get_holding.return_value = None
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
holding = repo.add_holding("pid-1", "AAPL", shares=10, price=200.0)
assert holding.ticker == "AAPL"
assert holding.shares == 10
assert holding.avg_cost == 200.0
assert mock_supabase_client.upsert_holding.called
# ---------------------------------------------------------------------------
# add_holding — avg cost basis update
# ---------------------------------------------------------------------------
def test_add_holding_updates_avg_cost(mock_supabase_client, report_store):
"""add_holding() on existing position must update avg_cost correctly."""
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=10_000.0)
existing = _mock_holding(shares=50.0, avg_cost=190.0)
mock_supabase_client.get_holding.return_value = existing
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
holding = repo.add_holding("pid-1", "AAPL", shares=25, price=200.0)
# expected: (50*190 + 25*200) / 75 = 193.333...
expected_avg = (50 * 190.0 + 25 * 200.0) / 75
assert holding.shares == 75
assert holding.avg_cost == pytest.approx(expected_avg)
# ---------------------------------------------------------------------------
# add_holding — insufficient cash
# ---------------------------------------------------------------------------
def test_add_holding_raises_insufficient_cash(mock_supabase_client, report_store):
"""add_holding() must raise InsufficientCashError when cash < shares * price."""
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=500.0)
repo = _make_repo(mock_supabase_client, report_store)
with pytest.raises(InsufficientCashError):
repo.add_holding("pid-1", "AAPL", shares=10, price=200.0)
# ---------------------------------------------------------------------------
# remove_holding — full position
# ---------------------------------------------------------------------------
def test_remove_holding_full_position(mock_supabase_client, report_store):
"""remove_holding() selling all shares must delete the holding row."""
mock_supabase_client.get_holding.return_value = _mock_holding(shares=50.0)
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=5_000.0)
mock_supabase_client.delete_holding.return_value = None
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
result = repo.remove_holding("pid-1", "AAPL", shares=50.0, price=200.0)
assert result is None
assert mock_supabase_client.delete_holding.called
# ---------------------------------------------------------------------------
# remove_holding — partial position
# ---------------------------------------------------------------------------
def test_remove_holding_partial_position(mock_supabase_client, report_store):
"""remove_holding() selling a subset must reduce shares, not delete."""
mock_supabase_client.get_holding.return_value = _mock_holding(shares=50.0)
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=5_000.0)
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
result = repo.remove_holding("pid-1", "AAPL", shares=20.0, price=200.0)
assert result is not None
assert result.shares == 30.0
# ---------------------------------------------------------------------------
# remove_holding — errors
# ---------------------------------------------------------------------------
def test_remove_holding_raises_insufficient_shares(mock_supabase_client, report_store):
"""remove_holding() must raise InsufficientSharesError when shares > held."""
mock_supabase_client.get_holding.return_value = _mock_holding(shares=10.0)
repo = _make_repo(mock_supabase_client, report_store)
with pytest.raises(InsufficientSharesError):
repo.remove_holding("pid-1", "AAPL", shares=20.0, price=200.0)
def test_remove_holding_raises_when_ticker_not_held(mock_supabase_client, report_store):
"""remove_holding() must raise HoldingNotFoundError for unknown tickers."""
mock_supabase_client.get_holding.return_value = None
repo = _make_repo(mock_supabase_client, report_store)
with pytest.raises(HoldingNotFoundError):
repo.remove_holding("pid-1", "ZZZZ", shares=10.0, price=100.0)
# ---------------------------------------------------------------------------
# Cash accounting
# ---------------------------------------------------------------------------
def test_add_holding_deducts_cash(mock_supabase_client, report_store):
"""add_holding() must reduce portfolio.cash by shares * price."""
portfolio = _mock_portfolio(cash=10_000.0)
mock_supabase_client.get_portfolio.return_value = portfolio
mock_supabase_client.get_holding.return_value = None
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
repo.add_holding("pid-1", "AAPL", shares=10, price=200.0)
# Check the portfolio passed to update_portfolio had cash deducted
updated = mock_supabase_client.update_portfolio.call_args[0][0]
assert updated.cash == pytest.approx(8_000.0)
def test_remove_holding_credits_cash(mock_supabase_client, report_store):
"""remove_holding() must increase portfolio.cash by shares * price."""
portfolio = _mock_portfolio(cash=5_000.0)
mock_supabase_client.get_holding.return_value = _mock_holding(shares=50.0)
mock_supabase_client.get_portfolio.return_value = portfolio
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
repo.remove_holding("pid-1", "AAPL", shares=20.0, price=200.0)
updated = mock_supabase_client.update_portfolio.call_args[0][0]
assert updated.cash == pytest.approx(9_000.0)
# ---------------------------------------------------------------------------
# Trade recording
# ---------------------------------------------------------------------------
def test_add_holding_records_buy_trade(mock_supabase_client, report_store):
"""add_holding() must call client.record_trade() with action='BUY'."""
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=10_000.0)
mock_supabase_client.get_holding.return_value = None
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
repo.add_holding("pid-1", "AAPL", shares=10, price=200.0)
trade = mock_supabase_client.record_trade.call_args[0][0]
assert trade.action == "BUY"
assert trade.ticker == "AAPL"
assert trade.shares == 10
assert trade.total_value == pytest.approx(2_000.0)
def test_remove_holding_records_sell_trade(mock_supabase_client, report_store):
"""remove_holding() must call client.record_trade() with action='SELL'."""
mock_supabase_client.get_holding.return_value = _mock_holding(shares=50.0)
mock_supabase_client.get_portfolio.return_value = _mock_portfolio(cash=5_000.0)
mock_supabase_client.upsert_holding.side_effect = lambda h: h
mock_supabase_client.update_portfolio.side_effect = lambda p: p
mock_supabase_client.record_trade.side_effect = lambda t: t
repo = _make_repo(mock_supabase_client, report_store)
repo.remove_holding("pid-1", "AAPL", shares=20.0, price=200.0)
trade = mock_supabase_client.record_trade.call_args[0][0]
assert trade.action == "SELL"
assert trade.ticker == "AAPL"
assert trade.shares == 20.0
# ---------------------------------------------------------------------------
# Snapshot
# ---------------------------------------------------------------------------
def test_take_snapshot(mock_supabase_client, report_store):
"""take_snapshot() must enrich holdings and persist a PortfolioSnapshot."""
portfolio = _mock_portfolio(cash=5_000.0)
holding = _mock_holding(shares=50.0, avg_cost=190.0)
mock_supabase_client.get_portfolio.return_value = portfolio
mock_supabase_client.list_holdings.return_value = [holding]
mock_supabase_client.save_snapshot.side_effect = lambda s: s
repo = _make_repo(mock_supabase_client, report_store)
snapshot = repo.take_snapshot("pid-1", prices={"AAPL": 200.0})
assert mock_supabase_client.save_snapshot.called
assert snapshot.cash == 5_000.0
assert snapshot.num_positions == 1
assert snapshot.total_value == pytest.approx(5_000.0 + 50.0 * 200.0)
# ---------------------------------------------------------------------------
# Supabase integration tests (auto-skip without SUPABASE_CONNECTION_STRING)
# ---------------------------------------------------------------------------
@requires_supabase
def test_integration_create_and_get_portfolio():
"""Integration: create a portfolio, retrieve it, verify fields match."""
from tradingagents.portfolio.supabase_client import SupabaseClient
client = SupabaseClient.get_instance()
from tradingagents.portfolio.report_store import ReportStore
store = ReportStore()
repo = PortfolioRepository(client=client, store=store)
portfolio = repo.create_portfolio("Integration Test", initial_cash=50_000.0)
try:
fetched = repo.get_portfolio(portfolio.portfolio_id)
assert fetched.name == "Integration Test"
assert fetched.cash == pytest.approx(50_000.0)
assert fetched.initial_cash == pytest.approx(50_000.0)
finally:
client.delete_portfolio(portfolio.portfolio_id)
@requires_supabase
def test_integration_add_and_remove_holding():
"""Integration: add holding, verify; remove, verify deletion."""
from tradingagents.portfolio.supabase_client import SupabaseClient
client = SupabaseClient.get_instance()
from tradingagents.portfolio.report_store import ReportStore
store = ReportStore()
repo = PortfolioRepository(client=client, store=store)
portfolio = repo.create_portfolio("Hold Test", initial_cash=50_000.0)
try:
holding = repo.add_holding(
portfolio.portfolio_id, "AAPL", shares=10, price=200.0,
sector="Technology",
)
assert holding.ticker == "AAPL"
assert holding.shares == 10
# Verify cash deducted
p = repo.get_portfolio(portfolio.portfolio_id)
assert p.cash == pytest.approx(48_000.0)
# Sell all
result = repo.remove_holding(portfolio.portfolio_id, "AAPL", shares=10, price=210.0)
assert result is None
# Verify cash credited
p = repo.get_portfolio(portfolio.portfolio_id)
assert p.cash == pytest.approx(50_100.0)
finally:
client.delete_portfolio(portfolio.portfolio_id)
@requires_supabase
def test_integration_record_and_list_trades():
"""Integration: trades are recorded automatically via add/remove holding."""
from tradingagents.portfolio.supabase_client import SupabaseClient
client = SupabaseClient.get_instance()
from tradingagents.portfolio.report_store import ReportStore
store = ReportStore()
repo = PortfolioRepository(client=client, store=store)
portfolio = repo.create_portfolio("Trade Test", initial_cash=50_000.0)
try:
repo.add_holding(portfolio.portfolio_id, "MSFT", shares=5, price=400.0)
repo.remove_holding(portfolio.portfolio_id, "MSFT", shares=5, price=410.0)
trades = client.list_trades(portfolio.portfolio_id)
assert len(trades) == 2
assert trades[0].action == "SELL" # newest first
assert trades[1].action == "BUY"
finally:
client.delete_portfolio(portfolio.portfolio_id)
@requires_supabase
def test_integration_save_and_load_snapshot():
"""Integration: take snapshot, retrieve latest, verify total_value."""
from tradingagents.portfolio.supabase_client import SupabaseClient
client = SupabaseClient.get_instance()
from tradingagents.portfolio.report_store import ReportStore
store = ReportStore()
repo = PortfolioRepository(client=client, store=store)
portfolio = repo.create_portfolio("Snap Test", initial_cash=50_000.0)
try:
repo.add_holding(portfolio.portfolio_id, "AAPL", shares=10, price=200.0)
snapshot = repo.take_snapshot(portfolio.portfolio_id, prices={"AAPL": 210.0})
assert snapshot.num_positions == 1
assert snapshot.cash == pytest.approx(48_000.0)
assert snapshot.total_value == pytest.approx(48_000.0 + 10 * 210.0)
latest = client.get_latest_snapshot(portfolio.portfolio_id)
assert latest is not None
assert latest.snapshot_id == snapshot.snapshot_id
finally:
client.delete_portfolio(portfolio.portfolio_id)

View File

@ -0,0 +1,55 @@
"""Portfolio Manager — public package exports.
Import the primary interface classes from this package:
from tradingagents.portfolio import (
PortfolioRepository,
Portfolio,
Holding,
Trade,
PortfolioSnapshot,
PortfolioError,
PortfolioNotFoundError,
InsufficientCashError,
InsufficientSharesError,
)
"""
from __future__ import annotations
from tradingagents.portfolio.exceptions import (
PortfolioError,
PortfolioNotFoundError,
HoldingNotFoundError,
DuplicatePortfolioError,
InsufficientCashError,
InsufficientSharesError,
ConstraintViolationError,
ReportStoreError,
)
from tradingagents.portfolio.models import (
Holding,
Portfolio,
PortfolioSnapshot,
Trade,
)
from tradingagents.portfolio.repository import PortfolioRepository
__all__ = [
# Models
"Portfolio",
"Holding",
"Trade",
"PortfolioSnapshot",
# Repository (primary interface)
"PortfolioRepository",
# Exceptions
"PortfolioError",
"PortfolioNotFoundError",
"HoldingNotFoundError",
"DuplicatePortfolioError",
"InsufficientCashError",
"InsufficientSharesError",
"ConstraintViolationError",
"ReportStoreError",
]

View File

@ -0,0 +1,106 @@
"""Portfolio Manager configuration.
Integrates with the existing ``tradingagents/default_config.py`` pattern,
reading all portfolio settings from ``TRADINGAGENTS_<KEY>`` env vars.
Usage::
from tradingagents.portfolio.config import get_portfolio_config, validate_config
cfg = get_portfolio_config()
validate_config(cfg)
print(cfg["max_positions"]) # 15
"""
from __future__ import annotations
import os
from dotenv import load_dotenv
load_dotenv()
def _env(key: str, default=None):
"""Read ``TRADINGAGENTS_<KEY>`` from the environment.
Matches the convention in ``tradingagents/default_config.py``.
"""
val = os.getenv(f"TRADINGAGENTS_{key.upper()}")
if not val:
return default
return val
def _env_float(key: str, default=None):
val = _env(key)
if val is None:
return default
try:
return float(val)
except (ValueError, TypeError):
return default
def _env_int(key: str, default=None):
val = _env(key)
if val is None:
return default
try:
return int(val)
except (ValueError, TypeError):
return default
PORTFOLIO_CONFIG: dict = {
"supabase_connection_string": os.getenv("SUPABASE_CONNECTION_STRING", ""),
"data_dir": _env("PORTFOLIO_DATA_DIR", "reports"),
"max_positions": 15,
"max_position_pct": 0.15,
"max_sector_pct": 0.35,
"min_cash_pct": 0.05,
"default_budget": 100_000.0,
}
def get_portfolio_config() -> dict:
"""Return the merged portfolio config (defaults overridden by env vars).
Returns:
A dict with all portfolio configuration keys.
"""
cfg = dict(PORTFOLIO_CONFIG)
cfg["supabase_connection_string"] = os.getenv("SUPABASE_CONNECTION_STRING", cfg["supabase_connection_string"])
cfg["data_dir"] = _env("PORTFOLIO_DATA_DIR", cfg["data_dir"])
cfg["max_positions"] = _env_int("PM_MAX_POSITIONS", cfg["max_positions"])
cfg["max_position_pct"] = _env_float("PM_MAX_POSITION_PCT", cfg["max_position_pct"])
cfg["max_sector_pct"] = _env_float("PM_MAX_SECTOR_PCT", cfg["max_sector_pct"])
cfg["min_cash_pct"] = _env_float("PM_MIN_CASH_PCT", cfg["min_cash_pct"])
cfg["default_budget"] = _env_float("PM_DEFAULT_BUDGET", cfg["default_budget"])
return cfg
def validate_config(cfg: dict) -> None:
"""Validate a portfolio config dict, raising ValueError on invalid values.
Args:
cfg: Config dict as returned by ``get_portfolio_config()``.
Raises:
ValueError: With a descriptive message on the first failed check.
"""
if cfg["max_positions"] < 1:
raise ValueError(f"max_positions must be >= 1, got {cfg['max_positions']}")
if not (0 < cfg["max_position_pct"] <= 1.0):
raise ValueError(f"max_position_pct must be in (0, 1], got {cfg['max_position_pct']}")
if not (0 < cfg["max_sector_pct"] <= 1.0):
raise ValueError(f"max_sector_pct must be in (0, 1], got {cfg['max_sector_pct']}")
if not (0 <= cfg["min_cash_pct"] < 1.0):
raise ValueError(f"min_cash_pct must be in [0, 1), got {cfg['min_cash_pct']}")
if cfg["default_budget"] <= 0:
raise ValueError(f"default_budget must be > 0, got {cfg['default_budget']}")
if cfg["min_cash_pct"] + cfg["max_position_pct"] > 1.0:
raise ValueError(
f"min_cash_pct ({cfg['min_cash_pct']}) + max_position_pct ({cfg['max_position_pct']}) "
f"must be <= 1.0"
)

View File

@ -0,0 +1,60 @@
"""Domain exception hierarchy for the portfolio management package.
All exceptions raised by this package inherit from ``PortfolioError`` so that
callers can catch the entire family with a single ``except PortfolioError``.
Example::
from tradingagents.portfolio.exceptions import (
PortfolioError,
InsufficientCashError,
)
try:
repo.add_holding(pid, "AAPL", shares=100, price=195.50)
except InsufficientCashError as e:
print(f"Cannot buy: {e}")
except PortfolioError as e:
print(f"Unexpected portfolio error: {e}")
"""
from __future__ import annotations
class PortfolioError(Exception):
"""Base exception for all portfolio-management errors."""
class PortfolioNotFoundError(PortfolioError):
"""Raised when a requested portfolio_id does not exist in the database."""
class HoldingNotFoundError(PortfolioError):
"""Raised when a requested (portfolio_id, ticker) holding does not exist."""
class DuplicatePortfolioError(PortfolioError):
"""Raised when attempting to create a portfolio that already exists."""
class InsufficientCashError(PortfolioError):
"""Raised when a BUY order exceeds the portfolio's available cash balance."""
class InsufficientSharesError(PortfolioError):
"""Raised when a SELL order exceeds the number of shares held."""
class ConstraintViolationError(PortfolioError):
"""Raised when a trade would violate a PM constraint.
Constraints enforced:
- Max position size (default 15 % of portfolio value)
- Max sector exposure (default 35 % of portfolio value)
- Min cash reserve (default 5 % of portfolio value)
- Max number of positions (default 15)
"""
class ReportStoreError(PortfolioError):
"""Raised on filesystem read/write failures in ReportStore."""

View File

@ -0,0 +1,159 @@
-- =============================================================================
-- Portfolio Manager Agent — Initial Schema
-- Migration: 001_initial_schema.sql
-- Description: Creates all tables, indexes, and triggers for the portfolio
-- management data layer.
-- Safe to re-run: all statements use IF NOT EXISTS / CREATE OR REPLACE.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Table: portfolios
-- Purpose: One row per managed portfolio. Tracks cash balance, initial capital,
-- and a pointer to the filesystem report directory.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS portfolios (
portfolio_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
cash NUMERIC(18,4) NOT NULL CHECK (cash >= 0),
initial_cash NUMERIC(18,4) NOT NULL CHECK (initial_cash > 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
report_path TEXT, -- relative FS path to daily report dir
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE portfolios IS
'One row per managed portfolio. Tracks cash balance and links to filesystem reports.';
COMMENT ON COLUMN portfolios.report_path IS
'Relative path to the daily portfolio report directory, e.g. reports/daily/2026-03-20/portfolio';
COMMENT ON COLUMN portfolios.metadata IS
'Free-form JSONB for agent notes, tags, or strategy parameters.';
-- ---------------------------------------------------------------------------
-- Table: holdings
-- Purpose: Current open positions. One row per (portfolio, ticker). Deleted
-- when shares reach zero — zero-share rows are never stored.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS holdings (
holding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
ticker TEXT NOT NULL,
shares NUMERIC(18,6) NOT NULL CHECK (shares > 0),
avg_cost NUMERIC(18,4) NOT NULL CHECK (avg_cost >= 0),
sector TEXT,
industry TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT holdings_portfolio_ticker_unique UNIQUE (portfolio_id, ticker)
);
COMMENT ON TABLE holdings IS
'Open positions. Upserted on BUY (avg-cost update), deleted when fully sold.';
COMMENT ON COLUMN holdings.avg_cost IS
'Weighted-average cost basis per share in portfolio currency.';
-- ---------------------------------------------------------------------------
-- Table: trades
-- Purpose: Immutable append-only log of every mock trade. Never modified.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trades (
trade_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
ticker TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('BUY', 'SELL')),
shares NUMERIC(18,6) NOT NULL CHECK (shares > 0),
price NUMERIC(18,4) NOT NULL CHECK (price > 0),
total_value NUMERIC(18,4) NOT NULL CHECK (total_value > 0),
trade_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
rationale TEXT, -- PM agent rationale for this trade
signal_source TEXT, -- 'scanner' | 'holding_review' | 'pm_agent'
metadata JSONB NOT NULL DEFAULT '{}'
);
COMMENT ON TABLE trades IS
'Immutable trade log. Records every mock BUY/SELL with PM rationale.';
COMMENT ON COLUMN trades.rationale IS
'Natural-language reason provided by the Portfolio Manager Agent.';
COMMENT ON COLUMN trades.signal_source IS
'Which sub-system generated the trade signal: scanner, holding_review, or pm_agent.';
-- ---------------------------------------------------------------------------
-- Table: snapshots
-- Purpose: Immutable point-in-time portfolio state. Taken after each trade
-- execution session for performance tracking and time-series analysis.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE CASCADE,
snapshot_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_value NUMERIC(18,4) NOT NULL,
cash NUMERIC(18,4) NOT NULL,
equity_value NUMERIC(18,4) NOT NULL,
num_positions INTEGER NOT NULL CHECK (num_positions >= 0),
holdings_snapshot JSONB NOT NULL DEFAULT '[]', -- serialised List[Holding.to_dict()]
metadata JSONB NOT NULL DEFAULT '{}'
);
COMMENT ON TABLE snapshots IS
'Immutable portfolio snapshots for performance tracking (NAV series).';
COMMENT ON COLUMN snapshots.holdings_snapshot IS
'JSONB array of Holding.to_dict() objects at snapshot time.';
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
-- portfolios: lookup by name (uniqueness enforced at application level)
CREATE INDEX IF NOT EXISTS idx_portfolios_name
ON portfolios (name);
-- holdings: list all holdings for a portfolio (most frequent query)
CREATE INDEX IF NOT EXISTS idx_holdings_portfolio_id
ON holdings (portfolio_id);
-- holdings: fast (portfolio, ticker) point lookup for upserts
CREATE INDEX IF NOT EXISTS idx_holdings_portfolio_ticker
ON holdings (portfolio_id, ticker);
-- trades: list recent trades for a portfolio, newest first
CREATE INDEX IF NOT EXISTS idx_trades_portfolio_id_date
ON trades (portfolio_id, trade_date DESC);
-- trades: filter by ticker within a portfolio
CREATE INDEX IF NOT EXISTS idx_trades_portfolio_ticker
ON trades (portfolio_id, ticker);
-- snapshots: get latest snapshot for a portfolio
CREATE INDEX IF NOT EXISTS idx_snapshots_portfolio_id_date
ON snapshots (portfolio_id, snapshot_date DESC);
-- ---------------------------------------------------------------------------
-- updated_at trigger
-- Purpose: Automatically sets updated_at = NOW() on every UPDATE for mutable
-- tables (portfolios, holdings). Trades and snapshots are immutable.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to portfolios
CREATE OR REPLACE TRIGGER trg_portfolios_updated_at
BEFORE UPDATE ON portfolios
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Apply to holdings
CREATE OR REPLACE TRIGGER trg_holdings_updated_at
BEFORE UPDATE ON holdings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,314 @@
"""Data models for the Portfolio Manager Agent.
All models are Python ``dataclass`` types with:
- Full type annotations
- ``to_dict()`` for serialisation (JSON / Supabase)
- ``from_dict()`` class method for deserialisation
- ``enrich()`` for attaching runtime-computed fields
**float vs Decimal** monetary fields (cash, price, shares, etc.) use plain
``float`` throughout. Rationale:
1. This is **mock trading only** no real money changes hands. The cost of a
subtle floating-point rounding error is zero.
2. All upstream data sources (yfinance, Alpha Vantage, Finnhub) return ``float``
already. Converting to ``Decimal`` at the boundary would require a custom
JSON encoder *and* decoder everywhere, for no practical gain.
3. ``json.dumps`` serialises ``float`` natively; ``Decimal`` raises
``TypeError`` without a custom encoder.
4. If this ever becomes real-money trading, replace ``float`` with
``decimal.Decimal`` and add a ``DecimalEncoder`` the interface is
identical and the change is localised to this file.
See ``docs/portfolio/02_data_models.md`` for full field specifications.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# Portfolio
# ---------------------------------------------------------------------------
@dataclass
class Portfolio:
"""A managed investment portfolio.
Stored fields are persisted to Supabase. Computed fields (total_value,
equity_value, cash_pct) are populated by ``enrich()`` and are *not*
persisted.
"""
portfolio_id: str
name: str
cash: float
initial_cash: float
currency: str = "USD"
created_at: str = ""
updated_at: str = ""
report_path: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
# Runtime-computed (not stored in DB)
total_value: float | None = field(default=None, repr=False)
equity_value: float | None = field(default=None, repr=False)
cash_pct: float | None = field(default=None, repr=False)
def to_dict(self) -> dict[str, Any]:
"""Serialise stored fields to a flat dict for JSON / Supabase.
Runtime-computed fields are excluded.
"""
return {
"portfolio_id": self.portfolio_id,
"name": self.name,
"cash": self.cash,
"initial_cash": self.initial_cash,
"currency": self.currency,
"created_at": self.created_at,
"updated_at": self.updated_at,
"report_path": self.report_path,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Portfolio":
"""Deserialise from a DB row or JSON dict.
Missing optional fields default gracefully. Extra keys are ignored.
"""
return cls(
portfolio_id=data["portfolio_id"],
name=data["name"],
cash=float(data["cash"]),
initial_cash=float(data["initial_cash"]),
currency=data.get("currency", "USD"),
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", ""),
report_path=data.get("report_path"),
metadata=data.get("metadata") or {},
)
def enrich(self, holdings: list["Holding"]) -> "Portfolio":
"""Compute total_value, equity_value, cash_pct from holdings.
Modifies self in-place and returns self for chaining.
Args:
holdings: List of Holding objects with current_value populated
(i.e., ``holding.enrich()`` already called).
"""
self.equity_value = sum(
h.current_value for h in holdings if h.current_value is not None
)
self.total_value = self.cash + self.equity_value
self.cash_pct = self.cash / self.total_value if self.total_value != 0.0 else 1.0
return self
# ---------------------------------------------------------------------------
# Holding
# ---------------------------------------------------------------------------
@dataclass
class Holding:
"""An open position within a portfolio.
Stored fields are persisted to Supabase. Runtime-computed fields
(current_price, current_value, etc.) are populated by ``enrich()``.
"""
holding_id: str
portfolio_id: str
ticker: str
shares: float
avg_cost: float
sector: str | None = None
industry: str | None = None
created_at: str = ""
updated_at: str = ""
# Runtime-computed (not stored in DB)
current_price: float | None = field(default=None, repr=False)
current_value: float | None = field(default=None, repr=False)
cost_basis: float | None = field(default=None, repr=False)
unrealized_pnl: float | None = field(default=None, repr=False)
unrealized_pnl_pct: float | None = field(default=None, repr=False)
weight: float | None = field(default=None, repr=False)
def to_dict(self) -> dict[str, Any]:
"""Serialise stored fields only (runtime-computed fields excluded)."""
return {
"holding_id": self.holding_id,
"portfolio_id": self.portfolio_id,
"ticker": self.ticker,
"shares": self.shares,
"avg_cost": self.avg_cost,
"sector": self.sector,
"industry": self.industry,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Holding":
"""Deserialise from a DB row or JSON dict."""
return cls(
holding_id=data["holding_id"],
portfolio_id=data["portfolio_id"],
ticker=data["ticker"],
shares=float(data["shares"]),
avg_cost=float(data["avg_cost"]),
sector=data.get("sector"),
industry=data.get("industry"),
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", ""),
)
def enrich(self, current_price: float, portfolio_total_value: float) -> "Holding":
"""Populate runtime-computed fields in-place and return self.
Formula:
current_value = current_price * shares
cost_basis = avg_cost * shares
unrealized_pnl = current_value - cost_basis
unrealized_pnl_pct = unrealized_pnl / cost_basis (0 when cost_basis == 0)
weight = current_value / portfolio_total_value (0 when total == 0)
Args:
current_price: Latest market price for this ticker.
portfolio_total_value: Total portfolio value (cash + equity).
"""
self.current_price = current_price
self.current_value = current_price * self.shares
self.cost_basis = self.avg_cost * self.shares
self.unrealized_pnl = self.current_value - self.cost_basis
self.unrealized_pnl_pct = (
self.unrealized_pnl / self.cost_basis if self.cost_basis != 0.0 else 0.0
)
self.weight = (
self.current_value / portfolio_total_value if portfolio_total_value != 0.0 else 0.0
)
return self
# ---------------------------------------------------------------------------
# Trade
# ---------------------------------------------------------------------------
@dataclass
class Trade:
"""An immutable record of a single mock trade execution.
Trades are never modified after creation.
"""
trade_id: str
portfolio_id: str
ticker: str
action: str # "BUY" or "SELL"
shares: float
price: float
total_value: float
trade_date: str = ""
rationale: str | None = None
signal_source: str | None = None # "scanner" | "holding_review" | "pm_agent"
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Serialise all fields."""
return {
"trade_id": self.trade_id,
"portfolio_id": self.portfolio_id,
"ticker": self.ticker,
"action": self.action,
"shares": self.shares,
"price": self.price,
"total_value": self.total_value,
"trade_date": self.trade_date,
"rationale": self.rationale,
"signal_source": self.signal_source,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Trade":
"""Deserialise from a DB row or JSON dict."""
return cls(
trade_id=data["trade_id"],
portfolio_id=data["portfolio_id"],
ticker=data["ticker"],
action=data["action"],
shares=float(data["shares"]),
price=float(data["price"]),
total_value=float(data["total_value"]),
trade_date=data.get("trade_date", ""),
rationale=data.get("rationale"),
signal_source=data.get("signal_source"),
metadata=data.get("metadata") or {},
)
# ---------------------------------------------------------------------------
# PortfolioSnapshot
# ---------------------------------------------------------------------------
@dataclass
class PortfolioSnapshot:
"""An immutable point-in-time snapshot of portfolio state.
Taken after every trade execution session (Phase 5 of the PM workflow).
Used for NAV time-series, performance attribution, and risk backtesting.
"""
snapshot_id: str
portfolio_id: str
snapshot_date: str
total_value: float
cash: float
equity_value: float
num_positions: int
holdings_snapshot: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Serialise all fields. ``holdings_snapshot`` is already a list[dict]."""
return {
"snapshot_id": self.snapshot_id,
"portfolio_id": self.portfolio_id,
"snapshot_date": self.snapshot_date,
"total_value": self.total_value,
"cash": self.cash,
"equity_value": self.equity_value,
"num_positions": self.num_positions,
"holdings_snapshot": self.holdings_snapshot,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PortfolioSnapshot":
"""Deserialise from DB row or JSON dict.
``holdings_snapshot`` is parsed from a JSON string when needed.
"""
holdings_snapshot = data.get("holdings_snapshot", [])
if isinstance(holdings_snapshot, str):
holdings_snapshot = json.loads(holdings_snapshot)
return cls(
snapshot_id=data["snapshot_id"],
portfolio_id=data["portfolio_id"],
snapshot_date=data["snapshot_date"],
total_value=float(data["total_value"]),
cash=float(data["cash"]),
equity_value=float(data["equity_value"]),
num_positions=int(data["num_positions"]),
holdings_snapshot=holdings_snapshot,
metadata=data.get("metadata") or {},
)

View File

@ -0,0 +1,261 @@
"""Filesystem document store for Portfolio Manager reports.
Saves and loads all non-transactional portfolio artifacts (scans, per-ticker
analysis, holding reviews, risk metrics, PM decisions) using the existing
``tradingagents/report_paths.py`` path convention.
Directory layout::
reports/daily/{date}/
market/
macro_scan_summary.json save_scan / load_scan
{TICKER}/
complete_report.json save_analysis / load_analysis
portfolio/
{TICKER}_holding_review.json save/load_holding_review
{portfolio_id}_risk_metrics.json
{portfolio_id}_pm_decision.json
{portfolio_id}_pm_decision.md
Usage::
from tradingagents.portfolio.report_store import ReportStore
store = ReportStore()
store.save_scan("2026-03-20", {"watchlist": [...]})
data = store.load_scan("2026-03-20")
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from tradingagents.portfolio.exceptions import ReportStoreError
class ReportStore:
"""Filesystem document store for all portfolio-related reports.
Directories are created automatically on first write.
All load methods return ``None`` when the file does not exist.
"""
def __init__(self, base_dir: str | Path = "reports") -> None:
"""Initialise the store with a base reports directory.
Args:
base_dir: Root directory for all reports. Defaults to ``"reports"``
(relative to CWD), matching ``report_paths.REPORTS_ROOT``.
Override via the ``PORTFOLIO_DATA_DIR`` env var or
``get_portfolio_config()["data_dir"]``.
"""
self._base_dir = Path(base_dir)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _portfolio_dir(self, date: str) -> Path:
"""Return the portfolio subdirectory for a given date.
Path: ``{base_dir}/daily/{date}/portfolio/``
"""
return self._base_dir / "daily" / date / "portfolio"
def _write_json(self, path: Path, data: dict[str, Any]) -> Path:
"""Write a dict to a JSON file, creating parent directories as needed.
Args:
path: Target file path.
data: Data to serialise.
Returns:
The path written.
Raises:
ReportStoreError: On filesystem write failure.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
return path
except OSError as exc:
raise ReportStoreError(f"Failed to write {path}: {exc}") from exc
def _read_json(self, path: Path) -> dict[str, Any] | None:
"""Read a JSON file, returning None if the file does not exist.
Raises:
ReportStoreError: On JSON parse error (file exists but is corrupt).
"""
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ReportStoreError(f"Corrupt JSON at {path}: {exc}") from exc
# ------------------------------------------------------------------
# Macro Scan
# ------------------------------------------------------------------
def save_scan(self, date: str, data: dict[str, Any]) -> Path:
"""Save macro scan summary JSON.
Path: ``{base_dir}/daily/{date}/market/macro_scan_summary.json``
Args:
date: ISO date string, e.g. ``"2026-03-20"``.
data: Scan output dict (typically the macro_scan_summary).
Returns:
Path of the written file.
"""
path = self._base_dir / "daily" / date / "market" / "macro_scan_summary.json"
return self._write_json(path, data)
def load_scan(self, date: str) -> dict[str, Any] | None:
"""Load macro scan summary. Returns None if the file does not exist."""
path = self._base_dir / "daily" / date / "market" / "macro_scan_summary.json"
return self._read_json(path)
# ------------------------------------------------------------------
# Per-Ticker Analysis
# ------------------------------------------------------------------
def save_analysis(self, date: str, ticker: str, data: dict[str, Any]) -> Path:
"""Save per-ticker analysis report as JSON.
Path: ``{base_dir}/daily/{date}/{TICKER}/complete_report.json``
Args:
date: ISO date string.
ticker: Ticker symbol (stored as uppercase).
data: Analysis output dict.
"""
path = self._base_dir / "daily" / date / ticker.upper() / "complete_report.json"
return self._write_json(path, data)
def load_analysis(self, date: str, ticker: str) -> dict[str, Any] | None:
"""Load per-ticker analysis JSON. Returns None if the file does not exist."""
path = self._base_dir / "daily" / date / ticker.upper() / "complete_report.json"
return self._read_json(path)
# ------------------------------------------------------------------
# Holding Reviews
# ------------------------------------------------------------------
def save_holding_review(
self,
date: str,
ticker: str,
data: dict[str, Any],
) -> Path:
"""Save holding reviewer output for one ticker.
Path: ``{base_dir}/daily/{date}/portfolio/{TICKER}_holding_review.json``
Args:
date: ISO date string.
ticker: Ticker symbol (stored as uppercase).
data: HoldingReviewerAgent output dict.
"""
path = self._portfolio_dir(date) / f"{ticker.upper()}_holding_review.json"
return self._write_json(path, data)
def load_holding_review(self, date: str, ticker: str) -> dict[str, Any] | None:
"""Load holding review output. Returns None if the file does not exist."""
path = self._portfolio_dir(date) / f"{ticker.upper()}_holding_review.json"
return self._read_json(path)
# ------------------------------------------------------------------
# Risk Metrics
# ------------------------------------------------------------------
def save_risk_metrics(
self,
date: str,
portfolio_id: str,
data: dict[str, Any],
) -> Path:
"""Save risk computation results.
Path: ``{base_dir}/daily/{date}/portfolio/{portfolio_id}_risk_metrics.json``
Args:
date: ISO date string.
portfolio_id: UUID of the target portfolio.
data: Risk metrics dict (Sharpe, Sortino, VaR, etc.).
"""
path = self._portfolio_dir(date) / f"{portfolio_id}_risk_metrics.json"
return self._write_json(path, data)
def load_risk_metrics(
self,
date: str,
portfolio_id: str,
) -> dict[str, Any] | None:
"""Load risk metrics. Returns None if the file does not exist."""
path = self._portfolio_dir(date) / f"{portfolio_id}_risk_metrics.json"
return self._read_json(path)
# ------------------------------------------------------------------
# PM Decisions
# ------------------------------------------------------------------
def save_pm_decision(
self,
date: str,
portfolio_id: str,
data: dict[str, Any],
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)
Args:
date: ISO date string.
portfolio_id: UUID of the target portfolio.
data: PM decision dict (sells, buys, holds, rationale, ).
markdown: Optional human-readable version; written when provided.
Returns:
Path of the written JSON file.
"""
json_path = self._portfolio_dir(date) / f"{portfolio_id}_pm_decision.json"
self._write_json(json_path, data)
if markdown is not None:
md_path = self._portfolio_dir(date) / f"{portfolio_id}_pm_decision.md"
try:
md_path.write_text(markdown, encoding="utf-8")
except OSError as exc:
raise ReportStoreError(f"Failed to write {md_path}: {exc}") from exc
return json_path
def load_pm_decision(
self,
date: str,
portfolio_id: str,
) -> dict[str, Any] | None:
"""Load PM decision JSON. Returns None if the file does not exist."""
path = self._portfolio_dir(date) / f"{portfolio_id}_pm_decision.json"
return self._read_json(path)
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``.
Args:
portfolio_id: UUID of the target portfolio.
Returns:
Sorted list of Path objects, newest date first.
"""
pattern = f"daily/*/portfolio/{portfolio_id}_pm_decision.json"
return sorted(self._base_dir.glob(pattern), reverse=True)

View File

@ -0,0 +1,301 @@
"""Unified data-access facade for the Portfolio Manager.
``PortfolioRepository`` combines ``SupabaseClient`` (transactional data) and
``ReportStore`` (filesystem documents) into a single, business-logic-aware
interface.
Usage::
from tradingagents.portfolio import PortfolioRepository
repo = PortfolioRepository()
portfolio = repo.create_portfolio("Main Portfolio", initial_cash=100_000.0)
holding = repo.add_holding(portfolio.portfolio_id, "AAPL", shares=50, price=195.50)
See ``docs/portfolio/04_repository_api.md`` for full API documentation.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from tradingagents.portfolio.config import get_portfolio_config
from tradingagents.portfolio.exceptions import (
HoldingNotFoundError,
InsufficientCashError,
InsufficientSharesError,
)
from tradingagents.portfolio.models import (
Holding,
Portfolio,
PortfolioSnapshot,
Trade,
)
from tradingagents.portfolio.report_store import ReportStore
from tradingagents.portfolio.supabase_client import SupabaseClient
class PortfolioRepository:
"""Unified facade over SupabaseClient and ReportStore.
Implements business logic for:
- Average cost basis updates on repeated buys
- Cash deduction / credit on trades
- Constraint enforcement (cash, position size)
- Snapshot management
"""
def __init__(
self,
client: SupabaseClient | None = None,
store: ReportStore | None = None,
config: dict[str, Any] | None = None,
) -> None:
self._cfg = config or get_portfolio_config()
self._client = client or SupabaseClient.get_instance()
self._store = store or ReportStore(base_dir=self._cfg["data_dir"])
# ------------------------------------------------------------------
# Portfolio lifecycle
# ------------------------------------------------------------------
def create_portfolio(
self,
name: str,
initial_cash: float,
currency: str = "USD",
) -> Portfolio:
"""Create a new portfolio with the given starting capital."""
if initial_cash <= 0:
raise ValueError(f"initial_cash must be > 0, got {initial_cash}")
portfolio = Portfolio(
portfolio_id=str(uuid.uuid4()),
name=name,
cash=initial_cash,
initial_cash=initial_cash,
currency=currency,
)
return self._client.create_portfolio(portfolio)
def get_portfolio(self, portfolio_id: str) -> Portfolio:
"""Fetch a portfolio by ID."""
return self._client.get_portfolio(portfolio_id)
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."""
portfolio = self._client.get_portfolio(portfolio_id)
holdings = self._client.list_holdings(portfolio_id)
if prices:
# First pass: compute equity for total_value
equity = sum(
prices.get(h.ticker, 0.0) * h.shares for h in holdings
)
total_value = portfolio.cash + equity
# Second pass: enrich each holding with weight
for h in holdings:
if h.ticker in prices:
h.enrich(prices[h.ticker], total_value)
portfolio.enrich(holdings)
return portfolio, holdings
# ------------------------------------------------------------------
# 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."""
if shares <= 0:
raise ValueError(f"shares must be > 0, got {shares}")
if price <= 0:
raise ValueError(f"price must be > 0, got {price}")
cost = shares * price
portfolio = self._client.get_portfolio(portfolio_id)
if portfolio.cash < cost:
raise InsufficientCashError(
f"Need ${cost:.2f} but only ${portfolio.cash:.2f} available"
)
# Check for existing holding to update avg cost
existing = self._client.get_holding(portfolio_id, ticker)
if existing:
new_total_shares = existing.shares + shares
new_avg_cost = (
(existing.shares * existing.avg_cost + shares * price) / new_total_shares
)
existing.shares = new_total_shares
existing.avg_cost = new_avg_cost
if sector:
existing.sector = sector
if industry:
existing.industry = industry
holding = self._client.upsert_holding(existing)
else:
holding = Holding(
holding_id=str(uuid.uuid4()),
portfolio_id=portfolio_id,
ticker=ticker.upper(),
shares=shares,
avg_cost=price,
sector=sector,
industry=industry,
)
holding = self._client.upsert_holding(holding)
# Deduct cash
portfolio.cash -= cost
self._client.update_portfolio(portfolio)
# Record trade
trade = Trade(
trade_id=str(uuid.uuid4()),
portfolio_id=portfolio_id,
ticker=ticker.upper(),
action="BUY",
shares=shares,
price=price,
total_value=cost,
trade_date=datetime.now(timezone.utc).isoformat(),
signal_source="pm_agent",
)
self._client.record_trade(trade)
return holding
def remove_holding(
self,
portfolio_id: str,
ticker: str,
shares: float,
price: float,
) -> Holding | None:
"""Sell shares and update portfolio cash and holdings."""
if shares <= 0:
raise ValueError(f"shares must be > 0, got {shares}")
if price <= 0:
raise ValueError(f"price must be > 0, got {price}")
existing = self._client.get_holding(portfolio_id, ticker)
if not existing:
raise HoldingNotFoundError(
f"No holding for {ticker} in portfolio {portfolio_id}"
)
if existing.shares < shares:
raise InsufficientSharesError(
f"Hold {existing.shares} shares of {ticker}, cannot sell {shares}"
)
proceeds = shares * price
portfolio = self._client.get_portfolio(portfolio_id)
if existing.shares == shares:
# Full sell — delete holding
self._client.delete_holding(portfolio_id, ticker)
result = None
else:
existing.shares -= shares
result = self._client.upsert_holding(existing)
# Credit cash
portfolio.cash += proceeds
self._client.update_portfolio(portfolio)
# Record trade
trade = Trade(
trade_id=str(uuid.uuid4()),
portfolio_id=portfolio_id,
ticker=ticker.upper(),
action="SELL",
shares=shares,
price=price,
total_value=proceeds,
trade_date=datetime.now(timezone.utc).isoformat(),
signal_source="pm_agent",
)
self._client.record_trade(trade)
return result
# ------------------------------------------------------------------
# Snapshots
# ------------------------------------------------------------------
def take_snapshot(
self,
portfolio_id: str,
prices: dict[str, float],
) -> PortfolioSnapshot:
"""Take an immutable snapshot of the current portfolio state."""
portfolio, holdings = self.get_portfolio_with_holdings(portfolio_id, prices)
snapshot = PortfolioSnapshot(
snapshot_id=str(uuid.uuid4()),
portfolio_id=portfolio_id,
snapshot_date=datetime.now(timezone.utc).isoformat(),
total_value=portfolio.total_value or portfolio.cash,
cash=portfolio.cash,
equity_value=portfolio.equity_value or 0.0,
num_positions=len(holdings),
holdings_snapshot=[h.to_dict() for h in holdings],
)
return self._client.save_snapshot(snapshot)
# ------------------------------------------------------------------
# Report convenience methods
# ------------------------------------------------------------------
def save_pm_decision(
self,
portfolio_id: str,
date: str,
decision: dict[str, Any],
markdown: str | None = None,
) -> Path:
"""Save a PM agent decision and update portfolio.report_path."""
path = self._store.save_pm_decision(date, portfolio_id, decision, markdown)
# Update portfolio report_path
portfolio = self._client.get_portfolio(portfolio_id)
portfolio.report_path = str(self._store._portfolio_dir(date))
self._client.update_portfolio(portfolio)
return path
def load_pm_decision(
self,
portfolio_id: str,
date: str,
) -> dict[str, Any] | None:
"""Load a PM decision JSON. Returns None if not found."""
return self._store.load_pm_decision(date, portfolio_id)
def save_risk_metrics(
self,
portfolio_id: str,
date: str,
metrics: dict[str, Any],
) -> Path:
"""Save risk computation results."""
return self._store.save_risk_metrics(date, portfolio_id, metrics)
def load_risk_metrics(
self,
portfolio_id: str,
date: str,
) -> dict[str, Any] | None:
"""Load risk metrics. Returns None if not found."""
return self._store.load_risk_metrics(date, portfolio_id)

View File

@ -0,0 +1,391 @@
"""PostgreSQL database client for the Portfolio Manager.
Uses ``psycopg2`` with the ``SUPABASE_CONNECTION_STRING`` env var to talk
directly to the Supabase-hosted PostgreSQL database. No ORM see
``docs/agent/decisions/012-portfolio-no-orm.md`` for rationale.
Usage::
from tradingagents.portfolio.supabase_client import SupabaseClient
client = SupabaseClient.get_instance()
portfolio = client.get_portfolio("some-uuid")
"""
from __future__ import annotations
import json
import uuid
import psycopg2
import psycopg2.extras
from tradingagents.portfolio.config import get_portfolio_config
from tradingagents.portfolio.exceptions import (
DuplicatePortfolioError,
HoldingNotFoundError,
PortfolioError,
PortfolioNotFoundError,
)
from tradingagents.portfolio.models import (
Holding,
Portfolio,
PortfolioSnapshot,
Trade,
)
class SupabaseClient:
"""Singleton PostgreSQL CRUD client for portfolio data.
All public methods translate database errors into domain exceptions
and return typed model instances.
"""
_instance: SupabaseClient | None = None
def __init__(self, connection_string: str) -> None:
self._dsn = self._fix_dsn(connection_string)
self._conn = psycopg2.connect(self._dsn)
self._conn.autocommit = True
@staticmethod
def _fix_dsn(dsn: str) -> str:
"""URL-encode the password if it contains special characters."""
from urllib.parse import quote
if "://" not in dsn:
return dsn # already key=value format
scheme, rest = dsn.split("://", 1)
at_idx = rest.rfind("@")
if at_idx == -1:
return dsn
userinfo = rest[:at_idx]
hostinfo = rest[at_idx + 1:]
colon_idx = userinfo.find(":")
if colon_idx == -1:
return dsn
user = userinfo[:colon_idx]
password = userinfo[colon_idx + 1:]
encoded = quote(password, safe="")
return f"{scheme}://{user}:{encoded}@{hostinfo}"
@classmethod
def get_instance(cls) -> SupabaseClient:
"""Return the singleton instance, creating it if necessary."""
if cls._instance is None:
cfg = get_portfolio_config()
dsn = cfg["supabase_connection_string"]
if not dsn:
raise PortfolioError(
"SUPABASE_CONNECTION_STRING not configured. "
"Set it in .env or as an environment variable."
)
cls._instance = cls(dsn)
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Close and reset the singleton (for testing)."""
if cls._instance is not None:
try:
cls._instance._conn.close()
except Exception:
pass
cls._instance = None
def _cursor(self):
"""Return a RealDictCursor."""
return self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# ------------------------------------------------------------------
# Portfolio CRUD
# ------------------------------------------------------------------
def create_portfolio(self, portfolio: Portfolio) -> Portfolio:
"""Insert a new portfolio row."""
pid = portfolio.portfolio_id or str(uuid.uuid4())
try:
with self._cursor() as cur:
cur.execute(
"""INSERT INTO portfolios
(portfolio_id, name, cash, initial_cash, currency, report_path, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *""",
(pid, portfolio.name, portfolio.cash, portfolio.initial_cash,
portfolio.currency, portfolio.report_path,
json.dumps(portfolio.metadata)),
)
row = cur.fetchone()
except psycopg2.errors.UniqueViolation as exc:
raise DuplicatePortfolioError(f"Portfolio already exists: {pid}") from exc
return self._row_to_portfolio(row)
def get_portfolio(self, portfolio_id: str) -> Portfolio:
"""Fetch a portfolio by ID."""
with self._cursor() as cur:
cur.execute("SELECT * FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if not row:
raise PortfolioNotFoundError(f"Portfolio not found: {portfolio_id}")
return self._row_to_portfolio(row)
def list_portfolios(self) -> list[Portfolio]:
"""Return all portfolios ordered by created_at DESC."""
with self._cursor() as cur:
cur.execute("SELECT * FROM portfolios ORDER BY created_at DESC")
rows = cur.fetchall()
return [self._row_to_portfolio(r) for r in rows]
def update_portfolio(self, portfolio: Portfolio) -> Portfolio:
"""Update mutable portfolio fields (cash, report_path, metadata)."""
with self._cursor() as cur:
cur.execute(
"""UPDATE portfolios
SET cash = %s, report_path = %s, metadata = %s
WHERE portfolio_id = %s
RETURNING *""",
(portfolio.cash, portfolio.report_path,
json.dumps(portfolio.metadata), portfolio.portfolio_id),
)
row = cur.fetchone()
if not row:
raise PortfolioNotFoundError(f"Portfolio not found: {portfolio.portfolio_id}")
return self._row_to_portfolio(row)
def delete_portfolio(self, portfolio_id: str) -> None:
"""Delete a portfolio and all associated data (CASCADE)."""
with self._cursor() as cur:
cur.execute(
"DELETE FROM portfolios WHERE portfolio_id = %s RETURNING portfolio_id",
(portfolio_id,),
)
row = cur.fetchone()
if not row:
raise PortfolioNotFoundError(f"Portfolio not found: {portfolio_id}")
# ------------------------------------------------------------------
# Holdings CRUD
# ------------------------------------------------------------------
def upsert_holding(self, holding: Holding) -> Holding:
"""Insert or update a holding row (upsert on portfolio_id + ticker)."""
hid = holding.holding_id or str(uuid.uuid4())
with self._cursor() as cur:
cur.execute(
"""INSERT INTO holdings
(holding_id, portfolio_id, ticker, shares, avg_cost, sector, industry)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT ON CONSTRAINT holdings_portfolio_ticker_unique
DO UPDATE SET shares = EXCLUDED.shares,
avg_cost = EXCLUDED.avg_cost,
sector = EXCLUDED.sector,
industry = EXCLUDED.industry
RETURNING *""",
(hid, holding.portfolio_id, holding.ticker.upper(),
holding.shares, holding.avg_cost, holding.sector, holding.industry),
)
row = cur.fetchone()
return self._row_to_holding(row)
def get_holding(self, portfolio_id: str, ticker: str) -> Holding | None:
"""Return the holding for (portfolio_id, ticker), or None."""
with self._cursor() as cur:
cur.execute(
"SELECT * FROM holdings WHERE portfolio_id = %s AND ticker = %s",
(portfolio_id, ticker.upper()),
)
row = cur.fetchone()
return self._row_to_holding(row) if row else None
def list_holdings(self, portfolio_id: str) -> list[Holding]:
"""Return all holdings for a portfolio ordered by cost_basis DESC."""
with self._cursor() as cur:
cur.execute(
"""SELECT * FROM holdings
WHERE portfolio_id = %s
ORDER BY shares * avg_cost DESC""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_holding(r) for r in rows]
def delete_holding(self, portfolio_id: str, ticker: str) -> None:
"""Delete the holding for (portfolio_id, ticker)."""
with self._cursor() as cur:
cur.execute(
"DELETE FROM holdings WHERE portfolio_id = %s AND ticker = %s RETURNING holding_id",
(portfolio_id, ticker.upper()),
)
row = cur.fetchone()
if not row:
raise HoldingNotFoundError(
f"Holding not found: {ticker} in portfolio {portfolio_id}"
)
# ------------------------------------------------------------------
# Trades
# ------------------------------------------------------------------
def record_trade(self, trade: Trade) -> Trade:
"""Insert a new trade record."""
tid = trade.trade_id or str(uuid.uuid4())
with self._cursor() as cur:
cur.execute(
"""INSERT INTO trades
(trade_id, portfolio_id, ticker, action, shares, price,
total_value, rationale, signal_source, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *""",
(tid, trade.portfolio_id, trade.ticker, trade.action,
trade.shares, trade.price, trade.total_value,
trade.rationale, trade.signal_source,
json.dumps(trade.metadata)),
)
row = cur.fetchone()
return self._row_to_trade(row)
def list_trades(
self,
portfolio_id: str,
ticker: str | None = None,
limit: int = 100,
) -> list[Trade]:
"""Return recent trades for a portfolio, newest first."""
if ticker:
query = """SELECT * FROM trades
WHERE portfolio_id = %s AND ticker = %s
ORDER BY trade_date DESC LIMIT %s"""
params = (portfolio_id, ticker.upper(), limit)
else:
query = """SELECT * FROM trades
WHERE portfolio_id = %s
ORDER BY trade_date DESC LIMIT %s"""
params = (portfolio_id, limit)
with self._cursor() as cur:
cur.execute(query, params)
rows = cur.fetchall()
return [self._row_to_trade(r) for r in rows]
# ------------------------------------------------------------------
# Snapshots
# ------------------------------------------------------------------
def save_snapshot(self, snapshot: PortfolioSnapshot) -> PortfolioSnapshot:
"""Insert a new immutable portfolio snapshot."""
sid = snapshot.snapshot_id or str(uuid.uuid4())
with self._cursor() as cur:
cur.execute(
"""INSERT INTO snapshots
(snapshot_id, portfolio_id, total_value, cash, equity_value,
num_positions, holdings_snapshot, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *""",
(sid, snapshot.portfolio_id, snapshot.total_value,
snapshot.cash, snapshot.equity_value, snapshot.num_positions,
json.dumps(snapshot.holdings_snapshot),
json.dumps(snapshot.metadata)),
)
row = cur.fetchone()
return self._row_to_snapshot(row)
def get_latest_snapshot(self, portfolio_id: str) -> PortfolioSnapshot | None:
"""Return the most recent snapshot for a portfolio, or None."""
with self._cursor() as cur:
cur.execute(
"""SELECT * FROM snapshots
WHERE portfolio_id = %s
ORDER BY snapshot_date DESC LIMIT 1""",
(portfolio_id,),
)
row = cur.fetchone()
return self._row_to_snapshot(row) if row else None
def list_snapshots(
self,
portfolio_id: str,
limit: int = 30,
) -> list[PortfolioSnapshot]:
"""Return snapshots newest-first up to limit."""
with self._cursor() as cur:
cur.execute(
"""SELECT * FROM snapshots
WHERE portfolio_id = %s
ORDER BY snapshot_date DESC LIMIT %s""",
(portfolio_id, limit),
)
rows = cur.fetchall()
return [self._row_to_snapshot(r) for r in rows]
# ------------------------------------------------------------------
# Row -> Model helpers
# ------------------------------------------------------------------
@staticmethod
def _row_to_portfolio(row: dict) -> Portfolio:
metadata = row.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
return Portfolio(
portfolio_id=str(row["portfolio_id"]),
name=row["name"],
cash=float(row["cash"]),
initial_cash=float(row["initial_cash"]),
currency=row["currency"].strip(),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
report_path=row.get("report_path"),
metadata=metadata,
)
@staticmethod
def _row_to_holding(row: dict) -> Holding:
return Holding(
holding_id=str(row["holding_id"]),
portfolio_id=str(row["portfolio_id"]),
ticker=row["ticker"],
shares=float(row["shares"]),
avg_cost=float(row["avg_cost"]),
sector=row.get("sector"),
industry=row.get("industry"),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
)
@staticmethod
def _row_to_trade(row: dict) -> Trade:
metadata = row.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
return Trade(
trade_id=str(row["trade_id"]),
portfolio_id=str(row["portfolio_id"]),
ticker=row["ticker"],
action=row["action"],
shares=float(row["shares"]),
price=float(row["price"]),
total_value=float(row["total_value"]),
trade_date=str(row["trade_date"]),
rationale=row.get("rationale"),
signal_source=row.get("signal_source"),
metadata=metadata,
)
@staticmethod
def _row_to_snapshot(row: dict) -> PortfolioSnapshot:
holdings = row.get("holdings_snapshot") or []
if isinstance(holdings, str):
holdings = json.loads(holdings)
metadata = row.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
return PortfolioSnapshot(
snapshot_id=str(row["snapshot_id"]),
portfolio_id=str(row["portfolio_id"]),
snapshot_date=str(row["snapshot_date"]),
total_value=float(row["total_value"]),
cash=float(row["cash"]),
equity_value=float(row["equity_value"]),
num_positions=int(row["num_positions"]),
holdings_snapshot=holdings,
metadata=metadata,
)