-- ============================================================================= -- 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();