# 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
