Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 227b563ba2 | |||
| 5c5d9bfee7 | |||
| 2220f9f807 | |||
| c06c24d8bc | |||
| 0b503c20af | |||
| 4ef7a54ad5 | |||
| bd293d6f48 | |||
| 5c68e9526c | |||
| c5e6a013f4 | |||
| d083eb7bf9 | |||
| e7e7d1470d | |||
| c56c9cc903 |
@@ -135,7 +135,8 @@
|
||||
- **임시 파일 관리**: 개발/디버깅 목적의 모든 휘발성 임시 파일 및 로그는 반드시 `Temp/` 디렉토리 하위에서만 생성해야 하며, 루트나 다른 패키지 경로에 임시 파일을 만드는 것은 금지한다. 불가피하게 생성할 경우 반드시 접두사/접미사 규칙(`debug_*`, `tmp_*`, `mock_*`, `*_temp.*`)을 준수하여 `.gitignore`에 필터링되도록 한다.
|
||||
|
||||
## 5b. Blazor & API-First 개발 규칙 (TaxBaik 참조 모델 적용)
|
||||
- **API-First 아키텍처**: Blazor Server UI 계층은 비즈니스 로직이나 DB에 직접 결합되지 않고, `IXxxBrowserClient` 등의 추상화된 API 클라이언트(HTTP/RESTful)를 통해서만 백엔드 API와 통신한다.
|
||||
- **렌더 모드 표준**: Blazor **Interactive WebAssembly** 를 기본 렌더 모드로 한다. InteractiveServer 는 사용하지 않으며, UI 컴포넌트는 **MudBlazor** 로 통일한다 (Fluent UI 는 폐기).
|
||||
- **API-First 아키텍처**: Blazor Interactive WebAssembly UI 계층은 비즈니스 로직이나 DB에 직접 결합되지 않고, `IXxxBrowserClient` 등의 추상화된 API 클라이언트(HTTP/RESTful)를 통해서만 백엔드 API와 통신한다.
|
||||
- **이중 토큰 인증 패턴**: Access Token(15분) 및 Refresh Token(7일) 이중 토큰 패턴을 적용하며, HttpClient 요청 시 401 Unauthorized를 가로채어 자동으로 localStorage의 Refresh Token으로 토큰을 자동 갱신 및 재시도하는 `TokenRefreshHandler` (DelegatingHandler) 구조를 준수한다.
|
||||
- **실시간 알림 (SignalR)**: 실시간 알림 기능은 상태를 직접 동기화하는 용도가 아닌 단순 Event-driven 브로드캐스트 알림으로 설계하며, 클라이언트는 알림 수신 후 API 호출을 통해 최종 데이터를 검증 및 동기화한다.
|
||||
- **UI/UX 구현**:
|
||||
|
||||
@@ -7,18 +7,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
**QuantEngine v0.1** — A comprehensive quantitative analysis and data collection system for retirement asset portfolio management.
|
||||
|
||||
- **Architecture**: .NET 9 + C# (web UI + APIs), Python (legacy data collection/analysis)
|
||||
- **Web UI**: Blazor WebAssembly (Fluent UI Blazor v5) + ASP.NET Core Web API
|
||||
- **Web UI**: Blazor Interactive WebAssembly (MudBlazor) + ASP.NET Core Web API (API-First)
|
||||
- **Database**: PostgreSQL (Npgsql 8.0), single unified database
|
||||
- **Data Source**: KIS Open API (quotations/ranking read-only), with fallbacks
|
||||
- **Key Runtimes**: .NET 9, Python 3.9+, Node.js 16+
|
||||
|
||||
### Current Status (2026-06-29)
|
||||
### Migration Phases Status (2026-06-29)
|
||||
|
||||
- ✅ Python codebase operational (1,140 files)
|
||||
- ✅ .NET 9 migration in progress (Phase 1: Web UI)
|
||||
- ✅ Blazor WASM with Fluent UI v5 RC baseline
|
||||
- ✅ ASP.NET Core API endpoints operational
|
||||
- ✅ PostgreSQL migration complete
|
||||
**Phase 1: Web UI Migration** 🔄 정책 전환 (2026-06-30)
|
||||
- **신규 표준**: Blazor **Interactive WebAssembly** 렌더 모드 + **MudBlazor** 컴포넌트 + API-First
|
||||
- **이전 표준(폐기)**: Fluent UI Blazor v5 / InteractiveServer 렌더 모드는 더 이상 사용하지 않음
|
||||
- Pages: Home, Workspace, Collection, Tables, MainLayout
|
||||
- 코드 전환 작업은 `docs/WBS_10_DOTNET_MIGRATION_HARDENING_2026_06_30.md` 의 **WBS-A7** 로 추적
|
||||
|
||||
**Phase 2: KIS Data Collection Pipeline** ✅ 95% COMPLETE
|
||||
- ✅ KIS API Client: Full implementation complete
|
||||
- IKisApiClient interface (5 quotation methods)
|
||||
- KisApiClient with real HTTP implementation + token caching
|
||||
- All governance rules enforced (no trading APIs)
|
||||
- Windows env var + registry fallback for credentials
|
||||
- Build: 0 errors, 0 warnings
|
||||
- ✅ PostgreSQL Infrastructure: Complete
|
||||
- PostgresTokenCache (token management, 10-min skew)
|
||||
- CollectionRepository (full CRUD + dashboard aggregations)
|
||||
- Auto-creates kis_tokens, kis_collection_runs, kis_collection_snapshots, kis_collection_errors
|
||||
- Dapper ORM + parameterized SQL (injection-proof)
|
||||
- ✅ Web API Endpoints: Complete
|
||||
- CollectionEndpoints (6 endpoints: state, runs, snapshots, errors, latest, start)
|
||||
- ApiClient for Blazor consumption
|
||||
- ✅ Blazor UI: Complete
|
||||
- Collection.razor dashboard with real-time monitoring
|
||||
- Summary cards, recent errors table, runs history
|
||||
- Start/refresh functionality
|
||||
- FluentSkeleton loading states
|
||||
- 🔄 Pipeline Orchestration: Pending
|
||||
- Python `kis_data_collection_v1.py` → .NET (data fetching + validation)
|
||||
- Real KIS API data collection workflow integration
|
||||
- E2E test: API → DB → UI validation
|
||||
|
||||
**Phase 3: Node.js→.NET CLI Tools** 📋 PLANNED
|
||||
- Makefile created (npm → make mappings)
|
||||
- np operations documented
|
||||
|
||||
**Status Summary**:
|
||||
- Python codebase: Operational (1,140 files)
|
||||
- .NET 9 coverage: Core (✅), Infrastructure (✅), API (✅), Web UI (✅)
|
||||
- Database: PostgreSQL fully migrated
|
||||
- Release gates: Python gates remain authority until Phase 2 integration testing complete
|
||||
|
||||
## Deployment & Operations
|
||||
|
||||
@@ -49,22 +84,24 @@ sudo systemctl restart quantengine-api
|
||||
|
||||
### Framework & Design System
|
||||
|
||||
- **Primary Framework**: [Fluent UI Blazor v5](https://v5.fluentui-blazor.net/)
|
||||
- **Design System**: Microsoft Fluent Design System (WCAG 2.1 AA)
|
||||
- **Deprecation**: MudBlazor is deprecated. Migrate all existing pages to Fluent UI v5 progressively.
|
||||
- **Primary Framework**: [MudBlazor](https://mudblazor.com/)
|
||||
- **Design System**: Material Design (MudBlazor), 고밀도/대량 데이터 성능 우선
|
||||
- **Render Mode**: **Interactive WebAssembly** 를 기본 렌더 모드로 한다 (API-First). InteractiveServer 는 사용하지 않는다.
|
||||
- **Deprecation**: **Fluent UI Blazor v5 는 폐기**한다. 기존 Fluent UI 페이지는 MudBlazor 로 점진 이전한다.
|
||||
|
||||
### Component Development Rules
|
||||
|
||||
1. **All UI Development** (New + Refactored):
|
||||
- Use Fluent UI Blazor v5 components exclusively
|
||||
- Fall back to pure HTML/CSS if Fluent v5 doesn't provide
|
||||
- **Never introduce MudBlazor components** (deprecated)
|
||||
- Progressively migrate existing MudBlazor to Fluent v5
|
||||
- Use **MudBlazor** components exclusively
|
||||
- Fall back to pure HTML/CSS if MudBlazor doesn't provide
|
||||
- **Never introduce Fluent UI components** (deprecated)
|
||||
- Progressively migrate existing Fluent UI to MudBlazor
|
||||
- **API-First**: UI 는 DB/비즈니스 로직에 직접 결합하지 않고 추상화된 API 클라이언트(HTTP)로만 통신 (AGENTS.md §5b 준수)
|
||||
|
||||
2. **Loading States** (Priority order):
|
||||
- `<FluentSkeleton>` — **Default** for lists, cards, dashboards, detail pages
|
||||
- `<MudSkeleton>` — **Default** for lists, cards, dashboards, detail pages
|
||||
- Pure HTML `<div class="skeleton">` — For custom layouts
|
||||
- `MudProgressCircular` / `MudProgressLinear` — Exception only (existing legacy)
|
||||
- `<MudProgressCircular>` / `<MudProgressLinear>` — 명시적 진행 표시가 필요한 경우
|
||||
- Blocking spinners — **Avoid**
|
||||
|
||||
3. **Data Rendering Pattern**:
|
||||
@@ -72,38 +109,68 @@ sudo systemctl restart quantengine-api
|
||||
- On data arrival: Replace skeleton with actual UI
|
||||
- Never show blank states while loading
|
||||
|
||||
4. **Component Mapping** (Fluent UI v5):
|
||||
4. **Component Mapping** (MudBlazor):
|
||||
|
||||
| UI Element | Fluent UI Component | Alternative |
|
||||
| UI Element | MudBlazor Component | Alternative |
|
||||
|-----------|-------------------|-------------|
|
||||
| Button | `<FluentButton>` | - |
|
||||
| Input field | `<FluentTextField>` | HTML `<input>` |
|
||||
| Dropdown | `<FluentSelect>` | HTML `<select>` |
|
||||
| Data grid | `<FluentDataGrid>` | HTML `<table>` |
|
||||
| Card | `<FluentCard>` | HTML `<div class="card">` |
|
||||
| Badge/Status | `<FluentBadge>` | HTML `<span>` |
|
||||
| Layout container | `<FluentStack>` | HTML `<div>` |
|
||||
| Accordion | `<FluentAccordion>` | HTML `<details>` |
|
||||
| Navigation | `<FluentNavMenu>` | HTML `<nav>` |
|
||||
| Loading | `<FluentSkeleton>` | CSS skeleton animation |
|
||||
| Icons | `<FluentIcon>` | SVG inline |
|
||||
| Button | `<MudButton>` | - |
|
||||
| Input field | `<MudTextField>` | HTML `<input>` |
|
||||
| Dropdown | `<MudSelect>` | HTML `<select>` |
|
||||
| Data grid | `<MudDataGrid Dense Virtualize>` | HTML `<table>` |
|
||||
| Card | `<MudCard>` | HTML `<div class="card">` |
|
||||
| Badge/Status | `<MudBadge>` / `<MudChip>` | HTML `<span>` |
|
||||
| Layout container | `<MudStack>` / `<MudGrid>` | HTML `<div>` |
|
||||
| Accordion | `<MudExpansionPanels>` | HTML `<details>` |
|
||||
| Navigation | `<MudNavMenu>` | HTML `<nav>` |
|
||||
| Loading | `<MudSkeleton>` | CSS skeleton animation |
|
||||
| Icons | `<MudIcon>` | SVG inline |
|
||||
| Modal/Dialog | `<MudDialog>` (CRUD: 모달 패턴, 삭제: ConfirmDialog) | - |
|
||||
|
||||
## Development Commands
|
||||
## Development Commands (Phase 1 + 2)
|
||||
|
||||
### Python / Node.js (Legacy & Release Gates)
|
||||
```powershell
|
||||
# NPM / Python validation
|
||||
npm install
|
||||
npm run ops:validate
|
||||
|
||||
# .NET Development (if dotnet/ folder exists)
|
||||
cd dotnet
|
||||
dotnet restore
|
||||
dotnet build
|
||||
dotnet watch run --project src/DataFeed.Api
|
||||
npm run ops:validate # Warn-only validation
|
||||
npm run full-gate # Strict validation (all gates PASS)
|
||||
npm run ops:data-collect # KIS collection (Python subprocess)
|
||||
npm run ops:release # Full release DAG
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
### .NET (Primary - Phase 1 + 2)
|
||||
```powershell
|
||||
cd src/dotnet
|
||||
dotnet restore
|
||||
dotnet build # Debug build (0 errors, 0 warnings)
|
||||
dotnet build -c Release # Release build
|
||||
dotnet watch run --project QuantEngine.Web # Hot-reload (http://localhost:5265)
|
||||
dotnet run --project QuantEngine.Web # Run API server
|
||||
```
|
||||
|
||||
### Collection Pipeline Testing (Phase 2)
|
||||
```powershell
|
||||
# Set KIS credentials (sandbox account)
|
||||
$env:KIS_APP_Key_TEST = "your_kis_test_key"
|
||||
$env:KIS_APP_Secret_TEST = "your_kis_test_secret"
|
||||
|
||||
# Start web server (http://localhost:5265)
|
||||
dotnet run --project QuantEngine.Web
|
||||
|
||||
# Verify Collection dashboard
|
||||
# Navigate to http://localhost:5265/collection
|
||||
# - Click "Start Collection" to trigger async run
|
||||
# - Backend uses PostgreSQL-backed data storage
|
||||
# - Dashboard updates with run status, snapshots, errors
|
||||
|
||||
# Verify API endpoints
|
||||
curl http://localhost:5265/api/collection/state
|
||||
curl http://localhost:5265/api/collection/runs
|
||||
curl "http://localhost:5265/api/collection/latest/005930"
|
||||
```
|
||||
|
||||
## API Endpoints (Phase 1 + 2)
|
||||
|
||||
### Workspace & History (Phase 1)
|
||||
All endpoints prefixed with `/api/`:
|
||||
|
||||
| Route | Purpose |
|
||||
@@ -117,6 +184,38 @@ All endpoints prefixed with `/api/`:
|
||||
| `POST /account-snapshot/import-tsv` | Import TSV |
|
||||
| `POST /autofix` | Auto-correct data |
|
||||
|
||||
### Collection Pipeline (Phase 2)
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /collection/state` | Dashboard summary (runs, snapshots, errors) |
|
||||
| `GET /collection/runs` | Recent collection runs (paginated) |
|
||||
| `GET /collection/runs/{runId}/snapshots` | Snapshots from a run |
|
||||
| `GET /collection/runs/{runId}/errors` | Errors from a run |
|
||||
| `GET /collection/latest/{ticker}` | Latest snapshots for ticker |
|
||||
| `POST /collection/run` | Start new collection run (async) |
|
||||
|
||||
## KIS API Client Security (Phase 2)
|
||||
|
||||
### Governance Enforcement
|
||||
- **Read-Only Mandate**: `AssertReadOnly(path, trId)` blocks all trading-related endpoints
|
||||
- **Forbidden Paths**: `/trading/` substring triggers 🚫 immediate exception
|
||||
- **Forbidden TR_IDs**: TTTC* / VTTC* prefixes (buy/sell order codes) blocked
|
||||
- **Source**: `governance/rules/06_no_direct_api_trading.yaml`
|
||||
|
||||
### Token Management
|
||||
- **ITokenCache** abstraction: PostgreSQL-backed in production
|
||||
- **Credential Loading**:
|
||||
- Windows environment variables: `KIS_APP_Key`, `KIS_APP_Secret`, `KIS_APP_Key_TEST`, `KIS_APP_Secret_TEST`
|
||||
- Fallback: `HKCU\Environment` registry (Windows only)
|
||||
- Account modes: `"real"` (prod) vs `"mock"` (sandbox)
|
||||
|
||||
### Quotation Methods (All Read-Only)
|
||||
1. **GetCurrentPriceAsync** (FHKST01010100) — Current price inquiry
|
||||
2. **GetAskingPrice10LevelAsync** (FHKST01010200) — Order book (10-level)
|
||||
3. **GetDailyShortSaleAsync** (FHPST04830000) — Short-sale trends
|
||||
4. **GetDailyItemChartPriceAsync** (FHKST03010100) — Daily OHLCV data
|
||||
5. **GetInvestorTrendAsync** (FHKST01010900) — Investor sentiment (개인/외국인/기관)
|
||||
|
||||
## Notes for Contributors
|
||||
|
||||
- **SQL Safety**: Whitelist-only table access (enum switch)
|
||||
|
||||
@@ -1378,6 +1378,8 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행)
|
||||
|
||||
### WBS-10: C#/.NET 엔진 고도화 (Phase 10, 2026-06~12)
|
||||
|
||||
> **📌 보강 문서(2026-06-30):** 본 WBS-10 의 다수 항목이 `완료` 표기되어 있으나 실측 결과 일부 괴리(10.6 파이프라인·10.9 보안 실질 미완성)가 확인되었다. 마이그레이션 완성 우선 + 상용화 잔여 작업의 재정의는 [WBS_10_DOTNET_MIGRATION_HARDENING_2026_06_30.md](./WBS_10_DOTNET_MIGRATION_HARDENING_2026_06_30.md) 참조.
|
||||
|
||||
> 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준.
|
||||
> Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Blazor 대시보드 기본 구현 완료.
|
||||
> **미구현**: Application 서비스 일부, 공식 엔진, 하네스 주입, 파이프라인 오케스트레이터.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# WBS-10 보강: .NET Core 마이그레이션 완성 & 상용화 로드맵 (2026-06-30)
|
||||
|
||||
> 본 문서는 [docs/ROADMAP_WBS.md](./ROADMAP_WBS.md) 의 **WBS-10(.NET 엔진 고도화)** 을 현 시점 실측 기준으로 재진단하고, 마이그레이션 완성과 단일 사용자 상용 운영에 필요한 잔여 작업을 재정의한다.
|
||||
>
|
||||
> **작성 배경:** 기존 WBS-10 의 다수 항목이 `완료` 로 표기되어 있으나, 2026-06-30 소스 실측 결과 **표기와 실제 상태 간 괴리**가 확인되었다. 본 문서는 그 괴리를 정리하고 실제 잔여 작업을 추적한다.
|
||||
>
|
||||
> **의사결정(사용자 확정):** ① 우선순위 = **마이그레이션 완성 우선**, ② 산출물 = **로드맵/WBS 문서**, ③ 인증 모델 = **단일 사용자 + 기본 보호**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — 왜 이 보강이 필요한가
|
||||
|
||||
QuantEngine 은 은퇴자산 포트폴리오 운용을 위한 결정론적 퀀트 엔진이다. canonical 권위는 여전히 **Python 구현(219 파일, 24,683 lines)** 에 있고, `.NET 10` 마이그레이션은 Core / Application / Infrastructure / Web / Tools / Tests 6개 프로젝트로 구조화되어 Phase 1(Web UI)·Phase 2(KIS 수집)까지 도달했다.
|
||||
|
||||
그러나 다음 세 가지 근본 결손으로 마이그레이션 완료 및 상용 기준에 미달한다.
|
||||
|
||||
1. **마이그레이션 미완성** — 도메인 단일 권위가 Python 에 잔존. `PipelineOrchestrator` 가 실제 로직이 아닌 시뮬레이션 스텁. Python↔.NET 패리티가 일부 도메인 계산기에만 존재. GAS 공식 14건 미이관.
|
||||
2. **상용 운영 결손** — 소스에 하드코딩 시크릿 잔존, `.gitignore` 의 `bin/obj` 누락으로 빌드 산출물 git 추적, 헬스체크·메트릭·재시도·스케줄러·운영 구성(`appsettings.Production.json`) 부재.
|
||||
3. **검증 공백** — KIS→스냅샷→정성매도 전 구간 E2E 와 CI 커버리지 게이트 부재.
|
||||
|
||||
---
|
||||
|
||||
## 2. 표기 vs 실제 괴리 정리 (2026-06-30 실측)
|
||||
|
||||
| 기존 WBS | 기존 표기 | 실측 상태 | 괴리 / 조치 |
|
||||
|---|---|---|---|
|
||||
| WBS-10.6 파이프라인 오케스트레이터 | **완료** | `PipelineOrchestrator.cs` 가 각 단계를 `Task.Delay(10)` 로만 시뮬레이션. 실제 서비스 호출 없음 | 🔴 **실질 미완성.** → 본 문서 **A1** 로 재추적 |
|
||||
| WBS-10.9 보안 강화 | **완료** | `appsettings.json` 은 `Password=;` 처리됨. 그러나 `Program.cs:19` 텔레그램 토큰 평문, `Program.cs:34` DB 패스워드 폴백 평문 잔존. `.gitignore` 에 `bin/obj` 없음 → 산출물 git 추적 | 🔴 **부분 완료(핵심 누락).** → 본 문서 **P0** 로 재추적 |
|
||||
| WBS-10.8 데이터 수집 오케스트레이터 | **TODO** | 실제로는 `DataCollectionService.cs`(KIS 수집 오케스트레이션) 구현·커밋됨. 단 파일명/구조가 WBS 기재(`DataCollectionOrchestrator.cs`)와 불일치 | 🟡 **표기 미갱신.** → 본 문서 **A3** 로 정합화 |
|
||||
| WBS-10.3~10.5 도메인/공식/하네스 패리티 | 완료 | `DomainParityTests`, `FormulaEngineTests`, `HarnessInjector` 패리티 존재 확인 | ✅ 유효. 단 패리티 범위가 도메인 계산기에 한정 → 수집/정성매도/스냅샷은 미커버 (**A2** 확장) |
|
||||
| WBS-10.7 Application 서비스 | 부분 완료 | 4개 서비스 구현 확인 | ✅ 유효 |
|
||||
|
||||
> **핵심 시사점:** 기존 WBS-10 은 "완료" 표기가 실제보다 앞서 있다. 특히 보안(10.9)과 파이프라인(10.6)은 표기와 달리 **실질 미완성**이므로, 후속 작업은 표기를 신뢰하지 말고 본 문서의 실측 기준을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 로드맵 (마이그레이션 완성 우선)
|
||||
|
||||
```
|
||||
[P0 선행 게이트] 보안·위생 차단 ──► 반드시 먼저
|
||||
│
|
||||
▼
|
||||
[Track A] 마이그레이션 완성 (PRIMARY) [Track B] 상용 안정화 (SECONDARY, 병행)
|
||||
A1 PipelineOrchestrator 실구현 B1 구성/시크릿 체계화
|
||||
A2 패리티 하네스 확장(수집·정성매도) B2 기본 인증(단일 사용자)
|
||||
A3 데이터 수집 파이프라인 E2E 정합화 B3 헬스체크·메트릭
|
||||
A4 정성매도/스냅샷 어드민 포팅 B4 재시도(Polly)·스케줄러
|
||||
A5 GAS 잔여 14개 공식 이관 B5 배포(Docker/CI 게이트)
|
||||
A6 SQLite→PostgreSQL 단일화 + Python 폐기 B6 통합/E2E 테스트·커버리지 게이트
|
||||
```
|
||||
|
||||
### 마일스톤
|
||||
|
||||
| 마일스톤 | 구성 | 완료 기준 |
|
||||
|---|---|---|
|
||||
| **M1 위생 확보** | P0 | git 에서 시크릿/산출물 제거, 시크릿 외부화·회전 |
|
||||
| **M2 패리티 기반** | A1·A2 | `.NET` 도메인이 Python 골든 벡터와 1:1 일치, 실 파이프라인 산출 |
|
||||
| **M3 수집 자립** | A3·A4·B4 | `.NET` 단독 KIS→스냅샷→정성매도 무인 실행 |
|
||||
| **M4 단일 권위 전환** | A5·A6 | Python 런타임 의존 제거, `.NET` canonical 승격 |
|
||||
| **M5 상용 운영** | B1~B6 | 단일 사용자 보호·관측·배포 체계 가동 |
|
||||
|
||||
---
|
||||
|
||||
## 4. WBS (작업 분해 구조)
|
||||
|
||||
각 항목: **목표 / 완료 판정(Acceptance) / 주요 파일 / 검증 명령**.
|
||||
|
||||
### P0 — 선행 보안·위생 게이트 (🔴 Critical, 최우선)
|
||||
|
||||
#### WBS-P0.1 빌드 산출물 git 추적 제거
|
||||
- **목표:** `.gitignore` 에 .NET 표준 패턴(`bin/`, `obj/`, `publish-output/`, `*.user`) 추가, 추적 중 산출물 `git rm -r --cached` 처리.
|
||||
- **판정:** `git status` 에 `bin/obj` 변경 미표시.
|
||||
- **파일:** `.gitignore`.
|
||||
- **검증:** `git status --porcelain | grep -E 'bin/|obj/'` → 0건.
|
||||
|
||||
#### WBS-P0.2 하드코딩 시크릿 제거·회전
|
||||
- **목표:** `Program.cs:19` 텔레그램 토큰·채팅ID, `Program.cs:34` DB 패스워드 폴백을 환경변수/`dotnet user-secrets`/`appsettings.Production.json`(비추적)로 이전. 노출 토큰·DB 비밀번호 **회전**.
|
||||
- **판정:** 소스 전역 시크릿 평문 0건, 구성 누락 시 앱 기동 거부(fail-fast).
|
||||
- **파일:** `Program.cs`, `appsettings*.json`, `Infrastructure/TelegramSink.cs`.
|
||||
- **검증:** `Select-String -Pattern '8734507814|C8RFlZ9f' src/dotnet -Recurse` → 0건.
|
||||
|
||||
#### WBS-P0.3 git 이력 시크릿 정리 (선택)
|
||||
- **목표:** 노출 토큰 회전 완료 시 이력 재작성 생략 가능. 회전 불가 시 `git filter-repo` 로 이력 제거 검토.
|
||||
- **판정:** 회전 완료 또는 이력 정리 완료 중 택1 기록.
|
||||
|
||||
> **주의:** WBS-10.9 가 `완료` 로 표기되어 있으나 위 P0.1·P0.2 는 미해결 상태다. 본 게이트 완료 전까지 후속 트랙 착수를 보류한다.
|
||||
|
||||
### Track A — 마이그레이션 완성 (PRIMARY)
|
||||
|
||||
#### WBS-A1 PipelineOrchestrator 실제 구현
|
||||
- **목표:** `Task.Delay` 시뮬레이션 제거. 7단계(수집→정규화→팩터→결정→리스크게이트→리포트→영속화)를 실제 서비스 호출로 연결.
|
||||
- **판정:** 입력 스냅샷에 대해 결정 패킷 산출, 각 단계 결과가 `engine_history` 에 기록.
|
||||
- **파일:** `QuantEngine.Application/Services/PipelineOrchestrator.cs`, 관련 `Services/*`.
|
||||
- **검증:** `dotnet test --filter Pipeline` → 실데이터 기반 산출물 `gate: PASS`.
|
||||
|
||||
#### WBS-A2 패리티 하네스 확장 (수집·정성매도)
|
||||
- **목표:** 기존 도메인 계산기 패리티(10.3~10.5)를 **수집 정규화·정성매도·하네스 주입 전체**로 확장. `spec/13_formula_registry.yaml`(149 공식) 기준 골든 벡터를 Python 에서 추출해 `.NET` 결과와 비교.
|
||||
- **판정:** 핵심 공식 전부 Python 과 동일 출력(부동소수 허용오차 내), 패리티 리포트 JSON 생성.
|
||||
- **파일:** `QuantEngine.Core.Tests/ParityTests/`, `tests/golden/`.
|
||||
- **검증:** `dotnet test --filter Parity` → 전건 PASS.
|
||||
|
||||
#### WBS-A3 데이터 수집 파이프라인 E2E 정합화
|
||||
- **목표:** `DataCollectionService.cs`(구현됨)를 기준으로 WBS 표기 정합화, `kis_data_collection_v1.py` 잔여 로직 완전 이관, KIS→PostgreSQL 스냅샷 E2E 검증. Naver/Yahoo 폴백 다중화 명문화.
|
||||
- **판정:** `.NET` 단독 실데이터 수집·저장 성공, 폴백 동작 확인.
|
||||
- **파일:** `Application/Services/DataCollectionService.cs`, `Infrastructure/External/*`.
|
||||
|
||||
#### WBS-A4 정성매도·스냅샷 어드민 포팅
|
||||
- **목표:** `qualitative_sell_strategy_v1.py`, `snapshot_admin_*_v1.py` 를 `.NET` 서비스/엔드포인트로 이관.
|
||||
- **판정:** 정성매도 5팩터 confluence 결과 Python 일치, 스냅샷 승인 워크플로우가 Web UI 에서 동작.
|
||||
- **파일:** `QuantEngine.Core/Domain/`, `QuantEngine.Web/Endpoints/`, `Components/Pages/`.
|
||||
|
||||
#### WBS-A5 GAS 잔여 14개 공식 이관
|
||||
- **목표:** `governance/gas_logic_migration_ledger_v1.yaml` 의 TODO 14건을 `.NET` 포팅 + parity.
|
||||
- **판정:** 원장 전 항목 `status: DONE`, parity 통과.
|
||||
- **파일:** `QuantEngine.Core/Domain/`, `governance/gas_logic_migration_ledger_v1.yaml`.
|
||||
|
||||
#### WBS-A6 SQLite→PostgreSQL 단일화 및 Python 런타임 폐기
|
||||
- **목표:** canonical DB 를 PostgreSQL 로 일원화, `src/quant_engine/*.db` 의존 제거, Python 런타임 도구를 `.NET`/`Tools` 로 대체.
|
||||
- **판정:** 운영 경로 Python 호출 0건, 모든 데이터 PostgreSQL 단일 소스.
|
||||
- **파일:** `Infrastructure/Data/DbMigrator.cs`, `Makefile`, `tools/`.
|
||||
|
||||
#### WBS-A7 UI 프레임워크 전환 — Fluent UI → MudBlazor + Interactive WebAssembly (2026-06-30 방침)
|
||||
- **배경:** UI 표준을 **MudBlazor** 컴포넌트 + **Interactive WebAssembly** 렌더 모드 + **API-First** 로 전환(방침 확정). 기존 Fluent UI v5 / InteractiveServer 는 폐기. 정책은 [CLAUDE.md](../CLAUDE.md) 및 [AGENTS.md](../AGENTS.md) §5b 에 반영 완료.
|
||||
- **목표:**
|
||||
- csproj 패키지 교체: `Microsoft.FluentUI.AspNetCore.Components*` 제거 → `MudBlazor` 추가.
|
||||
- 렌더 모드 전환: `Program.cs` 의 `AddInteractiveServerComponents`/`AddInteractiveServerRenderMode` → `AddInteractiveWebAssemblyComponents`/`AddInteractiveWebAssemblyRenderMode`, 클라이언트 프로젝트(`QuantEngine.Web.Client`) 분리.
|
||||
- `App.razor`: Fluent CSS/JS·`FluentDesignSystemProvider` 제거 → MudBlazor `<MudThemeProvider>`/`<MudDialogProvider>`/`<MudSnackbarProvider>` + `MudBlazor.min.css/js` 삽입.
|
||||
- 전체 `.razor` 컴포넌트의 `Fluent*` → `Mud*` 치환(매핑표는 [CLAUDE.md](../CLAUDE.md) Component Mapping 참조).
|
||||
- API-First: UI 의 직접 DI 호출을 `IXxxBrowserClient`(HTTP) 경유로 전환, `TokenRefreshHandler` 패턴 적용.
|
||||
- **판정:** Fluent UI 패키지/참조 0건, `dotnet build` 오류 0, WASM 로드 후 `/quant/` 및 주요 페이지 정상 렌더, 비-API 라우트 동작 확인.
|
||||
- **주요 파일:** `QuantEngine.Web/QuantEngine.Web.csproj`, `Program.cs`, `Components/App.razor`, `Components/Layout/*.razor`, `Components/Pages/*.razor`, 신규 `QuantEngine.Web.Client/`.
|
||||
- **검증:** `Select-String -Pattern 'Fluent' src/dotnet/QuantEngine.Web -Recurse` → 0건; 브라우저에서 WASM 모드 동작 확인.
|
||||
|
||||
### Track B — 상용 안정화 (SECONDARY, 단일 사용자)
|
||||
|
||||
#### WBS-B1 구성·시크릿 체계화
|
||||
- **목표:** `appsettings.Production.json`(비추적), `IOptions<T>` + 시작 시 구성 검증(fail-fast), 연결 문자열/토큰 환경변수 표준화.
|
||||
- **판정:** 개발/운영 구성 분리, 필수 구성 누락 시 명확 오류로 기동 중단.
|
||||
|
||||
#### WBS-B2 기본 인증 (단일 사용자 보호)
|
||||
- **목표:** 공개 서버 노출 방어용 최소 인증 — 리버스 프록시 Basic Auth 또는 API Key 미들웨어 1종(`/api/*`·UI 보호). 본격 Identity/JWT 는 범위 외.
|
||||
- **판정:** 비인증 요청 401, 인증 요청만 수집/조회 가능.
|
||||
- **파일:** `Program.cs`, `Endpoints/CollectionEndpoints.cs`, Nginx 구성.
|
||||
|
||||
#### WBS-B3 헬스체크·메트릭
|
||||
- **목표:** `MapHealthChecks("/health")`(liveness) + `/health/ready`(PostgreSQL/KIS 토큰 점검), `prometheus-net` 기반 기본 메트릭.
|
||||
- **판정:** 배포 스크립트 헬스체크가 `/health/ready` 사용, 메트릭 엔드포인트 응답.
|
||||
- **파일:** `Program.cs`, `.gitea/workflows/deploy-prod.yml`.
|
||||
|
||||
#### WBS-B4 재시도(Polly)·백그라운드 스케줄러
|
||||
- **목표:** KIS/Naver/Yahoo HTTP 호출에 Polly 재시도·서킷브레이커, 주기적 수집을 `BackgroundService`(또는 systemd timer 연계)로 자동화.
|
||||
- **판정:** 일시적 5xx/네트워크 오류 자동 복구, 정해진 스케줄 무인 수집.
|
||||
- **파일:** `Program.cs`(HttpClient+Polly), 신규 `Application/Services/*BackgroundService.cs`.
|
||||
|
||||
#### WBS-B5 배포 (Docker/CI 게이트)
|
||||
- **목표:** 멀티스테이지 `Dockerfile` + `docker-compose.yml`(app+PostgreSQL), `.gitea` CI 에 `dotnet build`+`dotnet test` 게이트 추가.
|
||||
- **판정:** 컨테이너 로컬 기동 성공, CI 에서 테스트 실패 시 배포 차단.
|
||||
- **파일:** 신규 `Dockerfile`, `docker-compose.yml`, `.gitea/workflows/ci.yml`.
|
||||
|
||||
#### WBS-B6 통합·E2E 테스트 및 커버리지 게이트
|
||||
- **목표:** Testcontainers(PostgreSQL) 통합테스트, KIS→스냅샷→정성매도 E2E, coverlet 커버리지 임계값을 CI 게이트로 연결.
|
||||
- **판정:** E2E 1건 이상 그린, 커버리지 임계 미달 시 CI 실패.
|
||||
- **파일:** `QuantEngine.Core.Tests/`(통합/E2E), `.gitea/workflows/ci.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 5. 개선·보완·고도화 제안 (Track A/B 외 권고)
|
||||
|
||||
- **결정 재현성 감사:** 동일 입력 → 동일 출력 결정론 검증을 CI 상시 게이트로 편입 ([governance/adr/0003-no-llm-numeric-generation.md](../governance/adr/0003-no-llm-numeric-generation.md) 정신 계승).
|
||||
- **캘리브레이션 실증 연계:** [spec/27_bch_calibration_runbook.yaml](../spec/27_bch_calibration_runbook.yaml) 의 `0/190 CALIBRATED` 문제를 마이그레이션과 분리된 데이터 트랙으로 별도 추적(본 WBS 범위 밖, 링크 유지).
|
||||
- **장애 단일점 보강:** Naver Cloudflare 403 폴백 경로를 Yahoo/KIS 다중화로 명문화(WBS-A3 연동).
|
||||
- **운영 가시성:** 구조화 로깅에 상관관계 ID(correlation id) 추가, 수집 실행별 추적 가능화.
|
||||
- **비밀 회전 정책:** KIS appkey/secret, 텔레그램 토큰, DB 비밀번호의 주기적 회전 절차를 [docs/runbook.md](./runbook.md) 에 문서화.
|
||||
- **WBS 표기 정합성 거버넌스:** 본 문서에서 드러난 "완료 표기 vs 실측" 괴리 재발 방지를 위해, 각 WBS 완료 시 **검증 명령 출력 캡처를 증빙으로 첨부**하는 규칙을 강화([AGENTS.md](../AGENTS.md) 의 검증·증빙 강제 원칙 적용).
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법 (각 단계 실행 시)
|
||||
|
||||
- **P0:** `git status` 산출물 미추적 확인, 시크릿 평문 grep 0건, 회전된 자격증명으로 정상 기동.
|
||||
- **Track A:** `cd src/dotnet && dotnet test` 로 패리티/단위/E2E 그린. 패리티 리포트 JSON 을 Python 출력과 diff. 운영 경로 Python 호출 0건.
|
||||
- **Track B:** `curl /health/ready` 200, 비인증 요청 401, `docker compose up` 기동, CI 테스트/커버리지 게이트 동작. Polly 재시도는 장애 주입 테스트로 검증.
|
||||
|
||||
---
|
||||
|
||||
## 7. 실행 순서 요약
|
||||
|
||||
1. **P0 선행 게이트** (WBS-P0.1~P0.3) — 보안·위생 차단. **(기존 10.9 完了 표기 무시, 실측 기준 처리)**
|
||||
2. **Track A** (A1→A2→A3→A4→A5→A6) — 마이그레이션 완성(우선).
|
||||
3. **Track B** (B1~B6) — 단일 사용자 상용 안정화(A 와 병행, B1·B3 조기 착수 권장).
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
public class DataCollectionService
|
||||
{
|
||||
private readonly IKisApiClient _kisApiClient;
|
||||
private readonly ICollectionRepository _repository;
|
||||
|
||||
public DataCollectionService(
|
||||
IKisApiClient kisApiClient,
|
||||
ICollectionRepository repository)
|
||||
{
|
||||
_kisApiClient = kisApiClient;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<CollectionRunResult> RunCollectionAsync(
|
||||
string runId,
|
||||
string account,
|
||||
List<string> tickers)
|
||||
{
|
||||
var result = new CollectionRunResult
|
||||
{
|
||||
RunId = runId,
|
||||
StartedAt = KstNowIso(),
|
||||
Status = "RUNNING"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _repository.SaveRunAsync(new CollectionRunRecord(
|
||||
RunId: runId,
|
||||
Status: "RUNNING",
|
||||
StartedAt: result.StartedAt
|
||||
));
|
||||
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
foreach (var ticker in tickers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = await CollectOneAsync(ticker, account);
|
||||
var provenance = new Dictionary<string, object>
|
||||
{
|
||||
{ "ticker", ticker },
|
||||
{ "source", "kis_open_api" }
|
||||
};
|
||||
|
||||
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
||||
RunId: runId,
|
||||
DatasetName: "data_feed",
|
||||
Ticker: ticker,
|
||||
SourceName: "kis_open_api",
|
||||
PayloadJson: JsonSerializer.Serialize(normalized),
|
||||
CapturedAt: KstNowIso()
|
||||
));
|
||||
|
||||
successCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCount++;
|
||||
System.Diagnostics.Debug.WriteLine($"Error collecting {ticker}: {ex.Message}");
|
||||
|
||||
await _repository.SaveErrorAsync(new CollectionErrorRecord(
|
||||
RunId: runId,
|
||||
SourceName: "kis_collector",
|
||||
ErrorKind: ex.GetType().Name,
|
||||
ErrorMessage: ex.Message,
|
||||
Ticker: ticker
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
var finishedAt = KstNowIso();
|
||||
await _repository.UpdateRunStatusAsync(
|
||||
runId,
|
||||
errorCount == 0 ? "COMPLETED" : "COMPLETED_WITH_ERRORS",
|
||||
finishedAt,
|
||||
successCount,
|
||||
errorCount
|
||||
);
|
||||
|
||||
result.Status = errorCount == 0 ? "COMPLETED" : "COMPLETED_WITH_ERRORS";
|
||||
result.FinishedAt = finishedAt;
|
||||
result.SuccessCount = successCount;
|
||||
result.ErrorCount = errorCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Fatal error in collection run {runId}: {ex}");
|
||||
await _repository.UpdateRunStatusAsync(runId, "FAILED", KstNowIso());
|
||||
result.Status = "FAILED";
|
||||
result.ErrorMessage = ex.Message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object>> CollectOneAsync(string ticker, string account)
|
||||
{
|
||||
var normalized = new Dictionary<string, object> { { "ticker", ticker } };
|
||||
|
||||
try
|
||||
{
|
||||
var price = await _kisApiClient.GetCurrentPriceAsync(ticker, account);
|
||||
normalized["current_price"] = CoerceFloat(FindFirstValue(price, "stck_prpr", "stck_clpr", "close"));
|
||||
normalized["open"] = CoerceFloat(FindFirstValue(price, "stck_oprc", "open"));
|
||||
normalized["high"] = CoerceFloat(FindFirstValue(price, "stck_hgpr", "high"));
|
||||
normalized["low"] = CoerceFloat(FindFirstValue(price, "stck_lwpr", "low"));
|
||||
normalized["prev_close"] = CoerceFloat(FindFirstValue(price, "prdy_vrss"));
|
||||
normalized["volume"] = CoerceFloat(FindFirstValue(price, "acml_vol", "volume"));
|
||||
normalized["change_pct"] = CoerceFloat(FindFirstValue(price, "prdy_ctrt"));
|
||||
normalized["price_status"] = "OK";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
normalized["price_status"] = "ERROR";
|
||||
normalized["price_error"] = ex.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var orderbook = await _kisApiClient.GetAskingPrice10LevelAsync(ticker, account);
|
||||
var output1 = ExtractObject(orderbook, "output1");
|
||||
normalized["ask_1"] = CoerceFloat(FindFirstValue(output1, "askp1"));
|
||||
normalized["bid_1"] = CoerceFloat(FindFirstValue(output1, "bidp1"));
|
||||
normalized["orderbook_status"] = "OK";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
normalized["orderbook_status"] = "ERROR";
|
||||
normalized["orderbook_error"] = ex.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var start = DateTime.Now.AddDays(-10).ToString("yyyyMMdd");
|
||||
var end = DateTime.Now.ToString("yyyyMMdd");
|
||||
var shortSale = await _kisApiClient.GetDailyShortSaleAsync(ticker, start, end, account);
|
||||
var rows = ExtractArray(shortSale, "output2");
|
||||
if (rows.Count > 0 && rows[0] is Dictionary<string, object> latest)
|
||||
{
|
||||
normalized["short_turnover_share"] = CoerceFloat(latest.GetValueOrDefault("ssts_vol_rlim"));
|
||||
}
|
||||
normalized["short_sale_status"] = "OK";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
normalized["short_sale_status"] = "ERROR";
|
||||
normalized["short_sale_error"] = ex.Message;
|
||||
}
|
||||
|
||||
normalized["collection_as_of"] = KstNowIso();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static object? FindFirstValue(Dictionary<string, object> payload, params string[] keys)
|
||||
{
|
||||
var stack = new Stack<object>();
|
||||
stack.Push(payload);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var item = stack.Pop();
|
||||
if (item is Dictionary<string, object> dict)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (dict.TryGetValue(key, out var value) && value != null && !string.IsNullOrEmpty(value.ToString()))
|
||||
return value;
|
||||
}
|
||||
foreach (var value in dict.Values)
|
||||
if (value != null) stack.Push(value);
|
||||
}
|
||||
else if (item is JsonElement elem && elem.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (elem.TryGetProperty(key, out var prop) && prop.ValueKind != System.Text.Json.JsonValueKind.Null)
|
||||
return prop;
|
||||
}
|
||||
foreach (var prop in elem.EnumerateObject())
|
||||
stack.Push(prop.Value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? CoerceFloat(object? value)
|
||||
{
|
||||
if (value == null || string.IsNullOrEmpty(value.ToString()))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var str = value.ToString()?.Replace(",", "").Replace("%", "") ?? "";
|
||||
return double.TryParse(str, out var d) ? d : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ExtractObject(Dictionary<string, object> payload, string key)
|
||||
{
|
||||
if (payload.TryGetValue(key, out var value) && value is Dictionary<string, object> dict)
|
||||
return dict;
|
||||
if (value is JsonElement elem && elem.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(elem.GetRawText()) ?? new();
|
||||
return new();
|
||||
}
|
||||
|
||||
private static List<object> ExtractArray(Dictionary<string, object> payload, string key)
|
||||
{
|
||||
if (payload.TryGetValue(key, out var value))
|
||||
{
|
||||
if (value is List<object> list) return list;
|
||||
if (value is JsonElement elem && elem.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<object>>(elem.GetRawText()) ?? new();
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
private static string KstNowIso() =>
|
||||
DateTime.Now.ToString("o");
|
||||
}
|
||||
|
||||
public class CollectionRunResult
|
||||
{
|
||||
public string RunId { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string StartedAt { get; set; } = "";
|
||||
public string? FinishedAt { get; set; }
|
||||
public int SuccessCount { get; set; }
|
||||
public int ErrorCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-39
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v10.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v10.0": {
|
||||
"QuantEngine.Application/1.0.0": {
|
||||
"dependencies": {
|
||||
"QuantEngine.Core": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"QuantEngine.Application.dll": {}
|
||||
}
|
||||
},
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"runtime": {
|
||||
"QuantEngine.Core.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"QuantEngine.Application/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ef7a54ad55182e164ca78e8af21f2a5e214c98f")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
930d2761d13a35c4440ddf7633edaa4c1f2424cf64d2d3e7777d3bad3db490e2
|
||||
e3d73b83f89256e561af0334bd1c6aa38e9e47f25cf6ce5907009a31d56d309d
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
-4
@@ -1,4 +0,0 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+325c6d64e17702c514691d989194bc4dc0d08460")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Application")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
890881f507161f08897bd1d5e06cebf860cb871f7935eb98cd6cf03b0b68e760
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
is_global = true
|
||||
build_property.TargetFramework = net10.0
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v10.0
|
||||
build_property.TargetPlatformMinVersion =
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
build_property.InvariantGlobalization =
|
||||
build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = QuantEngine.Application
|
||||
build_property.ProjectDir = C:\Temp\data_feed\src\dotnet\QuantEngine.Application\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
// <auto-generated/>
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Net.Http;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
94fda82733bc65260c13686a5de328e1d15725563416d1a333b2b9d5e49304c8
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\bin\Release\net10.0\QuantEngine.Application.deps.json
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\bin\Release\net10.0\QuantEngine.Application.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\bin\Release\net10.0\QuantEngine.Application.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\bin\Release\net10.0\QuantEngine.Core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\bin\Release\net10.0\QuantEngine.Core.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.csproj.AssemblyReference.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.GeneratedMSBuildEditorConfig.editorconfig
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.AssemblyInfoInputs.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.AssemblyInfo.cs
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.csproj.CoreCompileInputs.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEng.294596D8.Up2Date
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\refint\QuantEngine.Application.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\QuantEngine.Application.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Application\obj\Release\net10.0\ref\QuantEngine.Application.dll
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Core.Tests")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ef7a54ad55182e164ca78e8af21f2a5e214c98f")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Core.Tests")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Core.Tests")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
29474253b828ac86032076fb599b35fb19bd047e400aaabe06be96e0930730fc
|
||||
e4771135b81bbeef377e0f0cdbafc89d7c10d2257171ab0f1a12919a2264d756
|
||||
|
||||
BIN
Binary file not shown.
+8
-8
@@ -8,7 +8,10 @@ C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngin
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Core.Tests.runtimeconfig.json
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Core.Tests.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Core.Tests.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Dapper.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.VisualStudio.CodeCoverage.Shim.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.Extensions.Logging.Abstractions.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.TestPlatform.CoreUtilities.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.TestPlatform.PlatformAbstractions.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.VisualStudio.TestPlatform.ObjectModel.dll
|
||||
@@ -17,6 +20,7 @@ C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.TestPlatform.Utilities.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.VisualStudio.TestPlatform.Common.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Newtonsoft.Json.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Npgsql.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\xunit.assert.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\xunit.core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\xunit.execution.dotnet.dll
|
||||
@@ -85,8 +89,12 @@ C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\zh-Hans\Mi
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\zh-Hant\Microsoft.TestPlatform.CommunicationUtilities.resources.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\zh-Hant\Microsoft.TestPlatform.CrossPlatEngine.resources.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\zh-Hant\Microsoft.VisualStudio.TestPlatform.Common.resources.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Application.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Infrastructure.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Core.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Application.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Infrastructure.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\QuantEngine.Core.Tests.csproj.AssemblyReference.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\QuantEngine.Core.Tests.GeneratedMSBuildEditorConfig.editorconfig
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\QuantEngine.Core.Tests.AssemblyInfoInputs.cache
|
||||
@@ -98,11 +106,3 @@ C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\refint\Qua
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\QuantEngine.Core.Tests.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\QuantEngine.Core.Tests.genruntimeconfig.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\obj\Debug\net10.0\ref\QuantEngine.Core.Tests.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Dapper.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Microsoft.Extensions.Logging.Abstractions.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\Npgsql.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Application.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Infrastructure.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Application.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core.Tests\bin\Debug\net10.0\QuantEngine.Infrastructure.pdb
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,12 +3,40 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace QuantEngine.Core.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// KIS Open API 클라이언트 (read-only 전용).
|
||||
/// 매수/매도 주문은 절대 금지 (governance/rules/06_no_direct_api_trading.yaml).
|
||||
/// </summary>
|
||||
public interface IKisApiClient
|
||||
{
|
||||
Task<string> GetCurrentPriceAsync(string code);
|
||||
Task<string> GetAskingPrice10LevelAsync(string code);
|
||||
Task<string> GetDailyShortSaleAsync(string code, string startDate, string endDate);
|
||||
Task<string> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D");
|
||||
Task<string> GetInvestorTrendAsync(string code);
|
||||
/// <summary>
|
||||
/// 주식현재가 시세 조회.
|
||||
/// TR_ID: FHKST01010100
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 호가/예상체결 (10단계).
|
||||
/// TR_ID: FHKST01010200
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 국내주식 공매도 일별추이.
|
||||
/// TR_ID: FHPST04830000
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 일자별 차트.
|
||||
/// TR_ID: FHKST03010100
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 투자자 매매동향 (개인/외국인/기관).
|
||||
/// TR_ID: FHKST01010900
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock");
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v10.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v10.0": {
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"runtime": {
|
||||
"QuantEngine.Core.dll": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ef7a54ad55182e164ca78e8af21f2a5e214c98f")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a275438f4b4df0f8d54e6834eea46c8f83eabbb1cf21ee0533f06d867e49ec68
|
||||
2af86bfa0044f5751630cbff48def744178c05fd574a80bbbeccfb462b7302fc
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
79145411294c3e36a015e6e3a0e89de48f8827bccbb71741b1505491550e55a3
|
||||
b49c624a74a19d171e6b45c0e42dc7f77445eb8fdde390082a56dd78ecd8c3b8
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-4
@@ -1,4 +0,0 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
|
||||
@@ -1,22 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+325c6d64e17702c514691d989194bc4dc0d08460")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Core")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
94a37093348f0cfed0034377ce1a1c3ba1c5b37e294c89b8abda19ed68ad21e9
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
is_global = true
|
||||
build_property.TargetFramework = net10.0
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v10.0
|
||||
build_property.TargetPlatformMinVersion =
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
build_property.InvariantGlobalization =
|
||||
build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = QuantEngine.Core
|
||||
build_property.ProjectDir = C:\Temp\data_feed\src\dotnet\QuantEngine.Core\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
@@ -1,8 +0,0 @@
|
||||
// <auto-generated/>
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Net.Http;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
8f5c08fbd8e56f6e9f9d0c5132c0db634d6a09e579d72883d00b4b4dc6141b7e
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\bin\Release\net10.0\QuantEngine.Core.deps.json
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\bin\Release\net10.0\QuantEngine.Core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\bin\Release\net10.0\QuantEngine.Core.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.GeneratedMSBuildEditorConfig.editorconfig
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.AssemblyInfoInputs.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.AssemblyInfo.cs
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.csproj.CoreCompileInputs.cache
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\refint\QuantEngine.Core.dll
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\QuantEngine.Core.pdb
|
||||
C:\Temp\data_feed\src\dotnet\QuantEngine.Core\obj\Release\net10.0\ref\QuantEngine.Core.dll
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
-15
@@ -171,10 +171,10 @@ namespace QuantEngine.Infrastructure.External
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
public Task<string> GetCurrentPriceAsync(string code)
|
||||
public async Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
@@ -182,12 +182,13 @@ namespace QuantEngine.Infrastructure.External
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public Task<string> GetAskingPrice10LevelAsync(string code)
|
||||
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
@@ -195,12 +196,13 @@ namespace QuantEngine.Infrastructure.External
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public Task<string> GetDailyShortSaleAsync(string code, string startDate, string endDate)
|
||||
public async Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock")
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/daily-short-sale",
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/daily-short-sale",
|
||||
"FHPST04830000",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
@@ -210,12 +212,13 @@ namespace QuantEngine.Infrastructure.External
|
||||
{ "FID_INPUT_DATE_2", endDate }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public Task<string> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D")
|
||||
public async Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock")
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
|
||||
"FHKST03010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
@@ -227,12 +230,13 @@ namespace QuantEngine.Infrastructure.External
|
||||
{ "FID_ORG_ADJ_PRC", "0" }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public Task<string> GetInvestorTrendAsync(string code)
|
||||
public async Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
@@ -240,6 +244,7 @@ namespace QuantEngine.Infrastructure.External
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.Repositories
|
||||
{
|
||||
public class CollectionRepository : ICollectionRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public CollectionRepository(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task SaveRunAsync(CollectionRunRecord run)
|
||||
{
|
||||
await EnsureTablesAsync();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_collection_runs (run_id, status, started_at, finished_at, total_snapshots, total_errors, updated_at)
|
||||
VALUES (@RunId, @Status, @StartedAt, @FinishedAt, @TotalSnapshots, @TotalErrors, @UpdatedAt)
|
||||
ON CONFLICT (run_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
finished_at = EXCLUDED.finished_at,
|
||||
total_snapshots = EXCLUDED.total_snapshots,
|
||||
total_errors = EXCLUDED.total_errors,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
run
|
||||
);
|
||||
}
|
||||
|
||||
public async Task UpdateRunStatusAsync(string runId, string status, string? finishedAt = null, int? totalSnapshots = null, int? totalErrors = null)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
UPDATE quantengine.kis_collection_runs
|
||||
SET status = @Status, finished_at = @FinishedAt, total_snapshots = @TotalSnapshots, total_errors = @TotalErrors, updated_at = @UpdatedAt
|
||||
WHERE run_id = @RunId",
|
||||
new { RunId = runId, Status = status, FinishedAt = finishedAt, TotalSnapshots = totalSnapshots, TotalErrors = totalErrors, UpdatedAt = DateTime.UtcNow.ToString("o") }
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SaveSnapshotAsync(CollectionSnapshotRecord snapshot)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_collection_snapshots (run_id, dataset_name, ticker, source_name, payload_json, captured_at, created_at)
|
||||
VALUES (@RunId, @DatasetName, @Ticker, @SourceName, @PayloadJson, @CapturedAt, @CreatedAt)
|
||||
ON CONFLICT (run_id, ticker, source_name) DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
captured_at = EXCLUDED.captured_at",
|
||||
snapshot
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SaveErrorAsync(CollectionErrorRecord error)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_collection_errors (run_id, source_name, error_kind, error_message, ticker, created_at)
|
||||
VALUES (@RunId, @SourceName, @ErrorKind, @ErrorMessage, @Ticker, @CreatedAt)",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return (await conn.QueryAsync<CollectionRunRecord>(@"
|
||||
SELECT run_id as RunId, status, started_at as StartedAt, finished_at as FinishedAt,
|
||||
total_snapshots as TotalSnapshots, total_errors as TotalErrors, updated_at as UpdatedAt
|
||||
FROM quantengine.kis_collection_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT @Limit",
|
||||
new { Limit = limit }
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return (await conn.QueryAsync<CollectionSnapshotRecord>(@"
|
||||
SELECT run_id as RunId, dataset_name as DatasetName, ticker, source_name as SourceName,
|
||||
payload_json as PayloadJson, captured_at as CapturedAt, created_at as CreatedAt
|
||||
FROM quantengine.kis_collection_snapshots
|
||||
WHERE run_id = @RunId
|
||||
ORDER BY captured_at DESC",
|
||||
new { RunId = runId }
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return (await conn.QueryAsync<CollectionErrorRecord>(@"
|
||||
SELECT run_id as RunId, source_name as SourceName, error_kind as ErrorKind,
|
||||
error_message as ErrorMessage, ticker as Ticker, created_at as CreatedAt
|
||||
FROM quantengine.kis_collection_errors
|
||||
WHERE run_id = @RunId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit",
|
||||
new { RunId = runId, Limit = limit }
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<CollectionDashboardStateRecord> GetDashboardStateAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
var lastRun = await conn.QueryFirstOrDefaultAsync<CollectionRunRecord>(@"
|
||||
SELECT run_id as RunId, status, started_at as StartedAt, finished_at as FinishedAt,
|
||||
total_snapshots as TotalSnapshots, total_errors as TotalErrors, updated_at as UpdatedAt
|
||||
FROM quantengine.kis_collection_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1");
|
||||
|
||||
var stats = await conn.QueryFirstOrDefaultAsync<dynamic>(@"
|
||||
SELECT
|
||||
COALESCE(SUM(total_snapshots), 0) as TotalSnapshots,
|
||||
COALESCE(SUM(total_errors), 0) as TotalErrors
|
||||
FROM quantengine.kis_collection_runs");
|
||||
|
||||
var recentErrors = (await conn.QueryAsync<CollectionErrorRecord>(@"
|
||||
SELECT run_id as RunId, source_name as SourceName, error_kind as ErrorKind,
|
||||
error_message as ErrorMessage, ticker as Ticker, created_at as CreatedAt
|
||||
FROM quantengine.kis_collection_errors
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5")).ToList();
|
||||
|
||||
return new CollectionDashboardStateRecord(
|
||||
LastRunId: lastRun?.RunId,
|
||||
LastRunStatus: lastRun?.Status,
|
||||
LastFinishedAt: lastRun?.FinishedAt,
|
||||
TotalSnapshots: stats?.TotalSnapshots ?? 0,
|
||||
TotalErrors: stats?.TotalErrors ?? 0,
|
||||
RecentErrors: recentErrors
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return (await conn.QueryAsync<CollectionSnapshotRecord>(@"
|
||||
SELECT run_id as RunId, dataset_name as DatasetName, ticker, source_name as SourceName,
|
||||
payload_json as PayloadJson, captured_at as CapturedAt, created_at as CreatedAt
|
||||
FROM quantengine.kis_collection_snapshots
|
||||
WHERE ticker = @Ticker
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT @Limit",
|
||||
new { Ticker = ticker, Limit = limit }
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureTablesAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
CREATE TABLE IF NOT EXISTS quantengine.kis_collection_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
total_snapshots INTEGER,
|
||||
total_errors INTEGER,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quantengine.kis_collection_snapshots (
|
||||
run_id TEXT NOT NULL,
|
||||
dataset_name TEXT,
|
||||
ticker TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
captured_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (run_id, ticker, source_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quantengine.kis_collection_errors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
error_kind TEXT NOT NULL,
|
||||
error_message TEXT,
|
||||
ticker TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_runs_started_at ON quantengine.kis_collection_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_snapshots_ticker ON quantengine.kis_collection_snapshots(ticker);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_snapshots_captured_at ON quantengine.kis_collection_snapshots(captured_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_errors_run_id ON quantengine.kis_collection_errors(run_id);
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// KIS (한국투자증권) Open API 클라이언트.
|
||||
/// 조회(read-only) 전용. 주문 API는 절대 호출하지 않음.
|
||||
/// </summary>
|
||||
public class KisApiClient : IKisApiClient
|
||||
{
|
||||
private const string RealDomain = "https://openapi.koreainvestment.com:9443";
|
||||
private const string MockDomain = "https://openapivts.koreainvestment.com:29443";
|
||||
private const int TokenRefreshSkewMinutes = 10;
|
||||
|
||||
private static readonly string[] ForbiddenPathSubstrings = { "/trading/" };
|
||||
private static readonly string[] ForbiddenTrIdPrefixes =
|
||||
{
|
||||
"TTTC08", "VTTC08", "TTTC01", "VTTC01",
|
||||
"TTTC8434R", "VTTC8434R"
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenCache _tokenCache;
|
||||
private readonly ILogger<KisApiClient> _logger;
|
||||
|
||||
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/daily-short-sale",
|
||||
"FHPST04830000",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
|
||||
"FHKST03010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate },
|
||||
{ "FID_PERIOD_DIV_CODE", period },
|
||||
{ "FID_ORG_ADJ_PRC", "0" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object>> SendRequestAsync(
|
||||
string account,
|
||||
string path,
|
||||
string trId,
|
||||
Dictionary<string, string> parameters)
|
||||
{
|
||||
AssertReadOnly(path, trId);
|
||||
|
||||
var creds = KisCredentials.Load(account);
|
||||
var token = await GetOrRefreshTokenAsync(creds);
|
||||
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
{ "Authorization", $"Bearer {token}" },
|
||||
{ "appkey", creds.AppKey },
|
||||
{ "appsecret", creds.AppSecret },
|
||||
{ "tr_id", trId },
|
||||
{ "custtype", "P" }
|
||||
};
|
||||
|
||||
var url = $"{creds.Domain}{path}";
|
||||
var queryString = string.Join("&", parameters.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
url += $"?{queryString}";
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
foreach (var header in headers)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
return result ?? new Dictionary<string, object>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "KIS request failed: {Path} / {TrId}", path, trId);
|
||||
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId}.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
|
||||
{
|
||||
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
|
||||
if (!string.IsNullOrEmpty(cachedToken))
|
||||
return cachedToken;
|
||||
|
||||
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"{creds.Domain}/oauth2/tokenP",
|
||||
tokenRequest
|
||||
);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
|
||||
|
||||
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
|
||||
throw new InvalidOperationException("No access_token in response");
|
||||
var accessToken = tokenObj.ToString()!;
|
||||
|
||||
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
|
||||
? expiresObj.ToString()
|
||||
: "86400";
|
||||
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400;
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||
|
||||
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
|
||||
return accessToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "KIS token refresh failed");
|
||||
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertReadOnly(string path, string trId)
|
||||
{
|
||||
foreach (var forbidden in ForbiddenPathSubstrings)
|
||||
{
|
||||
if (path.Contains(forbidden, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path}. " +
|
||||
"이 엔진은 매수/매도를 API로 직접 실행하지 않습니다 (governance/rules/06_no_direct_api_trading.yaml)."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var prefix in ForbiddenTrIdPrefixes)
|
||||
{
|
||||
if (trId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={trId}. " +
|
||||
"이 엔진은 매수/매도를 API로 직접 실행하지 않습니다 (governance/rules/06_no_direct_api_trading.yaml)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private class KisCredentials
|
||||
{
|
||||
public string AppKey { get; }
|
||||
public string AppSecret { get; }
|
||||
public string Account { get; }
|
||||
public string Domain { get; }
|
||||
|
||||
private KisCredentials(string appKey, string appSecret, string account)
|
||||
{
|
||||
AppKey = appKey;
|
||||
AppSecret = appSecret;
|
||||
Account = account;
|
||||
Domain = account == "real" ? RealDomain : MockDomain;
|
||||
}
|
||||
|
||||
public static KisCredentials Load(string account = "mock")
|
||||
{
|
||||
if (account != "real" && account != "mock")
|
||||
throw new ArgumentException("account must be 'real' or 'mock'");
|
||||
|
||||
var (keyName, secretName) = account == "real"
|
||||
? ("KIS_APP_Key", "KIS_APP_Secret")
|
||||
: ("KIS_APP_Key_TEST", "KIS_APP_Secret_TEST");
|
||||
|
||||
var appKey = ReadEnvVar(keyName);
|
||||
var appSecret = ReadEnvVar(secretName);
|
||||
|
||||
if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret))
|
||||
throw new InvalidOperationException(
|
||||
$"{keyName}/{secretName} 환경변수를 찾을 수 없습니다. " +
|
||||
"Windows 환경변수 설정 후 새 셸에서 재시도하거나 HKCU\\Environment 레지스트리를 확인하세요."
|
||||
);
|
||||
|
||||
return new KisCredentials(appKey, appSecret, account);
|
||||
}
|
||||
|
||||
private static string? ReadEnvVar(string name)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
|
||||
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Environment");
|
||||
var regValue = key?.GetValue(name) as string;
|
||||
if (!string.IsNullOrEmpty(regValue))
|
||||
return regValue;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.Services
|
||||
{
|
||||
public class PostgresTokenCache : ITokenCache
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private static readonly int TokenRefreshSkewMinutes = 10;
|
||||
|
||||
public PostgresTokenCache(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<string?> GetCachedTokenAsync(string account)
|
||||
{
|
||||
await EnsureTableAsync();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
var token = await conn.QueryFirstOrDefaultAsync<dynamic>(@"
|
||||
SELECT access_token as AccessToken, expires_at as ExpiresAt
|
||||
FROM quantengine.kis_tokens
|
||||
WHERE account = @Account",
|
||||
new { Account = account }
|
||||
);
|
||||
|
||||
if (token == null)
|
||||
return null;
|
||||
|
||||
var expiresAt = DateTime.Parse(token.ExpiresAt);
|
||||
var now = DateTime.UtcNow;
|
||||
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
|
||||
|
||||
// Return token only if it expires more than refresh skew from now
|
||||
if (expiresAt > now.Add(refreshSkew))
|
||||
return token.AccessToken;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SaveTokenAsync(string account, string token, DateTime expiresAt)
|
||||
{
|
||||
await EnsureTableAsync();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_tokens (account, access_token, expires_at, updated_at)
|
||||
VALUES (@Account, @Token, @ExpiresAt, @UpdatedAt)
|
||||
ON CONFLICT (account) DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
new
|
||||
{
|
||||
Account = account,
|
||||
Token = token,
|
||||
ExpiresAt = expiresAt.ToString("o"),
|
||||
UpdatedAt = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task ClearExpiredTokensAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
await conn.ExecuteAsync(@"
|
||||
DELETE FROM quantengine.kis_tokens
|
||||
WHERE expires_at < @Now",
|
||||
new { Now = DateTime.UtcNow.ToString("o") }
|
||||
);
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
CREATE TABLE IF NOT EXISTS quantengine.kis_tokens (
|
||||
account TEXT PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_tokens_expires_at ON quantengine.kis_tokens(expires_at);
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-124
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v10.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v10.0": {
|
||||
"QuantEngine.Infrastructure/1.0.0": {
|
||||
"dependencies": {
|
||||
"Dapper": "2.1.79",
|
||||
"Npgsql": "10.0.3",
|
||||
"QuantEngine.Application": "1.0.0",
|
||||
"QuantEngine.Core": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"QuantEngine.Infrastructure.dll": {}
|
||||
}
|
||||
},
|
||||
"Dapper/2.1.79": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Dapper.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.1.79.29349"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": {
|
||||
"assemblyVersion": "10.0.0.0",
|
||||
"fileVersion": "10.0.25.52411"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions/10.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": {
|
||||
"assemblyVersion": "10.0.0.0",
|
||||
"fileVersion": "10.0.25.52411"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Npgsql/10.0.3": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Npgsql.dll": {
|
||||
"assemblyVersion": "10.0.3.0",
|
||||
"fileVersion": "10.0.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"QuantEngine.Application/1.0.0": {
|
||||
"dependencies": {
|
||||
"QuantEngine.Core": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"QuantEngine.Application.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"runtime": {
|
||||
"QuantEngine.Core.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"QuantEngine.Infrastructure/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Dapper/2.1.79": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-8YijbzgTfmqmQOnVNorYM6K++pxqnW3nJ4aC1sRHzxUA2CcuoJ9gsTem3kgBnPRMc38zZHl4Esb6hAezXIEEuw==",
|
||||
"path": "dapper/2.1.79",
|
||||
"hashPath": "dapper.2.1.79.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==",
|
||||
"path": "microsoft.extensions.dependencyinjection.abstractions/10.0.0",
|
||||
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions/10.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
|
||||
"path": "microsoft.extensions.logging.abstractions/10.0.0",
|
||||
"hashPath": "microsoft.extensions.logging.abstractions.10.0.0.nupkg.sha512"
|
||||
},
|
||||
"Npgsql/10.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||
"path": "npgsql/10.0.3",
|
||||
"hashPath": "npgsql.10.0.3.nupkg.sha512"
|
||||
},
|
||||
"QuantEngine.Application/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"QuantEngine.Core/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ef7a54ad55182e164ca78e8af21f2a5e214c98f")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
81874ee42dc1cd987327edd0297aee8e6b316cf668043fbbe4613e20c39b86f1
|
||||
a7887332378dbaa77da02dc01c1ab2e7eeaf008da768765c1d130ac779512930
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ef37ffbb87bec2866e799f108f761929231bdf4f3ec552a36b0f7987d485aa40
|
||||
fa897814897a7f9fc8482c16d1fdf5031e7aaa5172cdd1a52e59a54d590109c6
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user