# VS-08: Risk Dashboard — Data Contract

**Domain:** Comprehensive Risk Monitoring  
**Pattern:** Point-in-Time (PIT) Read Model + Event Stream

---

## Schema Overview

| Table | Purpose | Ownership | TTL |
|-------|---------|-----------|-----|
| `risk_management.dashboard_snapshots` | Cached aggregations (portfolio + risk + stress + alerts) | VS-08 | <1hr |
| `risk_management.vw_dashboard_data` | JOIN view (portfolio_positions + risk_metrics + stress + alerts) | Read-only | — |

### dashboard_snapshots (PIT Write Model)

Cached snapshot of portfolio risk profile, refreshed on-demand or event-triggered.

```sql
CREATE TABLE IF NOT EXISTS risk_management.dashboard_snapshots (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portfolio_id UUID NOT NULL,
    snapshot_date DATE NOT NULL,
    
    -- Portfolio aggregates
    total_portfolio_value DECIMAL(18, 2) NOT NULL,
    position_count INT NOT NULL,
    
    -- Risk metrics (VS-05)
    var95 DECIMAL(18, 2),
    sharpe_ratio NUMERIC(5, 2),
    sortino_ratio NUMERIC(5, 2),
    volatility_percent NUMERIC(5, 2),
    concentration_top_five_percent NUMERIC(5, 2),
    max_position_percent NUMERIC(5, 2),
    
    -- Stress scenario flags (VS-06)
    bull_scenario_loss_percent NUMERIC(6, 2),
    bear_scenario_loss_percent NUMERIC(6, 2),
    rate_shock_loss_percent NUMERIC(6, 2),
    vol_spike_loss_percent NUMERIC(6, 2),
    
    -- Alert count (VS-07)
    alert_initial_count INT DEFAULT 0,
    alert_warning_count INT DEFAULT 0,
    alert_critical_count INT DEFAULT 0,
    
    -- Audit
    published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    revision INT DEFAULT 1,
    source_component VARCHAR(50) NOT NULL, -- 'api' or 'event'
    
    CONSTRAINT fk_portfolio FOREIGN KEY (portfolio_id)
        REFERENCES risk_management.portfolios(id),
    CONSTRAINT unique_snapshot_per_portfolio_per_date
        UNIQUE(portfolio_id, snapshot_date, published_at DESC)
);

CREATE INDEX idx_dashboard_portfolio_date 
    ON risk_management.dashboard_snapshots(portfolio_id, snapshot_date DESC);
```

### vw_dashboard_data (Read-Only JOIN View)

Real-time aggregation view joining VS-04~07 source tables. Used by API endpoint for <500ms latency.

```sql
CREATE OR REPLACE VIEW risk_management.vw_dashboard_data AS
SELECT
    p.portfolio_id,
    p.snapshot_date,
    
    -- Portfolio (VS-04)
    COUNT(DISTINCT pp.symbol) as position_count,
    SUM(pp.market_value) as total_portfolio_value,
    
    -- Risk Metrics (VS-05)
    (SELECT var95 FROM risk_management.risk_metrics 
     WHERE portfolio_id = p.portfolio_id 
       AND published_at <= CURRENT_TIMESTAMP 
       AND removed_at IS NULL
     ORDER BY published_at DESC LIMIT 1) as var95,
    
    (SELECT sharpe_ratio FROM risk_management.risk_metrics 
     WHERE portfolio_id = p.portfolio_id 
       AND published_at <= CURRENT_TIMESTAMP 
       AND removed_at IS NULL
     ORDER BY published_at DESC LIMIT 1) as sharpe_ratio,
    
    -- Stress (VS-06)
    (SELECT portfolio_loss_percent FROM risk_management.stress_test_results 
     WHERE portfolio_id = p.portfolio_id 
       AND scenario_name = 'bear'
       AND published_at <= CURRENT_TIMESTAMP 
     ORDER BY published_at DESC LIMIT 1) as bear_loss_percent,
    
    -- Alerts (VS-07)
    COUNT(CASE WHEN ra.severity = 'Warning' THEN 1 END) as warning_alert_count
    
FROM risk_management.portfolios p
LEFT JOIN risk_management.portfolio_positions pp 
    ON p.id = pp.portfolio_id 
    AND pp.published_at <= CURRENT_TIMESTAMP 
    AND pp.removed_at IS NULL
LEFT JOIN risk_management.risk_alerts ra 
    ON p.id = ra.portfolio_id 
    AND ra.published_at <= CURRENT_TIMESTAMP 
    AND ra.removed_at IS NULL 
    AND ra.resolved_at IS NULL
WHERE p.published_at <= CURRENT_TIMESTAMP 
  AND p.removed_at IS NULL
GROUP BY p.id, p.snapshot_date;
```

---

## Query Patterns

