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

Portfolio Risk Dashboard

Real-time risk metrics, stress scenarios, and alerts

+
+ Portfolio Health: +
+
+
+ {{ dashboard.healthScore }}/100 +
-
+
+ {{ error }} + +
+ +
+ Loading dashboard... +
+ +
+ +
+

Portfolio Composition

+
+
+ Total Value + ${{ dashboard.portfolio.totalValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }} +
+
+ Positions + {{ dashboard.portfolio.positions.length }} +
+
+ + + + + + + + + + + + + + + + + + + +
SymbolQuantityPriceValueWeight
{{ 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 + + +
+

Risk Insights

+ +
@@ -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); + } +}