# 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 ```