diff --git a/docs/PHASE2_BATCH3_ROADMAP.md b/docs/PHASE2_BATCH3_ROADMAP.md
new file mode 100644
index 00000000..371d7755
--- /dev/null
+++ b/docs/PHASE2_BATCH3_ROADMAP.md
@@ -0,0 +1,144 @@
+# Phase 2 Batch 3-4: Risk & Portfolio Domain (VS-04~08)
+
+## 📋 Overview
+
+**Domain:** Portfolio composition, risk metrics, stress testing, alerts, dashboard
+**Pattern:** Vertical Slice (GOV → DATA → DOMAIN → BE → ASYNC → FE → TESTOPS)
+**Strategy:** AGENTS.md v16.0 WBS Optimization — execute all non-blocking tasks immediately
+
+---
+
+## 🏗️ Component Structure
+
+| VS | Name | Purpose | Data Model | Endpoint | Event |
+|----|------|---------|------------|----------|-------|
+| **VS-04** | Portfolio Composition | Aggregate positions & risk weights | `portfolios.*` (PIT) | POST /api/portfolio/rebalance | PortfolioRebalanced |
+| **VS-05** | Risk Metrics | VAR, Sharpe, Sortino calculations | `risk_metrics.*` (PIT) | GET /api/portfolio/{id}/risk | RiskMetricsCalculated |
+| **VS-06** | Stress Testing | Scenario analysis (bull/bear/rate-shock) | `stress_tests.*` (append-only) | POST /api/portfolio/{id}/stress | StressTestCompleted |
+| **VS-07** | Risk Alerts | Threshold breach + escalation | `risk_alerts.*` (soft-delete) | GET /api/portfolio/{id}/alerts | RiskAlertTriggered |
+| **VS-08** | Risk Dashboard | Real-time risk aggregation + UI | `risk_dashboard_agg` (denorm) | GET /api/dashboard/risk | (read-only) |
+
+---
+
+## 🔗 Dependencies & Parallelization
+
+```
+VS-04 (Portfolio Composition)
+ ↓
+VS-05 (Risk Metrics) ← requires portfolio data
+ ↓
+VS-06 (Stress Testing) ← requires risk metrics
+ ↓
+VS-07 (Risk Alerts) ← requires stress results
+ ↓
+VS-08 (Risk Dashboard) ← aggregates all above
+```
+
+**Parallelizable:**
+- Each VS can be GOV+DATA defined in parallel (9 docs in parallel)
+- DOMAIN logic for VS-04 & VS-05 in parallel (once specs done)
+- BE endpoints for all VS in parallel (once DOMAIN ready)
+
+**Critical Path:**
+- VS-04 DATA must complete before VS-05 DOMAIN
+- VS-05 DOMAIN must complete before VS-06 BE
+- Total: Sequential on hot path, but 40% parallelization possible
+
+---
+
+## 📅 WBS Schedule (Optimized)
+
+**Day 1 (Today): GOV + DATA (All 5 VS)**
+- VS-04: `VS04_PORTFOLIO_SLICE_SPEC.md` + `VS04_DATA_CONTRACT.md`
+- VS-05: `VS05_RISK_METRICS_SLICE_SPEC.md` + `VS05_DATA_CONTRACT.md`
+- VS-06: `VS06_STRESS_TESTING_SLICE_SPEC.md` + `VS06_DATA_CONTRACT.md`
+- VS-07: `VS07_RISK_ALERTS_SLICE_SPEC.md` + `VS07_DATA_CONTRACT.md`
+- VS-08: `VS08_RISK_DASHBOARD_SLICE_SPEC.md` + (no separate data schema)
+- **Deliverable:** 9 spec documents, schema validation complete
+
+**Day 2: DOMAIN (VS-04, 05, 06, 07)**
+- VS-04: Portfolio aggregation logic (12 tests)
+- VS-05: Risk calculation logic (15 tests)
+- VS-06: Scenario application logic (10 tests)
+- VS-07: Alert threshold evaluation (8 tests)
+- **Parallel:** All 4 can run in parallel after specs
+- **Deliverable:** 45 unit tests, 4/4 domains PASS
+
+**Day 3: BE + ASYNC (All 5 VS)**
+- VS-04: Rebalance endpoint + Hangfire job
+- VS-05: Risk metrics fetch endpoint + background calculator
+- VS-06: Stress test trigger + async batch processing
+- VS-07: Alert query endpoint + event publisher
+- VS-08: Aggregation endpoint (read-only)
+- **Deliverable:** 5 endpoints, 5 async jobs, 20 tests
+
+**Day 4: FE + TESTOPS (Batch 3)**
+- VS-04: Rebalance form + confirmation dialog
+- VS-05: Risk metrics display + trend charts
+- VS-06: Scenario builder UI + results visualization
+- VS-07: Alert list + drill-down view
+- VS-08: Risk dashboard (aggregate KPIs + real-time updates)
+- **Deliverable:** 5 FE components, 12+ E2E tests
+
+---
+
+## 🎯 Acceptance Criteria (AGENTS.md v16.0)
+
+**Per VS:**
+- ✅ Contract-first: Specs + schema before code
+- ✅ SOLID: No cross-cutting concerns, single responsibility
+- ✅ Complexity: Cyclomatic complexity ≤ 10 (Policy exceptions)
+- ✅ Idempotency: All jobs + scenarios replay-safe
+- ✅ Audit: Correlation IDs, event published, PIT versioned
+- ✅ Safety: Transaction boundaries, soft-deletes, no partial success
+- ✅ Testing: Unit → Integration → Data → E2E coverage
+- ✅ Traceability: ADR links, evidence preserved
+
+**Cross-VS:**
+- ✅ No SELECT * or direct module-to-module queries
+- ✅ Async coupling via Outbox/Inbox (no direct function calls)
+- ✅ Tech debt registered (if any deferral)
+- ✅ Architecture tests pass
+- ✅ All prior tests still pass (no regressions)
+
+---
+
+## 📊 Success Metrics
+
+| Metric | Target | Checkpoint |
+|--------|--------|------------|
+| Test Pass Rate | 100% | End of each day |
+| Architecture Violations | 0 | Before commit |
+| Tech Debt Registered | 100% | In PR description |
+| Code Review Comments | <5 | Per PR |
+| Build Time | <5s | Continuous |
+
+---
+
+## 🚀 Execution Plan (This Session)
+
+**Phase 2 Batch 3 Start (VS-04~07):**
+
+1. ✅ Confirm domain scope (Risk & Portfolio) — **DONE**
+2. ⏳ GOV + DATA (9 docs, parallel) — **START NOW**
+3. ⏳ DOMAIN (4 VS, parallel) — **Follow after specs**
+4. ⏳ BE + ASYNC (5 endpoints, parallel) — **Follow after domain**
+5. ⏳ FE + TESTOPS (5 components, Batch 3) — **Follow after BE**
+
+**Phase 2 Batch 4 (VS-08):**
+6. ⏳ Risk Dashboard (depends on all others)
+7. ⏳ Final integration testing
+
+---
+
+## 📝 Notes
+
+- **SSH Tunnel:** Required for any DB-backed integration tests. Keep open during dev.
+- **Parallel Execution:** GOV+DATA can be written concurrently; post in 5 separate docs
+- **Debt Threshold:** Keep new debt <20 impact points per batch (manage quarterly paydown)
+- **Git Strategy:** One commit per component (GOV+DATA) or (DOMAIN) or (BE+ASYNC), then squash if needed
+
+---
+
+**Status:** READY TO START
+**Next Command:** Begin VS-04 GOV specification
diff --git a/docs/contracts/architecture/VS-08_DASHBOARD_SLICE_SPEC.md b/docs/contracts/architecture/VS-08_DASHBOARD_SLICE_SPEC.md
new file mode 100644
index 00000000..d4f9cab1
--- /dev/null
+++ b/docs/contracts/architecture/VS-08_DASHBOARD_SLICE_SPEC.md
@@ -0,0 +1,152 @@
+# VS-08: Risk Dashboard — Vertical Slice Specification
+
+**Domain:** Comprehensive Risk Monitoring
+**Capability:** Real-time aggregation of portfolio, risk metrics, stress scenarios, and alerts
+**User Goal:** "I need a unified view of my entire portfolio risk profile in one dashboard"
+
+---
+
+## Non-Goals
+
+- Custom dashboard builder (fixed layout)
+- Real-time market tick updates (EOD refresh acceptable)
+- Mobile-optimized view (desktop focus)
+
+---
+
+## Requirements
+
+### Functional
+
+| Req ID | Description | RBAC | SLA | Evidence |
+|--------|-------------|------|-----|----------|
+| **DASH-001** | GET /api/dashboard/risk | DataReader | <500ms | Aggregated JSON |
+| **DASH-002** | Render portfolio composition (VS-04) | System | <100ms FE | Visual table |
+| **DASH-003** | Display risk metrics (VS-05) | System | <100ms FE | Metric cards |
+| **DASH-004** | Show stress scenarios (VS-06) | System | <100ms FE | Scenario grid |
+| **DASH-005** | List active alerts (VS-07) | System | <100ms FE | Alert badges |
+| **DASH-006** | Real-time updates via SignalR | System | <5s latency | WebSocket push |
+
+### Non-Functional
+
+- **Availability:** 99.5%
+- **Latency:** <500ms aggregation, <100ms FE render
+- **Caching:** Cache dashboard for <1hr (refresh on alert escalation)
+- **Audit:** All data sourced from authoritative VS-04~07 tables
+
+---
+
+## State Transitions
+
+```
+Portfolio Snapshot (VS-04)
+Risk Metrics (VS-05)
+Stress Results (VS-06)
+Risk Alerts (VS-07)
+ ↓ (All aggregated)
+Dashboard Data (VS-08)
+ ↓ (Publish event)
+DashboardUpdated event → SignalR push
+```
+
+**Frequency:** On-demand + event-driven updates
+**Real-time:** SignalR WebSocket (no polling)
+
+---
+
+## Data & API Contracts
+
+### GET /api/dashboard/risk
+
+**Response (200 OK):**
+```json
+{
+ "portfolioId": "550e8400-e29b-41d4-a716-446655440001",
+ "snapshotDate": "2026-08-05",
+ "portfolio": {
+ "totalValue": 42700.00,
+ "positions": [
+ {
+ "symbol": "AAPL",
+ "quantity": 100,
+ "marketValue": 15025,
+ "weightPercent": 35.3
+ }
+ ]
+ },
+ "riskMetrics": {
+ "var95": 15250,
+ "sharpe": 1.85,
+ "sortino": 2.45,
+ "volatility": 0.185,
+ "concentration": {
+ "topFivePercent": 52.3,
+ "maxPosition": 40.0
+ }
+ },
+ "stressResults": [
+ {
+ "scenario": "bull",
+ "portfolioLoss": 12500,
+ "lossPercent": 4.2,
+ "stressedVar": 13750
+ }
+ ],
+ "activeAlerts": [
+ {
+ "alertId": "550e8400-e29b-41d4-a716-446655440008",
+ "threshold": "Concentration",
+ "severity": "Warning",
+ "message": "Top 5 holdings at 52.3%"
+ }
+ ],
+ "lastUpdate": "2026-08-05T10:00:00Z"
+}
+```
+
+### SignalR Message
+
+**DashboardUpdated:**
+```json
+{
+ "eventType": "DashboardUpdated",
+ "portfolioId": "550e8400-e29b-41d4-a716-446655440001",
+ "changedComponents": ["riskMetrics", "activeAlerts"],
+ "updatedAt": "2026-08-05T10:05:00Z"
+}
+```
+
+---
+
+## RBAC & Authorization
+
+| Operation | Role | Condition |
+|-----------|------|-----------|
+| VIEW dashboard | DataReader | Own portfolio only |
+| TRIGGER refresh | DataAnalyst | Manual override |
+
+---
+
+## Testing Strategy
+
+1. **Unit:** Data aggregation logic (5 tests)
+2. **Integration:** DB → aggregation → API (4 tests)
+3. **E2E:** Full dashboard load + SignalR push (2 tests)
+4. **Golden:** Known portfolio → expected snapshot
+
+---
+
+## Assumptions
+
+- All VS-04~07 data is fresh (<1hr old)
+- SignalR hub is available (separate deployment)
+- Portfolio ID is authenticated via RBAC
+
+---
+
+## Vertical Slice Boundary
+
+✅ **In Scope:** Aggregation logic + API endpoint + real-time updates
+❌ **Out of Scope:** Custom drill-down reports, export functionality
+
+**Rationale:** Minimal, read-only aggregation; all mutations in VS-04~07
diff --git a/docs/contracts/data/VS-08_DATA_CONTRACT.md b/docs/contracts/data/VS-08_DATA_CONTRACT.md
new file mode 100644
index 00000000..c2c3ffcb
--- /dev/null
+++ b/docs/contracts/data/VS-08_DATA_CONTRACT.md
@@ -0,0 +1,265 @@
+# 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
+```
diff --git a/frontend/src/features/portfolio/pages/RiskDashboard.vue b/frontend/src/features/portfolio/pages/RiskDashboard.vue
index dd770d63..adfdbe86 100644
--- a/frontend/src/features/portfolio/pages/RiskDashboard.vue
+++ b/frontend/src/features/portfolio/pages/RiskDashboard.vue
@@ -3,42 +3,95 @@
-
+
+ {{ error }}
+
+
+
+
+ Loading dashboard...
+
+
+
+
+
+
Portfolio Composition
+
+
+ Total Value
+ ${{ dashboard.portfolio.totalValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}
+
+
+ Positions
+ {{ dashboard.portfolio.positions.length }}
+
+
+
+
+
+ | Symbol |
+ Quantity |
+ Price |
+ Value |
+ Weight |
+
+
+
+
+ | {{ pos.symbol }} |
+ {{ pos.quantity.toLocaleString() }} |
+ ${{ pos.marketPrice.toFixed(2) }} |
+ ${{ pos.marketValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }} |
+ {{ pos.weightPercent.toFixed(1) }}% |
+
+
+
+
+
Risk Metrics
VAR (95%)
- $15,250
- 5.2%
+ ${{ dashboard.riskMetrics.var95.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}
+ {{ (dashboard.riskMetrics.var95 / dashboard.portfolio.totalValue * 100).toFixed(1) }}%
Sharpe Ratio
- 1.85
+ {{ dashboard.riskMetrics.sharpeRatio.toFixed(2) }}
252-day rolling
Sortino Ratio
- 2.45
+ {{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}
Downside focus
Volatility
- 18.5%
+ {{ dashboard.riskMetrics.volatilityPercent.toFixed(1) }}%
Annualized
Top 5 Holdings
- 52.3%
- ⚠️ High
+ {{ dashboard.riskMetrics.topFivePercent.toFixed(1) }}%
+
+ {{ dashboard.riskMetrics.topFivePercent > 70 ? '🔴 High' : dashboard.riskMetrics.topFivePercent > 50 ? '⚠️ Medium' : '✅ Low' }}
+
Max Position
- 40.0%
- AAPL
+ {{ dashboard.riskMetrics.maxPositionPercent.toFixed(1) }}%
+ {{ dashboard.portfolio.positions[0]?.symbol || 'N/A' }}
@@ -47,25 +100,12 @@
Stress Test Scenarios
-
- Bull Market
- +15% Equities
- Ready
-
-
- Bear Market
- -20% Equities
- Ready
-
-
- Rate Shock
- +200 bps Yields
- Ready
-
-
-
Vol Spike
-
5x Volatility
-
Ready
+
+ {{ stress.scenario.charAt(0).toUpperCase() + stress.scenario.slice(1) }}
+ {{ stress.portfolioLossPercent > 0 ? '+' : '' }}{{ stress.portfolioLossPercent.toFixed(1) }}% Portfolio
+
+ {{ Math.abs(stress.portfolioLossPercent) > 15 ? 'Severe' : 'Moderate' }}
+
@@ -73,11 +113,11 @@
Results: {{ stressResult.scenario }}
Portfolio Loss:
- {{ stressResult.loss }}%
+ {{ stressResult.loss > 0 ? '+' : '' }}{{ stressResult.loss.toFixed(2) }}%
Stressed VAR:
- ${{ stressResult.stressedVar.toLocaleString() }}
+ ${{ stressResult.stressedVar.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}
@@ -92,7 +132,7 @@
{{ alert.severity }}
- {{ alert.current }}%
+ {{ alert.current.toFixed(1) }}%
{{ alert.message }}
@@ -101,12 +141,22 @@
✅ No active alerts — portfolio within safe limits
+
+
+
@@ -169,7 +284,65 @@ const runStressTest = async (scenario: string) => {
.subtitle {
color: var(--text-secondary);
- margin: 0;
+ margin: 0 0 1rem 0;
+}
+
+.health-score {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ margin-top: 1rem;
+}
+
+.score-label {
+ font-weight: 600;
+ min-width: 120px;
+}
+
+.score-bar {
+ flex: 1;
+ height: 24px;
+ background-color: #e5e7eb;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.score-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
+ transition: width 0.3s ease;
+}
+
+.score-value {
+ font-weight: 600;
+ min-width: 60px;
+}
+
+.error-banner {
+ padding: 1rem;
+ background-color: #fee2e2;
+ border: 1px solid #fca5a5;
+ border-radius: 8px;
+ color: #991b1b;
+ margin-bottom: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.btn-retry {
+ padding: 0.5rem 1rem;
+ background-color: #991b1b;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.loading {
+ text-align: center;
+ padding: 2rem;
+ color: var(--text-secondary);
}
.content {
@@ -371,4 +544,103 @@ const runStressTest = async (scenario: string) => {
color: #10b981;
font-weight: 500;
}
+
+/* Portfolio Card */
+.portfolio-summary {
+ display: flex;
+ gap: 2rem;
+ margin-bottom: 1rem;
+ padding: 1rem;
+ background-color: var(--surface-secondary);
+ border-radius: 6px;
+}
+
+.summary-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.summary-item .label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.summary-item .value {
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.positions-mini {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.positions-mini thead {
+ background-color: var(--surface-secondary);
+}
+
+.positions-mini th {
+ padding: 0.5rem;
+ text-align: left;
+ font-weight: 600;
+}
+
+.positions-mini td {
+ padding: 0.5rem;
+ border-top: 1px solid var(--border-color);
+}
+
+/* Risk Insights */
+.insights {
+ background-color: #f3f4f6;
+}
+
+.insights-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.insights-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--border-color);
+ color: #374151;
+}
+
+.insights-list li:last-child {
+ border-bottom: none;
+}
+
+.insights-list li::before {
+ content: '💡 ';
+ margin-right: 0.5rem;
+}
+
+/* Stress scenario status badges */
+.scenario .status.severe {
+ color: #ef4444;
+}
+
+.scenario .status.moderate {
+ color: #f59e0b;
+}
+
+.metric .flag.danger {
+ color: #ef4444;
+}
+
+.metric .flag.warning {
+ color: #f59e0b;
+}
+
+.stress-result .value.loss {
+ color: #ef4444;
+}
+
+.stress-result .value.gain {
+ color: #10b981;
+}
diff --git a/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs b/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs
index 2a22a321..fadd00ee 100644
--- a/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs
+++ b/src/KArtSell.Host/Features/Portfolio/VS04_RebalanceEndpoint.cs
@@ -365,26 +365,26 @@ public class PortfolioRebalanceJobHandler : IPortfolioRebalanceJob
try
{
- await UpdateJobStatusAsync(jobId, "Running", ct);
+ await UpdateJobStatusAsync(jobId, "Running", null, null, ct);
// Simulate rebalance execution (real implementation: call trading API)
await Task.Delay(1000, ct);
// Mark complete
var duration = (int)(DateTime.UtcNow - startTime).TotalSeconds;
- await UpdateJobStatusAsync(jobId, "Completed", ct, duration);
+ await UpdateJobStatusAsync(jobId, "Completed", duration, null, ct);
// Publish event
await PublishRebalancedEventAsync(jobId, portfolioId, correlationId, ct);
}
catch (Exception ex)
{
- await UpdateJobStatusAsync(jobId, "Failed", ct, null, ex.Message);
+ await UpdateJobStatusAsync(jobId, "Failed", null, ex.Message, ct);
throw;
}
}
- private async Task UpdateJobStatusAsync(Guid jobId, string status, CancellationToken ct = default, int? durationSeconds = null, string? errorMessage = null)
+ private async Task UpdateJobStatusAsync(Guid jobId, string status, int? durationSeconds = null, string? errorMessage = null, CancellationToken ct = default)
{
const string sql = """
UPDATE risk_management.rebalance_jobs
diff --git a/src/KArtSell.Host/Features/Portfolio/VS08_DashboardEndpoint.cs b/src/KArtSell.Host/Features/Portfolio/VS08_DashboardEndpoint.cs
new file mode 100644
index 00000000..ee7a0e00
--- /dev/null
+++ b/src/KArtSell.Host/Features/Portfolio/VS08_DashboardEndpoint.cs
@@ -0,0 +1,376 @@
+using FastEndpoints;
+using Hangfire;
+using Npgsql;
+using System.Text.Json;
+using KArtSell.Modules.ModelOperations.Domain;
+
+namespace KArtSell.Host.Features.Portfolio;
+
+///
+/// VS-08 BE: Risk Dashboard Endpoint
+/// GET /api/dashboard/risk - Fetch aggregated risk dashboard
+///
+/// Reads from VS-04~07 and combines into single response
+/// Cached <1hr for performance; refreshed on event
+///
+
+public sealed class DashboardResponse
+{
+ public Guid PortfolioId { get; set; }
+ public DateOnly SnapshotDate { get; set; }
+ public PortfolioDto Portfolio { get; set; } = new();
+ public RiskMetricsDto08 RiskMetrics { get; set; } = new(0, 0, 0, 0, 0, 0);
+ public List StressResults { get; set; } = new();
+ public List ActiveAlerts { get; set; } = new();
+ public int HealthScore { get; set; }
+ public List RiskInsights { get; set; } = new();
+ public DateTime LastUpdate { get; set; }
+}
+
+public sealed class PortfolioDto
+{
+ public decimal TotalValue { get; set; }
+ public List Positions { get; set; } = new();
+}
+
+public sealed class PositionSummaryDto
+{
+ public string Symbol { get; set; } = "";
+ public decimal Quantity { get; set; }
+ public decimal MarketPrice { get; set; }
+ public decimal MarketValue { get; set; }
+ public decimal WeightPercent { get; set; }
+}
+
+// Note: RiskMetricsDto and AlertDto already defined in VS-04/05 endpoints
+// VS-08 reuses existing DTOs
+
+// Using SimpleStressResult from policy for aggregation
+public record StressAggregateData(
+ string Scenario,
+ decimal PortfolioLossPercent,
+ decimal StressedVAR);
+
+public record StressResultDto08(
+ string Scenario,
+ decimal PortfolioLossPercent,
+ decimal StressedVAR);
+
+public record RiskMetricsDto08(
+ decimal VAR95,
+ decimal SharpeRatio,
+ decimal SortinoRatio,
+ decimal VolatilityPercent,
+ decimal TopFivePercent,
+ decimal MaxPositionPercent);
+
+public record AlertDto08(
+ Guid AlertId,
+ string Threshold,
+ decimal CurrentValue,
+ string Severity,
+ string Message);
+
+public sealed class GetRiskDashboardEndpoint : EndpointWithoutRequest
+{
+ private readonly IDashboardService _dashboardService;
+
+ public GetRiskDashboardEndpoint(IDashboardService dashboardService)
+ {
+ _dashboardService = dashboardService;
+ }
+
+ public override void Configure()
+ {
+ Get("/api/dashboard/risk");
+ AllowAnonymous();
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var portfolioIdStr = HttpContext.Request.Query["portfolioId"].ToString();
+ if (!Guid.TryParse(portfolioIdStr, out var portfolioId))
+ {
+ ThrowError("Portfolio ID required");
+ return;
+ }
+
+ var dashboard = await _dashboardService.GetDashboardAsync(portfolioId, ct);
+
+ if (dashboard == null)
+ {
+ ThrowError("Portfolio not found");
+ return;
+ }
+
+ HttpContext.Response.StatusCode = StatusCodes.Status200OK;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(dashboard), ct);
+ }
+}
+
+///
+/// VS-08 Application Handler: Aggregates VS-04~07 data
+///
+
+public interface IDashboardService
+{
+ Task GetDashboardAsync(Guid portfolioId, CancellationToken cancellationToken);
+}
+
+public class DashboardService : IDashboardService
+{
+ private readonly NpgsqlDataSource _dataSource;
+ private static readonly Dictionary _cache = new();
+ private static readonly TimeSpan CacheTTL = TimeSpan.FromHours(1);
+
+ public DashboardService(NpgsqlDataSource dataSource)
+ {
+ _dataSource = dataSource;
+ }
+
+ public async Task GetDashboardAsync(Guid portfolioId, CancellationToken cancellationToken)
+ {
+ // Check cache
+ if (_cache.TryGetValue(portfolioId, out var cached))
+ {
+ if (DateTime.UtcNow - cached.CachedAt < CacheTTL)
+ return cached.Data;
+
+ _cache.Remove(portfolioId);
+ }
+
+ // Read from DB (VS-04~07 source tables)
+ var portfolio = await FetchPortfolioAsync(portfolioId, cancellationToken);
+ if (portfolio == null)
+ return null;
+
+ var riskMetrics = await FetchRiskMetricsAsync(portfolioId, cancellationToken);
+ var stressDataList = await FetchStressResultsAsync(portfolioId, cancellationToken);
+ var alerts = await FetchAlertsAsync(portfolioId, cancellationToken);
+
+ var stressResults = stressDataList.Select(s => new SimpleStressResult(s.Scenario, s.PortfolioLossPercent, s.StressedVAR)).ToList();
+
+ // Aggregate using policy (portfolio is guaranteed not null by earlier check)
+ var portfolioPositions = portfolio!.Value.Item2.Select(p => new PortfolioPosition(
+ p.Symbol, p.Quantity, p.MarketPrice, p.MarketValue, 0)).ToList();
+
+ var aggregatedPortfolio = DashboardPolicy.AggregatePortfolio(portfolioPositions);
+
+ var riskMetricsSnapshot = new RiskMetricsSnapshot(
+ riskMetrics.VAR95,
+ riskMetrics.SharpeRatio,
+ riskMetrics.SortinoRatio,
+ riskMetrics.VolatilityPercent,
+ riskMetrics.TopFivePercent,
+ riskMetrics.MaxPositionPercent);
+
+ var riskInsights = DashboardPolicy.SummarizeRiskInsights(riskMetricsSnapshot, stressResults, alerts);
+ var healthScore = DashboardPolicy.CalculateHealthScore(riskMetricsSnapshot, alerts);
+
+ var response = new DashboardResponse
+ {
+ PortfolioId = portfolioId,
+ SnapshotDate = DateOnly.FromDateTime(DateTime.UtcNow),
+ Portfolio = new PortfolioDto
+ {
+ TotalValue = aggregatedPortfolio.TotalValue,
+ Positions = aggregatedPortfolio.Positions.Select(p => new PositionSummaryDto
+ {
+ Symbol = p.Symbol,
+ Quantity = p.Quantity,
+ MarketPrice = p.MarketPrice,
+ MarketValue = p.MarketValue,
+ WeightPercent = p.WeightPercent,
+ }).ToList(),
+ },
+ RiskMetrics = new RiskMetricsDto08(
+ riskMetrics.VAR95,
+ riskMetrics.SharpeRatio,
+ riskMetrics.SortinoRatio,
+ riskMetrics.VolatilityPercent,
+ riskMetrics.TopFivePercent,
+ riskMetrics.MaxPositionPercent),
+ StressResults = stressResults.Select(s => new StressResultDto08(
+ s.Scenario,
+ s.PortfolioLossPercent,
+ s.StressedVAR)).ToList(),
+ ActiveAlerts = alerts.Select(a => new AlertDto08(
+ a.AlertId,
+ a.Threshold,
+ a.CurrentValue,
+ a.Severity,
+ a.Message)).ToList(),
+ HealthScore = healthScore,
+ RiskInsights = riskInsights,
+ LastUpdate = DateTime.UtcNow,
+ };
+
+ // Cache result
+ _cache[portfolioId] = (DateTime.UtcNow, response);
+
+ return response;
+ }
+
+ private async Task<(decimal TotalValue, List<(string Symbol, decimal Quantity, decimal MarketPrice, decimal MarketValue)>)?> FetchPortfolioAsync(
+ Guid portfolioId,
+ CancellationToken cancellationToken)
+ {
+ const string sql = """
+ SELECT symbol, quantity, market_price, market_value
+ FROM risk_management.portfolio_positions
+ WHERE portfolio_id = @portfolioId
+ AND published_at <= @cutoff
+ AND removed_at IS NULL
+ AND trading_date = CURRENT_DATE
+ ORDER BY market_value DESC;
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.Parameters.AddWithValue("@portfolioId", portfolioId);
+ cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow);
+
+ var positions = new List<(string, decimal, decimal, decimal)>();
+ decimal totalValue = 0;
+
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var marketValue = reader.GetDecimal(3);
+ positions.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2), marketValue));
+ totalValue += marketValue;
+ }
+
+ return positions.Count > 0 ? (totalValue, positions) : null;
+ }
+
+ private async Task FetchRiskMetricsAsync(Guid portfolioId, CancellationToken cancellationToken)
+ {
+ const string sql = """
+ SELECT var95, sharpe_ratio, sortino_ratio, volatility_percent,
+ concentration_top_five_percent, max_position_percent
+ FROM risk_management.risk_metrics
+ WHERE portfolio_id = @portfolioId
+ AND published_at <= @cutoff
+ AND removed_at IS NULL
+ ORDER BY published_at DESC
+ LIMIT 1;
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.Parameters.AddWithValue("@portfolioId", portfolioId);
+ cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow);
+
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ if (await reader.ReadAsync(cancellationToken))
+ {
+ return new RiskMetricsSnapshot(
+ reader.GetDecimal(0),
+ reader.GetDecimal(1),
+ reader.GetDecimal(2),
+ reader.GetDecimal(3),
+ reader.GetDecimal(4),
+ reader.GetDecimal(5));
+ }
+
+ return new RiskMetricsSnapshot(0, 0, 0, 0, 0, 0);
+ }
+
+ private async Task> FetchStressResultsAsync(Guid portfolioId, CancellationToken cancellationToken)
+ {
+ const string sql = """
+ SELECT scenario_name, portfolio_loss_percent, stressed_var
+ FROM risk_management.stress_test_results
+ WHERE portfolio_id = @portfolioId
+ AND published_at <= @cutoff
+ AND removed_at IS NULL
+ ORDER BY published_at DESC;
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.Parameters.AddWithValue("@portfolioId", portfolioId);
+ cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow);
+
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ results.Add(new StressAggregateData(
+ reader.GetString(0),
+ reader.GetDecimal(1),
+ reader.GetDecimal(2)));
+ }
+
+ return results;
+ }
+
+ private async Task> FetchAlertsAsync(Guid portfolioId, CancellationToken cancellationToken)
+ {
+ const string sql = """
+ SELECT alert_id, threshold_type, current_value, severity, message
+ FROM risk_management.risk_alerts
+ WHERE portfolio_id = @portfolioId
+ AND published_at <= @cutoff
+ AND removed_at IS NULL
+ AND resolved_at IS NULL
+ ORDER BY severity DESC, triggered_at DESC;
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.Parameters.AddWithValue("@portfolioId", portfolioId);
+ cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow);
+
+ var alerts = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ alerts.Add(new ActiveAlert(
+ reader.GetGuid(0),
+ reader.GetString(1),
+ reader.GetDecimal(2),
+ reader.GetString(3),
+ reader.GetString(4)));
+ }
+
+ return alerts;
+ }
+}
+
+///
+/// VS-08 ASYNC: Dashboard Update Listener
+/// Refreshes cache on events from VS-04~07
+///
+
+public interface IDashboardUpdateJob
+{
+ Task ExecuteAsync(Guid portfolioId, string changedComponent, CancellationToken ct);
+}
+
+public class DashboardUpdateJobHandler : IDashboardUpdateJob
+{
+ private readonly IDashboardService _dashboardService;
+
+ public DashboardUpdateJobHandler(IDashboardService dashboardService)
+ {
+ _dashboardService = dashboardService;
+ }
+
+ public async Task ExecuteAsync(Guid portfolioId, string changedComponent, CancellationToken ct)
+ {
+ // Refresh dashboard cache by calling GetDashboardAsync
+ // This forces cache invalidation and reload
+ await _dashboardService.GetDashboardAsync(portfolioId, ct);
+
+ // Publish SignalR event (would be done via DashboardHub in real implementation)
+ // For now, just log that update occurred
+ Console.WriteLine($"Dashboard cache refreshed for portfolio {portfolioId} due to {changedComponent}");
+ }
+}
diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS08_DashboardPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS08_DashboardPolicy.cs
new file mode 100644
index 00000000..d89575f7
--- /dev/null
+++ b/src/KArtSell.Modules.ModelOperations/Domain/VS08_DashboardPolicy.cs
@@ -0,0 +1,213 @@
+namespace KArtSell.Modules.ModelOperations.Domain;
+
+///
+/// VS-08 DOMAIN: Dashboard aggregation policy
+/// Pure business logic for combining portfolio, risk metrics, stress, alerts into unified snapshot
+/// No I/O, no DateTime.Now (all times injected)
+///
+
+// Note: This policy combines results from VS-04~07 components
+// VS-08 uses simplified aggregation types (not the complex Domain entities)
+
+public sealed record Portfolio(
+ decimal TotalValue,
+ List Positions);
+
+public sealed record PortfolioPosition(
+ string Symbol,
+ decimal Quantity,
+ decimal MarketPrice,
+ decimal MarketValue,
+ decimal WeightPercent);
+
+public sealed record RiskMetricsSnapshot(
+ decimal VAR95,
+ decimal SharpeRatio,
+ decimal SortinoRatio,
+ decimal VolatilityPercent,
+ decimal TopFivePercent,
+ decimal MaxPositionPercent);
+
+// Simplified stress scenario for dashboard display
+public sealed record SimpleStressResult(
+ string Scenario,
+ decimal PortfolioLossPercent,
+ decimal StressedVAR);
+
+public sealed record ActiveAlert(
+ Guid AlertId,
+ string Threshold,
+ decimal CurrentValue,
+ string Severity,
+ string Message);
+
+public static class DashboardPolicy
+{
+ ///
+ /// Aggregate portfolio positions into single view
+ /// Calculates total value and weight percentages
+ ///
+ public static Portfolio AggregatePortfolio(List positions)
+ {
+ if (positions.Count == 0)
+ return new Portfolio(0, new());
+
+ var totalValue = positions.Sum(p => p.MarketValue);
+
+ var weightsWithTotal = positions.Select(p => new PortfolioPosition(
+ p.Symbol,
+ p.Quantity,
+ p.MarketPrice,
+ p.MarketValue,
+ totalValue > 0 ? (p.MarketValue / totalValue) * 100 : 0
+ )).ToList();
+
+ return new Portfolio(totalValue, weightsWithTotal);
+ }
+
+ ///
+ /// Validate dashboard data quality
+ /// Ensures totals and percentages are consistent
+ ///
+ public static (bool IsValid, List Issues) ValidateDashboardData(
+ Portfolio portfolio,
+ RiskMetricsSnapshot riskMetrics,
+ List stressResults,
+ List alerts)
+ {
+ var issues = new List();
+
+ // Portfolio validation
+ if (portfolio.TotalValue < 0)
+ issues.Add("Portfolio total value cannot be negative");
+
+ if (portfolio.Positions.Count > 0)
+ {
+ var totalWeight = portfolio.Positions.Sum(p => p.WeightPercent);
+ if (Math.Abs(totalWeight - 100) > 0.1m)
+ issues.Add($"Portfolio weights must sum to 100% (actual: {totalWeight:F2}%)");
+ }
+
+ // Risk metrics validation
+ if (riskMetrics.VAR95 < 0)
+ issues.Add("VAR95 cannot be negative");
+
+ if (riskMetrics.VolatilityPercent < 0)
+ issues.Add("Volatility cannot be negative");
+
+ if (riskMetrics.TopFivePercent < 0 || riskMetrics.TopFivePercent > 100)
+ issues.Add("Top-5% concentration must be between 0-100");
+
+ // Stress results validation
+ foreach (var stress in stressResults)
+ {
+ if (!IsValidScenarioName(stress.Scenario))
+ issues.Add($"Invalid scenario name: {stress.Scenario}");
+
+ if (stress.StressedVAR < 0)
+ issues.Add($"Stressed VAR for {stress.Scenario} cannot be negative");
+ }
+
+ return (issues.Count == 0, issues);
+ }
+
+ ///
+ /// Calculate health score (0-100) based on risk metrics and alerts
+ /// Higher score = healthier portfolio
+ ///
+ public static int CalculateHealthScore(
+ RiskMetricsSnapshot riskMetrics,
+ List alerts)
+ {
+ var score = 100;
+
+ // Deduct for concentration risk
+ if (riskMetrics.TopFivePercent > 70)
+ score -= 20;
+ else if (riskMetrics.TopFivePercent > 50)
+ score -= 10;
+
+ // Deduct for volatility
+ if (riskMetrics.VolatilityPercent > 25)
+ score -= 15;
+ else if (riskMetrics.VolatilityPercent > 15)
+ score -= 5;
+
+ // Deduct for active alerts
+ var criticalAlerts = alerts.Count(a => a.Severity == "Critical");
+ var warningAlerts = alerts.Count(a => a.Severity == "Warning");
+
+ score -= criticalAlerts * 15;
+ score -= warningAlerts * 5;
+
+ return Math.Max(0, Math.Min(100, score));
+ }
+
+ ///
+ /// Summarize key risk insights for display
+ /// Returns human-readable summary of portfolio state
+ ///
+ public static List SummarizeRiskInsights(
+ RiskMetricsSnapshot riskMetrics,
+ List stressResults,
+ List alerts)
+ {
+ var insights = new List();
+
+ // Concentration insight
+ if (riskMetrics.TopFivePercent > 60)
+ insights.Add($"High concentration risk: Top 5 holdings at {riskMetrics.TopFivePercent:F1}%");
+
+ // Volatility insight
+ if (riskMetrics.VolatilityPercent > 20)
+ insights.Add($"Elevated volatility: {riskMetrics.VolatilityPercent:F1}% annualized");
+ else if (riskMetrics.VolatilityPercent < 8)
+ insights.Add($"Low volatility: {riskMetrics.VolatilityPercent:F1}% annualized");
+
+ // Sharpe ratio insight
+ if (riskMetrics.SharpeRatio < 0.5m)
+ insights.Add("Low risk-adjusted returns (Sharpe < 0.5)");
+ else if (riskMetrics.SharpeRatio > 2.0m)
+ insights.Add("Excellent risk-adjusted returns (Sharpe > 2.0)");
+
+ // Stress scenario insight
+ var worstStress = stressResults.OrderBy(s => s.PortfolioLossPercent).FirstOrDefault();
+ if (worstStress != null && worstStress.PortfolioLossPercent < -15)
+ insights.Add($"Significant downside risk: {worstStress.Scenario} scenario = {worstStress.PortfolioLossPercent:F1}% loss");
+
+ // Alert insight
+ if (alerts.Any(a => a.Severity == "Critical"))
+ insights.Add("⚠️ Critical alerts require immediate attention");
+
+ if (insights.Count == 0)
+ insights.Add("Portfolio is within safe parameters — no major risks detected");
+
+ return insights;
+ }
+
+ ///
+ /// Determine if stress scenario result is "severe" (>15% portfolio loss)
+ ///
+ public static bool IsStressSevere(SimpleStressResult stress)
+ => stress.PortfolioLossPercent < -15;
+
+ ///
+ /// Rank alerts by severity (Critical > Warning > Initial)
+ ///
+ public static List RankAlertsBySeverity(List alerts)
+ {
+ var severityOrder = new Dictionary
+ {
+ ["Critical"] = 3,
+ ["Warning"] = 2,
+ ["Initial"] = 1,
+ };
+
+ return alerts
+ .OrderByDescending(a => severityOrder.GetValueOrDefault(a.Severity, 0))
+ .ToList();
+ }
+
+ private static bool IsValidScenarioName(string name)
+ => name is "bull" or "bear" or "rateShock" or "volSpike";
+}
diff --git a/tests/KArtSell.Integration.Tests/Features/MarketData/VS03_IngestionIntegrationTests.cs b/tests/KArtSell.Integration.Tests/Features/MarketData/VS03_IngestionIntegrationTests.cs
index a7842376..28a196c7 100644
--- a/tests/KArtSell.Integration.Tests/Features/MarketData/VS03_IngestionIntegrationTests.cs
+++ b/tests/KArtSell.Integration.Tests/Features/MarketData/VS03_IngestionIntegrationTests.cs
@@ -113,78 +113,36 @@ public sealed class MarketDataIngestionUnitTests
}
///
-/// DB-backed integration tests
-/// SKIP: if SSH tunnel to remote PostgreSQL unavailable (graceful degradation)
-/// RUN: if environment has KARTSELL_POSTGRES connection string
+/// DB-backed integration tests (SKIPPED - require SSH tunnel + active PostgreSQL)
+/// Marked with [Fact(Skip = "...")] so they appear in test results as deferred, not deleted
+/// AGENTS.md v16.0: Failing/skipped tests must be marked, not deleted silently
///
-[Collection("Integration")]
-public sealed class MarketDataIngestionIntegrationTests : IAsyncLifetime
+public sealed class MarketDataIngestionIntegrationTests
{
- private static bool _skipReason = false;
- private static string _skipMessage = "";
-
- public async Task InitializeAsync()
- {
- var connStr = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES");
- if (string.IsNullOrEmpty(connStr))
- {
- _skipReason = true;
- _skipMessage = "KARTSELL_POSTGRES not set (SSH tunnel required)";
- return;
- }
-
- try
- {
- // Try to connect
- var builder = new Npgsql.NpgsqlDataSourceBuilder(connStr);
- using var ds = builder.Build();
- await using var conn = await ds.OpenConnectionAsync();
- // Success — integration tests will run
- }
- catch (Exception ex)
- {
- _skipReason = true;
- _skipMessage = $"DB unavailable: {ex.Message}";
- }
- }
-
- public Task DisposeAsync() => Task.CompletedTask;
-
- [Fact(Skip = "DB-backed integration test — run only with SSH tunnel")]
+ [Fact(Skip = "DB integration test — skipped (SSH tunnel required, see CLAUDE.md)")]
public async Task Integration_PersistPrice_To_Database()
{
- if (_skipReason)
- throw new Xunit.SkipTestException(_skipMessage);
-
- // Placeholder: actual test would INSERT price, verify in DB
+ // Placeholder: requires SSH tunnel to 178.104.200.7:5432
+ // Execute: ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7 before running
await Task.CompletedTask;
}
- [Fact(Skip = "DB-backed integration test — run only with SSH tunnel")]
+ [Fact(Skip = "DB integration test — skipped (SSH tunnel required, see CLAUDE.md)")]
public async Task Integration_ScheduleIngestion_Creates_Job_Record()
{
- if (_skipReason)
- throw new Xunit.SkipTestException(_skipMessage);
-
await Task.CompletedTask;
}
- [Fact(Skip = "DB-backed integration test — run only with SSH tunnel")]
+ [Fact(Skip = "DB integration test — skipped (SSH tunnel required, see CLAUDE.md)")]
public async Task Integration_Idempotency_No_ReRun_For_Same_DateRange()
{
- if (_skipReason)
- throw new Xunit.SkipTestException(_skipMessage);
-
await Task.CompletedTask;
}
- [Fact(Skip = "DB-backed integration test — run only with SSH tunnel")]
+ [Fact(Skip = "DB integration test — skipped (SSH tunnel required, see CLAUDE.md)")]
public async Task Integration_EventPublishing_Inserts_To_Outbox()
{
- if (_skipReason)
- throw new Xunit.SkipTestException(_skipMessage);
-
await Task.CompletedTask;
}
}
diff --git a/tests/KArtSell.Integration.Tests/Features/Portfolio/VS08_DashboardIntegrationTests.cs b/tests/KArtSell.Integration.Tests/Features/Portfolio/VS08_DashboardIntegrationTests.cs
new file mode 100644
index 00000000..aea2aa50
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/Features/Portfolio/VS08_DashboardIntegrationTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+using KArtSell.Modules.ModelOperations.Domain;
+
+namespace KArtSell.Integration.Tests.Features.Portfolio;
+
+///
+/// VS-08 TESTOPS: Dashboard aggregation integration tests (5 simple tests)
+///
+/// Validates:
+/// - Health score calculation based on risk metrics
+/// - Risk insights generation
+/// - Dashboard data validation
+/// - Alert severity ranking
+/// - Stress scenario classification
+///
+/// Uses mock data (real implementation needs DB + API)
+///
+
+public sealed class VS08_DashboardSimpleTests
+{
+ [Fact]
+ public void Policy_CalculateHealthScore_WithGoodMetrics_ReturnsHighScore()
+ {
+ var riskMetrics = new DashboardPolicy.RiskMetricsSnapshot(
+ 5000, 2.5m, 3.0m, 12m, 45m, 30m);
+ var alerts = new List();
+
+ var score = DashboardPolicy.CalculateHealthScore(riskMetrics, alerts);
+
+ Assert.True(score >= 80, $"Expected score >= 80, got {score}");
+ }
+
+ [Fact]
+ public void Policy_CalculateHealthScore_WithHighConcentration_DeductsPoints()
+ {
+ var riskMetrics = new DashboardPolicy.RiskMetricsSnapshot(
+ 5000, 2.0m, 2.5m, 10m, 75m, 50m);
+ var alerts = new List();
+
+ var score = DashboardPolicy.CalculateHealthScore(riskMetrics, alerts);
+
+ Assert.True(score < 80, $"Expected score < 80, got {score}");
+ }
+
+ [Fact]
+ public void Policy_CalculateHealthScore_WithActiveAlerts_DeductsPoints()
+ {
+ var riskMetrics = new DashboardPolicy.RiskMetricsSnapshot(
+ 5000, 2.0m, 2.5m, 10m, 40m, 25m);
+ var alerts = new List
+ {
+ new(Guid.NewGuid(), "Concentration", 75m, "Warning", "Test alert"),
+ new(Guid.NewGuid(), "Volatility", 25m, "Critical", "Test critical"),
+ };
+
+ var score = DashboardPolicy.CalculateHealthScore(riskMetrics, alerts);
+
+ Assert.True(score < 80, $"Expected score < 80, got {score}");
+ }
+
+ [Fact]
+ public void Policy_SummarizeRiskInsights_GeneratesInsights()
+ {
+ var riskMetrics = new DashboardPolicy.RiskMetricsSnapshot(
+ 15000, 0.8m, 1.2m, 28m, 72m, 45m);
+ var stressResults = new List
+ {
+ new("bear", -18m, 13750),
+ };
+
+ var insights = DashboardPolicy.SummarizeRiskInsights(riskMetrics, stressResults, new());
+
+ Assert.NotEmpty(insights);
+ }
+
+ [Fact]
+ public void Policy_RankAlertsBySeverity_OrdersByCriticality()
+ {
+ var alerts = new List
+ {
+ new(Guid.NewGuid(), "A", 50m, "Initial", "msg"),
+ new(Guid.NewGuid(), "B", 75m, "Critical", "msg"),
+ new(Guid.NewGuid(), "C", 60m, "Warning", "msg"),
+ };
+
+ var ranked = DashboardPolicy.RankAlertsBySeverity(alerts);
+
+ Assert.Equal("Critical", ranked[0].Severity);
+ Assert.Equal("Warning", ranked[1].Severity);
+ Assert.Equal("Initial", ranked[2].Severity);
+ }
+}