Files
KArtSell.Aegis/docs/contracts/data/VS-08_DATA_CONTRACT.md
T
kjh2064 2eee44d19b feat: Phase 3 VS-08 Risk Dashboard — GOV+DATA+DOMAIN+BE+FE (5/7)
- 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>
2026-08-05 22:12:06 +09:00

7.9 KiB

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.

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.

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)

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

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)

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

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:

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

{
  "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

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