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:
commit
3e9322ae4b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 1–3 |
|
||||
|
||||
**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 | ~2–3 days |
|
||||
| 2 | Holding Reviewer Agent | ~1 day |
|
||||
| 3 | Risk metrics engine (Phase 3 of workflow) | ~1–2 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: ~15–22 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
|
||||
|
|
@ -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:** 2–3 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:** 1–2 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:** 3–4 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:** 3–4 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:** 4–5 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:** 3–4 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: ~18–24 hours**
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`)
|
||||
|
|
@ -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": "..."},
|
||||
)
|
||||
```
|
||||
|
|
@ -0,0 +1 @@
|
|||
# tests/portfolio package marker
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 {},
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Reference in New Issue