[OQI-008] feat: Add portfolio DDL schema with tables for portfolios, allocations, goals

- Added portfolio schema to 01-schemas.sql
- Created enums: risk_profile, goal_status, rebalance_action, allocation_status
- Created tables: portfolios, portfolio_allocations, portfolio_goals
- Created tables: rebalance_history, portfolio_snapshots
- Added triggers for updated_at and goal progress calculations
- Added indexes for performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:20:48 -06:00
parent 439489bde4
commit 3fbb1c21e6
7 changed files with 360 additions and 0 deletions

View File

@ -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';

View File

@ -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'
);

View File

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

View File

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

View File

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

View File

@ -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';

View File

@ -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';