# VS-04: Portfolio Composition — Vertical Slice Specification **Domain:** Risk & Portfolio Management **Capability:** Aggregate positions across holdings, calculate risk weights, trigger rebalancing **User Goal:** "I need to see my current portfolio composition and rebalance when drift exceeds threshold" --- ## Non-Goals - Automatic rebalancing (manual approval required) - Real-time streaming (EOD snapshots acceptable) - Tax-lot tracking (summary-level only) - Factor decomposition (separate slice) --- ## Requirements ### Functional | Req ID | Description | RBAC | SLA | Evidence | |--------|-------------|------|-----|----------| | **PORT-001** | GET /api/portfolio/{id}/composition | DataReader | <100ms | JSON response w/ position array | | **PORT-002** | POST /api/portfolio/{id}/rebalance | PortfolioManager | 202 Accepted | Job queued + CorrelationId returned | | **PORT-003** | Portfolio must reflect latest market prices | DataAdmin | <5m | Check trade_date ≤ cutoff | | **PORT-004** | Rebalance is idempotent (same target → no re-run) | System | N/A | Check idempotency key in DB | | **PORT-005** | Soft-delete supports historical portfolio views | DataAnalyst | <1s | WHERE removed_at IS NULL for current | ### Non-Functional - **Availability:** 99.5% (allows 1 failure/week) - **Latency:** GET <100ms, POST response <500ms - **Data Freshness:** Prices <5min old (EOD snapshot) - **Audit:** All state changes traced via CorrelationId + JobRunId --- ## State Transitions ``` Portfolio (Current) ↓ POST /rebalance PortfolioRebalanceJob (Queued via Hangfire) ↓ execution Rebalance Approved (Manual step) OR Target Weights Updated ↓ event PortfolioRebalanced event published to outbox ↓ inbox consumer Downstream systems notified (Risk, Reporting, etc.) ``` **Idempotency:** Same `{portfolio_id, target_weights_hash, correlation_id}` → no job re-queue --- ## Data & API Contracts ### GET /api/portfolio/{portfolioId}/composition **Response (200 OK):** ```json { "portfolioId": "550e8400-e29b-41d4-a716-446655440001", "snapshotDate": "2026-08-05", "positions": [ { "symbol": "AAPL", "quantity": 100, "marketPrice": 150.25, "marketValue": 15025.00, "weightPercent": 35.5, "riskScore": 7.2 } ], "totalValue": 42500.00, "lastUpdate": "2026-08-05T09:00:00Z" } ``` ### POST /api/portfolio/{portfolioId}/rebalance **Request:** ```json { "targetWeights": [ { "symbol": "AAPL", "targetPercent": 40 }, { "symbol": "MSFT", "targetPercent": 30 }, { "symbol": "GOOGL", "targetPercent": 30 } ], "driftThreshold": 5 } ``` **Response (202 Accepted):** ```json { "jobId": "550e8400-e29b-41d4-a716-446655440002", "status": "Queued", "correlationId": "port-2026-08-05-001", "queuedAt": "2026-08-05T09:15:00Z" } ``` ### Events **PortfolioRebalanced:** ```json { "eventId": "550e8400-e29b-41d4-a716-446655440003", "eventType": "PortfolioRebalanced", "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" } ``` --- ## RBAC & Authorization | Operation | Role | Condition | |-----------|------|-----------| | VIEW composition | DataReader | Own portfolio only | | POST rebalance | PortfolioManager | Own portfolio + no freeze window | | APPROVE rebalance | RiskCommittee | Cross-portfolio veto power | --- ## Testing Strategy 1. **Unit:** Portfolio aggregation logic (12 tests) - Aggregate prices across positions - Calculate weights - Detect drift vs. target 2. **Integration:** DB persistence (4 tests) - Insert portfolio + positions (PIT) - Verify idempotency (same date range → no re-run) - Soft-delete + historical queries - Event published to outbox 3. **E2E:** API flow (3 tests) - GET /composition returns current weights - POST /rebalance queues job + returns jobId - Job executes + event published 4. **Golden/OOS:** Portfolio drift scenarios (3 tests) - Normal rebalance - Emergency rebalance (drift > 20%) - Frozen portfolio (rebalance blocked) --- ## Assumptions - Market prices updated daily at 9:00 KST (before market open) - Rebalance requires manual approval (not automatic) - Portfolio snapshot is EOD (not intraday) - Risk scores provided by VS-05 (Risk Metrics) --- ## Open Questions / Decisions Recorded - **Q:** Should rebalance trigger automatic monitoring jobs? **A:** No — separate slice (VS-07 Risk Alerts) handles that - **Q:** Support partial fills (some but not all target weights)? **A:** Yes — status=PartiallyRebalanced, record drift after partial fill --- ## Vertical Slice Boundary (Thin Slice) ✅ **In Scope:** Aggregation logic + API endpoint + Hangfire job + event publishing ❌ **Out of Scope:** Risk metrics (VS-05), approval workflow (separate), tax-lot accounting **Rationale:** Minimal, vertical, independently deployable; downstream systems (Risk, Reporting) consume events asynchronously