# VS-04: Portfolio Composition — Data Contract **Version:** 1.0 **Compliance:** Point-in-Time (PIT) + Soft-Delete + Append-Only Audit **Migration:** `0033_portfolio_composition.sql` (DbUp) --- ## Schema Design ### 1. `portfolios` (PIT — Write Model) Stores portfolio snapshots. New state appended as revision; reads filter `WHERE removed_at IS NULL AND published_at <= cutoff`. ```sql CREATE TABLE risk_management.portfolios ( portfolio_id UUID PRIMARY KEY, portfolio_name VARCHAR(255) NOT NULL, account_id UUID NOT NULL, -- PIT envelope revision INT NOT NULL DEFAULT 1, published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, removed_at TIMESTAMP NULL, -- Audit created_by VARCHAR(100), updated_by VARCHAR(100), correlation_id UUID, -- Status status VARCHAR(50) NOT NULL DEFAULT 'Active', -- Active, Frozen, Liquidating rebalance_frequency VARCHAR(50), -- Monthly, Quarterly, Manual -- Constraints UNIQUE(portfolio_id, revision), CHECK (removed_at IS NULL OR removed_at >= published_at) ); ``` ### 2. `portfolio_positions` (PIT — Composition) Holdings within a portfolio. Each position tracks FIFO cost, market value, risk weight. ```sql CREATE TABLE risk_management.portfolio_positions ( position_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id), -- Instrument symbol VARCHAR(10) NOT NULL, instrument_type VARCHAR(20), -- Stock, Bond, Fund, Derivative -- Quantity & Cost quantity DECIMAL(18, 8) NOT NULL, cost_basis_per_unit DECIMAL(15, 4), total_cost_basis DECIMAL(20, 2), -- Market Data (snapshot) market_price DECIMAL(15, 4) NOT NULL, market_value DECIMAL(20, 2) NOT NULL, -- Risk weight_percent DECIMAL(5, 2), -- [0, 100] risk_score DECIMAL(3, 1), -- [0, 10] from VS-05 -- PIT trading_date DATE NOT NULL, published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, revision INT NOT NULL DEFAULT 1, removed_at TIMESTAMP NULL, -- Audit correlation_id UUID, data_source VARCHAR(50), -- Constraints UNIQUE(portfolio_id, symbol, trading_date, revision), CHECK (quantity >= 0), CHECK (market_price > 0), CHECK (weight_percent BETWEEN 0 AND 100) ); ``` ### 3. `rebalance_jobs` (Append-Only — Audit) Immutable log of all rebalance requests. Status progresses: Queued → Running → Completed/Failed. ```sql CREATE TABLE risk_management.rebalance_jobs ( job_id UUID PRIMARY KEY, portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id), -- Request target_weights_hash VARCHAR(64), -- Hash of target weights (idempotency) drift_threshold DECIMAL(5, 2), requested_by VARCHAR(100), requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Execution status VARCHAR(50) NOT NULL DEFAULT 'Queued', -- Queued, Running, Completed, Failed, PartiallyRebalanced started_at TIMESTAMP NULL, completed_at TIMESTAMP NULL, duration_seconds INT NULL, -- Results old_weight_snapshot JSONB, -- Array of {symbol, percent} new_weight_snapshot JSONB, -- Array of {symbol, percent} trades_executed INT DEFAULT 0, trades_failed INT DEFAULT 0, -- Error handling error_message TEXT NULL, retry_count INT DEFAULT 0, -- Audit correlation_id UUID NOT NULL, job_run_id UUID NOT NULL, UNIQUE(target_weights_hash, correlation_id, portfolio_id) -- Idempotency ); ``` ### 4. `rebalance_events` (Append-Only — Published Events) Published to `shared.outbox` via EventPublisher; processed by inbox consumers. **Schema (JSONB in outbox.payload):** ```json { "eventId": "550e8400-e29b-41d4-a716-446655440003", "eventType": "PortfolioRebalanced", "aggregateId": "550e8400-e29b-41d4-a716-446655440001", "portfolioId": "550e8400-e29b-41d4-a716-446655440001", "oldWeights": [ { "symbol": "AAPL", "percent": 35.5 } ], "newWeights": [ { "symbol": "AAPL", "percent": 40.0 } ], "rebalancedAt": "2026-08-05T09:30:00Z", "correlationId": "port-2026-08-05-001" } ``` --- ## PIT Query Patterns ### Current Portfolio Composition ```sql SELECT p.portfolio_id, p.portfolio_name, pos.symbol, pos.quantity, pos.market_price, pos.market_value, pos.weight_percent FROM risk_management.portfolios p INNER JOIN risk_management.portfolio_positions pos ON p.portfolio_id = pos.portfolio_id WHERE p.published_at <= @cutoff AND p.removed_at IS NULL AND pos.published_at <= @cutoff AND pos.removed_at IS NULL AND pos.trading_date = CURRENT_DATE ORDER BY p.portfolio_id, pos.weight_percent DESC; ``` ### Historical Portfolio (as of Date) ```sql SELECT * FROM risk_management.portfolios p WHERE p.portfolio_id = @portfolioId AND p.published_at <= @asOfDate AND p.removed_at IS NULL ORDER BY p.published_at DESC LIMIT 1; ``` ### Idempotency Check ```sql SELECT job_id FROM risk_management.rebalance_jobs WHERE portfolio_id = @portfolioId AND target_weights_hash = @hash AND correlation_id = @correlationId AND status IN ('Running', 'Completed') LIMIT 1; ``` --- ## Upsert Strategy **On new rebalance request:** ```sql INSERT INTO risk_management.rebalance_jobs (job_id, portfolio_id, target_weights_hash, correlation_id, status) VALUES (@jobId, @portfolioId, @hash, @correlationId, 'Queued') ON CONFLICT (target_weights_hash, correlation_id, portfolio_id) DO UPDATE SET status = 'Queued' WHERE EXCLUDED.status = 'Completed'; ``` **Idempotency:** Same hash + correlationId → no duplicate job --- ## Migration Path **Fresh Install:** 1. Create `risk_management` schema 2. Create tables: portfolios, portfolio_positions, rebalance_jobs 3. Create indexes on (portfolio_id, published_at), (trading_date), (status) **Upgrade from v0 (if pre-existing):** 1. Backfill `published_at` = migration timestamp 2. Backfill `revision` = 1 3. Set `removed_at = NULL` for active records **Rollback:** - No data loss: Remove `removed_at IS NULL` filter to see all revisions - No cascade: rebalance_jobs remain immutable --- ## Indexes (Performance SLA: <100ms GET) | Table | Columns | Reason | |-------|---------|--------| | portfolios | (portfolio_id, published_at, removed_at) | Fast current snapshot lookup | | portfolio_positions | (portfolio_id, trading_date, published_at) | Fast composition query | | portfolio_positions | (symbol, trading_date) | Fast market data rollup | | rebalance_jobs | (portfolio_id, status, created_at) | Fast pending job lookup | | rebalance_jobs | (target_weights_hash, correlation_id) | Fast idempotency check | --- ## Data Freshness Guarantees - **Prices:** Updated daily at 9:00 KST (before market open) - **Positions:** Snapshot at market close (16:00 KST) - **Rebalance jobs:** Queued immediately, executed within 5 minutes - **Events:** Published synchronously (no queue lag) --- ## Compliance ✅ **AGENTS.md v16.0:** - No SELECT * (explicit columns) - PIT versioning (published_at, revision, removed_at) - Soft-delete (removed_at, not hard delete) - Append-only audit (rebalance_jobs immutable) - Correlation ID tracing (correlation_id + job_run_id) - Idempotency key (target_weights_hash + correlation_id) ✅ **Data Integrity:** - Referential integrity (FK to portfolios) - Check constraints (weight_percent, quantity >= 0) - Unique constraints (PIT envelope) ✅ **Auditability:** - All mutations traced (published_at, correlation_id) - Full history preserved (removed_at enables rollback query) --- ## Test Scenarios | Test | Data Setup | Assertion | |------|-----------|-----------| | Fresh portfolio | INSERT portfolio + positions | Current query returns correct values | | Historical query | Add revision 2 to same portfolio | AS-OF query returns v1 snapshot | | Idempotency | Same rebalance_hash twice | Job not duplicated | | Soft-delete | Set removed_at on position | Query filters correctly | | Drift detection | weight_percent > drift_threshold | Rebalance triggered |