# Phase 1 Implementation Plan — Data Foundation ## 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**