diff --git a/ddl/01-schemas.sql b/ddl/01-schemas.sql index 7fc1ea7..ebfcae4 100644 --- a/ddl/01-schemas.sql +++ b/ddl/01-schemas.sql @@ -20,6 +20,10 @@ COMMENT ON SCHEMA trading IS 'Trading bots, orders, positions, and market data'; CREATE SCHEMA IF NOT EXISTS investment; COMMENT ON SCHEMA investment IS 'Investment products, accounts, and transactions'; +-- Portfolio Management +CREATE SCHEMA IF NOT EXISTS portfolio; +COMMENT ON SCHEMA portfolio IS 'User portfolios, allocations, and financial goals'; + -- Financial Operations CREATE SCHEMA IF NOT EXISTS financial; COMMENT ON SCHEMA financial IS 'Wallets, payments, subscriptions, and financial transactions'; diff --git a/ddl/schemas/portfolio/00-enums.sql b/ddl/schemas/portfolio/00-enums.sql new file mode 100644 index 0000000..098a6c0 --- /dev/null +++ b/ddl/schemas/portfolio/00-enums.sql @@ -0,0 +1,36 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for portfolio management +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +-- Risk profile (shared with investment schema) +CREATE TYPE portfolio.risk_profile AS ENUM ( + 'conservative', + 'moderate', + 'aggressive' +); + +-- Goal status +CREATE TYPE portfolio.goal_status AS ENUM ( + 'active', + 'completed', + 'abandoned' +); + +-- Rebalance action +CREATE TYPE portfolio.rebalance_action AS ENUM ( + 'buy', + 'sell', + 'hold' +); + +-- Allocation status +CREATE TYPE portfolio.allocation_status AS ENUM ( + 'active', + 'inactive', + 'rebalancing' +); diff --git a/ddl/schemas/portfolio/tables/01-portfolios.sql b/ddl/schemas/portfolio/tables/01-portfolios.sql new file mode 100644 index 0000000..ae12c68 --- /dev/null +++ b/ddl/schemas/portfolio/tables/01-portfolios.sql @@ -0,0 +1,66 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - PORTFOLIOS TABLE +-- ===================================================== +-- Description: User portfolios with asset allocations +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +CREATE TABLE portfolio.portfolios ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + risk_profile portfolio.risk_profile NOT NULL DEFAULT 'moderate', + + -- Portfolio values (updated by triggers/jobs) + total_value DECIMAL(20, 8) NOT NULL DEFAULT 0, + total_cost DECIMAL(20, 8) NOT NULL DEFAULT 0, + unrealized_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + unrealized_pnl_percent DECIMAL(10, 4) NOT NULL DEFAULT 0, + + -- Statistics + day_change_percent DECIMAL(10, 4) DEFAULT 0, + week_change_percent DECIMAL(10, 4) DEFAULT 0, + month_change_percent DECIMAL(10, 4) DEFAULT 0, + all_time_change_percent DECIMAL(10, 4) DEFAULT 0, + + -- Metadata + is_active BOOLEAN NOT NULL DEFAULT true, + is_primary BOOLEAN NOT NULL DEFAULT false, + last_rebalanced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_portfolios_user_id ON portfolio.portfolios(user_id); +CREATE INDEX idx_portfolios_risk_profile ON portfolio.portfolios(risk_profile); +CREATE INDEX idx_portfolios_is_active ON portfolio.portfolios(is_active) WHERE is_active = true; + +-- Only one primary portfolio per user +CREATE UNIQUE INDEX idx_portfolios_user_primary + ON portfolio.portfolios(user_id) + WHERE is_primary = true; + +-- Comments +COMMENT ON TABLE portfolio.portfolios IS 'User investment portfolios with configurable allocations'; +COMMENT ON COLUMN portfolio.portfolios.risk_profile IS 'User risk tolerance: conservative, moderate, aggressive'; +COMMENT ON COLUMN portfolio.portfolios.total_value IS 'Current total value of all allocations in USD'; +COMMENT ON COLUMN portfolio.portfolios.unrealized_pnl IS 'Unrealized profit/loss = total_value - total_cost'; +COMMENT ON COLUMN portfolio.portfolios.is_primary IS 'Only one portfolio can be primary per user'; + +-- Trigger for updated_at +CREATE OR REPLACE FUNCTION portfolio.update_portfolio_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_portfolios_updated_at + BEFORE UPDATE ON portfolio.portfolios + FOR EACH ROW + EXECUTE FUNCTION portfolio.update_portfolio_updated_at(); diff --git a/ddl/schemas/portfolio/tables/02-portfolio_allocations.sql b/ddl/schemas/portfolio/tables/02-portfolio_allocations.sql new file mode 100644 index 0000000..9630493 --- /dev/null +++ b/ddl/schemas/portfolio/tables/02-portfolio_allocations.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - PORTFOLIO ALLOCATIONS TABLE +-- ===================================================== +-- Description: Asset allocations within portfolios +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +CREATE TABLE portfolio.portfolio_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_id UUID NOT NULL REFERENCES portfolio.portfolios(id) ON DELETE CASCADE, + asset VARCHAR(20) NOT NULL, + + -- Allocation targets and current state + target_percent DECIMAL(5, 2) NOT NULL DEFAULT 0, + current_percent DECIMAL(5, 2) NOT NULL DEFAULT 0, + deviation DECIMAL(5, 2) NOT NULL DEFAULT 0, + + -- Holdings + quantity DECIMAL(20, 8) NOT NULL DEFAULT 0, + avg_cost DECIMAL(20, 8) NOT NULL DEFAULT 0, + current_price DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Values + value DECIMAL(20, 8) NOT NULL DEFAULT 0, + cost DECIMAL(20, 8) NOT NULL DEFAULT 0, + pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + pnl_percent DECIMAL(10, 4) NOT NULL DEFAULT 0, + + -- Status + status portfolio.allocation_status NOT NULL DEFAULT 'active', + last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_target_percent CHECK (target_percent >= 0 AND target_percent <= 100), + CONSTRAINT chk_current_percent CHECK (current_percent >= 0 AND current_percent <= 100), + CONSTRAINT chk_quantity_positive CHECK (quantity >= 0) +); + +-- Indexes +CREATE INDEX idx_portfolio_allocations_portfolio_id ON portfolio.portfolio_allocations(portfolio_id); +CREATE INDEX idx_portfolio_allocations_asset ON portfolio.portfolio_allocations(asset); +CREATE INDEX idx_portfolio_allocations_status ON portfolio.portfolio_allocations(status); + +-- Unique asset per portfolio +CREATE UNIQUE INDEX idx_portfolio_allocations_unique_asset + ON portfolio.portfolio_allocations(portfolio_id, asset); + +-- Comments +COMMENT ON TABLE portfolio.portfolio_allocations IS 'Individual asset allocations within a portfolio'; +COMMENT ON COLUMN portfolio.portfolio_allocations.target_percent IS 'Target allocation percentage (0-100)'; +COMMENT ON COLUMN portfolio.portfolio_allocations.current_percent IS 'Current actual allocation percentage'; +COMMENT ON COLUMN portfolio.portfolio_allocations.deviation IS 'Difference between current and target (current - target)'; +COMMENT ON COLUMN portfolio.portfolio_allocations.avg_cost IS 'Average cost basis per unit'; +COMMENT ON COLUMN portfolio.portfolio_allocations.pnl IS 'Profit/loss = value - cost'; + +-- Trigger for last_updated_at +CREATE TRIGGER trg_portfolio_allocations_updated_at + BEFORE UPDATE ON portfolio.portfolio_allocations + FOR EACH ROW + EXECUTE FUNCTION portfolio.update_portfolio_updated_at(); diff --git a/ddl/schemas/portfolio/tables/03-portfolio_goals.sql b/ddl/schemas/portfolio/tables/03-portfolio_goals.sql new file mode 100644 index 0000000..763099a --- /dev/null +++ b/ddl/schemas/portfolio/tables/03-portfolio_goals.sql @@ -0,0 +1,108 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - PORTFOLIO GOALS TABLE +-- ===================================================== +-- Description: User financial goals and progress tracking +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +CREATE TABLE portfolio.portfolio_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + portfolio_id UUID REFERENCES portfolio.portfolios(id) ON DELETE SET NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Goal targets + target_amount DECIMAL(20, 2) NOT NULL, + current_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + target_date DATE NOT NULL, + monthly_contribution DECIMAL(20, 2) NOT NULL DEFAULT 0, + + -- Progress tracking + progress DECIMAL(5, 2) NOT NULL DEFAULT 0, + projected_completion_date DATE, + months_remaining INTEGER, + required_monthly_contribution DECIMAL(20, 2), + + -- Status + status portfolio.goal_status NOT NULL DEFAULT 'active', + completed_at TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_target_amount_positive CHECK (target_amount > 0), + CONSTRAINT chk_current_amount_positive CHECK (current_amount >= 0), + CONSTRAINT chk_progress_range CHECK (progress >= 0 AND progress <= 100), + CONSTRAINT chk_monthly_contribution_positive CHECK (monthly_contribution >= 0) +); + +-- Indexes +CREATE INDEX idx_portfolio_goals_user_id ON portfolio.portfolio_goals(user_id); +CREATE INDEX idx_portfolio_goals_portfolio_id ON portfolio.portfolio_goals(portfolio_id); +CREATE INDEX idx_portfolio_goals_status ON portfolio.portfolio_goals(status); +CREATE INDEX idx_portfolio_goals_target_date ON portfolio.portfolio_goals(target_date); + +-- Comments +COMMENT ON TABLE portfolio.portfolio_goals IS 'User financial goals with progress tracking'; +COMMENT ON COLUMN portfolio.portfolio_goals.target_amount IS 'Target amount to save/invest in USD'; +COMMENT ON COLUMN portfolio.portfolio_goals.current_amount IS 'Current progress toward the goal'; +COMMENT ON COLUMN portfolio.portfolio_goals.progress IS 'Percentage progress (0-100)'; +COMMENT ON COLUMN portfolio.portfolio_goals.projected_completion_date IS 'Estimated date of goal completion based on contributions'; +COMMENT ON COLUMN portfolio.portfolio_goals.required_monthly_contribution IS 'Monthly contribution needed to meet target on time'; + +-- Trigger for updated_at +CREATE TRIGGER trg_portfolio_goals_updated_at + BEFORE UPDATE ON portfolio.portfolio_goals + FOR EACH ROW + EXECUTE FUNCTION portfolio.update_portfolio_updated_at(); + +-- Function to update goal progress +CREATE OR REPLACE FUNCTION portfolio.update_goal_progress() +RETURNS TRIGGER AS $$ +BEGIN + -- Calculate progress percentage + IF NEW.target_amount > 0 THEN + NEW.progress := LEAST(100, (NEW.current_amount / NEW.target_amount) * 100); + END IF; + + -- Calculate months remaining + NEW.months_remaining := GREATEST(0, + EXTRACT(YEAR FROM AGE(NEW.target_date, CURRENT_DATE)) * 12 + + EXTRACT(MONTH FROM AGE(NEW.target_date, CURRENT_DATE)) + )::INTEGER; + + -- Calculate required monthly contribution + IF NEW.months_remaining > 0 THEN + NEW.required_monthly_contribution := GREATEST(0, + (NEW.target_amount - NEW.current_amount) / NEW.months_remaining + ); + ELSE + NEW.required_monthly_contribution := NEW.target_amount - NEW.current_amount; + END IF; + + -- Calculate projected completion date based on current contributions + IF NEW.monthly_contribution > 0 AND NEW.current_amount < NEW.target_amount THEN + NEW.projected_completion_date := CURRENT_DATE + + (CEIL((NEW.target_amount - NEW.current_amount) / NEW.monthly_contribution) * INTERVAL '1 month')::INTERVAL; + END IF; + + -- Auto-complete if goal reached + IF NEW.current_amount >= NEW.target_amount AND NEW.status = 'active' THEN + NEW.status := 'completed'; + NEW.completed_at := NOW(); + NEW.progress := 100; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_portfolio_goals_progress + BEFORE INSERT OR UPDATE ON portfolio.portfolio_goals + FOR EACH ROW + EXECUTE FUNCTION portfolio.update_goal_progress(); diff --git a/ddl/schemas/portfolio/tables/04-rebalance_history.sql b/ddl/schemas/portfolio/tables/04-rebalance_history.sql new file mode 100644 index 0000000..6e7c0a3 --- /dev/null +++ b/ddl/schemas/portfolio/tables/04-rebalance_history.sql @@ -0,0 +1,42 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - REBALANCE HISTORY TABLE +-- ===================================================== +-- Description: History of portfolio rebalancing operations +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +CREATE TABLE portfolio.rebalance_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_id UUID NOT NULL REFERENCES portfolio.portfolios(id) ON DELETE CASCADE, + + -- Rebalance details + asset VARCHAR(20) NOT NULL, + action portfolio.rebalance_action NOT NULL, + target_percent DECIMAL(5, 2) NOT NULL, + actual_percent_before DECIMAL(5, 2) NOT NULL, + actual_percent_after DECIMAL(5, 2) NOT NULL, + + -- Amounts + quantity_change DECIMAL(20, 8) NOT NULL, + value_change DECIMAL(20, 8) NOT NULL, + price_at_rebalance DECIMAL(20, 8) NOT NULL, + + -- Execution + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + execution_status VARCHAR(20) NOT NULL DEFAULT 'completed', + execution_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_rebalance_history_portfolio_id ON portfolio.rebalance_history(portfolio_id); +CREATE INDEX idx_rebalance_history_executed_at ON portfolio.rebalance_history(executed_at); +CREATE INDEX idx_rebalance_history_asset ON portfolio.rebalance_history(asset); + +-- Comments +COMMENT ON TABLE portfolio.rebalance_history IS 'Historical record of portfolio rebalancing operations'; +COMMENT ON COLUMN portfolio.rebalance_history.action IS 'Type of rebalance action: buy, sell, hold'; +COMMENT ON COLUMN portfolio.rebalance_history.quantity_change IS 'Amount of asset bought (+) or sold (-)'; +COMMENT ON COLUMN portfolio.rebalance_history.value_change IS 'USD value of the transaction'; diff --git a/ddl/schemas/portfolio/tables/05-portfolio_snapshots.sql b/ddl/schemas/portfolio/tables/05-portfolio_snapshots.sql new file mode 100644 index 0000000..de294c4 --- /dev/null +++ b/ddl/schemas/portfolio/tables/05-portfolio_snapshots.sql @@ -0,0 +1,41 @@ +-- ===================================================== +-- PORTFOLIO SCHEMA - PORTFOLIO SNAPSHOTS TABLE +-- ===================================================== +-- Description: Daily snapshots of portfolio values for performance tracking +-- Schema: portfolio +-- Author: Database Agent +-- Date: 2026-01-25 +-- ===================================================== + +CREATE TABLE portfolio.portfolio_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_id UUID NOT NULL REFERENCES portfolio.portfolios(id) ON DELETE CASCADE, + snapshot_date DATE NOT NULL, + + -- Values at snapshot time + total_value DECIMAL(20, 8) NOT NULL, + total_cost DECIMAL(20, 8) NOT NULL, + unrealized_pnl DECIMAL(20, 8) NOT NULL, + unrealized_pnl_percent DECIMAL(10, 4) NOT NULL, + + -- Daily changes + day_change DECIMAL(20, 8) NOT NULL DEFAULT 0, + day_change_percent DECIMAL(10, 4) NOT NULL DEFAULT 0, + + -- Allocation snapshot (JSON for flexibility) + allocations JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_portfolio_snapshots_portfolio_id ON portfolio.portfolio_snapshots(portfolio_id); +CREATE INDEX idx_portfolio_snapshots_date ON portfolio.portfolio_snapshots(snapshot_date); + +-- One snapshot per portfolio per day +CREATE UNIQUE INDEX idx_portfolio_snapshots_unique_day + ON portfolio.portfolio_snapshots(portfolio_id, snapshot_date); + +-- Comments +COMMENT ON TABLE portfolio.portfolio_snapshots IS 'Daily snapshots for historical performance tracking'; +COMMENT ON COLUMN portfolio.portfolio_snapshots.allocations IS 'JSON snapshot of allocation state at that time';