2eee44d19b
- VS-08_DASHBOARD_SLICE_SPEC.md: Comprehensive dashboard specification - VS-08_DATA_CONTRACT.md: PIT aggregation schema + caching strategy - VS08_DashboardPolicy.cs: Aggregation logic (health score, insights, validation) - VS08_DashboardEndpoint.cs: GET /api/dashboard/risk + cache layer - RiskDashboard.vue: Unified portfolio view with real-time metrics - VS08_DashboardIntegrationTests.cs: 5 core policy tests Status: GOV+DATA+DOMAIN+BE+ASYNC+FE complete (5/7 vertical slices) TESTOPS: In progress (test suite has minor compatibility issues with VS-04/07) Cumulative: Phase 2 Batch 3 + Phase 3 = 27/36 components (75% COMPLETE) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
266 lines
7.9 KiB
Markdown
266 lines
7.9 KiB
Markdown
# 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
|
|
```
|