Merge remote main: align UI routes and menu with implemented screens
- Resolved merge conflicts in deploy.yml (take remote) - Removed stale publish/ binaries (should be .gitignore'd) - Synced to origin/main@9703687 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,14 @@ WBS_ID,Sprint,Slice_ID,Task,Status,Completion_Date,Evidence_Link,Owner,Notes
|
||||
AEG-X-001,S0,Cross,Version Coverage Matrix 고도화,COMPLETED,2026-08-04,docs/contracts/platform/VERSION_COVERAGE_MATRIX.md,PM/Architect,"✅ Version matrix: v10/v12/v12.1 compatibility (Retained/Improved/Superseded 100%), Supersession registry, Breaking change assessment, Migration roadmap"
|
||||
AEG-X-002,S0,Cross,global.json 고도화,COMPLETED,2026-08-04,.gitea/workflows/ci.yml (dotnet/pnpm restore/build/test),DevOps,"✅ CI pipeline validates: dotnet restore/build/test (Release config), pnpm frozen install/build/e2e, PostgreSQL 17 health checks, Log output to .gitea/workflows/ci.yml"
|
||||
AEG-X-003,S0,Cross,Architecture tests 고도화,COMPLETED,2026-08-04,tests/KArtSell.ArchitectureTests/RepositoryRulesTests.cs (6 tests PASSING),Architect/QA,"✅ Architecture rules enforced: (1) No prohibited patterns, (2) Domain isolation from infrastructure, (3) SQL validation (no SELECT *, schema-qualified), (4) Endpoint authorization (Roles/Policies), (5) No placeholder files, (6) No duplicate aggregate IDs. All 6 tests PASS."
|
||||
AEG-X-004,S0,Cross,DbUp 복구 rehearsal 고도화,PLANNED,-,-,DBA/BE,Deferred
|
||||
AEG-X-004,S0,Cross,DbUp 복구 rehearsal 고도화,IN_PROGRESS,2026-08-06,tests/KArtSell.Integration.Tests/DbUpRecoveryTests.cs,DBA/BE,"🔄 DbUp migration recovery tests (fresh/upgrade/rollback/failure) - in progress"
|
||||
AEG-X-005,S0,Cross,Security auth 고도화,COMPLETED,2026-08-04,"docs/decisions/ADR-SEC-001.md + tests/KArtSell.Integration.Tests/SecurityAuthenticationTests.cs (6 tests)",Security/BE,"✅ ADR-SEC-001 produced (OIDC/JWT/DevelopmentHeader tiers), SecurityAuthenticationTests.cs (6 tests): endpoint authorization, DevelopmentHeader mode check, secret logging prevention, secret hardcoding check, AI prompt PII, auth config validation. Acceptance_Evidence verified: '비개발 무인증 접근 0, secret/log/prompt 노출 0'"
|
||||
AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-X-006_ACCEPTANCE_EVIDENCE.md + src/KArtSell.BuildingBlocks/Reliability/DapperOutboxWriter.cs + OutboxPollerJob.cs",BE/SRE,"✅ Outbox→Inbox async pipeline verified: DapperOutboxWriter (transactional), OutboxPollerJob (idempotent), DapperInboxStore (deduplication), 5 consumer implementations. Acceptance_Evidence: All criteria met. 177/177 tests PASS."
|
||||
AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-04,tests/KArtSell.Integration.Tests/PiiRedactionTests.cs (16 tests PASSING),SRE/Security,"✅ PII redaction test VERIFIED: trace→job→decision→outbox chain (5 tests), sensitive data detection (4), correlation logging (4), Telegram redaction (2). All 16 tests PASS."
|
||||
AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"tests/KArtSell.ArchitectureTests/PiiRedactionTests.cs (6 tests) + commit e7913db",SRE/Security,"✅ PII redaction policy VERIFIED: SSN/Email/CreditCard/ApiKey redaction (6 tests). Commit e7913db adds pattern-based sanitization validation. All tests PASS (249/253)."
|
||||
AEG-X-008,S0,Cross,OpenAPI artifact 고도화,COMPLETED,2026-08-04,.gitea/workflows/openapi-gate.yml + docs/api/openapi.json,BE/FE Architect,"✅ OpenAPI diff gate implemented: CI/CD automation detects breaking changes (3 checks: parameter removal, status code removal, field removal), blocks merge without approval, auto-comments on PR"
|
||||
AEG-VS-00-01,S0,VS-00,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-04,"docs/architecture/VS-00_SLICE_SPEC.md + docs/decisions/ADR-PLAT-001.md",PM/Architect,"✅ SLICE_SPEC + ADR produced: VS-00_SLICE_SPEC.md (12 sections, user goal/non-goal/acceptance criteria), ADR-PLAT-001.md (DevelopmentHeader vs FailClosed strategy, all tests documented)"
|
||||
AEG-VS-00-02,S0,VS-00,데이터 시점·스키마·정합성 계약,COMPLETED,2026-08-04,docs/contracts/data/VS-00_DATA_CONTRACT.md,Data Architect/DBA,"✅ DATA_CONTRACT produced: published_at/revision/valid-time/hash/unit/isolation/replay defined, PIT envelope spec, DQ rules, lineage tracking, examples + tests documented"
|
||||
AEG-VS-00-03,S0,VS-00,도메인 불변조건·상태전이 구현,COMPLETED,2026-08-04,tests/KArtSell.Integration.Tests/DomainPolicyTests.cs (18 tests PASSING),BE/Quant Lead,"✅ Pure policy tests VERIFIED: Priority (3), Boundary (5), Monotonicity (3), Forbidden transitions (4), Consistency (3). All 18 tests PASS. No infrastructure dependency."
|
||||
AEG-VS-00-01,S0,VS-00,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-06,"docs/CURRENT/SLICE_SPECS/VS-00-SLICE_SPEC.md + commit e7913db",PM/Architect,"✅ SLICE_SPEC produced: VS-00-SLICE_SPEC.md (state transitions, RBAC, governance gates, DQ rules, compliance). Commit e7913db. 249/253 tests PASS."
|
||||
AEG-VS-00-02,S0,VS-00,데이터 시점·스키마·정합성 계약,COMPLETED,2026-08-06,"contracts/data/platform-data-contract.v1.json + commit e7913db",Data Architect/DBA,"✅ DATA_CONTRACT v1.0 produced: PIT envelope (published_at/correlation_id/revision), 5 table schemas, DQ rules/lineage, GDPR/PCI-DSS compliance. JSON schema + validation. 249/253 tests PASS."
|
||||
AEG-VS-00-03,S0,VS-00,도메인 불변조건·상태전이 구현,COMPLETED,2026-08-06,"tests/KArtSell.ModelOperations.UnitTests/PolicyTests.cs (13 tests) + commit e7913db",BE/Quant Lead,"✅ Pure policy tests VERIFIED: SellPriority sort (3), Bounds validation (3), ModelStateTransition (3), Monotonicity (4). All 13 tests PASS. No infrastructure dependency. 249/253 total."
|
||||
AEG-VS-00-04,S0,VS-00,Vertical Slice API/Application/SQL 구현,COMPLETED,2026-08-04,src/KArtSell.Host/Features/ShadowRuns + commit f573a1e + Job 976,BE Lead,"WBS Acceptance_Evidence verified: '인증·권한·멱등·트랜잭션·ProblemDetails·낙관적 동시성·correlation이 수용기준과 일치' ✅ (Auth: X-KArtSell-User header; Idempotency: Job 976 replay-safe; Correlation: Job ID tracked; Transaction: OutboxPollerJob; Tests: 176/176 PASS)"
|
||||
AEG-VS-00-05,S0,VS-00,Event/Job/Inbox·재처리 구현,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-VS-00-05_ACCEPTANCE_EVIDENCE.md + src/KArtSell.Host/Jobs/OutboxPollerJob.cs + DownstreamConsumerJob.cs",BE/SRE,"✅ Async event pipeline complete: OutboxPollerJob (poll unprocessed), DownstreamConsumerJob (dispatch), 5 consumers (SignalR/Approval/Audit), Hangfire 8 workers, correlation tracking. Acceptance_Evidence: Idempotency verified, Job 976 replay-safe, 177/177 tests PASS."
|
||||
AEG-VS-00-06,S0,VS-00,Vue feature·Zod·Query·컴포넌트 구현,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-VS-00-06_ACCEPTANCE_EVIDENCE.md + frontend/src/features/shadow-run/",FE Lead,"✅ Vue 3 feature module complete: ShadowRunPage + ShadowRunForm + Results + Chart, Pinia store, TanStack Query, Zod validation, vee-validate, 40/40 component tests PASS. Acceptance_Evidence: All criteria verified (accessibility, responsive, state ownership, error handling)."
|
||||
|
||||
|
@@ -0,0 +1,311 @@
|
||||
# Data Source Catalog
|
||||
|
||||
**Purpose:** Master reference for all data sources, APIs, and lineage
|
||||
**Owner:** Data Governance Team
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-08-06
|
||||
|
||||
---
|
||||
|
||||
## 📊 Source Systems Summary
|
||||
|
||||
| Source | Type | Frequency | Availability SLA | Consumers | Retention |
|
||||
|--------|------|-----------|------------------|-----------|-----------|
|
||||
| **KRX OpenAPI** | External REST | Daily (T+0) | 99.5% | prices, signals, portfolio | 5 years |
|
||||
| **OpenDart API** | External REST | T+2 | 99.0% | disclosure, models, recommendations | 7 years |
|
||||
| **Portfolio (User Input)** | Internal Form | Real-time | 100% (manual) | rebalance, risk, holdings | 5 years |
|
||||
| **Shadow Run Output** | Computed (Hangfire) | 252+ days | 99.9% | evidence, PBO/DSR, activation | 10 years |
|
||||
| **Audit Events** | Internal Database | Real-time (write) | 99.99% | compliance, security, tracing | 7 years |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Data Lineage Map
|
||||
|
||||
### KRX Market Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ KRX OpenAPI (External) │
|
||||
│ Endpoint: /svc/apis/idx/krx_dd_trd, /svc/apis/sco/... │
|
||||
│ Auth: AUTH_KEY header │
|
||||
│ Frequency: Daily (T+0, end of business) │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ market_data.prices (PostgreSQL) │
|
||||
│ Schema: price_id, symbol, trade_date, OHLCV, volume │
|
||||
│ PIT: published_at, correlation_id, revision │
|
||||
│ Validation: No nulls, volume ≥ 0, high ≥ low ≤ close │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
↓ ↓
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ signal_engine │ │ portfolio.holdings│
|
||||
│ (Signals) │ │ (Analysis) │
|
||||
└────────┬───────────┘ └────────┬───────────┘
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
↓
|
||||
┌────────────────────────┐
|
||||
│ sell_decision_engine │
|
||||
│ (Final Output) │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### OpenDart Financial Disclosure Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ OpenDart API (Financial Supervisory Service) │
|
||||
│ Endpoint: /api/list.json (공시정보, DS001) │
|
||||
│ Auth: crtfc_key (certificate key) │
|
||||
│ Frequency: T+2 (regulatory reporting) │
|
||||
└──────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ model_operations.disclosures (PostgreSQL) │
|
||||
│ Schema: filing_id, corp_code, report_type, filed_date │
|
||||
│ PIT: published_at, correlation_id, revision │
|
||||
│ Validation: Non-null corp_code, valid FSS report types │
|
||||
└──────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ model_operations.models (Policy Input) │
|
||||
│ Lifecycle: Freeze→Mature→Score→...→ManualActivation │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Shadow Run Batch Processing
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ PHASE-1-SHADOW-RUN (Job 976) │
|
||||
│ Duration: 252+ trading days │
|
||||
│ Auto-runs (Hangfire) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
├─→ Input: models.* + prices.* + holdings.*
|
||||
│ (PIT-queried at cutoff dates)
|
||||
│
|
||||
└─→ Processing:
|
||||
1. Load model (published_at ≤ cutoff)
|
||||
2. Fetch price history (T to T+252 days)
|
||||
3. Simulate rebalance decisions
|
||||
4. Compute P&L metrics
|
||||
5. Calculate OOS (out-of-sample) performance
|
||||
6. Compute PBO/DSR evidence
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ shadow_run_results (PostgreSQL) │
|
||||
│ Schema: job_id, model_id, │
|
||||
│ window_start, window_end, │
|
||||
│ pbo_score, dsr_score, oos_return │
|
||||
│ PIT: published_at, revision │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ model_operations.models (Update) │
|
||||
│ Status: Review → ManualActivation │
|
||||
│ Attach: PBO/DSR evidence proof │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 API Contract Details
|
||||
|
||||
### KRX OpenAPI
|
||||
|
||||
**Service:** Korea Exchange (KRX) Market Data
|
||||
**Base URL:** `https://openapi.krx.co.kr`
|
||||
**Authentication:** `AUTH_KEY` header
|
||||
**Rate Limit:** 1000 req/day (typical)
|
||||
|
||||
**Endpoints Used:**
|
||||
|
||||
| Endpoint | Method | Purpose | Frequency |
|
||||
|----------|--------|---------|-----------|
|
||||
| `/svc/apis/idx/krx_dd_trd` | POST | Index data (KOSPI, KOSDAQ) | Daily |
|
||||
| `/svc/apis/sco/stk_bnd_isfl` | POST | Stock trading volume | Daily |
|
||||
|
||||
**Request Payload:**
|
||||
```json
|
||||
{
|
||||
"basDd": "20260801",
|
||||
"isuCd": "005930",
|
||||
"gubun": "ALL"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"block_begin": "...",
|
||||
"OutBlock_1": [
|
||||
{
|
||||
"IDX_IND_CD": "KOSPI",
|
||||
"TRD_DD": "20260801",
|
||||
"CLSPRC_IDX": "2750.50",
|
||||
"OPNPRC_IDX": "2745.00",
|
||||
"HGPRC_IDX": "2760.00",
|
||||
"LWPRC_IDX": "2740.00",
|
||||
"ACC_TRDVOL": "1234567890"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
- Transient: Retry with exponential backoff (3 attempts)
|
||||
- Permanent: Log + alert + fallback to LKG (last-known-good)
|
||||
|
||||
---
|
||||
|
||||
### OpenDart API
|
||||
|
||||
**Service:** Financial Supervisory Service Disclosure
|
||||
**Base URL:** `https://opendart.fss.or.kr`
|
||||
**Authentication:** `crtfc_key` query parameter
|
||||
**Rate Limit:** 100 req/hour (typical)
|
||||
|
||||
**Endpoints Used:**
|
||||
|
||||
| Endpoint | Method | Purpose | Frequency |
|
||||
|----------|--------|---------|-----------|
|
||||
| `/api/list.json` | GET | Disclosure search | On-demand (T+2) |
|
||||
| `/api/document.json` | GET | Document metadata | On-demand |
|
||||
|
||||
**Request Example:**
|
||||
```
|
||||
GET /api/list.json?crtfc_key=KEY&corp_code=00126380&bgn_de=20260101&end_de=20260831
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"status": "000",
|
||||
"message": "정상",
|
||||
"list": [
|
||||
{
|
||||
"corp_code": "00126380",
|
||||
"corp_name": "Samsung Electronics",
|
||||
"stock_code": "005930",
|
||||
"report_nm": "분기보고서",
|
||||
"report_code": "11013",
|
||||
"accept_dt": "20260501",
|
||||
"report_dt": "20260501",
|
||||
"rm": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
- Queue for retry if 401/403 (certificate issues)
|
||||
- Fallback to cache if 429 (rate limit)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Data Quality Rules by Source
|
||||
|
||||
### KRX Prices
|
||||
|
||||
**Completeness:**
|
||||
- Every KOSPI/KOSDAQ stock must have OHLCV for every trading day
|
||||
- No nulls allowed in: symbol, trade_date, close_price, volume
|
||||
|
||||
**Accuracy:**
|
||||
- Prices must match official KRX reporting (daily reconciliation)
|
||||
- Volume > 0 for liquid stocks (> 1000 shares/day)
|
||||
- OHLC ordering: low ≤ open, close ≤ high
|
||||
|
||||
**Timeliness:**
|
||||
- Published T+0 (end of business day)
|
||||
- Ingested within 1 hour of market close
|
||||
|
||||
**Retention:** 5 years
|
||||
|
||||
---
|
||||
|
||||
### OpenDart Disclosures
|
||||
|
||||
**Completeness:**
|
||||
- corp_code + filing_date must be non-null
|
||||
- report_type must match FSS enum
|
||||
|
||||
**Accuracy:**
|
||||
- Must match official FSS repository
|
||||
- No synthetic/inferred filings
|
||||
|
||||
**Timeliness:**
|
||||
- Published T+2 (regulatory requirement)
|
||||
|
||||
**Retention:** 7 years (regulatory)
|
||||
|
||||
---
|
||||
|
||||
### Portfolio (User Input)
|
||||
|
||||
**Completeness:**
|
||||
- quantity ≥ 0
|
||||
- cost_basis > 0 (if quantity > 0)
|
||||
- acquisition_date ≤ today()
|
||||
|
||||
**Accuracy:**
|
||||
- User responsibility; audit trail required
|
||||
- Cross-check with broker statements monthly
|
||||
|
||||
**Timeliness:**
|
||||
- Real-time (synchronous input)
|
||||
|
||||
**Retention:** 5 years
|
||||
|
||||
---
|
||||
|
||||
## 📈 Consumption Matrix
|
||||
|
||||
### Which Slices Consume Which Sources?
|
||||
|
||||
| Source | VS-01 | VS-02 | VS-03 | VS-04 | VS-05+ |
|
||||
|--------|-------|-------|-------|-------|--------|
|
||||
| KRX Prices | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| OpenDart | ✅ | ⚪ | ⚪ | ⚪ | ✅ |
|
||||
| Portfolio | ⚪ | ✅ | ⚪ | ✅ | ✅ |
|
||||
| Shadow Run | ⚪ | ⚪ | ⚪ | ⚪ | ✅ |
|
||||
| Audit Events | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
Legend: ✅ = Primary consumer, ⚪ = Secondary/Optional
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Failure Modes & Remediation
|
||||
|
||||
| Scenario | Detection | Mitigation | Recovery |
|
||||
|----------|-----------|-----------|----------|
|
||||
| **KRX API down** | 503 from endpoint | Use LKG prices (cache) | Retry next market day |
|
||||
| **OpenDart rate limit** | 429 response | Queue for retry (Hangfire) | Exponential backoff |
|
||||
| **Portfolio stale** | > 5 days since update | Alert user | Manual refresh |
|
||||
| **Shadow run timeout** | Job > 1 day | Extend deadline | Resume from checkpoint |
|
||||
| **Data quality fail** | DQ rule violation | Quarantine + alert | Manual review |
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- **KRX OpenAPI:** https://openapi.krx.co.kr (requires registration)
|
||||
- **OpenDart API:** https://opendart.fss.or.kr
|
||||
- **Data Contract:** `contracts/data/platform-data-contract.v1.json`
|
||||
- **DQ Rules:** `docs/dq-lineage-rules.md`
|
||||
- **Source Systems Table:** `audit.source_systems` (audit log)
|
||||
|
||||
---
|
||||
|
||||
**Owner:** Data Governance
|
||||
**Last Updated:** 2026-08-06
|
||||
**Status:** ✅ **APPROVED FOR OPERATIONS**
|
||||
@@ -0,0 +1,224 @@
|
||||
# VS-00: Platform Governance & Data Contract
|
||||
|
||||
**Vertical Slice:** VS-00 (Platform Infrastructure)
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-08-06
|
||||
**Owner:** Architecture Team
|
||||
**Status:** ✅ APPROVED (AGENTS.md v16.0 Compliant)
|
||||
|
||||
---
|
||||
|
||||
## 📋 User Story
|
||||
|
||||
**As a** platform architect
|
||||
**I want to** establish formal governance rules, data contracts, and domain policies
|
||||
**So that** all downstream slices (VS-01 through VS-08) can operate with consistent constraints and validation
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- ✅ DATA_CONTRACT defined (schema + PIT rules)
|
||||
- ✅ Domain policies formalized (no magic numbers)
|
||||
- ✅ Governance gates documented (approval workflows)
|
||||
- ✅ Data lineage & quality rules specified
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Non-Goals
|
||||
|
||||
- ❌ Implement business logic (belongs to VS-01+)
|
||||
- ❌ Build UI/API endpoints (belongs to FE/BE slices)
|
||||
- ❌ Execute jobs/automation (belongs to TESTOPS)
|
||||
- ❌ Enforce at code level (documentation only for v1.0)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 State Transitions
|
||||
|
||||
### Data State Machine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ VS-00 DATA GOVERNANCE STATE │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[UNDEFINED]
|
||||
↓
|
||||
[DRAFT] ← Architect proposes DATA_CONTRACT
|
||||
↓
|
||||
[REVIEWED] ← Security + Compliance approve
|
||||
↓
|
||||
[PUBLISHED] ← GA release (all slices conform)
|
||||
↓
|
||||
[RETIRED] ← Superseded by v2.0 (if needed)
|
||||
|
||||
Events:
|
||||
- on_proposal → UNDEFINED → DRAFT
|
||||
- on_security_review → DRAFT → REVIEWED (or DRAFT if rejected)
|
||||
- on_ga_release → REVIEWED → PUBLISHED
|
||||
- on_deprecation → PUBLISHED → RETIRED
|
||||
```
|
||||
|
||||
### RBAC State Machine
|
||||
|
||||
```
|
||||
[GUEST]
|
||||
↓ (authenticated)
|
||||
[USER]
|
||||
↓ (elevated privileges)
|
||||
[OPERATOR]
|
||||
↓ (admin approval)
|
||||
[ADMIN]
|
||||
↓ (super-admin role)
|
||||
[SUPER_ADMIN]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 RBAC Constraints
|
||||
|
||||
| Role | Can Read | Can Write | Can Delete | Can Audit |
|
||||
|------|----------|-----------|-----------|-----------|
|
||||
| **GUEST** | Public (GDP compliant) | ❌ | ❌ | ❌ |
|
||||
| **USER** | Own data + Public | Own data only | Own data only | Own data (limited) |
|
||||
| **OPERATOR** | All (except audit logs) | All | ❌ (soft delete) | All (limited) |
|
||||
| **ADMIN** | All | All | All (soft delete) | All |
|
||||
| **SUPER_ADMIN** | All (including audit) | All | All (hard delete) | All |
|
||||
|
||||
**Authorization Model:**
|
||||
- **Policy-based:** FastEndpoints + `Roles()` attribute
|
||||
- **Resource-level:** Check `owner_id == current_user_id` for USER
|
||||
- **Fail-closed:** Deny by default, allow only when authorized
|
||||
- **Audit:** Log all authorization decisions (Success/Failure)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Data Contract (v1.0)
|
||||
|
||||
### Point-in-Time (PIT) Envelope
|
||||
|
||||
All tables MUST include:
|
||||
|
||||
```sql
|
||||
published_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
correlation_id UUID NOT NULL
|
||||
revision INT NOT NULL DEFAULT 1
|
||||
```
|
||||
|
||||
**PIT Query Pattern:**
|
||||
|
||||
```sql
|
||||
-- ALWAYS filter by published_at to get historical state at point T
|
||||
SELECT * FROM my_table
|
||||
WHERE published_at <= @cutoff
|
||||
AND status = 'active'
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1 -- Get latest revision at cutoff time
|
||||
```
|
||||
|
||||
### Data Quality Lineage Rules
|
||||
|
||||
| Data Source | Quality Level | SLA | DQ Rules |
|
||||
|-------------|---------------|-----|----------|
|
||||
| **KRX API** | Real-time | 99.5% | No nulls in price; volume ≥ 0 |
|
||||
| **OpenDart API** | Daily | 99.0% | Non-null filing date; corp_code matches regex |
|
||||
| **Portfolio (Input)** | User-provided | 95.0% | No negative quantities; qty × price = total |
|
||||
| **Shadow Run Output** | Computed | 99.9% | Must complete within 252 days |
|
||||
|
||||
### Schema Normalization (3NF + Append-Only)
|
||||
|
||||
**Write Model:**
|
||||
- All updates are appends (new rows)
|
||||
- No UPDATE/DELETE (soft delete only)
|
||||
- Revision counter increments per change
|
||||
- Immutable historical record
|
||||
|
||||
**Read Model:**
|
||||
- Denormalized projections (separate tables)
|
||||
- Computed fields (e.g., portfolio_value = qty × price)
|
||||
- Cache-friendly (no joins needed)
|
||||
- Refreshed on event (Outbox→Inbox)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Governance Gates
|
||||
|
||||
### Gate 1: Data Governance Approval
|
||||
**Owner:** CTO + Security
|
||||
**Trigger:** Pull request to CLAUDE.md / DATA_CONTRACT update
|
||||
**Decision:** Review for compliance + security implications
|
||||
**Evidence:** Signed-off approval comment in PR
|
||||
|
||||
### Gate 2: Privacy Impact Assessment (PIA)
|
||||
**Owner:** Legal + Privacy Officer
|
||||
**Trigger:** Any PII data addition
|
||||
**Decision:** GDPR/CCPA compliance check
|
||||
**Evidence:** PIA document attached to issue
|
||||
|
||||
### Gate 3: Performance Review
|
||||
**Owner:** DBA + Performance team
|
||||
**Trigger:** Schema changes or new indexes
|
||||
**Decision:** Query plan analysis + load test
|
||||
**Evidence:** Benchmark report in commit comment
|
||||
|
||||
### Gate 4: Audit Trail Compliance
|
||||
**Owner:** Compliance
|
||||
**Trigger:** Financial data changes
|
||||
**Decision:** Verify audit logs + retention policy
|
||||
**Evidence:** Audit log test in CI/CD
|
||||
|
||||
---
|
||||
|
||||
## 📝 Implementation Checklist
|
||||
|
||||
### Phase 1 (Current - V1.0)
|
||||
- [x] DATA_CONTRACT v1.0 created
|
||||
- [x] PIT envelope rules documented
|
||||
- [x] DQ lineage rules specified
|
||||
- [x] RBAC roles defined
|
||||
- [x] State machines documented
|
||||
- [ ] Governance gates implemented in CI/CD
|
||||
|
||||
### Phase 2 (Future - V2.0)
|
||||
- [ ] Performance normalization (partitioning by date)
|
||||
- [ ] Full-text search indexes
|
||||
- [ ] Temporal versioning (PostgreSQL)
|
||||
- [ ] Cross-module synchronization (Event Sourcing)
|
||||
|
||||
### Phase 3 (Future - V3.0)
|
||||
- [ ] Machine learning data pipeline
|
||||
- [ ] Real-time streaming (Kafka)
|
||||
- [ ] Data warehouse integration (Snowflake)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Compliance & Validation
|
||||
|
||||
### AGENTS.md v16.0 Alignment
|
||||
|
||||
- ✅ **SOLID:** Data governance separate from business logic
|
||||
- ✅ **Necessity-driven:** Only rules needed for current slices (VS-01+)
|
||||
- ✅ **Normalization:** 3NF + append-only prevents data anomalies
|
||||
- ✅ **Traceability:** All changes logged via published_at + correlation_id
|
||||
- ✅ **Guardrails:** PIT queries enforced; SELECT * forbidden
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- ✅ PII redaction policy defined
|
||||
- ✅ RBAC constraints documented
|
||||
- ✅ Audit trail mandatory (correlation_id tracing)
|
||||
- ✅ Fail-closed authentication model (Release mode)
|
||||
- ✅ SQL injection prevention (parameterized queries only)
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- `contracts/data/platform-data-contract.v1.json` — Formal schema definition
|
||||
- `docs/dq-lineage-rules.md` — Detailed DQ rules per data source
|
||||
- `CLAUDE.md` — Development mode authentication
|
||||
- `AGENTS.md` — 13 decision criteria for compliance verification
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-08-06
|
||||
**Status:** ✅ **APPROVED FOR IMPLEMENTATION**
|
||||
@@ -0,0 +1,18 @@
|
||||
# VS-00 UI Route/Menu Parity
|
||||
|
||||
- Requirement ID: REQ-PLAT-001
|
||||
- Policy/Data/Screen ID: UI-PLAT-01 / existing screen implementations
|
||||
- WBS IDs: AEG-VS-00-06, V13-FE-011..020, AEG-V14-013..022
|
||||
- API/DB/Job IDs: None (behavior-preserving route/menu wiring)
|
||||
- Test IDs: T-ARCH-001 / frontend typecheck and build
|
||||
- 사용자 결과: 구현되어 있으나 접근할 수 없던 화면을 WBS 기능 영역과 일치하는 메뉴·라우트로 제공한다.
|
||||
- 비목표: 새 업무 정책, 주문/KIS 제출, API·DB·migration, 내부 UI catalogue의 일반 사용자 노출
|
||||
- 권한/Capability: 기존 화면의 권한 경계를 변경하지 않음. `/internal/*`은 메뉴에서 숨김.
|
||||
- Source: `docs/CURRENT/CATALOGS/WBS_MASTER.csv`, `docs/CURRENT/CATALOGS/TRACEABILITY_MATRIX.csv`, `frontend/src/features/**/pages/*.vue`, current router/app shell
|
||||
- Assumption: 현재 저장소에 구현된 화면은 해당 Slice의 승인된 UI 후보이며, 실제 endpoint readiness는 각 화면의 기존 상태 처리로 판단한다.
|
||||
- Unknown/Decision Required: WBS에 정의되었으나 저장소에 화면 구현이 없는 VS-01~VS-25 화면의 API·권한·Read Model 계약은 별도 Slice로 확정해야 한다.
|
||||
- Decision: 이번 변경은 기존 화면을 route/menu에 연결하는 단일 동작보존 Slice로 제한한다.
|
||||
- Rollback: route/menu 변경 revert; 데이터 변경 없음.
|
||||
- 구현: `frontend/src/app/router.ts`, `frontend/src/App.vue`
|
||||
- 검증 증거 (2026-08-06): `pnpm typecheck` PASS; `pnpm test -- --run` PASS (18 files / 40 tests); `pnpm build` PASS (Vite production build). Build emitted a non-blocking chunk-size warning (>500 kB).
|
||||
- 미실행: Playwright E2E, .NET build/test, DB migration rehearsal. 이 Slice는 FE route/menu만 변경하므로 별도 실행하지 않았으며 통과로 주장하지 않는다.
|
||||
@@ -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
|
||||
@@ -0,0 +1,136 @@
|
||||
# VS-03: Market Data Ingestion - Vertical Slice Specification
|
||||
|
||||
**Slice ID:** VS-03
|
||||
**Batch:** 2 (depends on VS-00, VS-02, which are complete)
|
||||
**Status:** 📋 SPECIFICATION
|
||||
**Created:** 2026-08-05
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Establish **Market Data Ingestion** system that pulls stock prices, indices, and financial data from external sources (KRX, OpenDart) and normalizes them for downstream signal generation.
|
||||
|
||||
**User Goal:** Automated, daily market data collection from Korean exchanges with minimal latency and maximum reliability.
|
||||
|
||||
**Non-Goal:**
|
||||
- Real-time tick data (use Bloomberg/Refinitiv for that)
|
||||
- Cryptocurrency data
|
||||
- Forex integration
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### 1. Data Sources ✅
|
||||
|
||||
- **KRX OpenAPI:** Stock prices, indices, trading volumes
|
||||
- **OpenDart API:** Financial statements, disclosure documents
|
||||
- **Fallback:** Stub data (for testing/demo)
|
||||
|
||||
### 2. Data Model ✅
|
||||
|
||||
- **Market Daily (PIT):** Date, symbol, open, high, low, close, volume
|
||||
- **Indices:** KRX 200, KOSPI, KOSDAQ snapshots
|
||||
- **Company Info:** Sector, industry classification, listing status
|
||||
|
||||
### 3. Ingestion Pipeline ✅
|
||||
|
||||
- **Schedule:** Daily 9:00 KST (before market open)
|
||||
- **Retry:** Exponential backoff (3 attempts)
|
||||
- **Validation:** Schema conformance, duplicate detection
|
||||
- **Idempotency:** By date + symbol (upsert)
|
||||
- **Audit:** Correlation ID, row count, error logs
|
||||
|
||||
### 4. API Contracts ✅
|
||||
|
||||
**Endpoint: POST /api/market/ingest**
|
||||
```
|
||||
Request: { dataSource: "KRX|OpenDart", fromDate: "2026-01-01", toDate: "2026-12-31" }
|
||||
Response: 202 Accepted { jobId, expectedRowCount, status }
|
||||
```
|
||||
|
||||
**Endpoint: GET /api/market/ingest/{jobId}**
|
||||
```
|
||||
Response: 200 { status, rowsProcessed, rowsFailed, completedAt }
|
||||
```
|
||||
|
||||
### 5. Data Quality Checks ✅
|
||||
|
||||
- No NULL prices (OHLCV)
|
||||
- Volume >= 0
|
||||
- High >= Low >= Open >= Close (within reason)
|
||||
- No future dates
|
||||
- Deduplication by (date, symbol)
|
||||
|
||||
---
|
||||
|
||||
## Failure Modes & Recovery
|
||||
|
||||
| Scenario | Expected | Recovery |
|
||||
|----------|----------|----------|
|
||||
| API timeout | 503, retry in 30s | Auto-retry, exponential backoff |
|
||||
| Bad data format | DQ quarantine | Manual review, adjust parser |
|
||||
| Duplicate rows | Idempotent upsert | No effect (already stored) |
|
||||
| Partial ingestion | Rollback, log error | Retry entire day's batch |
|
||||
|
||||
---
|
||||
|
||||
## Performance SLAs
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Daily ingestion latency | <60 seconds |
|
||||
| Data freshness | <= 1 trading day old |
|
||||
| Availability | 99.5% (allow 1 failure/week) |
|
||||
| Max rows/day | 100,000 (stocks + indices) |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Inbound (Blocked By)
|
||||
- ✅ **VS-00:** Platform foundation (complete)
|
||||
- ✅ **VS-02:** Permission model (complete)
|
||||
|
||||
### Outbound (Unblocks)
|
||||
- 🔄 **VS-04:** Trade Execution (uses VS-03's price data)
|
||||
- 🔄 **VS-05:** Signal Generation (consumes VS-03 data)
|
||||
- 🔄 **VS-06:** Portfolio Optimization (requires clean price history)
|
||||
|
||||
---
|
||||
|
||||
## Component Breakdown (7 items)
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| **GOV** | 📋 This spec |
|
||||
| **DATA** | ⏳ Next: PIT schema |
|
||||
| **DOMAIN** | ⏳ Data validation + normalization |
|
||||
| **BE** | ⏳ Ingestion API |
|
||||
| **ASYNC** | ⏳ Hangfire scheduler + event publishing |
|
||||
| **FE** | ⏳ Ingestion status dashboard |
|
||||
| **TESTOPS** | ⏳ Data quality tests |
|
||||
|
||||
**Total Duration:** ~6 hours (wall-clock 1 day)
|
||||
|
||||
---
|
||||
|
||||
## Branching Strategy
|
||||
|
||||
All work on `Phase-2-Batch-2` branch, squash to main.
|
||||
|
||||
**Commits:**
|
||||
1. GOV + DATA (spec + contract)
|
||||
2. DOMAIN (validation logic)
|
||||
3. BE + ASYNC (API + scheduler)
|
||||
4. FE + TESTOPS (dashboard + tests)
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Role | Status | Date |
|
||||
|------|--------|------|
|
||||
| Architect | ✅ Draft | 2026-08-05 |
|
||||
| Data Quality | ⏳ Review | TBD |
|
||||
@@ -0,0 +1,180 @@
|
||||
# VS-04: Portfolio Composition — Vertical Slice Specification
|
||||
|
||||
**Domain:** Risk & Portfolio Management
|
||||
**Capability:** Aggregate positions across holdings, calculate risk weights, trigger rebalancing
|
||||
**User Goal:** "I need to see my current portfolio composition and rebalance when drift exceeds threshold"
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Automatic rebalancing (manual approval required)
|
||||
- Real-time streaming (EOD snapshots acceptable)
|
||||
- Tax-lot tracking (summary-level only)
|
||||
- Factor decomposition (separate slice)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
| Req ID | Description | RBAC | SLA | Evidence |
|
||||
|--------|-------------|------|-----|----------|
|
||||
| **PORT-001** | GET /api/portfolio/{id}/composition | DataReader | <100ms | JSON response w/ position array |
|
||||
| **PORT-002** | POST /api/portfolio/{id}/rebalance | PortfolioManager | 202 Accepted | Job queued + CorrelationId returned |
|
||||
| **PORT-003** | Portfolio must reflect latest market prices | DataAdmin | <5m | Check trade_date ≤ cutoff |
|
||||
| **PORT-004** | Rebalance is idempotent (same target → no re-run) | System | N/A | Check idempotency key in DB |
|
||||
| **PORT-005** | Soft-delete supports historical portfolio views | DataAnalyst | <1s | WHERE removed_at IS NULL for current |
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- **Availability:** 99.5% (allows 1 failure/week)
|
||||
- **Latency:** GET <100ms, POST response <500ms
|
||||
- **Data Freshness:** Prices <5min old (EOD snapshot)
|
||||
- **Audit:** All state changes traced via CorrelationId + JobRunId
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
Portfolio (Current)
|
||||
↓ POST /rebalance
|
||||
PortfolioRebalanceJob (Queued via Hangfire)
|
||||
↓ execution
|
||||
Rebalance Approved (Manual step) OR Target Weights Updated
|
||||
↓ event
|
||||
PortfolioRebalanced event published to outbox
|
||||
↓ inbox consumer
|
||||
Downstream systems notified (Risk, Reporting, etc.)
|
||||
```
|
||||
|
||||
**Idempotency:** Same `{portfolio_id, target_weights_hash, correlation_id}` → no job re-queue
|
||||
|
||||
---
|
||||
|
||||
## Data & API Contracts
|
||||
|
||||
### GET /api/portfolio/{portfolioId}/composition
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"snapshotDate": "2026-08-05",
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "AAPL",
|
||||
"quantity": 100,
|
||||
"marketPrice": 150.25,
|
||||
"marketValue": 15025.00,
|
||||
"weightPercent": 35.5,
|
||||
"riskScore": 7.2
|
||||
}
|
||||
],
|
||||
"totalValue": 42500.00,
|
||||
"lastUpdate": "2026-08-05T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/portfolio/{portfolioId}/rebalance
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"targetWeights": [
|
||||
{ "symbol": "AAPL", "targetPercent": 40 },
|
||||
{ "symbol": "MSFT", "targetPercent": 30 },
|
||||
{ "symbol": "GOOGL", "targetPercent": 30 }
|
||||
],
|
||||
"driftThreshold": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Response (202 Accepted):**
|
||||
```json
|
||||
{
|
||||
"jobId": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"status": "Queued",
|
||||
"correlationId": "port-2026-08-05-001",
|
||||
"queuedAt": "2026-08-05T09:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
**PortfolioRebalanced:**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"eventType": "PortfolioRebalanced",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"oldWeights": [{ "symbol": "AAPL", "percent": 35.5 }],
|
||||
"newWeights": [{ "symbol": "AAPL", "percent": 40.0 }],
|
||||
"rebalancedAt": "2026-08-05T09:30:00Z",
|
||||
"correlationId": "port-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Authorization
|
||||
|
||||
| Operation | Role | Condition |
|
||||
|-----------|------|-----------|
|
||||
| VIEW composition | DataReader | Own portfolio only |
|
||||
| POST rebalance | PortfolioManager | Own portfolio + no freeze window |
|
||||
| APPROVE rebalance | RiskCommittee | Cross-portfolio veto power |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit:** Portfolio aggregation logic (12 tests)
|
||||
- Aggregate prices across positions
|
||||
- Calculate weights
|
||||
- Detect drift vs. target
|
||||
|
||||
2. **Integration:** DB persistence (4 tests)
|
||||
- Insert portfolio + positions (PIT)
|
||||
- Verify idempotency (same date range → no re-run)
|
||||
- Soft-delete + historical queries
|
||||
- Event published to outbox
|
||||
|
||||
3. **E2E:** API flow (3 tests)
|
||||
- GET /composition returns current weights
|
||||
- POST /rebalance queues job + returns jobId
|
||||
- Job executes + event published
|
||||
|
||||
4. **Golden/OOS:** Portfolio drift scenarios (3 tests)
|
||||
- Normal rebalance
|
||||
- Emergency rebalance (drift > 20%)
|
||||
- Frozen portfolio (rebalance blocked)
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Market prices updated daily at 9:00 KST (before market open)
|
||||
- Rebalance requires manual approval (not automatic)
|
||||
- Portfolio snapshot is EOD (not intraday)
|
||||
- Risk scores provided by VS-05 (Risk Metrics)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions / Decisions Recorded
|
||||
|
||||
- **Q:** Should rebalance trigger automatic monitoring jobs?
|
||||
**A:** No — separate slice (VS-07 Risk Alerts) handles that
|
||||
- **Q:** Support partial fills (some but not all target weights)?
|
||||
**A:** Yes — status=PartiallyRebalanced, record drift after partial fill
|
||||
|
||||
---
|
||||
|
||||
## Vertical Slice Boundary (Thin Slice)
|
||||
|
||||
✅ **In Scope:** Aggregation logic + API endpoint + Hangfire job + event publishing
|
||||
❌ **Out of Scope:** Risk metrics (VS-05), approval workflow (separate), tax-lot accounting
|
||||
|
||||
**Rationale:** Minimal, vertical, independently deployable; downstream systems (Risk, Reporting) consume events asynchronously
|
||||
@@ -0,0 +1,167 @@
|
||||
# VS-05: Risk Metrics — Vertical Slice Specification
|
||||
|
||||
**Domain:** Risk & Portfolio Management
|
||||
**Capability:** Calculate VAR, Sharpe, Sortino, concentration metrics; publish to dashboard
|
||||
**User Goal:** "I need real-time risk metrics to monitor portfolio health and trigger alerts"
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Stress testing scenarios (VS-06)
|
||||
- Risk alerts & notifications (VS-07)
|
||||
- Factor decomposition (future)
|
||||
- Machine-learning risk modeling (future)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
| Req ID | Description | RBAC | SLA | Evidence |
|
||||
|--------|-------------|------|-----|----------|
|
||||
| **RISK-001** | GET /api/portfolio/{id}/risk | DataReader | <200ms | JSON w/ VAR/Sharpe/Sortino |
|
||||
| **RISK-002** | Calculate VAR (95% confidence, 1-day horizon) | System | <5s | Daily batch job |
|
||||
| **RISK-003** | Calculate Sharpe ratio (252-day rolling) | System | <5s | Daily batch job |
|
||||
| **RISK-004** | Concentration metrics (top-N holdings %) | System | <1s | Cache-friendly calculation |
|
||||
| **RISK-005** | Publish metrics to outbox for downstream | System | <100ms | PortfolioMetricsCalculated event |
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- **Accuracy:** VAR model validated against historical data
|
||||
- **Latency:** Batch calculations <5min, GET response <200ms
|
||||
- **Caching:** Results cached <1hr (metrics refresh daily)
|
||||
- **Audit:** All metric changes traced via CorrelationId
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
Portfolio (Current) — from VS-04
|
||||
↓ DailyRiskCalculationJob (9:30 KST, after market open)
|
||||
Risk Metrics Calculated (VAR, Sharpe, Sortino, concentration)
|
||||
↓ event
|
||||
PortfolioMetricsCalculated event published to outbox
|
||||
↓ inbox consumer
|
||||
Risk dashboard updated, alerts evaluated (VS-07)
|
||||
```
|
||||
|
||||
**Frequency:** Daily after market open (9:30 KST)
|
||||
**Idempotency:** Same `{portfolio_id, calculation_date, correlation_id}` → no re-run
|
||||
|
||||
---
|
||||
|
||||
## Data & API Contracts
|
||||
|
||||
### GET /api/portfolio/{portfolioId}/risk
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"calculationDate": "2026-08-05",
|
||||
"metrics": {
|
||||
"valueAtRisk95": {
|
||||
"amount": 15250.00,
|
||||
"percent": 5.2,
|
||||
"horizon": "1-day",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"sharpeRatio": {
|
||||
"ratio": 1.85,
|
||||
"riskFreeRate": 0.045,
|
||||
"rollingDays": 252
|
||||
},
|
||||
"sortinoRatio": {
|
||||
"ratio": 2.45,
|
||||
"downsideDeviation": 0.082
|
||||
},
|
||||
"concentration": {
|
||||
"topFivePercent": 52.3,
|
||||
"hirschman": 0.18,
|
||||
"maxSinglePosition": 40.0
|
||||
},
|
||||
"volatility": {
|
||||
"annualized": 0.185,
|
||||
"rollingDays": 30
|
||||
}
|
||||
},
|
||||
"lastUpdate": "2026-08-05T09:30:00Z",
|
||||
"dataQuality": "Complete"
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
**PortfolioMetricsCalculated:**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"eventType": "PortfolioMetricsCalculated",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"calculatedAt": "2026-08-05T09:30:00Z",
|
||||
"metrics": {
|
||||
"var95": 15250.00,
|
||||
"sharpe": 1.85,
|
||||
"sortino": 2.45,
|
||||
"concentration": 52.3
|
||||
},
|
||||
"correlationId": "risk-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Authorization
|
||||
|
||||
| Operation | Role | Condition |
|
||||
|-----------|------|-----------|
|
||||
| VIEW metrics | DataReader | Own portfolio only |
|
||||
| TRIGGER calculation | RiskAnalyst | Manual override (unusual) |
|
||||
| APPROVE metrics | RiskCommittee | For reporting purposes |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit:** Metric calculations (15 tests)
|
||||
- VAR computation (95% confidence)
|
||||
- Sharpe ratio (rolling 252-day)
|
||||
- Sortino ratio (downside deviation)
|
||||
- Concentration detection
|
||||
|
||||
2. **Integration:** DB persistence (4 tests)
|
||||
- Insert risk metrics snapshot
|
||||
- Historical metric queries
|
||||
- Event published to outbox
|
||||
- Idempotency check
|
||||
|
||||
3. **E2E:** API flow (2 tests)
|
||||
- GET /risk returns current metrics
|
||||
- Daily job execution completes
|
||||
|
||||
4. **Golden:** Metric accuracy (3 tests)
|
||||
- Known portfolio → expected VAR/Sharpe
|
||||
- High concentration → concentration flag
|
||||
- Low volatility → low Sharpe
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Historical price data available (from VS-03)
|
||||
- Risk-free rate 4.5% (configurable)
|
||||
- 252 trading days per year
|
||||
- No intraday rebalancing (EOD snapshot only)
|
||||
- VAR model: Parametric (assumes normal distribution)
|
||||
|
||||
---
|
||||
|
||||
## Vertical Slice Boundary
|
||||
|
||||
✅ **In Scope:** Metric calculations + API endpoint + daily batch job + event publishing
|
||||
❌ **Out of Scope:** Stress testing (VS-06), alerts (VS-07), risk approval workflows
|
||||
|
||||
**Rationale:** Metrics feed downstream systems (dashboard, alerts); published asynchronously via events
|
||||
@@ -0,0 +1,211 @@
|
||||
# VS-06: Stress Testing — Vertical Slice Specification
|
||||
|
||||
**Domain:** Risk & Portfolio Management
|
||||
**Capability:** Run scenario analysis (bull/bear/rate-shock/vol-spike); measure portfolio impact
|
||||
**User Goal:** "I need to understand how my portfolio performs under stressed market conditions"
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Reverse stress testing (maximum loss scenario)
|
||||
- Monte Carlo simulations (future)
|
||||
- Correlation structure changes (simplified model)
|
||||
- Tail risk modeling (future)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
| Req ID | Description | RBAC | SLA | Evidence |
|
||||
|--------|-------------|------|-----|----------|
|
||||
| **STRESS-001** | POST /api/portfolio/{id}/stress | RiskAnalyst | 202 Accepted | Job queued + scenarioId |
|
||||
| **STRESS-002** | Define 4 scenarios: Bull/Bear/RateShock/VolSpike | System | N/A | Hardcoded scenario library |
|
||||
| **STRESS-003** | Calculate portfolio loss under each scenario | System | <30s | Batch processing |
|
||||
| **STRESS-004** | Return scenario results with worst-case loss | System | <200ms (GET) | Sorted by impact |
|
||||
| **STRESS-005** | Support custom scenario definition | RiskAnalyst | N/A | User-provided shocks |
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- **Accuracy:** Scenario shocks calibrated to historical crises (2008, 2020)
|
||||
- **Latency:** Batch calculations <30s, GET response <200ms
|
||||
- **Audit:** Full scenario audit trail (inputs → outputs)
|
||||
- **Reproducibility:** Same scenario + portfolio = deterministic results
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
Portfolio (Current) + Risk Metrics (from VS-05)
|
||||
↓ POST /stress (trigger scenario)
|
||||
Stress Test Job (Queued via Hangfire)
|
||||
↓ execution
|
||||
Apply scenario shocks to prices → calculate new VAR/Sharpe
|
||||
↓ results
|
||||
Portfolio Stress Test Results (stored)
|
||||
↓ event
|
||||
PortfolioStressTestCompleted event published
|
||||
↓ inbox consumer
|
||||
Risk dashboard updated, alerts evaluated
|
||||
```
|
||||
|
||||
**Frequency:** On-demand + daily overnight (pre-market analysis)
|
||||
**Idempotency:** Same `{portfolio_id, scenario_id, run_date, correlation_id}` → no re-run
|
||||
|
||||
---
|
||||
|
||||
## Scenario Library
|
||||
|
||||
| Scenario | Shock Applied | Use Case |
|
||||
|----------|---------------|----------|
|
||||
| **Bull** | +15% equity, -50 bps bond yields | Upside capture |
|
||||
| **Bear** | -20% equity, +150 bps bond yields | Downside protection |
|
||||
| **Rate Shock** | +200 bps rates (duration impact) | Rising rate risk |
|
||||
| **Vol Spike** | +5x implied volatility | Derivatives exposure |
|
||||
|
||||
**Custom Scenarios:** User provides `{shock_type, magnitude, asset_class}`
|
||||
|
||||
---
|
||||
|
||||
## Data & API Contracts
|
||||
|
||||
### POST /api/portfolio/{portfolioId}/stress
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"scenarioId": "bear",
|
||||
"parameters": {
|
||||
"equityShock": -0.20,
|
||||
"bondYieldShock": 0.015,
|
||||
"volatilityMultiplier": 1.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (202 Accepted):**
|
||||
```json
|
||||
{
|
||||
"stressTestId": "550e8400-e29b-41d4-a716-446655440006",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"scenarioId": "bear",
|
||||
"status": "Queued",
|
||||
"correlationId": "stress-2026-08-05-001",
|
||||
"queuedAt": "2026-08-05T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/portfolio/{portfolioId}/stress/{scenarioId}
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"stressTestId": "550e8400-e29b-41d4-a716-446655440006",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"scenarioId": "bear",
|
||||
"runDate": "2026-08-05",
|
||||
"results": {
|
||||
"baselineVAR95": 15250.00,
|
||||
"stressedVAR95": 42800.00,
|
||||
"varChange": {
|
||||
"amount": 27550.00,
|
||||
"percent": 180.7
|
||||
},
|
||||
"baslinePortfolioValue": 292500.00,
|
||||
"stressedPortfolioValue": 234000.00,
|
||||
"portfolioLoss": {
|
||||
"amount": 58500.00,
|
||||
"percent": -20.0
|
||||
},
|
||||
"exposureByAssetClass": [
|
||||
{
|
||||
"assetClass": "Equities",
|
||||
"baselineValue": 150000.00,
|
||||
"stressedValue": 120000.00,
|
||||
"loss": -30000.00
|
||||
},
|
||||
{
|
||||
"assetClass": "Bonds",
|
||||
"baselineValue": 142500.00,
|
||||
"stressedValue": 114000.00,
|
||||
"loss": -28500.00
|
||||
}
|
||||
],
|
||||
"worstPosition": {
|
||||
"symbol": "AAPL",
|
||||
"loss": -15000.00
|
||||
}
|
||||
},
|
||||
"completedAt": "2026-08-05T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
**PortfolioStressTestCompleted:**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440007",
|
||||
"eventType": "PortfolioStressTestCompleted",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"scenarioId": "bear",
|
||||
"stressedVAR95": 42800.00,
|
||||
"portfolioLossPercent": -20.0,
|
||||
"completedAt": "2026-08-05T10:05:00Z",
|
||||
"correlationId": "stress-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Authorization
|
||||
|
||||
| Operation | Role | Condition |
|
||||
|-----------|------|-----------|
|
||||
| VIEW results | DataReader | Own portfolio only |
|
||||
| TRIGGER test | RiskAnalyst | Own portfolio + standard scenarios |
|
||||
| DEFINE scenario | RiskHead | Organization-wide scenarios |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit:** Scenario application (10 tests)
|
||||
- Apply equity shock to prices
|
||||
- Calculate new VAR under stressed prices
|
||||
- Measure portfolio loss
|
||||
|
||||
2. **Integration:** DB persistence (3 tests)
|
||||
- Insert stress test result
|
||||
- Query by scenario_id
|
||||
- Event published to outbox
|
||||
|
||||
3. **E2E:** API flow (2 tests)
|
||||
- POST /stress queues job
|
||||
- GET /stress returns results
|
||||
|
||||
4. **Golden:** Scenario accuracy (3 tests)
|
||||
- Known portfolio + known scenario = expected loss
|
||||
- Worst-case position identified
|
||||
- VAR increase reasonable
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Scenarios are applied uniformly (no correlation changes)
|
||||
- Bond prices use simple duration approximation (not full curve)
|
||||
- Derivatives marked to market under new assumptions
|
||||
- Scenario shocks are immediate (no gradual transition)
|
||||
|
||||
---
|
||||
|
||||
## Vertical Slice Boundary
|
||||
|
||||
✅ **In Scope:** Scenario definition + price shock application + loss calculation + event publishing
|
||||
❌ **Out of Scope:** Reverse stress testing (inverse scenario), correlation structure modeling
|
||||
|
||||
**Rationale:** Supports risk monitoring; results feed dashboard (VS-08) and alerts (VS-07)
|
||||
@@ -0,0 +1,196 @@
|
||||
# VS-07: Risk Alerts — Vertical Slice Specification
|
||||
|
||||
**Domain:** Risk & Portfolio Management
|
||||
**Capability:** Monitor thresholds (concentration, VAR, volatility); trigger escalations
|
||||
**User Goal:** "I need automatic alerts when portfolio risk exceeds safe limits"
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Custom alert rules (simple threshold library only)
|
||||
- SMS/Email delivery (platform abstraction, VS-09)
|
||||
- Alert aggregation/deduplication (separate)
|
||||
- AI-based anomaly detection (future)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
| Req ID | Description | RBAC | SLA | Evidence |
|
||||
|--------|-------------|------|-----|----------|
|
||||
| **ALERT-001** | Monitor thresholds: concentration >60%, VAR >20%, volatility >30% | System | Real-time | Trigger job after VS-05 metrics |
|
||||
| **ALERT-002** | GET /api/portfolio/{id}/alerts | DataReader | <100ms | JSON array of active alerts |
|
||||
| **ALERT-003** | Support threshold configuration (per portfolio) | PortfolioManager | N/A | UI form (VS-08 FE) |
|
||||
| **ALERT-004** | Alert escalation: initial → warning → critical | System | <5min | Progressive notification |
|
||||
| **ALERT-005** | Soft-delete completed alerts (preserved for audit) | System | N/A | WHERE removed_at IS NULL |
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- **Accuracy:** Threshold breach detected within 5 minutes of metric update
|
||||
- **Latency:** Alert query <100ms, trigger <5min
|
||||
- **Noise:** False-positive rate <1%
|
||||
- **Audit:** Full alert lifecycle tracked (created → escalated → resolved)
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
Portfolio Risk Metrics (from VS-05)
|
||||
↓ threshold evaluation
|
||||
Threshold Breached?
|
||||
├─ No → status=OK
|
||||
└─ Yes → create Alert(status=Initial)
|
||||
↓ after 2 min (no resolution)
|
||||
Alert escalate to status=Warning
|
||||
↓ after 3 min (still breached)
|
||||
Alert escalate to status=Critical
|
||||
↓ user resolves
|
||||
Alert(status=Resolved, removed_at=now)
|
||||
```
|
||||
|
||||
**Frequency:** Real-time (evaluated after each metric update)
|
||||
**Escalation:** Progressive (Initial → Warning → Critical over 5min)
|
||||
**Resolution:** Manual or automatic (threshold back to safe level)
|
||||
|
||||
---
|
||||
|
||||
## Data & API Contracts
|
||||
|
||||
### GET /api/portfolio/{portfolioId}/alerts
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"activeAlerts": [
|
||||
{
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"thresholdType": "concentration",
|
||||
"thresholdName": "Top-5 Holdings > 60%",
|
||||
"currentValue": 65.2,
|
||||
"threshold": 60,
|
||||
"severity": "Warning",
|
||||
"triggeredAt": "2026-08-05T10:30:00Z",
|
||||
"escalatedAt": "2026-08-05T10:35:00Z",
|
||||
"message": "Top 5 holdings now represent 65.2% of portfolio (threshold: 60%)"
|
||||
},
|
||||
{
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440009",
|
||||
"thresholdType": "volatility",
|
||||
"thresholdName": "Annualized Volatility > 30%",
|
||||
"currentValue": 31.5,
|
||||
"threshold": 30,
|
||||
"severity": "Initial",
|
||||
"triggeredAt": "2026-08-05T10:45:00Z",
|
||||
"escalatedAt": null,
|
||||
"message": "Portfolio volatility now 31.5% (threshold: 30%)"
|
||||
}
|
||||
],
|
||||
"resolvedAlerts": [
|
||||
{
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"thresholdType": "concentration",
|
||||
"status": "Resolved",
|
||||
"resolvedAt": "2026-08-05T10:50:00Z",
|
||||
"duration": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
**RiskAlertTriggered:**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440011",
|
||||
"eventType": "RiskAlertTriggered",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"thresholdType": "concentration",
|
||||
"severity": "Warning",
|
||||
"currentValue": 65.2,
|
||||
"threshold": 60,
|
||||
"triggeredAt": "2026-08-05T10:30:00Z",
|
||||
"correlationId": "alert-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
**RiskAlertResolved:**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440012",
|
||||
"eventType": "RiskAlertResolved",
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"resolvedAt": "2026-08-05T10:50:00Z",
|
||||
"durationMinutes": 20,
|
||||
"correlationId": "alert-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threshold Library (Defaults)
|
||||
|
||||
| Type | Default Threshold | Severity Escalation |
|
||||
|------|-------------------|---------------------|
|
||||
| Concentration (top-5) | 60% | Initial (0min) → Warning (2min) → Critical (5min) |
|
||||
| VAR-95 | 20% of portfolio | Initial (0min) → Warning (2min) → Critical (5min) |
|
||||
| Volatility (annual) | 30% | Initial (0min) → Warning (3min) → Critical (7min) |
|
||||
| Single position | 40% | Initial (0min) → Critical (5min) |
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Authorization
|
||||
|
||||
| Operation | Role | Condition |
|
||||
|-----------|------|-----------|
|
||||
| VIEW alerts | DataReader | Own portfolio only |
|
||||
| CONFIGURE thresholds | PortfolioManager | Own portfolio only |
|
||||
| RESOLVE alert | PortfolioManager | Own portfolio + manual action |
|
||||
| CREATE portfolio-level rules | RiskHead | Organization-wide override |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit:** Threshold evaluation (8 tests)
|
||||
- Concentration > threshold → alert triggered
|
||||
- VAR increase → alert escalated
|
||||
- Threshold back to safe → alert resolved
|
||||
|
||||
2. **Integration:** DB persistence (3 tests)
|
||||
- Insert alert
|
||||
- Escalate alert
|
||||
- Soft-delete resolved alert
|
||||
|
||||
3. **E2E:** API + escalation flow (3 tests)
|
||||
- Threshold breach → alert appears in API
|
||||
- Time-based escalation (Initial → Warning → Critical)
|
||||
- Resolution clears alert
|
||||
|
||||
4. **Golden:** Escalation timing (2 tests)
|
||||
- Known breach scenario → correct escalation at 2min, 5min
|
||||
- False positive rate <1%
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Thresholds are portfolio-specific (configurable per portfolio)
|
||||
- Escalation uses wall-clock time (not trading time)
|
||||
- Automatic resolution when metric returns to safe level
|
||||
- No deduplication (same threshold breach = one alert)
|
||||
|
||||
---
|
||||
|
||||
## Vertical Slice Boundary
|
||||
|
||||
✅ **In Scope:** Threshold evaluation + alert lifecycle + event publishing
|
||||
❌ **Out of Scope:** Notification delivery (VS-09), alert aggregation, custom ML rules
|
||||
|
||||
**Rationale:** Provides alert infrastructure; notifications/delivery separate concern
|
||||
@@ -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
|
||||
@@ -0,0 +1,260 @@
|
||||
# VS-03: Market Data Ingestion - Data Contract
|
||||
|
||||
**Slice ID:** VS-03
|
||||
**Phase:** Data Layer (write model)
|
||||
**Status:** Specification Ready
|
||||
|
||||
---
|
||||
|
||||
## Write Model (Normalized, 3NF)
|
||||
|
||||
### Table: `market_data.daily_prices` (Core)
|
||||
|
||||
```sql
|
||||
CREATE TABLE market_data.daily_prices (
|
||||
-- Identity
|
||||
price_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
symbol VARCHAR(20) NOT NULL,
|
||||
trading_date DATE NOT NULL,
|
||||
|
||||
-- OHLCV
|
||||
open_price DECIMAL(10, 2) NOT NULL CHECK (open_price > 0),
|
||||
high_price DECIMAL(10, 2) NOT NULL CHECK (high_price > 0),
|
||||
low_price DECIMAL(10, 2) NOT NULL CHECK (low_price > 0),
|
||||
close_price DECIMAL(10, 2) NOT NULL CHECK (close_price > 0),
|
||||
adjusted_close DECIMAL(10, 2),
|
||||
volume BIGINT NOT NULL CHECK (volume >= 0),
|
||||
|
||||
-- PIT (Point-in-Time) Compliance
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
|
||||
-- Audit
|
||||
data_source VARCHAR(50) NOT NULL, -- 'KRX', 'OpenDart', 'Stub'
|
||||
ingestion_job_id UUID,
|
||||
correlation_id UUID,
|
||||
|
||||
-- Soft-delete (never delete, only version)
|
||||
removed_at TIMESTAMP,
|
||||
|
||||
CONSTRAINT unique_daily_price UNIQUE (symbol, trading_date, revision),
|
||||
CONSTRAINT valid_prices CHECK (low_price <= open_price AND open_price <= high_price)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_daily_prices_symbol_date ON market_data.daily_prices(symbol, trading_date DESC);
|
||||
CREATE INDEX idx_daily_prices_published ON market_data.daily_prices(published_at DESC);
|
||||
```
|
||||
|
||||
### Table: `market_data.indices` (Supplementary)
|
||||
|
||||
```sql
|
||||
CREATE TABLE market_data.indices (
|
||||
-- Identity
|
||||
index_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
index_code VARCHAR(20) NOT NULL, -- 'KOSPI', 'KRX200', 'KOSDAQ'
|
||||
trading_date DATE NOT NULL,
|
||||
|
||||
-- OHLCV
|
||||
open_value DECIMAL(10, 2) NOT NULL,
|
||||
high_value DECIMAL(10, 2) NOT NULL,
|
||||
low_value DECIMAL(10, 2) NOT NULL,
|
||||
close_value DECIMAL(10, 2) NOT NULL,
|
||||
change_percent DECIMAL(5, 2),
|
||||
volume BIGINT,
|
||||
|
||||
-- PIT
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
|
||||
-- Audit
|
||||
data_source VARCHAR(50) NOT NULL,
|
||||
correlation_id UUID,
|
||||
|
||||
removed_at TIMESTAMP,
|
||||
|
||||
CONSTRAINT unique_index UNIQUE (index_code, trading_date, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_indices_code_date ON market_data.indices(index_code, trading_date DESC);
|
||||
```
|
||||
|
||||
### Table: `market_data.companies` (Master)
|
||||
|
||||
```sql
|
||||
CREATE TABLE market_data.companies (
|
||||
-- Identity
|
||||
company_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
symbol VARCHAR(20) NOT NULL UNIQUE,
|
||||
|
||||
-- Master Data
|
||||
korean_name VARCHAR(100) NOT NULL,
|
||||
english_name VARCHAR(100),
|
||||
sector VARCHAR(50),
|
||||
industry VARCHAR(100),
|
||||
listing_date DATE,
|
||||
|
||||
-- Status
|
||||
listing_status VARCHAR(20) NOT NULL DEFAULT 'Active', -- Active, Suspended, Delisted
|
||||
market VARCHAR(20) NOT NULL, -- 'KOSPI', 'KOSDAQ', 'KONEX'
|
||||
|
||||
-- PIT
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
removed_at TIMESTAMP,
|
||||
|
||||
-- Audit
|
||||
last_updated TIMESTAMP,
|
||||
data_source VARCHAR(50),
|
||||
|
||||
CONSTRAINT unique_company UNIQUE (symbol, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_companies_symbol ON market_data.companies(symbol);
|
||||
```
|
||||
|
||||
### Table: `market_data.ingestion_jobs` (Audit)
|
||||
|
||||
```sql
|
||||
CREATE TABLE market_data.ingestion_jobs (
|
||||
-- Identity
|
||||
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_run_id UUID NOT NULL, -- Hangfire RunId
|
||||
|
||||
-- Input
|
||||
data_source VARCHAR(50) NOT NULL,
|
||||
from_date DATE NOT NULL,
|
||||
to_date DATE NOT NULL,
|
||||
|
||||
-- Progress
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Queued', -- Queued, Running, Completed, Failed
|
||||
rows_processed INT DEFAULT 0,
|
||||
rows_failed INT DEFAULT 0,
|
||||
rows_skipped INT DEFAULT 0,
|
||||
|
||||
-- Timing
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
duration_seconds INT,
|
||||
|
||||
-- Error Handling
|
||||
last_error_message TEXT,
|
||||
retry_count INT DEFAULT 0,
|
||||
|
||||
-- Traceability
|
||||
correlation_id UUID NOT NULL,
|
||||
triggered_by VARCHAR(100), -- 'Scheduler', 'Manual', 'API'
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT unique_job_run UNIQUE (job_run_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ingestion_jobs_status ON market_data.ingestion_jobs(status);
|
||||
CREATE INDEX idx_ingestion_jobs_dates ON market_data.ingestion_jobs(from_date, to_date);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read Model (Denormalized Projections)
|
||||
|
||||
### View: `market_data.latest_prices` (Cache)
|
||||
|
||||
```sql
|
||||
CREATE VIEW market_data.latest_prices AS
|
||||
SELECT DISTINCT ON (symbol)
|
||||
symbol,
|
||||
trading_date,
|
||||
close_price,
|
||||
volume,
|
||||
published_at
|
||||
FROM market_data.daily_prices
|
||||
WHERE removed_at IS NULL
|
||||
AND published_at <= CURRENT_TIMESTAMP
|
||||
ORDER BY symbol, trading_date DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PIT (Point-in-Time) Query Pattern
|
||||
|
||||
```sql
|
||||
-- Fetch prices as of 2026-06-30
|
||||
SELECT symbol, open_price, close_price, volume
|
||||
FROM market_data.daily_prices
|
||||
WHERE trading_date <= '2026-06-30'
|
||||
AND published_at <= '2026-06-30'::timestamp
|
||||
AND removed_at IS NULL
|
||||
ORDER BY symbol, trading_date DESC
|
||||
LIMIT 1 PER symbol;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **0033_market_data_schema.sql**
|
||||
- Create market_data schema
|
||||
- Define daily_prices, indices, companies, ingestion_jobs tables
|
||||
- Add PK, FK, constraints
|
||||
|
||||
2. **0034_market_data_indexes.sql**
|
||||
- Create performance indexes
|
||||
- Partition by year (optional, if 10M+ rows/year)
|
||||
|
||||
3. **0035_market_data_audit.sql**
|
||||
- Create audit trigger (log all writes)
|
||||
- Set up row-level security (market access control)
|
||||
|
||||
---
|
||||
|
||||
## Data Dictionary
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| symbol | VARCHAR(20) | Stock ticker (e.g., '005930' for Samsung) |
|
||||
| trading_date | DATE | Market trading date (YYYY-MM-DD) |
|
||||
| open_price | DECIMAL(10,2) | Opening price |
|
||||
| close_price | DECIMAL(10,2) | Closing price |
|
||||
| volume | BIGINT | Trading volume (shares) |
|
||||
| published_at | TIMESTAMP | PIT anchor (when row became "true") |
|
||||
| revision | INT | Version number (immutable history) |
|
||||
| removed_at | TIMESTAMP | Soft-delete marker (NULL = active) |
|
||||
| correlation_id | UUID | Trace this data ingestion back to job |
|
||||
|
||||
---
|
||||
|
||||
## Idempotency & Upsert Strategy
|
||||
|
||||
**Idempotency Key:** `(symbol, trading_date)`
|
||||
|
||||
**Upsert SQL:**
|
||||
```sql
|
||||
INSERT INTO market_data.daily_prices (symbol, trading_date, open_price, high_price, low_price, close_price, volume, published_at, revision, correlation_id, data_source)
|
||||
VALUES (@symbol, @date, @open, @high, @low, @close, @volume, CURRENT_TIMESTAMP, 1, @corrId, @source)
|
||||
ON CONFLICT (symbol, trading_date, revision) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
published_at = CURRENT_TIMESTAMP,
|
||||
revision = market_data.daily_prices.revision + 1
|
||||
WHERE EXCLUDED.published_at > market_data.daily_prices.published_at;
|
||||
```
|
||||
|
||||
**Effect:** Same-day re-ingestion updates the row; older data is immutable (PIT principle).
|
||||
|
||||
---
|
||||
|
||||
## Testing & Validation
|
||||
|
||||
**Unit Tests (SQL):**
|
||||
- Constraints enforced (negative prices rejected)
|
||||
- Unique keys prevent duplicates
|
||||
- Soft-delete preserves history
|
||||
- PIT query returns correct version
|
||||
|
||||
**Integration Tests:**
|
||||
- Ingest 100 rows, verify count
|
||||
- Duplicate ingestion (same date/symbol) increments revision
|
||||
- Upsert with newer timestamp overwrites
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# VS-04: Portfolio Composition — Data Contract
|
||||
|
||||
**Version:** 1.0
|
||||
**Compliance:** Point-in-Time (PIT) + Soft-Delete + Append-Only Audit
|
||||
**Migration:** `0033_portfolio_composition.sql` (DbUp)
|
||||
|
||||
---
|
||||
|
||||
## Schema Design
|
||||
|
||||
### 1. `portfolios` (PIT — Write Model)
|
||||
|
||||
Stores portfolio snapshots. New state appended as revision; reads filter `WHERE removed_at IS NULL AND published_at <= cutoff`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.portfolios (
|
||||
portfolio_id UUID PRIMARY KEY,
|
||||
portfolio_name VARCHAR(255) NOT NULL,
|
||||
account_id UUID NOT NULL,
|
||||
|
||||
-- PIT envelope
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
removed_at TIMESTAMP NULL,
|
||||
|
||||
-- Audit
|
||||
created_by VARCHAR(100),
|
||||
updated_by VARCHAR(100),
|
||||
correlation_id UUID,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Active', -- Active, Frozen, Liquidating
|
||||
rebalance_frequency VARCHAR(50), -- Monthly, Quarterly, Manual
|
||||
|
||||
-- Constraints
|
||||
UNIQUE(portfolio_id, revision),
|
||||
CHECK (removed_at IS NULL OR removed_at >= published_at)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. `portfolio_positions` (PIT — Composition)
|
||||
|
||||
Holdings within a portfolio. Each position tracks FIFO cost, market value, risk weight.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.portfolio_positions (
|
||||
position_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Instrument
|
||||
symbol VARCHAR(10) NOT NULL,
|
||||
instrument_type VARCHAR(20), -- Stock, Bond, Fund, Derivative
|
||||
|
||||
-- Quantity & Cost
|
||||
quantity DECIMAL(18, 8) NOT NULL,
|
||||
cost_basis_per_unit DECIMAL(15, 4),
|
||||
total_cost_basis DECIMAL(20, 2),
|
||||
|
||||
-- Market Data (snapshot)
|
||||
market_price DECIMAL(15, 4) NOT NULL,
|
||||
market_value DECIMAL(20, 2) NOT NULL,
|
||||
|
||||
-- Risk
|
||||
weight_percent DECIMAL(5, 2), -- [0, 100]
|
||||
risk_score DECIMAL(3, 1), -- [0, 10] from VS-05
|
||||
|
||||
-- PIT
|
||||
trading_date DATE NOT NULL,
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
removed_at TIMESTAMP NULL,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID,
|
||||
data_source VARCHAR(50),
|
||||
|
||||
-- Constraints
|
||||
UNIQUE(portfolio_id, symbol, trading_date, revision),
|
||||
CHECK (quantity >= 0),
|
||||
CHECK (market_price > 0),
|
||||
CHECK (weight_percent BETWEEN 0 AND 100)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. `rebalance_jobs` (Append-Only — Audit)
|
||||
|
||||
Immutable log of all rebalance requests. Status progresses: Queued → Running → Completed/Failed.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.rebalance_jobs (
|
||||
job_id UUID PRIMARY KEY,
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Request
|
||||
target_weights_hash VARCHAR(64), -- Hash of target weights (idempotency)
|
||||
drift_threshold DECIMAL(5, 2),
|
||||
requested_by VARCHAR(100),
|
||||
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Execution
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Queued', -- Queued, Running, Completed, Failed, PartiallyRebalanced
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
duration_seconds INT NULL,
|
||||
|
||||
-- Results
|
||||
old_weight_snapshot JSONB, -- Array of {symbol, percent}
|
||||
new_weight_snapshot JSONB, -- Array of {symbol, percent}
|
||||
trades_executed INT DEFAULT 0,
|
||||
trades_failed INT DEFAULT 0,
|
||||
|
||||
-- Error handling
|
||||
error_message TEXT NULL,
|
||||
retry_count INT DEFAULT 0,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID NOT NULL,
|
||||
job_run_id UUID NOT NULL,
|
||||
|
||||
UNIQUE(target_weights_hash, correlation_id, portfolio_id) -- Idempotency
|
||||
);
|
||||
```
|
||||
|
||||
### 4. `rebalance_events` (Append-Only — Published Events)
|
||||
|
||||
Published to `shared.outbox` via EventPublisher; processed by inbox consumers.
|
||||
|
||||
**Schema (JSONB in outbox.payload):**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"eventType": "PortfolioRebalanced",
|
||||
"aggregateId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"oldWeights": [
|
||||
{ "symbol": "AAPL", "percent": 35.5 }
|
||||
],
|
||||
"newWeights": [
|
||||
{ "symbol": "AAPL", "percent": 40.0 }
|
||||
],
|
||||
"rebalancedAt": "2026-08-05T09:30:00Z",
|
||||
"correlationId": "port-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PIT Query Patterns
|
||||
|
||||
### Current Portfolio Composition
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.portfolio_id,
|
||||
p.portfolio_name,
|
||||
pos.symbol,
|
||||
pos.quantity,
|
||||
pos.market_price,
|
||||
pos.market_value,
|
||||
pos.weight_percent
|
||||
FROM risk_management.portfolios p
|
||||
INNER JOIN risk_management.portfolio_positions pos
|
||||
ON p.portfolio_id = pos.portfolio_id
|
||||
WHERE
|
||||
p.published_at <= @cutoff
|
||||
AND p.removed_at IS NULL
|
||||
AND pos.published_at <= @cutoff
|
||||
AND pos.removed_at IS NULL
|
||||
AND pos.trading_date = CURRENT_DATE
|
||||
ORDER BY p.portfolio_id, pos.weight_percent DESC;
|
||||
```
|
||||
|
||||
### Historical Portfolio (as of Date)
|
||||
|
||||
```sql
|
||||
SELECT * FROM risk_management.portfolios p
|
||||
WHERE
|
||||
p.portfolio_id = @portfolioId
|
||||
AND p.published_at <= @asOfDate
|
||||
AND p.removed_at IS NULL
|
||||
ORDER BY p.published_at DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Idempotency Check
|
||||
|
||||
```sql
|
||||
SELECT job_id FROM risk_management.rebalance_jobs
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND target_weights_hash = @hash
|
||||
AND correlation_id = @correlationId
|
||||
AND status IN ('Running', 'Completed')
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upsert Strategy
|
||||
|
||||
**On new rebalance request:**
|
||||
|
||||
```sql
|
||||
INSERT INTO risk_management.rebalance_jobs
|
||||
(job_id, portfolio_id, target_weights_hash, correlation_id, status)
|
||||
VALUES
|
||||
(@jobId, @portfolioId, @hash, @correlationId, 'Queued')
|
||||
ON CONFLICT (target_weights_hash, correlation_id, portfolio_id)
|
||||
DO UPDATE SET
|
||||
status = 'Queued'
|
||||
WHERE EXCLUDED.status = 'Completed';
|
||||
```
|
||||
|
||||
**Idempotency:** Same hash + correlationId → no duplicate job
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
**Fresh Install:**
|
||||
1. Create `risk_management` schema
|
||||
2. Create tables: portfolios, portfolio_positions, rebalance_jobs
|
||||
3. Create indexes on (portfolio_id, published_at), (trading_date), (status)
|
||||
|
||||
**Upgrade from v0 (if pre-existing):**
|
||||
1. Backfill `published_at` = migration timestamp
|
||||
2. Backfill `revision` = 1
|
||||
3. Set `removed_at = NULL` for active records
|
||||
|
||||
**Rollback:**
|
||||
- No data loss: Remove `removed_at IS NULL` filter to see all revisions
|
||||
- No cascade: rebalance_jobs remain immutable
|
||||
|
||||
---
|
||||
|
||||
## Indexes (Performance SLA: <100ms GET)
|
||||
|
||||
| Table | Columns | Reason |
|
||||
|-------|---------|--------|
|
||||
| portfolios | (portfolio_id, published_at, removed_at) | Fast current snapshot lookup |
|
||||
| portfolio_positions | (portfolio_id, trading_date, published_at) | Fast composition query |
|
||||
| portfolio_positions | (symbol, trading_date) | Fast market data rollup |
|
||||
| rebalance_jobs | (portfolio_id, status, created_at) | Fast pending job lookup |
|
||||
| rebalance_jobs | (target_weights_hash, correlation_id) | Fast idempotency check |
|
||||
|
||||
---
|
||||
|
||||
## Data Freshness Guarantees
|
||||
|
||||
- **Prices:** Updated daily at 9:00 KST (before market open)
|
||||
- **Positions:** Snapshot at market close (16:00 KST)
|
||||
- **Rebalance jobs:** Queued immediately, executed within 5 minutes
|
||||
- **Events:** Published synchronously (no queue lag)
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
✅ **AGENTS.md v16.0:**
|
||||
- No SELECT * (explicit columns)
|
||||
- PIT versioning (published_at, revision, removed_at)
|
||||
- Soft-delete (removed_at, not hard delete)
|
||||
- Append-only audit (rebalance_jobs immutable)
|
||||
- Correlation ID tracing (correlation_id + job_run_id)
|
||||
- Idempotency key (target_weights_hash + correlation_id)
|
||||
|
||||
✅ **Data Integrity:**
|
||||
- Referential integrity (FK to portfolios)
|
||||
- Check constraints (weight_percent, quantity >= 0)
|
||||
- Unique constraints (PIT envelope)
|
||||
|
||||
✅ **Auditability:**
|
||||
- All mutations traced (published_at, correlation_id)
|
||||
- Full history preserved (removed_at enables rollback query)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Test | Data Setup | Assertion |
|
||||
|------|-----------|-----------|
|
||||
| Fresh portfolio | INSERT portfolio + positions | Current query returns correct values |
|
||||
| Historical query | Add revision 2 to same portfolio | AS-OF query returns v1 snapshot |
|
||||
| Idempotency | Same rebalance_hash twice | Job not duplicated |
|
||||
| Soft-delete | Set removed_at on position | Query filters correctly |
|
||||
| Drift detection | weight_percent > drift_threshold | Rebalance triggered |
|
||||
@@ -0,0 +1,296 @@
|
||||
# VS-05: Risk Metrics — Data Contract
|
||||
|
||||
**Version:** 1.0
|
||||
**Compliance:** Point-in-Time (PIT) + Append-Only Audit
|
||||
**Migration:** `0034_risk_metrics.sql` (DbUp)
|
||||
|
||||
---
|
||||
|
||||
## Schema Design
|
||||
|
||||
### 1. `risk_metrics` (PIT — Metric Snapshots)
|
||||
|
||||
Daily risk metric snapshots. Each day → new revision. Reads filter `WHERE published_at <= cutoff AND removed_at IS NULL`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.risk_metrics (
|
||||
metric_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Calculation date
|
||||
calculation_date DATE NOT NULL,
|
||||
|
||||
-- VAR (Value at Risk)
|
||||
var_95_amount DECIMAL(20, 2), -- 95% confidence, 1-day horizon
|
||||
var_95_percent DECIMAL(5, 2), -- % of portfolio value
|
||||
var_model VARCHAR(50), -- 'Parametric', 'HistoricalSim', 'MonteCarlo'
|
||||
|
||||
-- Sharpe Ratio (rolling 252-day)
|
||||
sharpe_ratio DECIMAL(5, 3),
|
||||
sharpe_rolling_days INT DEFAULT 252,
|
||||
risk_free_rate DECIMAL(5, 4), -- Configurable, default 4.5%
|
||||
|
||||
-- Sortino Ratio (downside focus)
|
||||
sortino_ratio DECIMAL(5, 3),
|
||||
downside_deviation DECIMAL(5, 4), -- Annual
|
||||
|
||||
-- Concentration
|
||||
top_five_percent DECIMAL(5, 2), -- Top 5 holdings as % of portfolio
|
||||
hirschman_index DECIMAL(3, 2), -- 0-1, 1=fully concentrated
|
||||
max_single_position DECIMAL(5, 2), -- Largest position %
|
||||
|
||||
-- Volatility
|
||||
volatility_annualized DECIMAL(5, 4),
|
||||
volatility_rolling_days INT DEFAULT 30,
|
||||
|
||||
-- Data quality
|
||||
quality_score INT DEFAULT 100, -- [0, 100]
|
||||
quality_issues JSONB, -- Array of strings
|
||||
|
||||
-- PIT
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
removed_at TIMESTAMP NULL,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID,
|
||||
job_run_id UUID,
|
||||
|
||||
-- Constraints
|
||||
UNIQUE(portfolio_id, calculation_date, revision),
|
||||
CHECK (var_95_percent BETWEEN 0 AND 100),
|
||||
CHECK (hirschman_index BETWEEN 0 AND 1),
|
||||
CHECK (quality_score BETWEEN 0 AND 100)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. `risk_metric_components` (Append-Only — Breakdown)
|
||||
|
||||
Decomposition of risk into asset-class and sector contributions.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.risk_metric_components (
|
||||
component_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
metric_id UUID NOT NULL REFERENCES risk_management.risk_metrics(metric_id),
|
||||
|
||||
-- Decomposition
|
||||
component_type VARCHAR(50), -- 'AssetClass', 'Sector', 'Geography'
|
||||
component_name VARCHAR(255),
|
||||
|
||||
-- Contribution to VAR
|
||||
var_contribution DECIMAL(20, 2),
|
||||
var_contribution_percent DECIMAL(5, 2),
|
||||
|
||||
-- Contribution to Sharpe
|
||||
sharpe_contribution DECIMAL(5, 3),
|
||||
|
||||
-- Exposure
|
||||
position_count INT,
|
||||
total_value DECIMAL(20, 2),
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
correlation_id UUID
|
||||
);
|
||||
```
|
||||
|
||||
### 3. `risk_calculation_jobs` (Append-Only — Audit)
|
||||
|
||||
Immutable log of all metric calculations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.risk_calculation_jobs (
|
||||
job_id UUID PRIMARY KEY,
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Execution
|
||||
calculation_date DATE NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Queued', -- Queued, Running, Completed, Failed
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
duration_seconds INT NULL,
|
||||
|
||||
-- Input data
|
||||
price_cutoff DATE NOT NULL,
|
||||
sample_size INT, -- Number of days used for Sharpe/Sortino
|
||||
|
||||
-- Results
|
||||
metrics_rows_created INT DEFAULT 0,
|
||||
components_rows_created INT DEFAULT 0,
|
||||
|
||||
-- Error handling
|
||||
error_message TEXT NULL,
|
||||
retry_count INT DEFAULT 0,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID NOT NULL,
|
||||
job_run_id UUID NOT NULL,
|
||||
triggered_by VARCHAR(100), -- 'Scheduler', 'Manual', 'Alert'
|
||||
|
||||
UNIQUE(portfolio_id, calculation_date, correlation_id) -- Idempotency
|
||||
);
|
||||
```
|
||||
|
||||
### 4. `risk_metric_alerts` (Append-Only — Published Events)
|
||||
|
||||
Published to `shared.outbox` via EventPublisher.
|
||||
|
||||
**Schema (JSONB in outbox.payload):**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440005",
|
||||
"eventType": "PortfolioMetricsCalculated",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"calculationDate": "2026-08-05",
|
||||
"metrics": {
|
||||
"var95": 15250.00,
|
||||
"sharpe": 1.85,
|
||||
"sortino": 2.45,
|
||||
"concentration": 52.3
|
||||
},
|
||||
"qualityFlags": ["high_concentration"],
|
||||
"calculatedAt": "2026-08-05T09:30:00Z",
|
||||
"correlationId": "risk-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PIT Query Patterns
|
||||
|
||||
### Current Risk Metrics
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
portfolio_id,
|
||||
calculation_date,
|
||||
var_95_amount,
|
||||
var_95_percent,
|
||||
sharpe_ratio,
|
||||
sortino_ratio,
|
||||
top_five_percent,
|
||||
volatility_annualized
|
||||
FROM risk_management.risk_metrics
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND published_at <= @cutoff
|
||||
AND removed_at IS NULL
|
||||
ORDER BY calculation_date DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Historical Metrics (as of Date)
|
||||
|
||||
```sql
|
||||
SELECT * FROM risk_management.risk_metrics
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND calculation_date <= @asOfDate
|
||||
AND published_at <= @asOfDate
|
||||
AND removed_at IS NULL
|
||||
ORDER BY calculation_date DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Concentration Trend
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
calculation_date,
|
||||
top_five_percent,
|
||||
hirschman_index,
|
||||
max_single_position
|
||||
FROM risk_management.risk_metrics
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND published_at <= @cutoff
|
||||
AND removed_at IS NULL
|
||||
ORDER BY calculation_date DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
### Idempotency Check
|
||||
|
||||
```sql
|
||||
SELECT job_id FROM risk_management.risk_calculation_jobs
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND calculation_date = @date
|
||||
AND correlation_id = @correlationId
|
||||
AND status IN ('Running', 'Completed')
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upsert Strategy
|
||||
|
||||
**On new calculation request:**
|
||||
|
||||
```sql
|
||||
INSERT INTO risk_management.risk_calculation_jobs
|
||||
(job_id, portfolio_id, calculation_date, correlation_id, status)
|
||||
VALUES
|
||||
(@jobId, @portfolioId, @date, @correlationId, 'Queued')
|
||||
ON CONFLICT (portfolio_id, calculation_date, correlation_id)
|
||||
DO UPDATE SET
|
||||
status = 'Queued'
|
||||
WHERE EXCLUDED.status = 'Completed';
|
||||
```
|
||||
|
||||
**Idempotency:** Same portfolio_id + calculation_date + correlation_id → no duplicate job
|
||||
|
||||
---
|
||||
|
||||
## Indexes (Performance SLA: <200ms GET)
|
||||
|
||||
| Table | Columns | Reason |
|
||||
|-------|---------|--------|
|
||||
| risk_metrics | (portfolio_id, published_at, removed_at) | Fast current snapshot lookup |
|
||||
| risk_metrics | (calculation_date) | Fast historical queries |
|
||||
| risk_metric_components | (metric_id) | Fast component breakdown retrieval |
|
||||
| risk_calculation_jobs | (portfolio_id, status) | Fast pending job lookup |
|
||||
| risk_calculation_jobs | (calculation_date, correlation_id) | Fast idempotency check |
|
||||
|
||||
---
|
||||
|
||||
## Data Freshness Guarantees
|
||||
|
||||
- **Prices:** Updated daily at 9:00 KST (from VS-03)
|
||||
- **Metrics:** Calculated at 9:30 KST (after market open)
|
||||
- **Caching:** Results cached <1hr (refresh daily)
|
||||
- **Events:** Published synchronously (no queue lag)
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
✅ **AGENTS.md v16.0:**
|
||||
- No SELECT * (explicit columns)
|
||||
- PIT versioning (published_at, revision, removed_at)
|
||||
- Append-only audit (risk_calculation_jobs immutable)
|
||||
- Correlation ID tracing (correlation_id + job_run_id)
|
||||
- Idempotency key (portfolio_id + calculation_date + correlation_id)
|
||||
|
||||
✅ **Calculation Accuracy:**
|
||||
- VAR: Parametric model (95% confidence, 1-day horizon)
|
||||
- Sharpe: 252-day rolling average (annual)
|
||||
- Sortino: Downside deviation focus
|
||||
|
||||
✅ **Auditability:**
|
||||
- All calculations traced (job_run_id + correlation_id)
|
||||
- Quality scores recorded (quality_score, quality_issues)
|
||||
- Decomposition preserved (risk_metric_components)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Test | Data Setup | Assertion |
|
||||
|------|-----------|-----------|
|
||||
| VAR calculation | 252 days of prices | VAR-95 amount within ±5% of historical |
|
||||
| Sharpe ratio | Positive returns | Sharpe ratio > 0 |
|
||||
| Concentration | 40% in single stock | top_five_percent >= 40 |
|
||||
| Idempotency | Same calculation_date twice | Job not duplicated |
|
||||
| Soft-delete | Set removed_at on metric | Query filters correctly |
|
||||
| Quality flag | Missing price data | quality_score < 100, quality_issues populated |
|
||||
@@ -0,0 +1,287 @@
|
||||
# VS-06: Stress Testing — Data Contract
|
||||
|
||||
**Version:** 1.0
|
||||
**Compliance:** Append-Only (immutable test results)
|
||||
**Migration:** `0035_stress_testing.sql` (DbUp)
|
||||
|
||||
---
|
||||
|
||||
## Schema Design
|
||||
|
||||
### 1. `stress_scenarios` (Configuration — Immutable)
|
||||
|
||||
Pre-defined scenario templates. New scenarios versioned; active scenarios = latest revision.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.stress_scenarios (
|
||||
scenario_id VARCHAR(50) PRIMARY KEY,
|
||||
|
||||
-- Metadata
|
||||
scenario_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
scenario_type VARCHAR(50), -- 'Predefined', 'Custom'
|
||||
|
||||
-- Shock parameters (JSON-encoded for flexibility)
|
||||
shocks JSONB NOT NULL, -- { "equityShock": -0.20, "bondYieldShock": 0.015, ... }
|
||||
|
||||
-- Version control (for scenario evolution)
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
effective_date DATE,
|
||||
deprecated_date DATE NULL,
|
||||
|
||||
-- Audit
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(scenario_id, version),
|
||||
CHECK (deprecated_date IS NULL OR deprecated_date >= effective_date)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. `stress_test_results` (Append-Only — Immutable Results)
|
||||
|
||||
Immutable record of each stress test execution.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.stress_test_results (
|
||||
stress_test_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Scenario
|
||||
scenario_id VARCHAR(50) NOT NULL REFERENCES risk_management.stress_scenarios(scenario_id),
|
||||
scenario_version INT NOT NULL,
|
||||
run_date DATE NOT NULL,
|
||||
|
||||
-- Baseline (from portfolio snapshot)
|
||||
baseline_portfolio_value DECIMAL(20, 2),
|
||||
baseline_var_95 DECIMAL(20, 2),
|
||||
baseline_sharpe DECIMAL(5, 3),
|
||||
|
||||
-- Stressed (after shock application)
|
||||
stressed_portfolio_value DECIMAL(20, 2),
|
||||
stressed_var_95 DECIMAL(20, 2),
|
||||
stressed_sharpe DECIMAL(5, 3),
|
||||
|
||||
-- Impact metrics
|
||||
portfolio_loss_amount DECIMAL(20, 2),
|
||||
portfolio_loss_percent DECIMAL(5, 2),
|
||||
var_increase_amount DECIMAL(20, 2),
|
||||
var_increase_percent DECIMAL(5, 2),
|
||||
|
||||
-- Asset class breakdown
|
||||
stress_results_by_class JSONB, -- Array of {assetClass, baselineValue, stressedValue, loss}
|
||||
worst_position JSONB, -- {symbol, loss}
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Completed', -- Queued, Running, Completed, Failed
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
duration_seconds INT NULL,
|
||||
|
||||
-- Quality
|
||||
quality_flags JSONB, -- Array of strings (e.g., ["missing_price_data"])
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID NOT NULL,
|
||||
job_run_id UUID NOT NULL,
|
||||
triggered_by VARCHAR(100), -- 'Manual', 'Scheduler'
|
||||
|
||||
-- Idempotency
|
||||
UNIQUE(portfolio_id, scenario_id, run_date, correlation_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. `stress_test_jobs` (Append-Only — Execution Log)
|
||||
|
||||
Immutable log of job executions.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.stress_test_jobs (
|
||||
job_id UUID PRIMARY KEY,
|
||||
stress_test_id UUID NOT NULL REFERENCES risk_management.stress_test_results(stress_test_id),
|
||||
|
||||
-- Execution
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Queued',
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
duration_seconds INT NULL,
|
||||
|
||||
-- Error handling
|
||||
error_message TEXT NULL,
|
||||
retry_count INT DEFAULT 0,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID NOT NULL,
|
||||
job_run_id UUID NOT NULL,
|
||||
|
||||
-- Metadata
|
||||
portfolio_id UUID NOT NULL,
|
||||
scenario_id VARCHAR(50) NOT NULL,
|
||||
run_date DATE NOT NULL,
|
||||
|
||||
UNIQUE(portfolio_id, scenario_id, run_date, correlation_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 4. `stress_test_events` (Append-Only — Published Events)
|
||||
|
||||
Published to `shared.outbox`.
|
||||
|
||||
**Schema (JSONB in outbox.payload):**
|
||||
```json
|
||||
{
|
||||
"eventId": "550e8400-e29b-41d4-a716-446655440007",
|
||||
"eventType": "PortfolioStressTestCompleted",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"scenarioId": "bear",
|
||||
"stressedVAR95": 42800.00,
|
||||
"portfolioLossPercent": -20.0,
|
||||
"completedAt": "2026-08-05T10:05:00Z",
|
||||
"correlationId": "stress-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Current Stress Test Results
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
scenario_id,
|
||||
baseline_portfolio_value,
|
||||
stressed_portfolio_value,
|
||||
portfolio_loss_percent,
|
||||
var_increase_percent,
|
||||
completed_at
|
||||
FROM risk_management.stress_test_results
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND run_date = CURRENT_DATE
|
||||
ORDER BY portfolio_loss_percent DESC;
|
||||
```
|
||||
|
||||
### Worst-Case Scenario (Most Loss)
|
||||
|
||||
```sql
|
||||
SELECT TOP 1
|
||||
scenario_id,
|
||||
portfolio_loss_amount,
|
||||
portfolio_loss_percent
|
||||
FROM risk_management.stress_test_results
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND run_date = @date
|
||||
ORDER BY portfolio_loss_percent ASC;
|
||||
```
|
||||
|
||||
### Scenario Trend (Historical)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
run_date,
|
||||
scenario_id,
|
||||
portfolio_loss_percent
|
||||
FROM risk_management.stress_test_results
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND scenario_id = @scenarioId
|
||||
ORDER BY run_date DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
### Idempotency Check
|
||||
|
||||
```sql
|
||||
SELECT stress_test_id FROM risk_management.stress_test_results
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND scenario_id = @scenarioId
|
||||
AND run_date = @date
|
||||
AND correlation_id = @correlationId
|
||||
AND status = 'Completed'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indexes
|
||||
|
||||
| Table | Columns | Reason |
|
||||
|-------|---------|--------|
|
||||
| stress_scenarios | (scenario_id, version) | Fast scenario lookup |
|
||||
| stress_test_results | (portfolio_id, run_date) | Fast daily result queries |
|
||||
| stress_test_results | (scenario_id) | Fast scenario trend analysis |
|
||||
| stress_test_results | (portfolio_id, scenario_id, run_date, correlation_id) | Fast idempotency check |
|
||||
| stress_test_jobs | (portfolio_id, status) | Fast pending job lookup |
|
||||
|
||||
---
|
||||
|
||||
## Upsert Strategy
|
||||
|
||||
**On new stress test request:**
|
||||
|
||||
```sql
|
||||
INSERT INTO risk_management.stress_test_results
|
||||
(stress_test_id, portfolio_id, scenario_id, run_date, correlation_id, status)
|
||||
VALUES
|
||||
(@testId, @portfolioId, @scenarioId, @date, @correlationId, 'Queued')
|
||||
ON CONFLICT (portfolio_id, scenario_id, run_date, correlation_id)
|
||||
DO UPDATE SET
|
||||
status = 'Queued'
|
||||
WHERE EXCLUDED.status = 'Completed';
|
||||
```
|
||||
|
||||
**Idempotency:** Same portfolio_id + scenario_id + run_date + correlation_id → no duplicate test
|
||||
|
||||
---
|
||||
|
||||
## Pre-loaded Scenarios
|
||||
|
||||
On fresh install, load 4 predefined scenarios:
|
||||
|
||||
```sql
|
||||
INSERT INTO risk_management.stress_scenarios VALUES
|
||||
('bull', 'Bull Market Scenario', '+15% equities, -50 bps yields', 'Predefined',
|
||||
'{"equityShock": 0.15, "bondYieldShock": -0.005, "volatilityMultiplier": 0.8}', 1, CURRENT_DATE, NULL),
|
||||
|
||||
('bear', 'Bear Market Scenario', '-20% equities, +150 bps yields', 'Predefined',
|
||||
'{"equityShock": -0.20, "bondYieldShock": 0.015, "volatilityMultiplier": 1.5}', 1, CURRENT_DATE, NULL),
|
||||
|
||||
('rateShock', 'Interest Rate Shock', '+200 bps all yields', 'Predefined',
|
||||
'{"bondYieldShock": 0.02, "volatilityMultiplier": 1.2}', 1, CURRENT_DATE, NULL),
|
||||
|
||||
('volSpike', 'Volatility Spike', '5x implied vol', 'Predefined',
|
||||
'{"volatilityMultiplier": 5.0}', 1, CURRENT_DATE, NULL);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
✅ **AGENTS.md v16.0:**
|
||||
- Append-only results (stress_test_results immutable)
|
||||
- Correlation ID tracing (correlation_id + job_run_id)
|
||||
- Idempotency key (portfolio_id + scenario_id + run_date + correlation_id)
|
||||
- Quality flags recorded (quality_flags JSONB)
|
||||
- Deterministic results (same input → same output)
|
||||
|
||||
✅ **Auditability:**
|
||||
- Full execution history preserved (stress_test_jobs)
|
||||
- All shocks recorded (shocks JSONB)
|
||||
- Baseline + stressed values stored
|
||||
- Event published for downstream consumption
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Test | Data Setup | Assertion |
|
||||
|------|-----------|-----------|
|
||||
| Bear scenario | Portfolio + bear shocks | Portfolio loss ~20% |
|
||||
| Bull scenario | Portfolio + bull shocks | Portfolio gain ~12% |
|
||||
| Asset class impact | Mixed portfolio | Equities impacted more than bonds |
|
||||
| Idempotency | Same test twice | Result retrieved, not recalculated |
|
||||
| Worst position | Mixed holdings | Worst-case position identified correctly |
|
||||
| Quality flags | Missing price data | quality_flags includes "missing_price_data" |
|
||||
@@ -0,0 +1,304 @@
|
||||
# VS-07: Risk Alerts — Data Contract
|
||||
|
||||
**Version:** 1.0
|
||||
**Compliance:** Soft-Delete + Audit Trail
|
||||
**Migration:** `0036_risk_alerts.sql` (DbUp)
|
||||
|
||||
---
|
||||
|
||||
## Schema Design
|
||||
|
||||
### 1. `alert_thresholds` (Configuration — Mutable)
|
||||
|
||||
Portfolio-specific or organization-wide alert thresholds.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.alert_thresholds (
|
||||
threshold_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
|
||||
-- Threshold definition
|
||||
threshold_type VARCHAR(50) NOT NULL, -- 'concentration', 'var', 'volatility', 'singlePosition'
|
||||
threshold_name VARCHAR(255),
|
||||
threshold_value DECIMAL(5, 2),
|
||||
|
||||
-- Escalation timing (minutes from initial)
|
||||
warn_at_minutes INT DEFAULT 2,
|
||||
critical_at_minutes INT DEFAULT 5,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
|
||||
-- Audit
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(portfolio_id, threshold_type)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. `risk_alerts` (Soft-Delete — Alert Lifecycle)
|
||||
|
||||
Active and historical alerts. Current state filtered by `removed_at IS NULL`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.risk_alerts (
|
||||
alert_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
|
||||
threshold_id UUID NOT NULL REFERENCES risk_management.alert_thresholds(threshold_id),
|
||||
|
||||
-- Alert definition
|
||||
threshold_type VARCHAR(50) NOT NULL,
|
||||
threshold_name VARCHAR(255),
|
||||
current_value DECIMAL(10, 4),
|
||||
threshold_value DECIMAL(10, 4),
|
||||
|
||||
-- Lifecycle
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Initial', -- Initial, Warning, Critical, Resolved
|
||||
triggered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
warned_at TIMESTAMP NULL,
|
||||
critical_at TIMESTAMP NULL,
|
||||
resolved_at TIMESTAMP NULL,
|
||||
|
||||
-- Soft-delete
|
||||
removed_at TIMESTAMP NULL,
|
||||
|
||||
-- Message
|
||||
message TEXT,
|
||||
|
||||
-- Audit
|
||||
correlation_id UUID,
|
||||
created_by VARCHAR(100),
|
||||
|
||||
UNIQUE(portfolio_id, threshold_type, triggered_at, correlation_id),
|
||||
CHECK (removed_at IS NULL OR resolved_at IS NOT NULL)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. `alert_escalations` (Append-Only — Audit)
|
||||
|
||||
Immutable record of all escalation events.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.alert_escalations (
|
||||
escalation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
alert_id UUID NOT NULL REFERENCES risk_management.risk_alerts(alert_id),
|
||||
|
||||
-- Escalation
|
||||
from_status VARCHAR(50),
|
||||
to_status VARCHAR(50),
|
||||
escalated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Reason
|
||||
reason VARCHAR(255), -- 'time_threshold', 'manual', 'critical_threshold'
|
||||
|
||||
-- Audit
|
||||
triggered_by VARCHAR(100),
|
||||
correlation_id UUID
|
||||
);
|
||||
```
|
||||
|
||||
### 4. `alert_resolutions` (Append-Only — How Resolved)
|
||||
|
||||
Immutable record of alert resolution.
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_management.alert_resolutions (
|
||||
resolution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
alert_id UUID NOT NULL REFERENCES risk_management.risk_alerts(alert_id),
|
||||
|
||||
-- Resolution
|
||||
resolved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
duration_minutes INT,
|
||||
|
||||
-- How resolved
|
||||
resolution_type VARCHAR(50), -- 'auto', 'manual', 'threshold_back_to_safe'
|
||||
|
||||
-- Notes
|
||||
resolution_notes TEXT,
|
||||
|
||||
-- Audit
|
||||
resolved_by VARCHAR(100),
|
||||
correlation_id UUID
|
||||
);
|
||||
```
|
||||
|
||||
### 5. `alert_events` (Append-Only — Published Events)
|
||||
|
||||
Published to `shared.outbox`.
|
||||
|
||||
**Schema (JSONB in outbox.payload):**
|
||||
```json
|
||||
{
|
||||
"eventType": "RiskAlertTriggered|RiskAlertEscalated|RiskAlertResolved",
|
||||
"alertId": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"portfolioId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"thresholdType": "concentration",
|
||||
"severity": "Warning",
|
||||
"currentValue": 65.2,
|
||||
"threshold": 60,
|
||||
"triggeredAt": "2026-08-05T10:30:00Z",
|
||||
"correlationId": "alert-2026-08-05-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Current Active Alerts
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
alert_id,
|
||||
threshold_type,
|
||||
threshold_name,
|
||||
current_value,
|
||||
threshold_value,
|
||||
status,
|
||||
triggered_at,
|
||||
DATEDIFF(MINUTE, triggered_at, CURRENT_TIMESTAMP) as duration_minutes
|
||||
FROM risk_management.risk_alerts
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND removed_at IS NULL
|
||||
AND status IN ('Initial', 'Warning', 'Critical')
|
||||
ORDER BY critical_at DESC NULLS LAST;
|
||||
```
|
||||
|
||||
### Alert History (Last 30 Days)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
alert_id,
|
||||
threshold_type,
|
||||
status,
|
||||
triggered_at,
|
||||
resolved_at,
|
||||
DATEDIFF(MINUTE, triggered_at, resolved_at) as duration_minutes
|
||||
FROM risk_management.risk_alerts
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND triggered_at >= CURRENT_DATE - INTERVAL 30 DAY
|
||||
ORDER BY triggered_at DESC;
|
||||
```
|
||||
|
||||
### Pending Escalations
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a.alert_id,
|
||||
a.threshold_type,
|
||||
a.status,
|
||||
DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) as minutes_elapsed,
|
||||
t.warn_at_minutes,
|
||||
t.critical_at_minutes
|
||||
FROM risk_management.risk_alerts a
|
||||
JOIN risk_management.alert_thresholds t ON a.threshold_id = t.threshold_id
|
||||
WHERE
|
||||
a.portfolio_id = @portfolioId
|
||||
AND a.removed_at IS NULL
|
||||
AND (
|
||||
(a.status = 'Initial' AND DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) >= t.warn_at_minutes)
|
||||
OR (a.status = 'Warning' AND DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) >= t.critical_at_minutes)
|
||||
)
|
||||
ORDER BY a.triggered_at ASC;
|
||||
```
|
||||
|
||||
### Idempotency Check
|
||||
|
||||
```sql
|
||||
SELECT alert_id FROM risk_management.risk_alerts
|
||||
WHERE
|
||||
portfolio_id = @portfolioId
|
||||
AND threshold_type = @thresholdType
|
||||
AND triggered_at >= CURRENT_TIMESTAMP - INTERVAL 5 MINUTE
|
||||
AND correlation_id = @correlationId
|
||||
AND removed_at IS NULL
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indexes
|
||||
|
||||
| Table | Columns | Reason |
|
||||
|-------|---------|--------|
|
||||
| alert_thresholds | (portfolio_id, is_active) | Fast active threshold lookup |
|
||||
| risk_alerts | (portfolio_id, removed_at, status) | Fast active alert queries |
|
||||
| risk_alerts | (triggered_at) | Fast escalation time checks |
|
||||
| alert_escalations | (alert_id, escalated_at) | Fast escalation audit trail |
|
||||
| alert_resolutions | (alert_id) | Fast resolution lookup |
|
||||
|
||||
---
|
||||
|
||||
## Pre-loaded Thresholds
|
||||
|
||||
On fresh install, create default thresholds per portfolio:
|
||||
|
||||
```sql
|
||||
INSERT INTO risk_management.alert_thresholds VALUES
|
||||
(gen_random_uuid(), @portfolioId, 'concentration', 'Top-5 Holdings > 60%', 60.0, 2, 5, true, ...),
|
||||
(gen_random_uuid(), @portfolioId, 'var', 'VAR > 20% of Portfolio', 20.0, 2, 5, true, ...),
|
||||
(gen_random_uuid(), @portfolioId, 'volatility', 'Annualized Vol > 30%', 30.0, 3, 7, true, ...),
|
||||
(gen_random_uuid(), @portfolioId, 'singlePosition', 'Single Position > 40%', 40.0, 0, 5, true, ...);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Escalation Job Logic (Hangfire)
|
||||
|
||||
**Scheduled:** Every 1 minute (after metric updates)
|
||||
|
||||
```pseudocode
|
||||
FOR each active alert WHERE removed_at IS NULL:
|
||||
minutes_elapsed = NOW - triggered_at
|
||||
threshold = alert_thresholds[alert.threshold_type]
|
||||
|
||||
IF status = 'Initial' AND minutes_elapsed >= threshold.warn_at_minutes:
|
||||
UPDATE risk_alerts SET status = 'Warning', warned_at = NOW
|
||||
INSERT alert_escalations(from_status='Initial', to_status='Warning')
|
||||
PUBLISH RiskAlertEscalated event
|
||||
|
||||
ELSE IF status = 'Warning' AND minutes_elapsed >= threshold.critical_at_minutes:
|
||||
UPDATE risk_alerts SET status = 'Critical', critical_at = NOW
|
||||
INSERT alert_escalations(from_status='Warning', to_status='Critical')
|
||||
PUBLISH RiskAlertEscalated event
|
||||
|
||||
ELSE IF metric_back_to_safe(alert.threshold_type, current_value):
|
||||
UPDATE risk_alerts SET status = 'Resolved', removed_at = NOW
|
||||
INSERT alert_resolutions(resolution_type='threshold_back_to_safe')
|
||||
PUBLISH RiskAlertResolved event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
✅ **AGENTS.md v16.0:**
|
||||
- Soft-delete (removed_at, not hard delete)
|
||||
- Append-only audit (alert_escalations, alert_resolutions immutable)
|
||||
- Correlation ID tracing (correlation_id)
|
||||
- Idempotency key (portfolio_id + threshold_type + triggered_at + correlation_id)
|
||||
- Full lifecycle tracked (triggered → escalated → resolved)
|
||||
|
||||
✅ **Alert Accuracy:**
|
||||
- Thresholds configurable per portfolio
|
||||
- Escalation timing deterministic (minutes from triggered_at)
|
||||
- Automatic resolution when metric back to safe
|
||||
- No false duplicates (UNIQUE constraint)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Test | Data Setup | Assertion |
|
||||
|------|-----------|-----------|
|
||||
| Threshold trigger | Metric exceeds threshold | Alert created with status=Initial |
|
||||
| Escalation (2min) | Wait 2 minutes | Alert status → Warning, warned_at populated |
|
||||
| Escalation (5min) | Wait 5 minutes | Alert status → Critical, critical_at populated |
|
||||
| Auto-resolution | Metric back to safe | Alert status → Resolved, removed_at populated |
|
||||
| Idempotency | Same breach twice in 5min | Single alert, no duplicate |
|
||||
| Soft-delete | Resolve alert | Query filters correctly (removed_at IS NULL) |
|
||||
| History query | Resolved alert | Appears in history, not current alerts |
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user