### 1. Fetch Dashboard Snapshot (GET /api/dashboard/risk)

**Source:** `dashboard_snapshots` cache OR `vw_dashboard_data` (fallback)

```sql
-- Try cache first (< 1 hour)
SELECT * FROM risk_management.dashboard_snapshots
WHERE portfolio_id = $1
  AND snapshot_date >= CURRENT_DATE - INTERVAL '1 hour'
  AND published_at <= $2
ORDER BY published_at DESC
LIMIT 1;

-- Fallback: read-only view (real-time)
SELECT * FROM risk_management.vw_dashboard_data
WHERE portfolio_id = $1
  AND snapshot_date = CURRENT_DATE;
```

### 2. Refresh Dashboard on Event

**Trigger:** PortfolioRebalanced, PortfolioMetricsCalculated, StressTestCompleted, AlertEscalated

```sql
INSERT INTO risk_management.dashboard_snapshots (
    portfolio_id, snapshot_date, total_portfolio_value, position_count,
    var95, sharpe_ratio, alert_warning_count, source_component, published_at
)
SELECT 
    portfolio_id, CURRENT_DATE,
    COALESCE(total_portfolio_value, 0),
    COALESCE(position_count, 0),
    var95, sharpe_ratio, warning_alert_count,
    'event', CURRENT_TIMESTAMP
FROM risk_management.vw_dashboard_data
WHERE portfolio_id = $1
ON CONFLICT (portfolio_id, snapshot_date, published_at DESC)
    DO UPDATE SET 
        total_portfolio_value = EXCLUDED.total_portfolio_value,
        revision = revision + 1,
        published_at = CURRENT_TIMESTAMP;
```

### 3. List All Positions (for dashboard visualization)

```sql
SELECT symbol, quantity, market_price, market_value, weight_percent
FROM risk_management.portfolio_positions
WHERE portfolio_id = $1
  AND published_at <= $2
  AND removed_at IS NULL
ORDER BY weight_percent DESC;
```

### 4. List Active Alerts

```sql
SELECT alert_id, threshold_type, current_value, severity, message
FROM risk_management.risk_alerts
WHERE portfolio_id = $1
  AND published_at <= $2
  AND removed_at IS NULL
  AND resolved_at IS NULL
ORDER BY severity DESC, triggered_at DESC;
```

---

## Idempotency & Concurrency

**Idempotency Key:** `(portfolio_id, snapshot_date, source_component)`

- Cache refresh from event is idempotent (no duplicates via UPSERT)
- Multiple concurrent API calls return same cached result
- View queries are always consistent (no transaction isolation needed)

---

## Performance SLA

| Query | Source | Latency | Cache |
|-------|--------|---------|-------|
| Dashboard snapshot | `dashboard_snapshots` | <100ms | 1 hour |
| Fallback (real-time) | `vw_dashboard_data` | <500ms | —  |
| Active alerts | Direct table | <50ms | —  |
| Positions table | Direct table | <100ms | —  |

**Indexes:**
```sql
CREATE INDEX idx_dashboard_portfolio_date 
    ON risk_management.dashboard_snapshots(portfolio_id, snapshot_date DESC);

CREATE INDEX idx_portfolio_positions_portfolio_date 
    ON risk_management.portfolio_positions(portfolio_id, trading_date DESC);

CREATE INDEX idx_risk_alerts_portfolio_resolved 
    ON risk_management.risk_alerts(portfolio_id, resolved_at, published_at DESC);
```

---

## Event Publishing (Outbox Integration)

When dashboard is refreshed, emit event for SignalR push:

**Event: DashboardUpdated**
```json
{
  "eventType": "DashboardUpdated",
  "portfolioId": "550e8400-e29b-41d4-a716-446655440001",
  "changedComponents": ["riskMetrics", "activeAlerts"],
  "snapshotId": "550e8400-e29b-41d4-a716-446655440002",
  "updatedAt": "2026-08-05T10:05:00Z"
}
```

Published via: `shared.outbox` → Hangfire → SignalR Hub → `DashboardHub.UpdateDashboard(portfolioId)`

---

## Testing Strategy

1. **Unit:** Aggregation SQL queries (with mock data)
2. **Integration:** Dashboard endpoint → cache hit/miss → DB fallback
3. **E2E:** Event trigger → dashboard update → SignalR push
4. **Golden:** Known portfolio snapshot → expected aggregates (variance <0.01%)

---

## Assumptions

- All source tables (VS-04~07) maintain PIT audit trail
- `published_at <= cutoff` enforced on all source reads
- Cache TTL managed by application (not DB expiry)
- SignalR hub configured separately; dashboard job just publishes event

---

## Migration

**DbUp Script:** `0034_VS08_DashboardSchema.sql`

```sql
-- Create tables, views, indexes
-- Seed initial cache from existing data if present
-- Grant SELECT on views to DataReader role
```
