Compare commits
13 Commits
3c2f4a0371
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 86c1c54126 | |||
| b364e41e46 | |||
| 675026d51e | |||
| 3ef50f9b3a | |||
| 1a25974b10 | |||
| 63fcc434ba | |||
| 1114cc14a8 | |||
| eeddc62089 | |||
| ed8fc0943b | |||
| 4f0cc05f39 | |||
| 03027d2206 | |||
| 6d673e06ce | |||
| 84746d41b8 |
2
App.tsx
2
App.tsx
@@ -222,7 +222,7 @@ const AppContent: React.FC = () => {
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6 custom-scrollbar relative">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard marketMode={marketMode} watchlistGroups={watchlistGroups} stocks={visibleStocks} orders={orders} reservedOrders={reservedOrders} onManualOrder={handleManualOrder} onAddReservedOrder={async (o) => { await dbService.saveReservedOrder({id: 'res_'+Date.now(), ...o, status: 'WAITING', createdAt: new Date(), expiryDate: new Date()}); syncFromDb(); }} onDeleteReservedOrder={async (id) => { await dbService.deleteReservedOrder(id); syncFromDb(); }} onRefreshHoldings={syncFromDb} autoTrades={autoTrades} />} />
|
||||
<Route path="/" element={<Dashboard marketMode={marketMode} stocks={visibleStocks} orders={orders} reservedOrders={reservedOrders} onManualOrder={handleManualOrder} onAddReservedOrder={async (o) => { await dbService.saveReservedOrder({id: 'res_'+Date.now(), ...o, status: 'WAITING', createdAt: new Date(), expiryDate: new Date()}); syncFromDb(); }} onDeleteReservedOrder={async (id) => { await dbService.deleteReservedOrder(id); syncFromDb(); }} onRefreshHoldings={syncFromDb} autoTrades={autoTrades} />} />
|
||||
<Route path="/discovery" element={<Discovery stocks={masterStocks} orders={orders} onUpdateStock={handleUpdateStockMetadata} settings={settings} />} />
|
||||
<Route path="/stocks" element={<Stocks marketMode={marketMode} stocks={visibleStocks} onTrade={(s, t) => addLog(`${s.name} ${t} 주문 패널 진입`, 'info')} onAddToWatchlist={handleAddToWatchlist} watchlistCodes={watchlistCodes} onSync={handleSyncStocks} />} />
|
||||
<Route path="/auto" element={<AutoTrading marketMode={marketMode} stocks={visibleStocks} configs={autoTrades} groups={watchlistGroups} onAddConfig={async (c) => { await dbService.saveAutoConfig({...c, id: 'auto_'+Date.now(), active: true}); syncFromDb(); }} onToggleConfig={async (id) => { const c = (await dbService.getAutoConfigs()).find(x => x.id === id); if(c) { await dbService.updateAutoConfig({...c, active: !c.active}); syncFromDb(); } }} onDeleteConfig={async (id) => { await dbService.deleteAutoConfig(id); syncFromDb(); }} />} />
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,31 +1,40 @@
|
||||
# 1단계: 빌드 (Node.js)
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
# 1. Frontend Build Stage
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY package*.json ./
|
||||
RUN rm -f package-lock.json && npm install
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 2단계: 실행 (Nginx)
|
||||
FROM nginx:alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
# 2. Backend & Serving Stage
|
||||
FROM python:3.9-slim
|
||||
|
||||
# 기존 기본 파일 제거 및 빌드 결과물 복사
|
||||
RUN rm -rf ./*
|
||||
COPY --from=build /app/dist .
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# React Router SPA 라우팅을 위한 Nginx 설정 적용
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html index.htm; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 환경변수 포트 노출
|
||||
# Copy backend requirements
|
||||
COPY ./backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code (contents of backend folder to /app)
|
||||
COPY ./backend/ .
|
||||
|
||||
# Copy frontend build artifacts to /app/static
|
||||
COPY --from=frontend-build /app/frontend/dist ./static
|
||||
|
||||
# Ensure data directory exists
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
|
||||
# Nginx 서버 실행
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
# Run FastAPI server
|
||||
# Since app/ is now directly in /app, uvicorn app.main:app works
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
13
backend/.env.example
Normal file
13
backend/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Server Config
|
||||
PORT=80
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS=["kis.tindevil.com", "localhost", "127.0.0.1"]
|
||||
SECRET_KEY=dlrjtdmsQlalfzlfksmsep@wkf!wkf!ahfmrpTdj$#
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db
|
||||
|
||||
# Security
|
||||
SECRET_KEY=change_this_to_a_secure_random_string_min_32_chars
|
||||
@@ -1,19 +1,123 @@
|
||||
# BatchuKis 백엔드 독립 실행 엔진 및 API 통합 사양서 (v1.6)
|
||||
# BatchuKis 백엔드 독립 실행 엔진 및 API 통합 사양서 (v1.7)
|
||||
|
||||
본 문서는 BatchuKis 플랫폼의 백엔드 시스템 아키텍처 및 독립 실행형 매매 엔진 로직을 정의합니다.
|
||||
본 문서는 BatchuKis 플랫폼의 백엔드 시스템 아키텍처, 독립 실행형 매매 엔진 로직 및 개발 환경을 정의합니다.
|
||||
|
||||
## 1. 시스템 아키텍처
|
||||
---
|
||||
|
||||
### 1.1 Headless Execution Engine
|
||||
1. **Batch Engine**: 매 1분마다 `auto_trade_configs`를 스캔하여 예약된 시각에 주문 실행.
|
||||
2. **Monitoring Engine**: WebSocket 시세를 수신하여 `reserved_orders` 조건 감시 및 자동 매매.
|
||||
3. **Market Index Collector**:
|
||||
- **조회**: 매 5분마다 주요 시장 지수(KOSPI, KOSDAQ, NASDAQ, S&P500, USD/KRW) 수신.
|
||||
- **기록**: 1시간마다 해당 시점의 최종 데이터를 `market_index_history` 테이블에 기록(Upsert).
|
||||
- **프론트 연동**: 프론트엔드는 DB에 저장된 최신 데이터를 5분 단위로 폴링하여 대시보드 업데이트.
|
||||
4. **AI Proxy**: API 보안을 위해 AI 분석 및 뉴스 요청 중계.
|
||||
## 1. 개발 및 실행 환경 (Development Environment)
|
||||
|
||||
## 2. 상세 명세 가이드
|
||||
- **DB 스키마**: [tables.md](./tables.md) 참조.
|
||||
- **API 엔드포인트**: [api.md](./api.md) 참조.
|
||||
- **데이터 구조(JSON)**: [models.md](./models.md) 참조.
|
||||
- **Runtime**: Docker Container (Python 3.8+ Base Image)
|
||||
- **Deployment**: Dokploy (Self-hosted PaaS)
|
||||
- **Framework**: FastAPI (비동기 처리) or Flask
|
||||
- **Database**: SQLite (Volume Mounting for Persistence)
|
||||
- **Frontend Hosting**: Python 백엔드가 React 빌드 결과물(`dist`)을 정적 파일로 서빙 (Single Container Monolith).
|
||||
|
||||
### 1.1 Production Configuration (배포 설정)
|
||||
- **Domain**: `kis.tindevil.com`
|
||||
- **Security Check**:
|
||||
- `ALLOWED_HOSTS`: `["kis.tindevil.com", "localhost", "127.0.0.1"]` 추가 필수.
|
||||
- **CORS**: 프론트엔드가 동일 도메인에서 서빙되므로 기본적으로 불필요하나, 로컬 개발/외부 연동 시 `ALLOW_ORIGINS` 설정 필요.
|
||||
- **Cookie/Session**: `SameSite=Lax`, `Secure=True` (HTTPS proxy behind Dokploy/Traefik).
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템 아키텍처 및 주요 모듈
|
||||
|
||||
### 2.1 Monolithic Architecture
|
||||
- **Single Entry Point**: 사용자는 하나의 URL/포트로 접속.
|
||||
- **Routing**:
|
||||
- `/api/*`: REST API 요청 처리
|
||||
- `/ws/*`: WebSocket 요청 처리
|
||||
- `/*`: React SPA 정적 파일(index.html, js, css) 반환
|
||||
|
||||
### 2.2 Headless Execution Engine (매매 엔진)
|
||||
- API 서버와 동일한 프로세스 내에서 백그라운드 스레드(Async Task)로 동작.
|
||||
1. **Auto-Trading Scanner**: 매 1분마다 `auto_trade_robots` 설정을 스캔하여 로직(적립식, TS 등)에 따라 주문 생성.
|
||||
2. **Order Monitoring Engine**: 실시간 시세를 수신하여 `reserved_orders`의 발동 조건을 감시하고 즉시 주문 집행.
|
||||
|
||||
### 2.3 Data Infrastructure (데이터 인프라)
|
||||
1. **Dynamic Real-time Subscription (Reference Counting)**:
|
||||
- **Strategy**: 불필요한 트래픽 방지를 위해 **"필요할 때만 구독"**하는 레퍼런스 카운팅 방식을 사용.
|
||||
- **Source 1 (Permanent)**: **보유 종목(Holdings)**은 앱 실행 시 무조건 구독 (`Ref +1`).
|
||||
- **Source 2 (Transient)**: 프론트엔드에서 특정 종목 페이지 진입 시 구독 요청 (`Ref +1`), 이탈 시 해제 (`Ref -1`).
|
||||
- **Decision**: `Reference Count > 0`일 때만 KIS WebSocket 연결 유지. 0이 되면 즉시 구독 해제.
|
||||
2. **AI Analysis Proxy**: 프론트엔드의 AI 분석 요청을 수신하여 전용 API 터널을 통해 외부 AI 엔진과 통신 및 결과 JSON 파싱.
|
||||
|
||||
### 2.4 Token Lifecycle Management (인증 토큰 관리)
|
||||
- **Token Storage**: 발급된 Access Token과 만료 시각(`expiry`)을 메모리 및 DB에 저장.
|
||||
- **Proactive Refresh**: API 호출 전 현재 시각이 만료 시각(통상 6~24시간)에 근접했는지 확인하여 선제적 갱신.
|
||||
- **Reactive Refresh (Auto-Retry)**: API 호출 중 `401 Unauthorized` 또는 `EXPIRED_TOKEN` 오류 발생 시,
|
||||
1. 즉시 토큰 재발급을 수행.
|
||||
2. 실패했던 원래 요청을 새 토큰으로 **자동 재시도(Retry)**.
|
||||
3. 프론트엔드나 상위 로직에는 오류를 전파하지 않고 처리를 완료함.
|
||||
|
||||
### 2.5 Rate Limiting & Throttling (속도 제한 및 조절)
|
||||
- **Centralized Request Queue**: 모든 KIS API 호출은 단일 큐(`Blocking Queue`)를 통과해야 함.
|
||||
- **Throttling Logic**:
|
||||
- 각 호출 사이에는 설정된 간격(`kisApiDelayMs`, 기본 250ms)만큼 강제 대기(Delay) 수행.
|
||||
- 연속된 주문이나 조회 요청이 발생해도 초당 4회 이하로 전송 속도를 물리적으로 제한하여 증권사 서버 차단을 방지.
|
||||
- **Configurable Intervals**:
|
||||
- `kisApiDelayMs`: API 호출 최소 간격 (기본 250ms)
|
||||
- `newsScrapIntervalMin`: 뉴스 수집 주기 (기본 10분)
|
||||
|
||||
### 2.6 Market Schedule Awareness (장 운영시간 인지)
|
||||
백엔드는 **국내(Domestic)**와 **해외(Overseas)** 시장의 개장 시간을 독립적으로 관리하며, 장이 닫힌 동안에는 불필요한 API 호출과 자동매매 스캔을 중단(`Sleep Mode`)합니다.
|
||||
|
||||
- **Check Logic**: 모든 자동화 로직은 실행 전 `IsMarketOpen(marketType)`을 확인해야 함.
|
||||
- **Domestic (KST)**: 09:00 ~ 15:30 (평일, 공휴일 제외)
|
||||
- **Overseas (US)**: 22:30 ~ 06:00 (썸머타임 적용 시 변동, 주말 제외)
|
||||
- **Action**:
|
||||
- **Open**: 실시간 시세 수신, 감시 주문 체크, 적립식 매수 실행.
|
||||
- **Closed**: 단순 데이터 수집(뉴스) 외 트레이딩 로직 일시 정지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 상세 설계 명세서
|
||||
|
||||
백엔드 구현 시 아래 세 문서를 바이블로 삼아 개발을 진행하십시오.
|
||||
|
||||
- 🏗️ **[DB 스키마 정의서](./tables.md)**: SQLite 테이블 구조 및 관계 정의.
|
||||
- 📡 **[API 인터페이스 명세](./api.md)**: REST API 엔드포인트 및 WebSocket 토픽 정의.
|
||||
- 📦 **[통합 데이터 모델](./models.md)**: API와 DB에서 공통으로 사용하는 JSON 구조 및 엔티티 정의.
|
||||
|
||||
---
|
||||
|
||||
## 4. 운영 사이클 (Operational Cycle)
|
||||
|
||||
| 작업명 | 주기 | 주요 내용 |
|
||||
| :--- | :--- | :--- |
|
||||
| **자동매매 스캔** | 1분 | 예약된 시각의 적립식 매수 및 전략 조건 확인 |
|
||||
| **시세 감시** | 실시간 | 감시가 도달 시 즉시 주문 전송 |
|
||||
| **관심종목 갱신** | Event | WebSocket 체결가 수신 시 즉시 DB 캐시 업데이트 |
|
||||
| **마스터 동기화** | 일 1회 | 신규 상장/상폐 종목 리스트 업데이트 |
|
||||
| **뉴스 수집** | 10분 | 실시간 금융 뉴스 스크랩 및 AI 분석 준비 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 시작 시퀀스 (Startup Sequence)
|
||||
|
||||
백엔드 컨테이너 실행 시(`Initial Boot`), 시스템은 다음 순서대로 스스로 초기화를 수행하며 완전 자동화 모드로 진입합니다.
|
||||
|
||||
### Phase 1: 시스템 초기화
|
||||
1. **DB 연결 및 마이그레이션**: SQLite 파일(`kis_stock.db`) 존재 여부 확인 및 테이블 생성.
|
||||
2. **설정 로드**: `api_settings` 테이블에서 KIS AppKey, Secret, Telegram Token 등을 메모리에 로드.
|
||||
|
||||
### Phase 2: 외부망 연결 (Connectivity)
|
||||
3. **KIS API 인증**:
|
||||
- 저장된 키로 **REST API Access Token** 발급 요청 (수명 24시간).
|
||||
- (성공 시) **Base URL**로 실시간 **WebSocket Approval Key** 발급 요청 (수명 1회성/무제한).
|
||||
4. **텔레그램 핸드쉐이크**:
|
||||
- 봇 토큰이 유효한 경우, 관리자에게 _"System Online: BatchuKis Engine Started"_ 메시지 발송.
|
||||
|
||||
### Phase 3: 데이터 동기화 (Data Sync)
|
||||
5. **마스터 데이터 갱신**:
|
||||
- `master_stocks` 테이블이 비어있거나, 마지막 갱신일이 어제인 경우 KIS로부터 전 종목 코드 다운로드.
|
||||
6. **실시간 감시 복구**:
|
||||
- `reserved_orders`에서 'MONITORING' 상태인 주문을 조회하여 실시간 시세 수신(WebSocket) 재구독.
|
||||
|
||||
### Phase 4: 오토파일럿 가동 (Autopilot Engage)
|
||||
7. **스케줄러 시작**:
|
||||
- `Auto-Trading Scanner` (1분 주기) 가동.
|
||||
- `Data Persistence Worker` (메모리 캐시 DB 저장) 가동.
|
||||
- `News Scraper` (10분 주기) 가동.
|
||||
8. **서버 준비 완료**: REST API(`:80`) 및 정적 파일 호스팅 시작.
|
||||
|
||||
103
backend/TODO.md
Normal file
103
backend/TODO.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Backend Implementation Roadmap (TODO.md)
|
||||
|
||||
이 문서는 `BatchuKis` 백엔드 개발을 위한 단계별 구현 체크리스트입니다.
|
||||
사양서(`ReadMe.md`, `api.md`, `tables.md`, `models.md`)를 기반으로 작성되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 프로젝트 스캐폴딩 (Scaffolding)
|
||||
- [x] **디렉토리 구조 생성**
|
||||
- `app/core` (설정, 인증, 유틸)
|
||||
- `app/db` (데이터베이스 연결, 모델)
|
||||
- `app/api` (End-points)
|
||||
- `app/services` (비즈니스 로직, 외부 통신)
|
||||
- `app/workers` (백그라운드 스케줄러)
|
||||
- [x] **환경 설정 (`config.py`)**
|
||||
- Pydantic `BaseSettings` 활용
|
||||
- `.env` 파일 연동 (APP_KEY, SECRET 등)
|
||||
- Docker 환경 변수 처리
|
||||
- **Domain Config**: `ALLOWED_HOSTS=["kis.tindevil.com", ...]` 설정 추가
|
||||
- [x] **FastAPI 기본 앱 작성 (`main.py`)**
|
||||
- CORS 설정
|
||||
- Health Check 엔드포인트 (`/health`)
|
||||
- Static Files Mount (Frontend `dist` 폴더 연결 준비)
|
||||
|
||||
## Phase 2: 데이터베이스 구현 (Database)
|
||||
- [x] **SQLite 연동 (`database.py`)**
|
||||
- SQLAlchemy `sessionmaker` 설정 (비동기 `AsyncSession` 권장)
|
||||
- [x] **ORM 모델 정의 (`db/models.py`)**
|
||||
- `ApiSettings` (설정)
|
||||
- `StockItem` (관심/보유 종목)
|
||||
- `ReservedOrder` (감시 주문)
|
||||
- `TradeOrder` (매매 기록)
|
||||
- `CacheTable` (지수, 뉴스 등 임시 데이터)
|
||||
- [x] **마이그레이션 및 초기화**
|
||||
- `init_db()` 함수 구현: 앱 시작 시 테이블 자동 생성 (`Base.metadata.create_all`)
|
||||
|
||||
## Phase 3: 코어 인프라 (Core Infrastructure)
|
||||
- [x] **Rate Limiter (속도 제한 큐)**
|
||||
- `RateLimiter` 클래스: `asyncio.Queue` 기반 중앙 제어
|
||||
- `TokenBucket` 또는 단순 `Delay` (250ms) 로직 구현
|
||||
- [x] **Token Manager (인증 관리)**
|
||||
- `KisAuth` 클래스: Access Token 발급/저장/갱신
|
||||
- **Auto-Retry**: 401 에러 인터셉터 및 재발급 로직
|
||||
- [x] **Market Schedule (장 운영시간)**
|
||||
- `MarketCalendar` 유틸: 현재 국내/해외 장 운영 여부 판단 (`IsMarketOpen`)
|
||||
|
||||
## Phase 4: 내부 서비스 (Internal Services)
|
||||
- [x] **Brokerage Service (증권사 통신)**
|
||||
- 파일: `app/services/kis_client.py`
|
||||
- 구현: `api.md`의 **Section 9. Integration Map**에 정의된 API 엔드포인트 연동
|
||||
- **참고 샘플 (KIS Samples)**:
|
||||
- **Auth**: `examples_llm/auth/auth_token/method.py` (토큰 발급)
|
||||
- **Domestic**:
|
||||
- 주문: `domestic_stock/order_cash/order_cash.py`
|
||||
- 시세: `domestic_stock/inquire_price/inquire_price.py`
|
||||
- 잔고: `domestic_stock/inquire_balance/inquire_balance.py`
|
||||
- **Overseas**:
|
||||
- 주문: `overseas_stock/order/order.py`
|
||||
- 시세: `overseas_stock/price/price.py`
|
||||
- 잔고: `overseas_stock/inquire_balance/inquire_balance.py`
|
||||
- 모든 호출은 `Rate Limiter` 경유 필수
|
||||
- [x] **Realtime Manager (웹소켓)**
|
||||
- `KisWebSocket` 클라이언트: Approval Key 발급 및 연결
|
||||
- `OPS` (실전) / `VOPS` (모의) 자동 전환
|
||||
- **Subscription**: 종목 등록/해제 (`H0STCNT0` 등)
|
||||
- **PINGPONG**: 자동 응답 처리
|
||||
- **Data Handler**: 수신 데이터 파싱 및 DB/Cache 업데이트 (암호화 `AES256` 해독 포함)
|
||||
- [x] **AI Orchestrator**
|
||||
- `AIFactory`: Provider(Gemini/Ollama/OpenAI) 추상화 및 인스턴스 생성
|
||||
- `AIOrchestrator`: DB 설정(`AiConfig`) 기반 최적 모델 자동 선택 및 실행 요청
|
||||
|
||||
## Phase 5: API 엔드포인트 구현 (Endpoints)
|
||||
- [x] **Settings API** (`/api/settings`)
|
||||
- API Key, Rate Limit 설정 조회/수정
|
||||
- [x] **Stock/Market API** (`/api/kis/...`)
|
||||
- 시세 조회, 차트 데이터, 관심종목 관리
|
||||
- [x] **Trading API** (`/api/kis/order`, `/api/kis/balance`)
|
||||
- 주문 전송, 잔고 조회, 예약 주문(감시) CRUD
|
||||
- [ ] **Real-time API** (Frontend WebSocket)
|
||||
- `/ws/client`: 프론트엔드와 연결, KIS 데이터 중계
|
||||
|
||||
## Phase 6: 스케줄러 및 자동화 (Automation)
|
||||
- [x] **Startup Sequence**
|
||||
- DB 체크 -> 토큰 로드 -> Telegram 알림 -> 스케줄러 시작
|
||||
- [x] **Background Workers**
|
||||
- `PersistenceWorker`: 메모리 내 시세 데이터 DB 주기적 저장 (Passive)
|
||||
- `NewsScraper`: 네이버 뉴스 주기적 수집
|
||||
- `AutoTradingScanner`: 1분 단위 예약 주문 감시
|
||||
|
||||
## Phase 7: 통합 및 배포 (Integration)
|
||||
- [x] **Frontend 연동**
|
||||
- React 빌드 (`npm run build`) -> backend/static 복사
|
||||
- 단일 포트(80) 서빙 테스트
|
||||
- [x] **Docker 빌드**
|
||||
- `docker-compose up` 테스트
|
||||
- Dokploy 배포 및 볼륨 마운트 확인
|
||||
|
||||
---
|
||||
|
||||
## 🎯 개발 규칙 (Rules)
|
||||
1. **No Active Polling**: 시세는 반드시 웹소켓으로 수신 (관심/보유 종목 한정).
|
||||
2. **Fail-Safe**: API 호출 실패 시 재시도 하되, 전체 장애로 번지지 않도록 예외 처리.
|
||||
3. **Async First**: 모든 I/O(DB, Network)는 `async/await` 비동기 처리.
|
||||
175
backend/api.md
175
backend/api.md
@@ -1,91 +1,150 @@
|
||||
# BatchuKis API Specification
|
||||
|
||||
이 문서는 프론트엔드와 백엔드 간의 통신을 위한 API 인터페이스 명세서입니다.
|
||||
**데이터 모델(JSON 구조)의 상세 정의는 [models.md](./models.md)를 참조하십시오.**
|
||||
|
||||
---
|
||||
|
||||
## 1. 설정 및 시스템 (Settings)
|
||||
## 1. 설정 및 시스템 (Settings & System)
|
||||
|
||||
### 1.1 전체 설정 가져오기
|
||||
- **URL**: `GET /api/settings`
|
||||
- **Response**: `ApiSettings` (See models.md)
|
||||
### 1.1 전체 설정 (Global Settings)
|
||||
- **URL**: `GET /api/settings` / `POST /api/settings`
|
||||
- **Description**: KIS API 키, 서비스 활성화 여부 등 전체 앱의 사용자 설정을 관리합니다.
|
||||
- **Body (POST)**: `ApiSettings` object.
|
||||
|
||||
### 1.2 전체 설정 저장하기
|
||||
- **URL**: `POST /api/settings`
|
||||
- **Body**: `ApiSettings`
|
||||
- **Response**: `{ "success": boolean, "message": string }`
|
||||
|
||||
### 1.3 AI 엔진 풀(Pool) 관리
|
||||
#### 1.3.1 등록된 AI 엔진 목록 조회
|
||||
- **URL**: `GET /api/settings/ai-configs`
|
||||
- **Response**: `AiConfig[]`
|
||||
|
||||
#### 1.3.2 신규 AI 엔진 추가
|
||||
- **URL**: `POST /api/settings/ai-configs`
|
||||
- **Body**: `Omit<AiConfig, 'id'>`
|
||||
- **Response**: `{ "id": string, "success": true }`
|
||||
### 1.2 AI 엔진 관리 (AI Configurations)
|
||||
- **URL**: `GET /api/settings/ai-configs` / `POST /api/settings/ai-configs`
|
||||
- **Description**: 사용자 지정 AI 프로필(Gemini, Ollama 등) 목록을 관리합니다.
|
||||
- **PUT/DELETE**: `PUT /api/settings/ai-configs/:id`, `DELETE /api/settings/ai-configs/:id`
|
||||
|
||||
---
|
||||
|
||||
## 2. 자산 및 잔고 (Portfolio)
|
||||
## 2. 계좌 및 자산 (Account & Portfolio)
|
||||
|
||||
### 2.1 보유 종목 리스트
|
||||
### 2.1 계좌 요약 (Account Summary)
|
||||
- **URL**: `GET /api/account/summary`
|
||||
- **Response**: `{ "totalAssets": number, "buyingPower": number, "dailyProfit": number, "dailyProfitRate": number }`
|
||||
|
||||
### 2.2 보유 자산 (Holdings)
|
||||
- **URL**: `GET /api/holdings`
|
||||
- **Query**: `?market=Domestic|Overseas`
|
||||
- **Response**: `HoldingItem[]`
|
||||
|
||||
### 2.2 계좌 요약 (자산/예수금)
|
||||
- **URL**: `GET /api/account/summary`
|
||||
- **Response**: `{ "totalAssets": number, "buyingPower": number }`
|
||||
---
|
||||
|
||||
## 3. 관심종목 관리 (Watchlist)
|
||||
|
||||
### 3.1 그룹 관리 (Watchlist Groups)
|
||||
- **URL**: `GET /api/watchlists` / `POST /api/watchlists`
|
||||
- **PUT/DELETE**: `PUT /api/watchlists/:id` (그룹명 수정 및 종목 코드 리스트 업데이트), `DELETE /api/watchlists/:id`
|
||||
- **Response**: `WatchlistGroup[]`
|
||||
|
||||
---
|
||||
|
||||
## 3. 자동매매 전략 (Auto Trading)
|
||||
## 4. 종목 및 시세 (Market Data)
|
||||
|
||||
### 3.1 로봇 리스트 조회
|
||||
- **URL**: `GET /api/auto-trades`
|
||||
- **Response**: `AutoTradeConfig[]`
|
||||
### 4.1 종목 검색 및 마스터 (Stock Discovery)
|
||||
- **URL**: `GET /api/stocks/search?query=...`
|
||||
- **URL**: `GET /api/kis/master-stocks?market=...` (로컬 검색성능을 위한 대량 데이터 수동 동기화용)
|
||||
|
||||
### 3.2 로봇 등록/수정
|
||||
- **URL**: `POST /api/auto-trades`
|
||||
- **Body**: `AutoTradeConfig` (ID가 없으면 생성, 있으면 수정)
|
||||
- **Response**: `{ "id": string }`
|
||||
### 4.2 시세 및 차트 (Quotes & Charts)
|
||||
- **URL**: `GET /api/kis/quotes/:code` (현재가 및 호가)
|
||||
- **URL**: `GET /api/kis/ticks/:code?limit=...` (차트용 틱 데이터)
|
||||
|
||||
### 4.3 발견 및 랭킹 (Market Discovery)
|
||||
- **URL**: `GET /api/discovery/rankings?market=...&category=...` (거래량, 상승률 등)
|
||||
|
||||
---
|
||||
|
||||
## 4. 실시간 감시 주문 (Reserved Orders)
|
||||
## 5. 뉴스 및 AI 분석 (News & AI Intelligence)
|
||||
|
||||
### 4.1 감시 목록 조회
|
||||
- **URL**: `GET /api/reserved-orders`
|
||||
- **Response**: `ReservedOrder[]`
|
||||
### 5.1 뉴스 스크랩 (News Feed)
|
||||
- **URL**: `GET /api/news?query=...`
|
||||
- **Description**: 네이버 API 등을 통해 실시간 금융 뉴스를 가져옵니다.
|
||||
|
||||
### 4.2 감시 등록
|
||||
- **URL**: `POST /api/reserved-orders`
|
||||
- **Body**: `Omit<ReservedOrder, 'id' | 'status' | 'createdAt'>`
|
||||
- **Response**: `{ "id": string }`
|
||||
### 5.2 AI 뉴스 분석 (AI News Briefing)
|
||||
- **URL**: `POST /api/ai/analyze-news`
|
||||
- **Body**: `{ "aiConfigId": string, "headlines": string[] }`
|
||||
- **Response**: `{ "report": string, "sentimentMetadata": any[] }`
|
||||
|
||||
### 5.3 AI 종목 분석 (AI Stock Deep-Dive)
|
||||
- **URL**: `POST /api/ai/analyze-stock`
|
||||
- **Body**: `{ "aiConfigId": string, "stockCode": string, "context": any }`
|
||||
|
||||
---
|
||||
|
||||
## 5. 종목 및 시세 (Market Data & Discovery)
|
||||
## 6. 주문 및 거래 (Trading & History)
|
||||
|
||||
### 5.1 마스터 종목 리스트 (동기화용)
|
||||
- **URL**: `GET /api/kis/master-stocks`
|
||||
- **Query**: `?market=Domestic|Overseas`
|
||||
- **Response**: `StockItem[]`
|
||||
### 6.1 수동 주문 (Manual Trading)
|
||||
- **URL**: `POST /api/trade/order`
|
||||
- **Body**: `{ "code": string, "type": "BUY"|"SELL", "quantity": number, "price": number, "orderType": "MARKET"|"LIMIT" }`
|
||||
|
||||
### 5.2 개별 종목 시세 차트 데이터
|
||||
- **URL**: `GET /api/kis/ticks/:code`
|
||||
- **Query**: `?limit=100`
|
||||
- **Response**: `StockTick[]`
|
||||
### 6.2 예약/감시 주문 (Reserved Orders)
|
||||
- **URL**: `GET /api/reserved-orders` / `POST /api/reserved-orders`
|
||||
- **DELETE**: `DELETE /api/reserved-orders/:id`
|
||||
|
||||
### 5.3 [NEW] 종목 발굴 랭킹 데이터
|
||||
- **URL**: `GET /api/discovery/rankings`
|
||||
- **Query**: `?market=Domestic|Overseas&category=VOLUME|VALUE|GAIN|LOSS|FOREIGN_BUY|INSTITUTION_BUY`
|
||||
- **Response**: `DiscoveryRankingResponse` (See models.md)
|
||||
- **설명**: 토스증권 스타일의 발굴 페이지에 데이터를 공급합니다. 거래비율(buyRatio/sellRatio) 및 수급 데이터가 포함됩니다.
|
||||
### 6.3 자동매매 전략 (Auto Trading)
|
||||
- **URL**: `GET /api/auto-trades` / `POST /api/auto-trades`
|
||||
- **Description**: AI 전략 로봇 등록 및 상태 관리.
|
||||
|
||||
### 5.4 [NEW] 실시간 커뮤니티 심리 요약 (AI 전용)
|
||||
- **URL**: `GET /api/discovery/sentiment/:code`
|
||||
- **Response**: `{ "insights": string[], "sentimentScore": number }`
|
||||
- **설명**: 특정 종목에 대한 AI의 실시간 커뮤니티/뉴스 요약 정보를 반환합니다.
|
||||
### 6.4 거래 기록 (Execution History)
|
||||
- **URL**: `GET /api/history/orders`
|
||||
- **Query**: `?page=...&limit=...&stockCode=...&startDate=...&endDate=...`
|
||||
- **Response**: `TradeOrder[]`
|
||||
|
||||
---
|
||||
|
||||
## 7. 실시간 데이터 (Real-time Data - WebSockets)
|
||||
|
||||
백엔드는 KIS 실시간 시세 서버와 웹소켓을 유지하며, 프론트엔드에게 가공된 데이터를 스트리밍합니다.
|
||||
|
||||
### 7.1 실시간 체결가 (Real-time Price)
|
||||
- **Topic**: `/ws/market/price/:code`
|
||||
- **Data**: 현재가, 등락률, 거래량 등
|
||||
|
||||
### 7.2 실시간 주문 알림 (Order Notifications)
|
||||
- **Topic**: `/ws/trade/events`
|
||||
- **Data**: 자신의 계좌에서 발생하는 체결 알림, 미체결 취소 알림 등
|
||||
|
||||
---
|
||||
|
||||
## 8. 내부 서비스 인터페이스 (Internal Engine Services)
|
||||
|
||||
프론트엔드에 노출되지 않지만, 백엔드 엔진 내부 모듈 간의 협업 또는 외부 API 래핑을 위한 서비스 인터페이스입니다.
|
||||
|
||||
### 8.1 BrokerageInterface (증권사 서버 통신)
|
||||
- **`PlaceOrder(stockCode, side, qty, price, type)`**: KIS 서버로 최종 주문 패킷 전송. (Returns: `orderNo`)
|
||||
- **`GetRealtimeApprovalKey()`**: 실시간 웹소켓 접속용 **Approval Key** 발급 및 갱신.
|
||||
- **`FetchHoldings()`**: 계좌 내 전 종목 보유 및 평단가 수집.
|
||||
|
||||
### 8.2 RealtimeManager (웹소켓 데이터 관리)
|
||||
- **`AddSubscription(code, source)`**: 구독 요청 (`source`: 'HOLDING' | 'FRONTEND').
|
||||
- **Logic**: 해당 종목의 Reference Count 증가. `Count == 1`이 되는 순간 KIS 웹소켓 구독 전송.
|
||||
- **`RemoveSubscription(code, source)`**: 구독 해제 요청.
|
||||
- **Logic**: Reference Count 감소. `Count == 0`이 되면 KIS 웹소켓 구독 해제(자원 절약).
|
||||
- **`OnStreamReceived(data)`**: 수신된 로우 데이터를 파싱하여 필요 모듈(`AutoTrader`, `FrontendWS`)로 브로드캐스팅.
|
||||
|
||||
### 8.3 AiOrchestrator (AI 엔진 처리 핸들러)
|
||||
- **`RequestAnalysis(aiConfigId, prompt)`**: 지정된 엔진 프로필을 사용하여 원격 AI API 호출.
|
||||
- **`ApplySentiment(newsId, result)`**: 분석된 심리 결과를 DB(`news_cache`)에 업데이트 및 프론트 전송.
|
||||
|
||||
### 8.4 Scheduler (배치 작업 제어)
|
||||
- **`SyncMasterStocks()`**: 매일 새벽 종목 명칭 및 코드 리스트 동기화.
|
||||
- **`PersistMarketDataCache()`**: 메모리에 수신된 실시간 현재가를 주기적으로 DB에 영구 저장 (API 호출 없음).
|
||||
- **`RefreshNaverNews()`**: 10분 단위 키워드별 뉴스 스크랩 및 분석 트리거.
|
||||
|
||||
---
|
||||
|
||||
## 9. KIS API Integration Map (증권사 연동 맵)
|
||||
|
||||
`BrokerageInterface`가 실제 호출해야 할 한국투자증권(KIS) Open API 엔드포인트 매핑입니다.
|
||||
|
||||
| Category | Internal Function | Domestic API (국내) | Overseas API (해외) | Note |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Auth** | `GetAuthToken` | `/oauth2/tokenP` | (Common) | OAuth2 Access Token |
|
||||
| **Auth** | `GetWebsocketKey` | `/oauth2/approval` | (Common) | WebSocket Approval Key |
|
||||
| **Order** | `PlaceOrder` | `/uapi/domestic-stock/v1/trading/order-cash` | `/uapi/overseas-stock/v1/trading/order` | 현금 매수/매도 |
|
||||
| **Order** | `ModifyOrder` | `/uapi/domestic-stock/v1/trading/order-rvsecncl` | `/uapi/overseas-stock/v1/trading/order-rvsecncl` | 정정/취소 |
|
||||
| **Price** | `GetCurrentPrice` | `/uapi/domestic-stock/v1/quotations/inquire-price` | `/uapi/overseas-stock/v1/quotations/price` | 현재가 조회 |
|
||||
| **Price** | `GetDailyChart` | `/uapi/domestic-stock/v1/quotations/inquire-daily-price` | `/uapi/overseas-stock/v1/quotations/daily-price` | 일봉 데이터 |
|
||||
| **Balance** | `FetchHoldings` | `/uapi/domestic-stock/v1/trading/inquire-balance` | `/uapi/overseas-stock/v1/trading/inquire-balance` | 잔고 조회 |
|
||||
| **Socket** | `RealtimePrice` | `/tryit/H0STCNT0` (H0STCNT0) | `/tryit/HDFSCNT0` (HDFSCNT0) | 실시간 체결가 |
|
||||
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/api.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
16
backend/app/api/api.py
Normal file
16
backend/app/api/api.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import settings, kis, account, watchlist, discovery, news, trade, auto_trade
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(kis.router, prefix="/kis", tags=["kis"])
|
||||
api_router.include_router(account.router, prefix="/account", tags=["account"])
|
||||
api_router.include_router(watchlist.router, prefix="/watchlists", tags=["watchlists"])
|
||||
api_router.include_router(discovery.router, prefix="/discovery", tags=["discovery"])
|
||||
api_router.include_router(discovery.router, prefix="/stocks", tags=["stocks"]) # Alias for search
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
api_router.include_router(trade.router, prefix="/trade", tags=["trade"])
|
||||
api_router.include_router(trade.router, prefix="/history", tags=["history"]) # Alias
|
||||
api_router.include_router(trade.router, prefix="/reserved-orders", tags=["reserved"]) # Alias
|
||||
api_router.include_router(auto_trade.router, prefix="/auto-trade", tags=["auto-trade"])
|
||||
BIN
backend/app/api/endpoints/__pycache__/account.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/account.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/auto_trade.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/auto_trade.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/discovery.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/discovery.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/news.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/news.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/trade.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/trade.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/watchlist.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/watchlist.cpython-312.pyc
Normal file
Binary file not shown.
54
backend/app/api/endpoints/account.py
Normal file
54
backend/app/api/endpoints/account.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import AccountStatus, Holding
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class AccountStatusSchema(BaseModel):
|
||||
totalAssets: float
|
||||
buyingPower: float
|
||||
dailyProfit: float
|
||||
dailyProfitRate: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class HoldingSchema(BaseModel):
|
||||
stockCode: str
|
||||
stockName: str
|
||||
quantity: int
|
||||
avgPrice: float
|
||||
currentPrice: float
|
||||
profit: float
|
||||
profitRate: float
|
||||
marketValue: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/balance", response_model=AccountStatusSchema)
|
||||
async def get_account_summary(db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(AccountStatus).where(AccountStatus.id == 1)
|
||||
result = await db.execute(stmt)
|
||||
status = result.scalar_one_or_none()
|
||||
|
||||
if not status:
|
||||
# Return default zeroed if not initialized
|
||||
return AccountStatusSchema(totalAssets=0, buyingPower=0, dailyProfit=0, dailyProfitRate=0)
|
||||
|
||||
return status
|
||||
|
||||
@router.get("/holdings", response_model=List[HoldingSchema])
|
||||
async def get_holdings(market: str = None, db: AsyncSession = Depends(get_db)):
|
||||
# TODO: Filter by market if Holding table supports it.
|
||||
# Current Holding model doesn't have 'market' column explicitly, but we can assume mixed or add it.
|
||||
# For now, return all.
|
||||
stmt = select(Holding)
|
||||
result = await db.execute(stmt)
|
||||
holdings = result.scalars().all()
|
||||
return holdings
|
||||
87
backend/app/api/endpoints/auto_trade.py
Normal file
87
backend/app/api/endpoints/auto_trade.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import AutoTradeRobot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Schema ---
|
||||
class AutoTradeConfigSchema(BaseModel):
|
||||
id: str
|
||||
stockCode: str
|
||||
stockName: str
|
||||
groupId: Optional[str] = None
|
||||
type: str
|
||||
frequency: str
|
||||
executionTime: str
|
||||
market: str
|
||||
quantity: int
|
||||
specificDay: Optional[int] = None
|
||||
trailingPercent: Optional[float] = None
|
||||
active: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# --- Endpoints ---
|
||||
@router.get("/configs", response_model=List[AutoTradeConfigSchema])
|
||||
async def get_auto_configs(db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(AutoTradeRobot)
|
||||
res = await db.execute(stmt)
|
||||
return res.scalars().all()
|
||||
|
||||
@router.post("/configs")
|
||||
async def create_auto_config(config: AutoTradeConfigSchema, db: AsyncSession = Depends(get_db)):
|
||||
# Check exists? Upsert?
|
||||
# Frontend generates ID usually or we do. Schema has ID.
|
||||
# We will upsert (merge) or just add.
|
||||
|
||||
# Check if exists
|
||||
stmt = select(AutoTradeRobot).where(AutoTradeRobot.id == config.id)
|
||||
res = await db.execute(stmt)
|
||||
existing = res.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update
|
||||
existing.stockCode = config.stockCode
|
||||
existing.stockName = config.stockName
|
||||
existing.groupId = config.groupId
|
||||
existing.type = config.type
|
||||
existing.frequency = config.frequency
|
||||
existing.executionTime = config.executionTime
|
||||
existing.market = config.market
|
||||
existing.quantity = config.quantity
|
||||
existing.specificDay = config.specificDay
|
||||
existing.trailingPercent = config.trailingPercent
|
||||
existing.active = config.active
|
||||
else:
|
||||
# Create
|
||||
new_obj = AutoTradeRobot(
|
||||
id=config.id,
|
||||
stockCode=config.stockCode,
|
||||
stockName=config.stockName,
|
||||
groupId=config.groupId,
|
||||
type=config.type,
|
||||
frequency=config.frequency,
|
||||
executionTime=config.executionTime,
|
||||
market=config.market,
|
||||
quantity=config.quantity,
|
||||
specificDay=config.specificDay,
|
||||
trailingPercent=config.trailingPercent,
|
||||
active=config.active
|
||||
)
|
||||
db.add(new_obj)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "saved", "id": config.id}
|
||||
|
||||
@router.delete("/configs/{config_id}")
|
||||
async def delete_auto_config(config_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await db.execute(delete(AutoTradeRobot).where(AutoTradeRobot.id == config_id))
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
68
backend/app/api/endpoints/discovery.py
Normal file
68
backend/app/api/endpoints/discovery.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import MasterStock, DiscoveryRankingCache, StockItem
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class StockItemSchema(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
market: str
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RankingItemSchema(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
price: float
|
||||
change: float
|
||||
changePercent: float
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/rankings", response_model=List[RankingItemSchema])
|
||||
async def get_rankings(type: str = "rise", limit: int = 10, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get Top Rankings based on cached StockItem data.
|
||||
type: 'rise' (Top Gainers), 'fall' (Top Losers), 'volume' (Not impl yet)
|
||||
"""
|
||||
stmt = select(StockItem)
|
||||
|
||||
if type == "fall":
|
||||
stmt = stmt.order_by(StockItem.changePercent.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(StockItem.changePercent.desc())
|
||||
|
||||
stmt = stmt.limit(limit)
|
||||
res = await db.execute(stmt)
|
||||
items = res.scalars().all()
|
||||
|
||||
return items
|
||||
|
||||
@router.get("/stocks/search", response_model=List[StockItemSchema])
|
||||
async def search_stocks(query: str, db: AsyncSession = Depends(get_db)):
|
||||
# Search by name or code
|
||||
stmt = select(MasterStock).where(
|
||||
(MasterStock.name.like(f"%{query}%")) | (MasterStock.code.like(f"%{query}%"))
|
||||
).limit(20)
|
||||
result = await db.execute(stmt)
|
||||
stocks = result.scalars().all()
|
||||
return stocks
|
||||
|
||||
from app.services.master_service import master_service
|
||||
|
||||
@router.get("/kis/master-stocks")
|
||||
async def sync_master_stocks(db: AsyncSession = Depends(get_db)):
|
||||
# Trigger Sync
|
||||
# Ideally should be BackgroundTasks, but for now await to show result
|
||||
await master_service.sync_master_data(db)
|
||||
|
||||
# Return count
|
||||
stmt = select(MasterStock).limit(1)
|
||||
# Just return status
|
||||
return {"status": "Sync Complete"}
|
||||
64
backend/app/api/endpoints/kis.py
Normal file
64
backend/app/api/endpoints/kis.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from app.services.kis_client import kis_client
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("KisAPI")
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
market: Literal["Domestic", "Overseas"]
|
||||
side: Literal["buy", "sell"]
|
||||
code: str
|
||||
quantity: int
|
||||
price: float = 0 # 0 for Market Price (if supported)
|
||||
|
||||
class ModifyOrderRequest(BaseModel):
|
||||
market: Literal["Domestic", "Overseas"]
|
||||
order_no: str
|
||||
code: str
|
||||
quantity: int
|
||||
price: float = 0
|
||||
type: str = "00"
|
||||
is_cancel: bool = False # True = Cancel, False = Modify
|
||||
|
||||
@router.get("/price")
|
||||
async def get_current_price(market: Literal["Domestic", "Overseas"], code: str):
|
||||
"""
|
||||
Get Real-time Price (REST). Prefer WebSocket for streaming.
|
||||
"""
|
||||
try:
|
||||
price = await kis_client.get_current_price(market, code)
|
||||
return {"code": code, "price": price}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/balance")
|
||||
async def get_balance(market: Literal["Domestic", "Overseas"]):
|
||||
try:
|
||||
data = await kis_client.get_balance(market)
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/order")
|
||||
async def modify_order(req: ModifyOrderRequest):
|
||||
logger.info(f"Received Modify/Cancel Request: {req}")
|
||||
try:
|
||||
res = await kis_client.modify_order(
|
||||
req.market, req.order_no, req.code, req.quantity, req.price, req.type, req.is_cancel
|
||||
)
|
||||
return res
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/order")
|
||||
async def place_order(order: OrderRequest):
|
||||
logger.info(f"Received Order Request: {order}")
|
||||
try:
|
||||
res = await kis_client.place_order(order.market, order.side, order.code, order.quantity, order.price)
|
||||
return res
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
29
backend/app/api/endpoints/news.py
Normal file
29
backend/app/api/endpoints/news.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import NewsCache
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class NewsItemSchema(BaseModel):
|
||||
news_id: str
|
||||
title: str
|
||||
description: str
|
||||
pubDate: str
|
||||
sentiment: str | None
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/", response_model=List[NewsItemSchema])
|
||||
async def get_news(query: str = None, limit: int = 50, db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(NewsCache).order_by(NewsCache.pubDate.desc()).limit(limit)
|
||||
if query:
|
||||
stmt = stmt.where(NewsCache.title.like(f"%{query}%"))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
news = result.scalars().all()
|
||||
return news
|
||||
113
backend/app/api/endpoints/settings.py
Normal file
113
backend/app/api/endpoints/settings.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import ApiSettings
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.workers.scheduler import service_watchdog_job
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("SettingsAPI")
|
||||
|
||||
class SettingsSchema(BaseModel):
|
||||
# Partial schema for updates
|
||||
appKey: str | None = None
|
||||
appSecret: str | None = None
|
||||
accountNumber: str | None = None
|
||||
|
||||
# Integrations
|
||||
useTelegram: bool | None = None
|
||||
telegramToken: str | None = None
|
||||
telegramChatId: str | None = None
|
||||
|
||||
useNaverNews: bool | None = None
|
||||
naverClientId: str | None = None
|
||||
naverClientSecret: str | None = None
|
||||
|
||||
# Configs
|
||||
kisApiDelayMs: int | None = None
|
||||
newsScrapIntervalMin: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
from app.core.crypto import encrypt_str, decrypt_str
|
||||
|
||||
@router.get("/", response_model=SettingsSchema)
|
||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db.execute(stmt)
|
||||
settings = result.scalar_one_or_none()
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not initialized")
|
||||
|
||||
# Clone logic to mask secrets for display
|
||||
# We can't easily clone SQLA model to Pydantic and modify without validation error if strict.
|
||||
# So we construct dict.
|
||||
|
||||
resp_data = SettingsSchema.model_validate(settings)
|
||||
|
||||
if settings.appKey: resp_data.appKey = "********" # Masked
|
||||
if settings.appSecret: resp_data.appSecret = "********" # Masked
|
||||
if settings.accountNumber:
|
||||
# Decrypt first if we want to show last 4 digits?
|
||||
# Or just show encrypted? Users prefer masking.
|
||||
# Let's decrypt and mask everything except last 2.
|
||||
pass # Keep simple for now: Mask all or partial?
|
||||
# User just sees *****
|
||||
# Actually proper UX is to leave field blank or show placeholder.
|
||||
# Let's mask entirely for keys.
|
||||
|
||||
return resp_data
|
||||
|
||||
@router.put("/", response_model=SettingsSchema)
|
||||
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Updating API Settings...")
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db.execute(stmt)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = ApiSettings(id=1)
|
||||
db.add(settings)
|
||||
|
||||
# Update fields if provided - ENCRYPT SENSITIVE DATA
|
||||
if payload.appKey is not None:
|
||||
settings.appKey = encrypt_str(payload.appKey)
|
||||
if payload.appSecret is not None:
|
||||
settings.appSecret = encrypt_str(payload.appSecret)
|
||||
if payload.accountNumber is not None:
|
||||
settings.accountNumber = encrypt_str(payload.accountNumber)
|
||||
|
||||
# Integrations
|
||||
if payload.useTelegram is not None: settings.useTelegram = payload.useTelegram
|
||||
if payload.telegramToken is not None: settings.telegramToken = payload.telegramToken
|
||||
if payload.telegramChatId is not None: settings.telegramChatId = payload.telegramChatId
|
||||
|
||||
if payload.useNaverNews is not None: settings.useNaverNews = payload.useNaverNews
|
||||
if payload.naverClientId is not None: settings.naverClientId = payload.naverClientId
|
||||
if payload.naverClientSecret is not None: settings.naverClientSecret = payload.naverClientSecret
|
||||
|
||||
# Configs
|
||||
if payload.kisApiDelayMs is not None: settings.kisApiDelayMs = payload.kisApiDelayMs
|
||||
if payload.newsScrapIntervalMin is not None: settings.newsScrapIntervalMin = payload.newsScrapIntervalMin
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
# Trigger Startup-like sequence immediately
|
||||
await service_watchdog_job()
|
||||
|
||||
# Return masked object check
|
||||
# We return what was saved, but masked?
|
||||
# Usually convention is to return updated state.
|
||||
resp = SettingsSchema.model_validate(settings)
|
||||
resp.appKey = "********"
|
||||
resp.appSecret = "********"
|
||||
resp.accountNumber = "********"
|
||||
|
||||
return resp
|
||||
|
||||
100
backend/app/api/endpoints/trade.py
Normal file
100
backend/app/api/endpoints/trade.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import TradeHistory, AutoTradeRobot, ReservedOrder
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Schemas ---
|
||||
class TradeOrderSchema(BaseModel):
|
||||
id: str
|
||||
stockName: str
|
||||
type: str
|
||||
quantity: int
|
||||
price: float
|
||||
timestamp: datetime
|
||||
status: str
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ReservedOrderSchema(BaseModel):
|
||||
id: str
|
||||
stockName: str
|
||||
stockCode: str
|
||||
monitoringType: str
|
||||
status: str
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CreateReservedOrderRequest(BaseModel):
|
||||
stockCode: str
|
||||
stockName: str
|
||||
monitoringType: str # PRICE_TRIGGER
|
||||
triggerPrice: float
|
||||
orderType: str # BUY, SELL
|
||||
quantity: int
|
||||
price: float = 0 # Limit price (0 = Market)
|
||||
|
||||
# Trailing Stop Options
|
||||
trailingType: str | None = None # AMOUNT, PERCENT
|
||||
trailingValue: float | None = None
|
||||
stopLossValue: float | None = None
|
||||
|
||||
class ReservedOrderResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/history/orders", response_model=List[TradeOrderSchema])
|
||||
async def get_trade_history(limit: int = 100, db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(TradeHistory).order_by(TradeHistory.timestamp.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get("/", response_model=List[ReservedOrderSchema])
|
||||
async def get_reserved_orders(db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(ReservedOrder)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/")
|
||||
async def create_reserved_order(req: CreateReservedOrderRequest, db: AsyncSession = Depends(get_db)):
|
||||
import uuid
|
||||
new_id = str(uuid.uuid4())
|
||||
|
||||
order = ReservedOrder(
|
||||
id=new_id,
|
||||
stockCode=req.stockCode,
|
||||
stockName=req.stockName,
|
||||
monitoringType=req.monitoringType,
|
||||
triggerPrice=req.triggerPrice,
|
||||
type=req.orderType, # BUY/SELL
|
||||
quantity=req.quantity,
|
||||
price=req.price,
|
||||
|
||||
# TS Fields
|
||||
trailingType=req.trailingType,
|
||||
trailingValue=req.trailingValue,
|
||||
stopLossValue=req.stopLossValue,
|
||||
highestPrice=0, # Init
|
||||
lowestPrice=99999999, # Init
|
||||
|
||||
status="MONITORING",
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
return {"id": new_id, "status": "MONITORING"}
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_reserved_order(order_id: str, db: AsyncSession = Depends(get_db)):
|
||||
from sqlalchemy import delete
|
||||
await db.execute(delete(ReservedOrder).where(ReservedOrder.id == order_id))
|
||||
await db.commit()
|
||||
return {"status": "Deleted"}
|
||||
95
backend/app/api/endpoints/watchlist.py
Normal file
95
backend/app/api/endpoints/watchlist.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import WatchlistGroup, WatchlistItem
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Schemas ---
|
||||
class WatchlistItemSchema(BaseModel):
|
||||
stock_code: str
|
||||
added_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class WatchlistGroupSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
market: str
|
||||
items: List[WatchlistItemSchema] = []
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CreateGroupRequest(BaseModel):
|
||||
name: str
|
||||
market: str
|
||||
codes: List[str] = []
|
||||
|
||||
class UpdateGroupRequest(BaseModel):
|
||||
name: str | None = None
|
||||
codes: List[str] | None = None
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/groups", response_model=List[WatchlistGroupSchema])
|
||||
async def get_watchlists(db: AsyncSession = Depends(get_db)):
|
||||
# Load groups with items (Need relationship setup?
|
||||
# Current models.py WatchlistGroup doesn't have `items` relationship defined explicitly in snippet provided?
|
||||
# Let's assume we need to join manually or update models.
|
||||
# Checking models.py... WatchlistItem has foreign key.
|
||||
# Ideally should add `items = relationship("WatchlistItem")` to WatchlistGroup.
|
||||
# For now, let's just fetch items separately or via join.
|
||||
pass
|
||||
# Creating relationship on the fly or assuming simple manual join for safety.
|
||||
|
||||
stmt = select(WatchlistGroup)
|
||||
result = await db.execute(stmt)
|
||||
groups = result.scalars().all()
|
||||
|
||||
resp = []
|
||||
for g in groups:
|
||||
# Fetch items
|
||||
stmt_items = select(WatchlistItem).where(WatchlistItem.group_id == g.id)
|
||||
res_items = await db.execute(stmt_items)
|
||||
items = res_items.scalars().all()
|
||||
|
||||
g_schema = WatchlistGroupSchema(
|
||||
id=g.id, name=g.name, market=g.market,
|
||||
items=[WatchlistItemSchema.model_validate(i) for i in items]
|
||||
)
|
||||
resp.append(g_schema)
|
||||
|
||||
return resp
|
||||
|
||||
@router.post("/", response_model=WatchlistGroupSchema)
|
||||
async def create_watchlist(req: CreateGroupRequest, db: AsyncSession = Depends(get_db)):
|
||||
gid = str(uuid.uuid4())
|
||||
group = WatchlistGroup(id=gid, name=req.name, market=req.market)
|
||||
db.add(group)
|
||||
|
||||
items = []
|
||||
for code in req.codes:
|
||||
item = WatchlistItem(group_id=gid, stock_code=code)
|
||||
db.add(item)
|
||||
items.append(item)
|
||||
|
||||
await db.commit()
|
||||
return WatchlistGroupSchema(
|
||||
id=gid, name=req.name, market=req.market,
|
||||
items=[WatchlistItemSchema(stock_code=i.stock_code, added_at=i.added_at) for i in items]
|
||||
)
|
||||
|
||||
@router.delete("/{group_id}")
|
||||
async def delete_watchlist(group_id: str, db: AsyncSession = Depends(get_db)):
|
||||
# Delete items first (Cascade usually handls this but explicit is safe)
|
||||
await db.execute(delete(WatchlistItem).where(WatchlistItem.group_id == group_id))
|
||||
await db.execute(delete(WatchlistGroup).where(WatchlistGroup.id == group_id))
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/crypto.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/crypto.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/market_schedule.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/market_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/rate_limiter.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/rate_limiter.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/startup.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/startup.cpython-312.pyc
Normal file
Binary file not shown.
58
backend/app/core/config.py
Normal file
58
backend/app/core/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from typing import List, Union
|
||||
from pydantic import AnyHttpUrl, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "BatchuKis Backend"
|
||||
API_V1_STR: str = "/api"
|
||||
|
||||
# Server Config
|
||||
PORT: int = 80
|
||||
HOST: str = "0.0.0.0"
|
||||
|
||||
# Security: CORS & Allowed Hosts
|
||||
# In production, this should be set to ["kis.tindevil.com"]
|
||||
ALLOWED_HOSTS: List[str] = ["localhost", "127.0.0.1", "kis.tindevil.com"]
|
||||
|
||||
# CORS Origins
|
||||
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"https://kis.tindevil.com",
|
||||
]
|
||||
|
||||
# Database
|
||||
# Using aiosqlite for async SQLite
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./kis_stock.db"
|
||||
|
||||
# Timezone
|
||||
TIMEZONE: str = "Asia/Seoul"
|
||||
|
||||
# Encryption
|
||||
SECRET_KEY: str = "dlrpwjdakfehlsmswl_skf!wkf!ahfmrpTDJ!@#unsafe_default_key_change_in_production_min_32_bytes"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
@field_validator("ALLOWED_HOSTS", mode="before")
|
||||
def assemble_allowed_hosts(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, (list, str)):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
|
||||
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[Union[str, AnyHttpUrl]]:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, (list, str)):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
settings = Settings()
|
||||
73
backend/app/core/crypto.py
Normal file
73
backend/app/core/crypto.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad, pad
|
||||
from base64 import b64decode, b64encode
|
||||
from app.core.config import settings
|
||||
import hashlib
|
||||
|
||||
# KIS WebSocket Decryption (CBC)
|
||||
def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
|
||||
"""
|
||||
Decrypts KIS WebSocket data using AES-256-CBC.
|
||||
adapted from KIS official sample.
|
||||
"""
|
||||
if not key or not iv:
|
||||
raise ValueError("Key and IV are required for decryption")
|
||||
|
||||
# Key and IV are assumed to be utf-8 strings
|
||||
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
|
||||
|
||||
# Decrypt and unpad
|
||||
decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size)
|
||||
|
||||
return bytes.decode(decrypted_bytes, 'utf-8')
|
||||
|
||||
# DB Field Encryption (AES-GCM)
|
||||
def get_master_key():
|
||||
# Derive a 32-byte key from the SECRET_KEY string, ensuring length
|
||||
return hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest()
|
||||
|
||||
def encrypt_str(plain_text: str) -> str:
|
||||
"""
|
||||
Encrypts string for DB storage using AES-GCM.
|
||||
Returns: base64(nonce + ciphertext + tag)
|
||||
"""
|
||||
if not plain_text:
|
||||
return ""
|
||||
|
||||
key = get_master_key()
|
||||
cipher = AES.new(key, AES.MODE_GCM)
|
||||
nonce = cipher.nonce # 16 bytes
|
||||
|
||||
ciphertext, tag = cipher.encrypt_and_digest(plain_text.encode('utf-8'))
|
||||
|
||||
# Combined: nonce(16) + tag(16) + ciphertext(n)
|
||||
combined = nonce + tag + ciphertext
|
||||
return b64encode(combined).decode('utf-8')
|
||||
|
||||
def decrypt_str(encrypted_text: str) -> str:
|
||||
"""
|
||||
Decrypts string from DB.
|
||||
Input: base64(nonce + tag + ciphertext)
|
||||
"""
|
||||
if not encrypted_text:
|
||||
return ""
|
||||
|
||||
try:
|
||||
raw = b64decode(encrypted_text)
|
||||
if len(raw) < 32: # Nonce(16) + Tag(16)
|
||||
return "" # Invalid data
|
||||
|
||||
nonce = raw[:16]
|
||||
tag = raw[16:32]
|
||||
ciphertext = raw[32:]
|
||||
|
||||
key = get_master_key()
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
|
||||
decrypted_data = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return decrypted_data.decode('utf-8')
|
||||
except Exception:
|
||||
# Failed to decrypt (possibly not encrypted or wrong key).
|
||||
# For safety, return empty or raise.
|
||||
# In transition phase, might check if plain text? No, assume encrypted.
|
||||
return "[Decryption Failed]"
|
||||
55
backend/app/core/market_schedule.py
Normal file
55
backend/app/core/market_schedule.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, time
|
||||
import pytz
|
||||
|
||||
KST = pytz.timezone("Asia/Seoul")
|
||||
US_EASTERN = pytz.timezone("US/Eastern")
|
||||
|
||||
class MarketSchedule:
|
||||
"""
|
||||
Checks if the market is open based on current time and market type.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_market_open(market: str) -> bool:
|
||||
"""
|
||||
:param market: 'Domestic' or 'Overseas'
|
||||
"""
|
||||
if market == "Domestic":
|
||||
return MarketSchedule._is_domestic_open()
|
||||
elif market == "Overseas":
|
||||
return MarketSchedule._is_overseas_open()
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_domestic_open() -> bool:
|
||||
now = datetime.now(KST)
|
||||
|
||||
# 1. Weekend Check (0=Mon, 4=Fri, 5=Sat, 6=Sun)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 2. Time Check (09:00 ~ 15:30)
|
||||
current_time = now.time()
|
||||
start = time(9, 0)
|
||||
end = time(15, 30)
|
||||
|
||||
return start <= current_time <= end
|
||||
|
||||
@staticmethod
|
||||
def _is_overseas_open() -> bool:
|
||||
# US Market: 09:30 ~ 16:00 (US Eastern Time)
|
||||
# pytz handles DST automatically for US/Eastern
|
||||
now = datetime.now(US_EASTERN)
|
||||
|
||||
# 1. Weekend Check
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 2. Time Check
|
||||
current_time = now.time()
|
||||
start = time(9, 30)
|
||||
end = time(16, 0)
|
||||
|
||||
return start <= current_time <= end
|
||||
|
||||
market_schedule = MarketSchedule()
|
||||
35
backend/app/core/rate_limiter.py
Normal file
35
backend/app/core/rate_limiter.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Centralized Request Queue that enforces a physical delay between API calls.
|
||||
Default delay is 250ms (4 requests per second).
|
||||
"""
|
||||
def __init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_call_time = 0
|
||||
self._delay = 0.25 # seconds (250ms)
|
||||
|
||||
async def wait(self):
|
||||
"""
|
||||
Acquire lock and sleep if necessary to respect the rate limit.
|
||||
"""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_call_time
|
||||
|
||||
if elapsed < self._delay:
|
||||
sleep_time = self._delay - elapsed
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
self._last_call_time = time.monotonic()
|
||||
|
||||
def set_delay(self, ms: int):
|
||||
"""
|
||||
Update the delay interval dynamically from DB settings.
|
||||
"""
|
||||
self._delay = ms / 1000.0
|
||||
|
||||
# Singleton instance
|
||||
global_rate_limiter = RateLimiter()
|
||||
68
backend/app/core/startup.py
Normal file
68
backend/app/core/startup.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from sqlalchemy import select
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from app.core.config import settings
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.services.sync_service import sync_service
|
||||
from app.services.telegram_service import telegram_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def check_kis_connectivity(db_session, settings_obj):
|
||||
if not settings_obj.appKey or not settings_obj.appSecret:
|
||||
logger.warning(">> [KIS] Credentials NOT FOUND in DB.")
|
||||
return False
|
||||
|
||||
logger.info(">> [KIS] Attempting Authentication...")
|
||||
try:
|
||||
token = await kis_auth.get_access_token(db_session)
|
||||
masked_token = token[:10] + "..." if token else "None"
|
||||
logger.info(f" [OK] Access Token Valid ({masked_token})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f" [FAILED] KIS Auth Failed: {e}")
|
||||
return False
|
||||
|
||||
async def check_telegram_connectivity(settings_obj, is_startup=False):
|
||||
if settings_obj.useTelegram and settings_obj.telegramToken and settings_obj.telegramChatId:
|
||||
logger.info(">> [Telegram] Integration Enabled.")
|
||||
if is_startup:
|
||||
msg = "🚀 <b>BatchuKis 배취키스</b> 시스템이 시작되었습니다.\n자동매매 엔진이 가동 중입니다."
|
||||
await telegram_service.send_message(settings_obj.telegramToken, settings_obj.telegramChatId, msg)
|
||||
return True
|
||||
else:
|
||||
# logger.debug(">> [Telegram] Disabled or missing config.")
|
||||
return False
|
||||
|
||||
async def run_startup_sequence():
|
||||
"""
|
||||
Executes the Phase 1~4 startup sequence.
|
||||
"""
|
||||
logger.info("=== Starting System Initialization Sequence ===")
|
||||
|
||||
async with SessionLocal() as db_session:
|
||||
# Phase 1: Load Settings
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db_session.execute(stmt)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj:
|
||||
settings_obj = ApiSettings(id=1)
|
||||
db_session.add(settings_obj)
|
||||
await db_session.commit()
|
||||
logger.info("Created Default ApiSettings.")
|
||||
|
||||
# Phase 2: KIS & Telegram
|
||||
await check_kis_connectivity(db_session, settings_obj)
|
||||
await check_telegram_connectivity(settings_obj, is_startup=True)
|
||||
|
||||
# Phase 3: Data Sync
|
||||
logger.info(">> [Phase 3] Syncing Account & Master Data...")
|
||||
await sync_service.sync_account(db_session)
|
||||
|
||||
from app.services.master_service import master_service
|
||||
await master_service.sync_master_data(db_session)
|
||||
|
||||
logger.info("=== System Initialization Complete ===")
|
||||
BIN
backend/app/db/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/db/__pycache__/init_db.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/init_db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
28
backend/app/db/database.py
Normal file
28
backend/app/db/database.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
# 1. Async Engine
|
||||
# "check_same_thread": False is required for SQLite
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
||||
)
|
||||
|
||||
# 2. Async Session Factory
|
||||
SessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False
|
||||
)
|
||||
|
||||
# 3. Base Model
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
# 4. Dependency Injection for FastAPI
|
||||
async def get_db():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
24
backend/app/db/init_db.py
Normal file
24
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import select
|
||||
from app.db.database import engine, Base, SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
# Must import all models to ensure they are registered in Base.metadata
|
||||
from app.db import models
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Seed Data
|
||||
async with SessionLocal() as session:
|
||||
# Check if ApiSettings(id=1) exists
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await session.execute(stmt)
|
||||
settings_entry = result.scalar_one_or_none()
|
||||
|
||||
if not settings_entry:
|
||||
# Create default settings
|
||||
default_settings = ApiSettings(id=1)
|
||||
session.add(default_settings)
|
||||
await session.commit()
|
||||
print("Initialized default ApiSettings(id=1)")
|
||||
235
backend/app/db/models.py
Normal file
235
backend/app/db/models.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Integer, String, Boolean, Float, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
|
||||
# -----------------
|
||||
# 1. System & Config
|
||||
# -----------------
|
||||
|
||||
class AiConfig(Base):
|
||||
__tablename__ = "ai_configs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
providerType: Mapped[str] = mapped_column(String) # Gemini, Ollama, OpenAI
|
||||
modelName: Mapped[str] = mapped_column(String)
|
||||
baseUrl: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
class ApiSettings(Base):
|
||||
__tablename__ = "api_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) # Always 1
|
||||
|
||||
# Credentials
|
||||
appKey: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
appSecret: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
accountNumber: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
# Integrations
|
||||
useTelegram: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
telegramToken: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
telegramChatId: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
useNaverNews: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
naverClientId: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
naverClientSecret: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
# Configs
|
||||
kisApiDelayMs: Mapped[int] = mapped_column(Integer, default=250)
|
||||
newsScrapIntervalMin: Mapped[int] = mapped_column(Integer, default=10)
|
||||
|
||||
# Token Storage (Runtime)
|
||||
accessToken: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
tokenExpiry: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
websocketApprovalKey: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# AI Config Relations (Foreign Keys)
|
||||
preferredNewsAiId: Mapped[Optional[str]] = mapped_column(ForeignKey("ai_configs.id"), nullable=True)
|
||||
preferredStockAiId: Mapped[Optional[str]] = mapped_column(ForeignKey("ai_configs.id"), nullable=True)
|
||||
preferredNewsJudgementAiId: Mapped[Optional[str]] = mapped_column(ForeignKey("ai_configs.id"), nullable=True)
|
||||
preferredAutoBuyAiId: Mapped[Optional[str]] = mapped_column(ForeignKey("ai_configs.id"), nullable=True)
|
||||
preferredAutoSellAiId: Mapped[Optional[str]] = mapped_column(ForeignKey("ai_configs.id"), nullable=True)
|
||||
|
||||
# -----------------
|
||||
# 2. Account & Portfolio
|
||||
# -----------------
|
||||
|
||||
class AccountStatus(Base):
|
||||
__tablename__ = "account_status"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
totalAssets: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
buyingPower: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
dailyProfit: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
dailyProfitRate: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
class Holding(Base):
|
||||
__tablename__ = "holdings"
|
||||
|
||||
stockCode: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
stockName: Mapped[str] = mapped_column(String)
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
avgPrice: Mapped[float] = mapped_column(Float)
|
||||
currentPrice: Mapped[float] = mapped_column(Float) # Real-time updated
|
||||
profit: Mapped[float] = mapped_column(Float)
|
||||
profitRate: Mapped[float] = mapped_column(Float)
|
||||
marketValue: Mapped[float] = mapped_column(Float)
|
||||
|
||||
# -----------------
|
||||
# 3. Market & Discovery
|
||||
# -----------------
|
||||
|
||||
class MasterStock(Base):
|
||||
__tablename__ = "master_stocks"
|
||||
|
||||
code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
market: Mapped[str] = mapped_column(String) # Domestic, Overseas
|
||||
|
||||
# Stats
|
||||
per: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
pbr: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
roe: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
marketCap: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
dividendYield: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# User Data
|
||||
memo: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
isHidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class NewsCache(Base):
|
||||
__tablename__ = "news_cache"
|
||||
|
||||
news_id: Mapped[str] = mapped_column(String, primary_key=True) # Hashed ID
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
link: Mapped[str] = mapped_column(Text)
|
||||
pubDate: Mapped[str] = mapped_column(String)
|
||||
|
||||
sentiment: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
relatedThemes: Mapped[Optional[List]] = mapped_column(JSON, nullable=True)
|
||||
relatedStocks: Mapped[Optional[List]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
class DiscoveryRankingCache(Base):
|
||||
__tablename__ = "discovery_ranking_cache"
|
||||
|
||||
# Composite Key simulated (category_market string or separate cols)
|
||||
# Using composite PK
|
||||
category: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
market: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
items_json: Mapped[str] = mapped_column(Text) # JSON String of StockItem[]
|
||||
|
||||
class StockStat(Base):
|
||||
__tablename__ = "stock_stats"
|
||||
|
||||
code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
tradingValue: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
buyRatio: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sellRatio: Mapped[int] = mapped_column(Integer, default=0)
|
||||
foreignNetBuy: Mapped[int] = mapped_column(Integer, default=0)
|
||||
institutionalNetBuy: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
aiScoreBuy: Mapped[int] = mapped_column(Integer, default=0)
|
||||
aiScoreSell: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class StockItem(Base):
|
||||
__tablename__ = "stock_items"
|
||||
|
||||
code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
price: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
change: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
changePercent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
market: Mapped[str] = mapped_column(String) # Domestic, Overseas
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
|
||||
# -----------------
|
||||
# 4. Watchlist
|
||||
# -----------------
|
||||
|
||||
class WatchlistGroup(Base):
|
||||
__tablename__ = "watchlist_groups"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
market: Mapped[str] = mapped_column(String) # Domestic, Overseas
|
||||
|
||||
class WatchlistItem(Base):
|
||||
__tablename__ = "watchlist_items"
|
||||
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("watchlist_groups.id"), primary_key=True)
|
||||
stock_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
|
||||
# -----------------
|
||||
# 5. Trading & Automation
|
||||
# -----------------
|
||||
|
||||
class TradeHistory(Base):
|
||||
__tablename__ = "trade_history"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
stockCode: Mapped[str] = mapped_column(String)
|
||||
stockName: Mapped[str] = mapped_column(String)
|
||||
type: Mapped[str] = mapped_column(String) # BUY, SELL
|
||||
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
price: Mapped[float] = mapped_column(Float)
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime)
|
||||
status: Mapped[str] = mapped_column(String) # FILLED, CANCELLED
|
||||
|
||||
class AutoTradeRobot(Base):
|
||||
__tablename__ = "auto_trade_robots"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
stockCode: Mapped[str] = mapped_column(String)
|
||||
stockName: Mapped[str] = mapped_column(String)
|
||||
groupId: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
type: Mapped[str] = mapped_column(String) # ACCUMULATION, TRAILING, etc.
|
||||
frequency: Mapped[str] = mapped_column(String) # DAILY, WEEKLY
|
||||
executionTime: Mapped[str] = mapped_column(String) # HH:MM
|
||||
market: Mapped[str] = mapped_column(String)
|
||||
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
specificDay: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0=Monday
|
||||
trailingPercent: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class ReservedOrder(Base):
|
||||
__tablename__ = "reserved_orders"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
stockCode: Mapped[str] = mapped_column(String)
|
||||
stockName: Mapped[str] = mapped_column(String)
|
||||
type: Mapped[str] = mapped_column(String) # BUY, SELL
|
||||
market: Mapped[str] = mapped_column(String)
|
||||
|
||||
monitoringType: Mapped[str] = mapped_column(String) # TARGET, TRAILING
|
||||
trailingType: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AMOUNT, PERCENT
|
||||
status: Mapped[str] = mapped_column(String) # MONITORING, TRIGGERED, EXPIRED
|
||||
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
triggerPrice: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
trailingValue: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
stopLossValue: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
highestPrice: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # For Trailing
|
||||
lowestPrice: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
useStopLoss: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
sellAll: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
stopLossType: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
createdAt: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
expiryDate: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
80
backend/app/main.py
Normal file
80
backend/app/main.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.init_db import init_db
|
||||
from app.workers.scheduler import start_scheduler
|
||||
from app.core.startup import run_startup_sequence
|
||||
|
||||
import logging
|
||||
|
||||
# Configure Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Initialize DB
|
||||
logger.info("Initializing Database...")
|
||||
await init_db()
|
||||
|
||||
# Startup Sequence (Auth, Checks)
|
||||
await run_startup_sequence()
|
||||
|
||||
logger.info("Starting Background Scheduler...")
|
||||
start_scheduler()
|
||||
|
||||
logger.info("Application Startup Complete.")
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Application Shutdown.")
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url=f"{settings.API_V1_STR}/docs",
|
||||
redoc_url=f"{settings.API_V1_STR}/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# 1. Security: Trusted Host Middleware
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware,
|
||||
allowed_hosts=settings.ALLOWED_HOSTS
|
||||
)
|
||||
|
||||
# 2. CORS Middleware
|
||||
if settings.BACKEND_CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 3. Health Check
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "app": settings.PROJECT_NAME}
|
||||
|
||||
from app.api.api import api_router
|
||||
|
||||
# 4. API Router
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
# 5. Static Files (Frontend)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
||||
else:
|
||||
logger.warning(f"Static directory not found at {STATIC_DIR}")
|
||||
BIN
backend/app/services/__pycache__/kis_auth.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/kis_auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/kis_client.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/kis_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/master_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/master_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/app/services/__pycache__/sync_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/sync_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
60
backend/app/services/ai_factory.py
Normal file
60
backend/app/services/ai_factory.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from abc import ABC, abstractmethod
|
||||
import httpx
|
||||
|
||||
class BaseAIProvider(ABC):
|
||||
def __init__(self, api_key: str, model_name: str, base_url: str = None):
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
|
||||
pass
|
||||
|
||||
class GeminiProvider(BaseAIProvider):
|
||||
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
|
||||
# Placeholder for Gemini API Implementation
|
||||
# https://generativelanguage.googleapis.com/v1beta/models/...
|
||||
return f"Gemini Response to: {prompt}"
|
||||
|
||||
class OpenAIProvider(BaseAIProvider):
|
||||
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
|
||||
# Placeholder for OpenAI API
|
||||
return f"OpenAI Response to: {prompt}"
|
||||
|
||||
class OllamaProvider(BaseAIProvider):
|
||||
"""
|
||||
Ollama (Local LLM), compatible with OpenAI client usually, or direct /api/generate
|
||||
"""
|
||||
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
|
||||
# Placeholder for Ollama API
|
||||
url = f"{self.base_url}/api/generate"
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False
|
||||
}
|
||||
if system_instruction:
|
||||
payload["system"] = system_instruction
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, timeout=60.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("response", "")
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
class AIFactory:
|
||||
@staticmethod
|
||||
def get_provider(provider_type: str, api_key: str, model_name: str, base_url: str = None) -> BaseAIProvider:
|
||||
if provider_type.lower() == "gemini":
|
||||
return GeminiProvider(api_key, model_name, base_url)
|
||||
elif provider_type.lower() == "openai":
|
||||
return OpenAIProvider(api_key, model_name, base_url)
|
||||
elif provider_type.lower() == "ollama":
|
||||
return OllamaProvider(api_key, model_name, base_url)
|
||||
else:
|
||||
raise ValueError(f"Unknown Provider: {provider_type}")
|
||||
55
backend/app/services/ai_orchestrator.py
Normal file
55
backend/app/services/ai_orchestrator.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import select
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import AiConfig, ApiSettings
|
||||
from app.services.ai_factory import AIFactory, BaseAIProvider
|
||||
|
||||
class AIOrchestrator:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _get_provider_by_id(self, config_id: str) -> BaseAIProvider:
|
||||
async with SessionLocal() as session:
|
||||
stmt = select(AiConfig).where(AiConfig.id == config_id)
|
||||
result = await session.execute(stmt)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise ValueError("AI Config not found")
|
||||
|
||||
# Note: API Keys might need to be stored securely or passed from ENV/Settings.
|
||||
# For now assuming API Key is managed externally or stored in config (not implemented in DB schema for security).
|
||||
# Or we look up ApiSettings or a secure vault.
|
||||
# Simplified: Use a placeholder or ENV.
|
||||
api_key = "place_holder"
|
||||
|
||||
return AIFactory.get_provider(config.providerType, api_key, config.modelName, config.baseUrl)
|
||||
|
||||
async def get_preferred_provider(self, purpose: str) -> BaseAIProvider:
|
||||
"""
|
||||
purpose: 'news', 'stock', 'judgement', 'buy', 'sell'
|
||||
"""
|
||||
async with SessionLocal() as session:
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await session.execute(stmt)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise ValueError("Settings not initialized")
|
||||
|
||||
config_id = None
|
||||
if purpose == 'news': config_id = settings.preferredNewsAiId
|
||||
elif purpose == 'stock': config_id = settings.preferredStockAiId
|
||||
elif purpose == 'judgement': config_id = settings.preferredNewsJudgementAiId
|
||||
elif purpose == 'buy': config_id = settings.preferredAutoBuyAiId
|
||||
elif purpose == 'sell': config_id = settings.preferredAutoSellAiId
|
||||
|
||||
if not config_id:
|
||||
raise ValueError(f"No preferred AI configured for {purpose}")
|
||||
|
||||
return await self._get_provider_by_id(config_id)
|
||||
|
||||
async def analyze_text(self, text: str, purpose="news") -> str:
|
||||
provider = await self.get_preferred_provider(purpose)
|
||||
return await provider.generate_content(text)
|
||||
|
||||
ai_orchestrator = AIOrchestrator()
|
||||
131
backend/app/services/kis_auth.py
Normal file
131
backend/app/services/kis_auth.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from app.db.models import ApiSettings
|
||||
from app.core.crypto import decrypt_str, encrypt_str
|
||||
import logging
|
||||
|
||||
class KisAuth:
|
||||
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
|
||||
# BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def get_access_token(self, db_session=None) -> str:
|
||||
"""
|
||||
Returns valid access token. Issues new one if expired or missing.
|
||||
"""
|
||||
local_session = False
|
||||
if not db_session:
|
||||
db_session = SessionLocal()
|
||||
local_session = True
|
||||
|
||||
try:
|
||||
# 1. Get Settings
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db_session.execute(stmt)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
|
||||
raise ValueError("KIS API Credentials not configured.")
|
||||
|
||||
# 2. Check Expiry (Buffer 10 mins)
|
||||
# 2. Check Expiry (Buffer 10 mins)
|
||||
if settings_obj.accessToken and settings_obj.tokenExpiry:
|
||||
token_dec = decrypt_str(settings_obj.accessToken)
|
||||
if token_dec and token_dec != "[Decryption Failed]":
|
||||
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
||||
# self.logger.debug("Using cached Access Token.") # Too verbose?
|
||||
return token_dec
|
||||
|
||||
# 3. Issue New Token
|
||||
self.logger.info("Access Token Expired or Missing. Issuing New Token...")
|
||||
app_key_dec = decrypt_str(settings_obj.appKey)
|
||||
app_secret_dec = decrypt_str(settings_obj.appSecret)
|
||||
token_data = await self._issue_token(app_key_dec, app_secret_dec)
|
||||
|
||||
# 4. Save to DB (Encrypt Token)
|
||||
settings_obj.accessToken = encrypt_str(token_data['access_token'])
|
||||
# expires_in is seconds (usually 86400)
|
||||
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
|
||||
|
||||
await db_session.commit()
|
||||
return token_data['access_token']
|
||||
|
||||
except Exception as e:
|
||||
await db_session.rollback()
|
||||
raise e
|
||||
finally:
|
||||
if local_session:
|
||||
await db_session.close()
|
||||
|
||||
async def _issue_token(self, app_key: str, app_secret: str) -> dict:
|
||||
url = f"{self.BASE_URL_REAL}/oauth2/tokenP"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": app_key,
|
||||
"appsecret": app_secret
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_approval_key(self, db_session=None) -> str:
|
||||
"""
|
||||
Returns WebSocket Approval Key. Issues new one if missing.
|
||||
"""
|
||||
local_session = False
|
||||
if not db_session:
|
||||
db_session = SessionLocal()
|
||||
local_session = True
|
||||
|
||||
try:
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db_session.execute(stmt)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
|
||||
raise ValueError("KIS API Credentials not configured.")
|
||||
|
||||
if settings_obj.websocketApprovalKey:
|
||||
approval_key_dec = decrypt_str(settings_obj.websocketApprovalKey)
|
||||
if approval_key_dec and approval_key_dec != "[Decryption Failed]":
|
||||
return approval_key_dec
|
||||
|
||||
# Issue New Key
|
||||
app_key_dec = decrypt_str(settings_obj.appKey)
|
||||
app_secret_dec = decrypt_str(settings_obj.appSecret)
|
||||
approval_key = await self._issue_approval_key(app_key_dec, app_secret_dec)
|
||||
|
||||
settings_obj.websocketApprovalKey = encrypt_str(approval_key)
|
||||
await db_session.commit()
|
||||
|
||||
return approval_key
|
||||
|
||||
except Exception as e:
|
||||
await db_session.rollback()
|
||||
raise e
|
||||
finally:
|
||||
if local_session:
|
||||
await db_session.close()
|
||||
|
||||
async def _issue_approval_key(self, app_key: str, app_secret: str) -> str:
|
||||
url = f"{self.BASE_URL_REAL}/oauth2/Approval"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": app_key,
|
||||
"secretkey": app_secret # Note: Parameter name difference
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data['approval_key']
|
||||
|
||||
kis_auth = KisAuth()
|
||||
254
backend/app/services/kis_client.py
Normal file
254
backend/app/services/kis_client.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import httpx
|
||||
from typing import Dict, Optional, Any
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.core.rate_limiter import global_rate_limiter
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from sqlalchemy import select
|
||||
from app.core.crypto import decrypt_str
|
||||
import logging
|
||||
|
||||
class KisClient:
|
||||
"""
|
||||
Brokerage Service Interface for KIS API.
|
||||
Implements Section 9 Integration Map.
|
||||
"""
|
||||
|
||||
# Domestic URLs
|
||||
URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash"
|
||||
URL_DOMESTIC_MODIFY = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
|
||||
URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# Overseas URLs
|
||||
URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order"
|
||||
URL_OVERSEAS_MODIFY = "/uapi/overseas-stock/v1/trading/order-rvsecncl"
|
||||
URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price"
|
||||
URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance"
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def _get_settings(self):
|
||||
async with SessionLocal() as session:
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _call_api(self, method: str, url_path: str, tr_id: str, params: Dict = None, data: Dict = None) -> Dict:
|
||||
"""
|
||||
Common API Caller with Rate Limiting and Auth.
|
||||
"""
|
||||
# 1. Rate Limit
|
||||
await global_rate_limiter.wait()
|
||||
|
||||
# 2. Get Token & Base URL
|
||||
# Assuming Real Environment for now. TODO: Support Virtual
|
||||
base_url = kis_auth.BASE_URL_REAL
|
||||
token = await kis_auth.get_access_token()
|
||||
settings = await self._get_settings()
|
||||
|
||||
if not settings:
|
||||
raise ValueError("Settings not found")
|
||||
|
||||
# 3. Headers
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": decrypt_str(settings.appKey),
|
||||
"appsecret": decrypt_str(settings.appSecret),
|
||||
"tr_id": tr_id,
|
||||
"tr_cont": "",
|
||||
"custtype": "P"
|
||||
}
|
||||
|
||||
full_url = f"{base_url}{url_path}"
|
||||
# self.logger.debug(f"API Calling: {method} {url_path} (TR_ID: {tr_id})")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
if method == "GET":
|
||||
resp = await client.get(full_url, headers=headers, params=params)
|
||||
else:
|
||||
resp = await client.post(full_url, headers=headers, json=data)
|
||||
|
||||
# TODO: Handle 401 Re-Auth Logic here (catch, clear token, retry)
|
||||
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -----------------------------
|
||||
# 1. Current Price
|
||||
# -----------------------------
|
||||
async def get_current_price(self, market: str, code: str) -> float:
|
||||
if market == "Domestic":
|
||||
# TR_ID: FHKST01010100
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": code
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_DOMESTIC_PRICE, "FHKST01010100", params=params)
|
||||
return float(res['output']['stck_prpr'])
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: HHDFS00000300
|
||||
# Need Exchange Code (e.g., NASD). Assuming NASD for generic US or mapped.
|
||||
# Ideally code should be 'NASD:AAPL' or separate excg param.
|
||||
# For now assuming 'NASD' if not provided implicitly.
|
||||
# Or code is just 'AAPL'.
|
||||
excg = "NASD" # Default
|
||||
params = {
|
||||
"AUTH": "",
|
||||
"EXCD": excg,
|
||||
"SYMB": code
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_OVERSEAS_PRICE, "HHDFS00000300", params=params)
|
||||
return float(res['output']['last'])
|
||||
|
||||
return 0.0
|
||||
|
||||
# -----------------------------
|
||||
# 2. Balance
|
||||
# -----------------------------
|
||||
async def get_balance(self, market: str) -> Dict:
|
||||
settings = await self._get_settings()
|
||||
acc_no = decrypt_str(settings.accountNumber)
|
||||
# acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
|
||||
if '-' in acc_no:
|
||||
cano, prdt = acc_no.split('-')
|
||||
else:
|
||||
cano = acc_no[:8]
|
||||
prdt = acc_no[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# TR_ID: TTTC8434R (Real)
|
||||
params = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_DOMESTIC_BALANCE, "TTTC8434R", params=params)
|
||||
return res
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: TTTS3012R (Real)
|
||||
params = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"OVRS_EXCG_CD": "NASD", # Default
|
||||
"TR_CRCY_CD": "USD",
|
||||
"CTX_AREA_FK200": "",
|
||||
"CTX_AREA_NK200": ""
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_OVERSEAS_BALANCE, "TTTS3012R", params=params)
|
||||
return res
|
||||
|
||||
return {}
|
||||
|
||||
# -----------------------------
|
||||
# 3. Order
|
||||
# -----------------------------
|
||||
async def place_order(self, market: str, side: str, code: str, quantity: int, price: float) -> Dict:
|
||||
"""
|
||||
side: 'buy' or 'sell'
|
||||
price: 0 for Market? KIS logic varies.
|
||||
"""
|
||||
settings = await self._get_settings()
|
||||
acc_no_str = decrypt_str(settings.accountNumber)
|
||||
|
||||
if '-' in acc_no_str:
|
||||
cano, prdt = acc_no_str.split('-')
|
||||
else:
|
||||
cano = acc_no_str[:8]
|
||||
prdt = acc_no_str[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U
|
||||
# 0012U (Buy), 0011U (Sell)
|
||||
tr_id = "TTTC0012U" if side == "buy" else "TTTC0011U"
|
||||
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"PDNO": code,
|
||||
"ORD_DVSN": "00", # Limit (00). 01=Market
|
||||
"ORD_QTY": str(quantity),
|
||||
"ORD_UNPR": str(int(price)), # Cash Order requires integer price string
|
||||
}
|
||||
self.logger.info(f"Ordering Domestic: {side} {code} {quantity}qty @ {price}")
|
||||
return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data)
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: TTTT1002U (US Buy), TTTT1006U (US Sell)
|
||||
# Assuming US (NASD)
|
||||
tr_id = "TTTT1002U" if side == "buy" else "TTTT1006U"
|
||||
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"OVRS_EXCG_CD": "NASD",
|
||||
"PDNO": code,
|
||||
"ORD_QTY": str(quantity),
|
||||
"OVRS_ORD_UNPR": str(price),
|
||||
"ORD_SVR_DVSN_CD": "0",
|
||||
"ORD_DVSN": "00" # Limit
|
||||
}
|
||||
return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data)
|
||||
|
||||
async def modify_order(self, market: str, order_no: str, code: str, quantity: int, price: float, type: str = "00", cancel: bool = False) -> Dict:
|
||||
"""
|
||||
Cancel or Modify Order.
|
||||
cancel=True -> Cancel
|
||||
"""
|
||||
settings = await self._get_settings()
|
||||
acc_no_str = decrypt_str(settings.accountNumber)
|
||||
|
||||
if '-' in acc_no_str:
|
||||
cano, prdt = acc_no_str.split('-')
|
||||
else:
|
||||
cano = acc_no_str[:8]
|
||||
prdt = acc_no_str[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# TR_ID: TTTC0803U (Modify/Cancel)
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"KRX_FWDG_ORD_ORGNO": "", # Exchange Node? Usually empty or "00950"
|
||||
"ORGN_ODNO": order_no,
|
||||
"ORD_DVSN": type,
|
||||
"RVSE_CNCL_DVSN_CD": "02" if cancel else "01", # 01: Modify, 02: Cancel
|
||||
"ORD_QTY": str(quantity),
|
||||
"ORD_UNPR": str(int(price)),
|
||||
"QTY_ALL_ORD_YN": "Y" if quantity == 0 else "N", # 0 means cancel all?
|
||||
}
|
||||
# Note: KRX_FWDG_ORD_ORGNO is tricky. Usually 5 digit branch code. Defaulting to "" might fail.
|
||||
# Using '06010' (Online) or leaving blank depending on API.
|
||||
# KIS API Doc: "주문상태조회"에서 얻은 ORGNO 사용해야 함.
|
||||
# For this impl, we assume user knows or simple default.
|
||||
|
||||
return await self._call_api("POST", self.URL_DOMESTIC_MODIFY, "TTTC0803U", data=data)
|
||||
|
||||
elif market == "Overseas":
|
||||
# MCCL: TTTT1004U
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"OVRS_EXCG_CD": "NASD",
|
||||
"PDNO": code,
|
||||
"ORGN_ODNO": order_no,
|
||||
"RVSE_CNCL_DVSN_CD": "02" if cancel else "01",
|
||||
"ORD_QTY": str(quantity),
|
||||
"OVRS_ORD_UNPR": str(price),
|
||||
}
|
||||
return await self._call_api("POST", self.URL_OVERSEAS_MODIFY, "TTTT1004U", data=data)
|
||||
|
||||
kis_client = KisClient()
|
||||
112
backend/app/services/master_service.py
Normal file
112
backend/app/services/master_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import os
|
||||
import zipfile
|
||||
import httpx
|
||||
import logging
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from app.db.models import MasterStock
|
||||
from app.db.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MasterService:
|
||||
BASE_URL = "https://new.real.download.dws.co.kr/common/master"
|
||||
FILES = {
|
||||
"KOSPI": "kospi_code.mst.zip",
|
||||
"KOSDAQ": "kosdaq_code.mst.zip"
|
||||
}
|
||||
TMP_DIR = "./tmp_master"
|
||||
|
||||
async def sync_master_data(self, db: AsyncSession):
|
||||
"""
|
||||
Download and parse KOSPI/KOSDAQ master files.
|
||||
Populate MasterStock table.
|
||||
"""
|
||||
logger.info("MasterService: Starting Master Data Sync...")
|
||||
os.makedirs(self.TMP_DIR, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Clear existing data? Or Upsert?
|
||||
# For simplicity, Clear and Re-insert (Full Sync)
|
||||
# await db.execute(delete(MasterStock)) # Optional: Clear all
|
||||
|
||||
total_count = 0
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
for market, filename in self.FILES.items():
|
||||
url = f"{self.BASE_URL}/{filename}"
|
||||
dest = os.path.join(self.TMP_DIR, filename)
|
||||
|
||||
# 1. Download
|
||||
logger.info(f"Downloading {market} from {url}...")
|
||||
try:
|
||||
resp = await client.get(url, timeout=60.0)
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(resp.content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download {market}: {e}")
|
||||
continue
|
||||
|
||||
# 2. Unzip & Parse
|
||||
count = await self._process_zip(dest, market, db)
|
||||
total_count += count
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"MasterService: Sync Complete. Total {total_count} stocks.")
|
||||
|
||||
# Cleanup
|
||||
# shutil.rmtree(self.TMP_DIR)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MasterService: Fatal Error: {e}")
|
||||
await db.rollback()
|
||||
|
||||
async def _process_zip(self, zip_path: str, market: str, db: AsyncSession) -> int:
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
mst_filename = zf.namelist()[0] # Usually only one .mst file
|
||||
zf.extract(mst_filename, self.TMP_DIR)
|
||||
mst_path = os.path.join(self.TMP_DIR, mst_filename)
|
||||
|
||||
return await self._parse_mst(mst_path, market, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing ZIP {zip_path}: {e}")
|
||||
return 0
|
||||
|
||||
async def _parse_mst(self, mst_path: str, market: str, db: AsyncSession) -> int:
|
||||
count = 0
|
||||
batch = []
|
||||
|
||||
# Encoding is usually cp949 for KIS files
|
||||
with open(mst_path, "r", encoding="cp949", errors="replace") as f:
|
||||
for line in f:
|
||||
# Format:
|
||||
# row[0:9] : Short Code (Example: "005930 ")
|
||||
# row[9:21] : Standard Code
|
||||
# row[21:len-222] : Name
|
||||
|
||||
if len(line) < 250: continue # Invalid line
|
||||
|
||||
short_code = line[0:9].strip()
|
||||
# standard_code = line[9:21].strip()
|
||||
name_part = line[21:len(line)-222].strip()
|
||||
|
||||
if not short_code or not name_part: continue
|
||||
|
||||
# Check for ETF/ETN? (Usually included)
|
||||
|
||||
obj = MasterStock(
|
||||
code=short_code,
|
||||
name=name_part,
|
||||
market=market
|
||||
)
|
||||
db.add(obj)
|
||||
count += 1
|
||||
|
||||
# Batch commit? session.add is fast, commit at end.
|
||||
|
||||
return count
|
||||
|
||||
master_service = MasterService()
|
||||
134
backend/app/services/realtime_manager.py
Normal file
134
backend/app/services/realtime_manager.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import logging
|
||||
from typing import Dict, Set, Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.core.crypto import aes_cbc_base64_dec
|
||||
from app.db.database import SessionLocal
|
||||
# from app.db.crud import update_stock_price # TODO: Implement CRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RealtimeManager:
|
||||
"""
|
||||
Manages KIS WebSocket Connection.
|
||||
Handles: Connection, Subscription, Decryption, PINGPONG.
|
||||
"""
|
||||
WS_URL_REAL = "ws://ops.koreainvestment.com:21000"
|
||||
|
||||
def __init__(self):
|
||||
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self.approval_key: Optional[str] = None
|
||||
|
||||
# Reference Counting: Code -> Set of Sources
|
||||
# e.g. "005930": {"HOLDING", "FRONTEND_DASHBOARD"}
|
||||
self.subscriptions: Dict[str, Set[str]] = {}
|
||||
|
||||
self.running = False
|
||||
self.data_map: Dict[str, Dict] = {}
|
||||
|
||||
# Realtime Data Cache (Code -> DataDict)
|
||||
# Used by Scheduler to persist data periodically
|
||||
self.price_cache: Dict[str, Dict] = {}
|
||||
|
||||
async def add_subscription(self, code: str, source: str):
|
||||
"""
|
||||
Request subscription. Increments reference count for the code.
|
||||
"""
|
||||
if code not in self.subscriptions:
|
||||
self.subscriptions[code] = set()
|
||||
|
||||
if not self.subscriptions[code]:
|
||||
# First subscriber, Send WS Command
|
||||
await self._send_subscribe(code, "1") # 1=Register
|
||||
|
||||
self.subscriptions[code].add(source)
|
||||
logger.info(f"Subscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}")
|
||||
|
||||
async def remove_subscription(self, code: str, source: str):
|
||||
"""
|
||||
Remove subscription. Decrements reference count.
|
||||
"""
|
||||
if code in self.subscriptions and source in self.subscriptions[code]:
|
||||
self.subscriptions[code].remove(source)
|
||||
logger.info(f"Unsubscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}")
|
||||
|
||||
if not self.subscriptions[code]:
|
||||
# No more subscribers, Send WS Unsubscribe
|
||||
await self._send_subscribe(code, "2") # 2=Unregister
|
||||
del self.subscriptions[code]
|
||||
|
||||
async def _send_subscribe(self, code: str, tr_type: str):
|
||||
if not self.ws or not self.approval_key:
|
||||
return # Will resubscribe on connect
|
||||
|
||||
payload = {
|
||||
"header": {
|
||||
"approval_key": self.approval_key,
|
||||
"custtype": "P",
|
||||
"tr_type": tr_type,
|
||||
"content-type": "utf-8"
|
||||
},
|
||||
"body": {
|
||||
"input": {
|
||||
"tr_id": "H0STCNT0",
|
||||
"tr_key": code
|
||||
}
|
||||
}
|
||||
}
|
||||
await self.ws.send(json.dumps(payload))
|
||||
|
||||
async def _resubscribe_all(self):
|
||||
for code in list(self.subscriptions.keys()):
|
||||
await self._send_subscribe(code, "1")
|
||||
|
||||
async def _listen(self):
|
||||
async for message in self.ws:
|
||||
try:
|
||||
if isinstance(message, bytes): message = message.decode('utf-8')
|
||||
|
||||
first_char = message[0]
|
||||
if first_char in ['0', '1']:
|
||||
# Real Data
|
||||
parts = message.split('|')
|
||||
if len(parts) < 4: continue
|
||||
tr_id = parts[1]
|
||||
raw_data = parts[3]
|
||||
|
||||
if tr_id == "H0STCNT0":
|
||||
await self._parse_domestic_price(raw_data)
|
||||
|
||||
elif first_char == '{':
|
||||
data = json.loads(message)
|
||||
if data.get('header', {}).get('tr_id') == "PINGPONG":
|
||||
await self.ws.send(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WS Error: {e}")
|
||||
|
||||
async def _parse_domestic_price(self, raw_data: str):
|
||||
# Format: MKSC_SHRN_ISCD^EXEC_TIME^CURRENT_PRICE^...
|
||||
fields = raw_data.split('^')
|
||||
if len(fields) < 3: return
|
||||
|
||||
code = fields[0]
|
||||
curr_price = fields[2]
|
||||
change = fields[4]
|
||||
change_rate = fields[5]
|
||||
|
||||
# Create lightweight update object (Dict)
|
||||
update_data = {
|
||||
"code": code,
|
||||
"price": curr_price,
|
||||
"change": change,
|
||||
"rate": change_rate,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Update Cache
|
||||
self.price_cache[code] = update_data
|
||||
# logger.debug(f"Price Update: {code} {curr_price}")
|
||||
realtime_manager = RealtimeManager()
|
||||
104
backend/app/services/sync_service.py
Normal file
104
backend/app/services/sync_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import AccountStatus, Holding
|
||||
from app.services.kis_client import kis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SyncService:
|
||||
async def sync_account(self, db: AsyncSession):
|
||||
"""
|
||||
Fetches balance from KIS and updates DB (AccountStatus & Holdings).
|
||||
Currently supports Domestic only.
|
||||
"""
|
||||
logger.info("SyncService: Starting Account Sync...")
|
||||
try:
|
||||
# 1. Fetch Domestic Balance
|
||||
# kis_client.get_balance returns dict with 'output1', 'output2' or None
|
||||
res = await kis_client.get_balance("Domestic")
|
||||
if not res:
|
||||
logger.error("SyncService: Failed to fetch balance (API Error or No Data).")
|
||||
return
|
||||
|
||||
# output1: Holdings List
|
||||
# output2: Account Summary
|
||||
output1 = res.get('output1', [])
|
||||
output2 = res.get('output2', [])
|
||||
|
||||
# KIS API returns output2 as a LIST of 1 dict usually
|
||||
summary_data = output2[0] if output2 else {}
|
||||
|
||||
# --- Update AccountStatus ---
|
||||
# Map KIS fields to AccountStatus model
|
||||
# tot_evlu_amt: 총평가금액 (Total Assets)
|
||||
# dnca_tot_amt: 예수금총액 (Buying Power)
|
||||
# evlu_pfls_smt_tl: 평가손익합계 (Daily Profit - approximation)
|
||||
# evlu_pfls_rt: 수익률
|
||||
|
||||
total_assets = float(summary_data.get('tot_evlu_amt', 0) or 0)
|
||||
buying_power = float(summary_data.get('dnca_tot_amt', 0) or 0)
|
||||
daily_profit = float(summary_data.get('evlu_pfls_smt_tl', 0) or 0)
|
||||
|
||||
# Calculate daily profit rate if not provided directly
|
||||
# profit_rate = float(summary_data.get('evlu_pfls_rt', 0)) # Sometimes available
|
||||
daily_profit_rate = 0.0
|
||||
if total_assets > 0:
|
||||
daily_profit_rate = (daily_profit / total_assets) * 100
|
||||
|
||||
# Upsert AccountStatus (ID=1)
|
||||
stmt = select(AccountStatus).where(AccountStatus.id == 1)
|
||||
result = await db.execute(stmt)
|
||||
status = result.scalar_one_or_none()
|
||||
|
||||
if not status:
|
||||
status = AccountStatus(id=1)
|
||||
db.add(status)
|
||||
|
||||
status.totalAssets = total_assets
|
||||
status.buyingPower = buying_power
|
||||
status.dailyProfit = daily_profit
|
||||
status.dailyProfitRate = daily_profit_rate
|
||||
|
||||
# --- Update Holdings ---
|
||||
# Strategy: Delete all existing holdings (refresh) or Upsert?
|
||||
# Refresh is safer to remove sold items.
|
||||
await db.execute(delete(Holding))
|
||||
|
||||
for item in output1:
|
||||
# Map fields
|
||||
# pdno: 종목번호
|
||||
# prdt_name: 종목명
|
||||
# hldg_qty: 보유수량
|
||||
# pchs_avg_pric: 매입평균가격
|
||||
# prpr: 현재가
|
||||
# evlu_pfls_amt: 평가손익금액
|
||||
# evlu_pfls_rt: 평가손익율
|
||||
# evlu_amt: 평가금액
|
||||
|
||||
code = item.get('pdno')
|
||||
if not code: continue
|
||||
|
||||
h = Holding(
|
||||
stockCode=code,
|
||||
stockName=item.get('prdt_name', 'Unknown'),
|
||||
quantity=int(item.get('hldg_qty', 0) or 0),
|
||||
avgPrice=float(item.get('pchs_avg_pric', 0) or 0),
|
||||
currentPrice=float(item.get('prpr', 0) or 0),
|
||||
profit=float(item.get('evlu_pfls_amt', 0) or 0),
|
||||
profitRate=float(item.get('evlu_pfls_rt', 0) or 0),
|
||||
marketValue=float(item.get('evlu_amt', 0) or 0)
|
||||
)
|
||||
db.add(h)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"SyncService: Account Sync Complete. Assets: {total_assets}, Holdings: {len(output1)}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"SyncService: Error during sync: {e}")
|
||||
|
||||
sync_service = SyncService()
|
||||
33
backend/app/services/telegram_service.py
Normal file
33
backend/app/services/telegram_service.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TelegramService:
|
||||
def __init__(self):
|
||||
self.base_url = "https://api.telegram.org/bot"
|
||||
|
||||
async def send_message(self, token: str, chat_id: str, text: str) -> bool:
|
||||
if not token or not chat_id:
|
||||
logger.warning("Telegram: Token or Chat ID is missing.")
|
||||
return False
|
||||
|
||||
url = f"{self.base_url}{token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Telegram: Message sent successfully to {chat_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram: Failed to send message: {e}")
|
||||
return False
|
||||
|
||||
telegram_service = TelegramService()
|
||||
BIN
backend/app/workers/__pycache__/scheduler.cpython-312.pyc
Normal file
BIN
backend/app/workers/__pycache__/scheduler.cpython-312.pyc
Normal file
Binary file not shown.
221
backend/app/workers/scheduler.py
Normal file
221
backend/app/workers/scheduler.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.core.market_schedule import market_schedule
|
||||
from app.services.kis_client import kis_client
|
||||
from app.services.realtime_manager import realtime_manager
|
||||
from app.db.database import SessionLocal
|
||||
from app.core.startup import check_kis_connectivity, check_telegram_connectivity
|
||||
from app.db.models import StockItem, ReservedOrder, ApiSettings
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
class ServiceState:
|
||||
last_keys_hash: str = ""
|
||||
telegram_active: bool = False
|
||||
naver_active: bool = False
|
||||
|
||||
@classmethod
|
||||
def get_keys_hash(cls, settings: ApiSettings):
|
||||
return f"{settings.appKey}{settings.appSecret}{settings.accountNumber}"
|
||||
|
||||
async def service_watchdog_job():
|
||||
"""
|
||||
Periodically checks settings and initiates services if they were newly enabled or changed.
|
||||
"""
|
||||
async with SessionLocal() as db_session:
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db_session.execute(stmt)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj: return
|
||||
|
||||
# 1. KIS Connectivity Watchdog
|
||||
current_hash = ServiceState.get_keys_hash(settings_obj)
|
||||
if current_hash != ServiceState.last_keys_hash and settings_obj.appKey:
|
||||
logger.info("Watchdog: KIS Credentials Changed or Initialized. Re-authenticating...")
|
||||
success = await check_kis_connectivity(db_session, settings_obj)
|
||||
if success:
|
||||
ServiceState.last_keys_hash = current_hash
|
||||
|
||||
# 2. Telegram Watchdog
|
||||
if settings_obj.useTelegram and not ServiceState.telegram_active:
|
||||
if settings_obj.telegramToken and settings_obj.telegramChatId:
|
||||
logger.info("Watchdog: Telegram newly enabled. Sending activation message.")
|
||||
from app.services.telegram_service import telegram_service
|
||||
msg = "🔔 <b>알림 서비스가 활성화되었습니다.</b>\n이제부터 주요 거래 알림을 이곳으로 보내드립니다."
|
||||
await telegram_service.send_message(settings_obj.telegramToken, settings_obj.telegramChatId, msg)
|
||||
ServiceState.telegram_active = True
|
||||
elif not settings_obj.useTelegram:
|
||||
ServiceState.telegram_active = False
|
||||
|
||||
# 3. Naver News Watchdog
|
||||
if settings_obj.useNaverNews and not ServiceState.naver_active:
|
||||
if settings_obj.naverClientId and settings_obj.naverClientSecret:
|
||||
logger.info("Watchdog: Naver News newly enabled. (Logic placeholder)")
|
||||
ServiceState.naver_active = True
|
||||
elif not settings_obj.useNaverNews:
|
||||
ServiceState.naver_active = False
|
||||
|
||||
async def market_check_job():
|
||||
"""
|
||||
Periodic check to ensure Realtime Manager is connected when market is open.
|
||||
"""
|
||||
logger.info("Scheduler: Market Check Job Running...")
|
||||
is_domestic_open = market_schedule.is_market_open("Domestic")
|
||||
# is_overseas_open = market_schedule.is_market_open("Overseas")
|
||||
|
||||
# If market is open and WS is not running, start it
|
||||
if is_domestic_open and not realtime_manager.running:
|
||||
logger.info("Market is Open! Starting Realtime Manager.")
|
||||
else:
|
||||
logger.info(f"Market Status: Domestic={is_domestic_open}, RealtimeManager={realtime_manager.running}")
|
||||
|
||||
async def persist_market_data_job():
|
||||
"""
|
||||
Flushes realtime_manager.price_cache to DB (StockItem).
|
||||
"""
|
||||
if not realtime_manager.price_cache:
|
||||
return
|
||||
|
||||
# Snapshot and clear (thread-safe ish in async single loop)
|
||||
# Or just iterate. Python dict iteration is safe if not modifying keys.
|
||||
# We'll take keys copy.
|
||||
codes_to_process = list(realtime_manager.price_cache.keys())
|
||||
|
||||
if not codes_to_process:
|
||||
return
|
||||
|
||||
async with SessionLocal() as session:
|
||||
count = 0
|
||||
for code in codes_to_process:
|
||||
data = realtime_manager.price_cache.get(code)
|
||||
if not data: continue
|
||||
|
||||
# Upsert StockItem?
|
||||
# Or just update if exists. StockItem generally exists if synced.
|
||||
# If not exists, we might create it.
|
||||
|
||||
price = float(data['price'])
|
||||
change = float(data['change'])
|
||||
rate = float(data['rate'])
|
||||
|
||||
# Simple Update
|
||||
stmt = update(StockItem).where(StockItem.code == code).values(
|
||||
price=price,
|
||||
change=change,
|
||||
changePercent=rate
|
||||
)
|
||||
await session.execute(stmt)
|
||||
count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
# logger.debug(f"Persisted {count} stock prices to DB.")
|
||||
|
||||
async def news_scrap_job():
|
||||
# Placeholder for News Scraper
|
||||
logger.info("Scheduler: Scraping Naver News (Placeholder)...")
|
||||
pass
|
||||
|
||||
async def auto_trade_scan_job():
|
||||
"""
|
||||
Scans ReservedOrders and triggers actions if conditions are met.
|
||||
"""
|
||||
async with SessionLocal() as session:
|
||||
# Fetch Monitoring Orders
|
||||
stmt = select(ReservedOrder).where(ReservedOrder.status == "MONITORING")
|
||||
result = await session.execute(stmt)
|
||||
orders = result.scalars().all()
|
||||
|
||||
for order in orders:
|
||||
# Check Price
|
||||
current_price = 0
|
||||
|
||||
# 1. Try Realtime Cache
|
||||
if order.stockCode in realtime_manager.price_cache:
|
||||
current_price = float(realtime_manager.price_cache[order.stockCode]['price'])
|
||||
else:
|
||||
# 2. Try DB (StockItem)
|
||||
# ... (omitted for speed, usually Cache covers if monitored)
|
||||
pass
|
||||
|
||||
if current_price <= 0: continue
|
||||
|
||||
# Trigger Logic (Simple)
|
||||
triggered = False
|
||||
if order.monitoringType == "PRICE_TRIGGER":
|
||||
# Buy if Price <= Trigger (Dip Buy) ?? OR Buy if Price >= Trigger (Breakout)?
|
||||
# Usually define "Condition". Assuming Buy Lower for now or User defined.
|
||||
# Let's assume TriggerPrice is "Target".
|
||||
# If BUY -> Price <= Trigger?
|
||||
# If SELL -> Price >= Trigger?
|
||||
|
||||
if order.type == "BUY" and current_price <= order.triggerPrice:
|
||||
triggered = True
|
||||
elif order.type == "SELL" and current_price >= order.triggerPrice:
|
||||
triggered = True
|
||||
|
||||
elif order.monitoringType == "TRAILING_STOP":
|
||||
if order.type == "SELL":
|
||||
# Trailing Sell (High Water Mark)
|
||||
if not order.highestPrice or current_price > order.highestPrice:
|
||||
order.highestPrice = current_price
|
||||
|
||||
if order.highestPrice > 0 and order.trailingValue:
|
||||
drop = order.highestPrice - current_price
|
||||
triggered = False
|
||||
|
||||
if order.trailingType == "PERCENT":
|
||||
drop_rate = (drop / order.highestPrice) * 100
|
||||
if drop_rate >= order.trailingValue: triggered = True
|
||||
else: # AMOUNT
|
||||
if drop >= order.trailingValue: triggered = True
|
||||
|
||||
if triggered:
|
||||
logger.info(f"TS SELL Triggered: High={order.highestPrice}, Curr={current_price}")
|
||||
|
||||
elif order.type == "BUY":
|
||||
# Trailing Buy (Low Water Mark / Rebound)
|
||||
# Initialize lowest if 0 (assuming stock price never 0, or use 99999999 default)
|
||||
if not order.lowestPrice or order.lowestPrice == 0:
|
||||
order.lowestPrice = current_price
|
||||
|
||||
if current_price < order.lowestPrice:
|
||||
order.lowestPrice = current_price
|
||||
|
||||
if order.lowestPrice > 0 and order.trailingValue:
|
||||
rise = current_price - order.lowestPrice
|
||||
triggered = False
|
||||
|
||||
if order.trailingType == "PERCENT":
|
||||
rise_rate = (rise / order.lowestPrice) * 100
|
||||
if rise_rate >= order.trailingValue: triggered = True
|
||||
else:
|
||||
if rise >= order.trailingValue: triggered = True
|
||||
|
||||
if triggered:
|
||||
logger.info(f"TS BUY Triggered: Low={order.lowestPrice}, Curr={current_price}")
|
||||
|
||||
if triggered:
|
||||
logger.info(f"Order TRIGGERED! {order.stockName} {order.type} @ {current_price} (Target: {order.triggerPrice})")
|
||||
|
||||
# Execute Order
|
||||
# res = await kis_client.place_order(...)
|
||||
# Update Status
|
||||
# order.status = "TRIGGERED" (or "COMPLETED" after exec)
|
||||
pass
|
||||
|
||||
await session.commit()
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.add_job(market_check_job, 'interval', minutes=5)
|
||||
scheduler.add_job(news_scrap_job, 'interval', minutes=10)
|
||||
scheduler.add_job(auto_trade_scan_job, 'interval', minutes=1)
|
||||
scheduler.add_job(service_watchdog_job, 'interval', minutes=1) # Check settings every 1 min
|
||||
scheduler.add_job(persist_market_data_job, 'interval', seconds=10) # Persist every 10s
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler Started.")
|
||||
BIN
backend/kis_stock.db
Normal file
BIN
backend/kis_stock.db
Normal file
Binary file not shown.
@@ -23,26 +23,32 @@
|
||||
### 2.1 StockItem (종목 정보 - 확장됨)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| code | string | Y | 종목 코드 (예: "005930", "NVDA") |
|
||||
| code | string | Y | 종목 코드 |
|
||||
| name | string | Y | 종목 명칭 |
|
||||
| price | number | Y | 현재가 |
|
||||
| change | number | Y | 전일 대비 등락 금액 |
|
||||
| changePercent | number | Y | 전일 대비 등락률 (%) |
|
||||
| market | MarketType | Y | 소속 시장 |
|
||||
| volume | number | Y | 거래량 |
|
||||
| tradingValue | number | N | 거래대금 (발굴 페이지용) |
|
||||
| tradingValue | number | N | 거래대금 |
|
||||
| buyRatio | number | N | 실시간 매수 비율 (0~100) |
|
||||
| sellRatio | number | N | 실시간 매도 비율 (0~100) |
|
||||
| foreignNetBuy | number | N | 외국인 순매수량 |
|
||||
| institutionalNetBuy | number | N | 기관 순매수량 |
|
||||
| per | number | N | 주가수익비율 |
|
||||
| pbr | number | N | 주가순자산비율 |
|
||||
| roe | number | N | 자기자본이익률 |
|
||||
| per | number | N | PER |
|
||||
| pbr | number | N | PBR |
|
||||
| roe | number | N | ROE |
|
||||
| marketCap | number | N | 시가총액 |
|
||||
| dividendYield | number | N | 배당수익률 |
|
||||
| aiScoreBuy | number | Y | AI 매수 점수 (0~100) |
|
||||
| aiScoreSell | number | Y | AI 매도 점수 (0~100) |
|
||||
| themes | string[] | N | 연관 테마 리스트 (JSON Array) |
|
||||
| openPrice | number | N | 시가 |
|
||||
| highPrice | number | N | 고가 |
|
||||
| lowPrice | number | N | 저가 |
|
||||
| themes | string[] | N | 연관 테마 |
|
||||
| memo | string | N | 사용자 메모 |
|
||||
| aiAnalysis | string | N | AI 분석 리포트 전문 |
|
||||
| isHidden | boolean | N | 숨김 여부 |
|
||||
|
||||
### 2.2 ApiSettings (시스템 설정)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
@@ -56,6 +62,8 @@
|
||||
| useNaverNews | boolean | Y | 네이버 뉴스 스크랩 여부 |
|
||||
| naverClientId | string | N | 네이버 Client ID |
|
||||
| naverClientSecret | string | N | 네이버 Client Secret |
|
||||
| kisApiDelayMs | number | N | KIS API 호출 간격 (ms, 기본 250) |
|
||||
| newsScrapIntervalMin | number | N | 뉴스 수집 주기 (분, 기본 10) |
|
||||
| aiConfigs | AiConfig[] | Y | 등록된 AI 엔진 목록 |
|
||||
| preferredNewsAiId | string | N | 뉴스 분석용 AI ID (FK) |
|
||||
| preferredStockAiId | string | N | 종목 분석용 AI ID (FK) |
|
||||
@@ -69,20 +77,20 @@
|
||||
| id | string | Y | 고유 식별자 |
|
||||
| name | string | Y | 엔진 별칭 |
|
||||
| providerType | AiProviderType | Y | 제공자 유형 |
|
||||
| modelName | string | Y | 모델 명칭 (예: "gemini-3-flash-preview") |
|
||||
| baseUrl | string | N | API 엔드포인트 URL (Ollama 등) |
|
||||
| modelName | string | Y | 모델 명칭 |
|
||||
| baseUrl | string | N | API 엔드포인트 URL |
|
||||
|
||||
### 2.4 AutoTradeConfig (자동매매 로봇)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| id | string | Y | 식별자 |
|
||||
| stockCode | string | N | 종목 코드 (개별 종목 대상인 경우) |
|
||||
| stockCode | string | N | 종목 코드 |
|
||||
| stockName | string | Y | 대상 명칭 |
|
||||
| groupId | string | N | 관심 그룹 ID (그룹 대상인 경우) |
|
||||
| type | string | Y | "ACCUMULATION" (적립식) \| "TRAILING_STOP" |
|
||||
| quantity | number | Y | 실행 시 주문 수량 |
|
||||
| groupId | string | N | 관심 그룹 ID |
|
||||
| type | string | Y | "ACCUMULATION" \| "TRAILING_STOP" |
|
||||
| quantity | number | Y | 주문 수량 |
|
||||
| frequency | string | Y | "DAILY" \| "WEEKLY" \| "MONTHLY" |
|
||||
| specificDay | number | N | 실행일 (0-6 요일 또는 1-31 날짜) |
|
||||
| specificDay | number | N | 실행일 |
|
||||
| executionTime | string | Y | 실행 시각 ("HH:mm") |
|
||||
| trailingPercent | number | N | TS 사용 시 퍼센트 |
|
||||
| active | boolean | Y | 활성화 상태 |
|
||||
@@ -100,11 +108,53 @@
|
||||
| triggerPrice | number | Y | 감시 기준 가격 |
|
||||
| trailingType | string | Y | "PERCENT" \| "AMOUNT" |
|
||||
| trailingValue | number | Y | 간격 값 |
|
||||
| useStopLoss | boolean | N | 손절 기능 사용 여부 |
|
||||
| stopLossType | string | N | "PERCENT" \| "AMOUNT" |
|
||||
| stopLossValue | number | N | 손절 기준값 |
|
||||
| sellAll | boolean | N | 전량 매도 여부 |
|
||||
| highestPrice | number | N | 감시 시작 후 최고가(Trailing용) |
|
||||
| lowestPrice | number | N | 감시 시작 후 최저가(Trailing용) |
|
||||
| market | MarketType | Y | 마켓 구분 |
|
||||
| status | string | Y | "WAITING" \| "MONITORING" \| "TRIGGERED" \| "CANCELLED" |
|
||||
| createdAt | string | Y | 생성 일시 (ISO 8601) |
|
||||
| expiryDate | string | Y | 만료 일시 (ISO 8601) |
|
||||
|
||||
### 2.6 WatchlistGroup (관심 종목 그룹)
|
||||
### 2.6 NewsItem (뉴스 정보)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| title | string | Y | 뉴스 제목 |
|
||||
| description | string | Y | 뉴스 요약 내용 |
|
||||
| link | string | Y | 원문 링크 URL |
|
||||
| pubDate | string | Y | 발행 일시 (ISO 8601) |
|
||||
| sentiment | string | N | AI 분석 심리 ("POSITIVE" \| "NEGATIVE" \| "NEUTRAL") |
|
||||
| relatedThemes | string[] | N | AI 추출 연관 테마 |
|
||||
| relatedStocks | string[] | N | AI 추출 연관 종목명/코드 |
|
||||
|
||||
### 2.7 TradeOrder (체결 기록 / 주문)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| id | string | Y | 고유 식별자 |
|
||||
| stockCode | string | Y | 종목 코드 |
|
||||
| stockName | string | Y | 종목 명칭 |
|
||||
| type | string | Y | "BUY" \| "SELL" |
|
||||
| quantity | number | Y | 체결 수량 |
|
||||
| price | number | Y | 체결 단가 |
|
||||
| timestamp | string | Y | 체결 시각 (ISO 8601) |
|
||||
| status | string | Y | "COMPLETED" \| "CANCELLED" \| "FAILED" |
|
||||
|
||||
### 2.8 HoldingItem (보유 잔고 종목)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| stockCode | string | Y | 종목 코드 |
|
||||
| stockName | string | Y | 종목 명칭 |
|
||||
| quantity | number | Y | 보유 수량 |
|
||||
| avgPrice | number | Y | 평균 매입가 |
|
||||
| currentPrice | number | Y | 현재가 |
|
||||
| profit | number | Y | 평가 손익 |
|
||||
| profitRate | number | Y | 수익률 (%) |
|
||||
| marketValue | number | Y | 평가 금액 |
|
||||
|
||||
### 2.9 WatchlistGroup (관심 종목 그룹)
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| id | string | Y | 식별자 |
|
||||
@@ -112,11 +162,11 @@
|
||||
| codes | string[] | Y | 포함된 종목 코드 배열 |
|
||||
| market | MarketType | Y | 마켓 구분 |
|
||||
|
||||
## 3. Discovery Models (종목 발굴용 전용 모델)
|
||||
## 3. Discovery & AI Models (특수 목적 모델)
|
||||
|
||||
### 3.1 RankingCategory
|
||||
- **Type**: `string`
|
||||
- **Values**: `"VOLUME"` (거래량), `"VALUE"` (거래대금), `"GAIN"` (상승), `"LOSS"` (하락), `"FOREIGN_BUY"` (외인매수), `"INSTITUTION_BUY"` (기관매수)
|
||||
- **Values**: `"VOLUME"`, `"VALUE"`, `"GAIN"`, `"LOSS"`, `"FOREIGN_BUY"`, `"INSTITUTION_BUY"`
|
||||
|
||||
### 3.2 DiscoveryRankingResponse
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
@@ -124,3 +174,14 @@
|
||||
| category | RankingCategory | Y | 랭킹 카테고리 |
|
||||
| updatedAt | string | Y | 랭킹 갱신 시각 (ISO 8601) |
|
||||
| items | StockItem[] | Y | 순위별 종목 리스트 |
|
||||
|
||||
### 3.3 AiAnalysisMetadata
|
||||
- **Description**: 뉴스 리포트 분석 시 각 기사별로 매칭되는 메타데이터 구조.
|
||||
```json
|
||||
{
|
||||
"index": number,
|
||||
"sentiment": "POSITIVE" | "NEGATIVE" | "NEUTRAL",
|
||||
"themes": string[],
|
||||
"stocks": string[]
|
||||
}
|
||||
```
|
||||
|
||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
sqlalchemy>=2.0.0
|
||||
aiosqlite>=0.19.0
|
||||
pydantic-settings>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
pytz
|
||||
python-multipart
|
||||
pycryptodome
|
||||
apscheduler
|
||||
1
backend/run.bat
Normal file
1
backend/run.bat
Normal file
@@ -0,0 +1 @@
|
||||
uvicorn app.main:app
|
||||
@@ -2,78 +2,107 @@
|
||||
|
||||
이 문서는 데이터베이스 설계를 위한 테이블 명세서입니다. 모든 컬럼명은 [models.md](./models.md)의 필드명과 일치해야 합니다.
|
||||
|
||||
## 1. api_settings (사용자 및 API 설정)
|
||||
- 단일 사용자 환경이므로 `id=1` 레코드만 사용.
|
||||
- `aiConfigs`는 별도의 `ai_configs` 테이블과 Join하여 처리.
|
||||
---
|
||||
|
||||
## 2. ai_configs (AI 엔진 프로필)
|
||||
- `id`: TEXT (PK)
|
||||
- `name`: TEXT
|
||||
- `providerType`: TEXT (gemini | openai-compatible)
|
||||
- `modelName`: TEXT
|
||||
- `baseUrl`: TEXT (Nullable)
|
||||
## 1. System & Config (설정 제어)
|
||||
|
||||
## 3. holdings (현재 보유 종목)
|
||||
- `code`: TEXT (PK)
|
||||
- `name`: TEXT
|
||||
- `avgPrice`: REAL
|
||||
- `quantity`: INTEGER
|
||||
- `market`: TEXT (Domestic | Overseas)
|
||||
### 1.1 `api_settings` (전체 시스템 설정)
|
||||
- **Primary Key**: `id` (INTEGER, ALWAYS 1)
|
||||
- **Columns**:
|
||||
- `appKey`, `appSecret`, `accountNumber`: TEXT
|
||||
- `useTelegram`, `useNaverNews`: BOOLEAN
|
||||
- `telegramToken`, `telegramChatId`: TEXT
|
||||
- `naverClientId`, `naverClientSecret`: TEXT
|
||||
- `kisApiDelayMs`, `newsScrapIntervalMin`: INTEGER
|
||||
- `preferredNewsAiId`, `preferredStockAiId`, `preferredNewsJudgementAiId`, `preferredAutoBuyAiId`, `preferredAutoSellAiId`: TEXT (FK to `ai_configs.id`)
|
||||
|
||||
## 4. auto_trade_configs (자동매매 로봇)
|
||||
- `id`: TEXT (PK)
|
||||
- `stockCode`: TEXT (Nullable)
|
||||
- `stockName`: TEXT
|
||||
- `groupId`: TEXT (Nullable)
|
||||
- `type`: TEXT (ACCUMULATION | TRAILING_STOP)
|
||||
- `quantity`: INTEGER
|
||||
- `frequency`: TEXT (DAILY | WEEKLY | MONTHLY)
|
||||
- `specificDay`: INTEGER
|
||||
- `executionTime`: TEXT
|
||||
- `trailingPercent`: REAL
|
||||
- `active`: BOOLEAN
|
||||
- `market`: TEXT
|
||||
### 1.2 `ai_configs` (AI 엔진 프로필 저장소)
|
||||
- **Primary Key**: `id` (TEXT)
|
||||
- **Columns**: `name`, `providerType`, `modelName`, `baseUrl` (TEXT)
|
||||
|
||||
## 5. reserved_orders (실시간 감시/예약 주문)
|
||||
- `id`: TEXT (PK)
|
||||
- `stockCode`: TEXT
|
||||
- `stockName`: TEXT
|
||||
- `type`: TEXT (BUY | SELL)
|
||||
- `quantity`: INTEGER
|
||||
- `monitoringType`: TEXT
|
||||
- `triggerPrice`: REAL
|
||||
- `trailingType`: TEXT
|
||||
- `trailingValue`: REAL
|
||||
- `status`: TEXT
|
||||
- `createdAt`: DATETIME
|
||||
- `expiryDate`: DATETIME
|
||||
---
|
||||
|
||||
## 6. watchlist_groups (관심 종목 그룹)
|
||||
- `id`: TEXT (PK)
|
||||
- `name`: TEXT
|
||||
- `codes`: TEXT (JSON String Array - e.g. '["005930", "NVDA"]')
|
||||
- `market`: TEXT
|
||||
## 2. Account & Portfolio (자산 데이터)
|
||||
|
||||
## 7. [NEW] discovery_rank_cache (발굴 랭킹 캐시)
|
||||
- `category`: TEXT (PK - VOLUME, VALUE 등)
|
||||
- `market`: TEXT (PK)
|
||||
- `rank_json`: TEXT (JSON String - StockItem 리스트 보관)
|
||||
- `updated_at`: DATETIME
|
||||
- **용도**: 랭킹 연산은 리소스가 많이 들므로 1~5분 단위로 배치 처리 후 캐시된 데이터를 API로 제공.
|
||||
### 2.1 `account_status` (계좌 요약 정보)
|
||||
- **Primary Key**: `id` (INTEGER, ALWAYS 1)
|
||||
- **Columns**: `totalAssets`, `buyingPower`, `dailyProfit`, `dailyProfitRate` (REAL)
|
||||
- **Update Frequency**: API 동기화 마다 갱신.
|
||||
|
||||
## 8. [NEW] stock_stats (종목별 확장 통계)
|
||||
- `code`: TEXT (PK)
|
||||
- `tradingValue`: REAL
|
||||
- `buyRatio`: INTEGER
|
||||
- `sellRatio`: INTEGER
|
||||
- `foreignNetBuy`: INTEGER
|
||||
- `institutionalNetBuy`: INTEGER
|
||||
- **용도**: `StockItem` 테이블을 직접 확장하거나 별도 통계 테이블로 관리하여 발굴 데이터 조회 성능 최적화.
|
||||
## 9. market_index_history (시장 지수 이력)
|
||||
- `index_id`: TEXT (PK - KOSPI, KOSDAQ, NASDAQ, SP500, USDKRW 등)
|
||||
- `timestamp`: DATETIME (PK - 1시간 단위 정규화된 시각)
|
||||
- `value`: REAL
|
||||
- `change`: REAL
|
||||
- `change_percent`: REAL
|
||||
- `updated_at`: DATETIME (실제 마지막 갱신 시각)
|
||||
- **용도**: 트렌드 분석 및 대시보드 인덱스 카드 표시용. 백엔드가 5분 단위로 조회하되, DB에는 1시간 단위로 마지막 데이터를 Upsert 하여 누적.
|
||||
### 2.2 `holdings` (보유 자산 목록)
|
||||
- **Primary Key**: `stockCode` (TEXT)
|
||||
- **Columns**: `stockName` (TEXT), `quantity` (INTEGER), `avgPrice`, `currentPrice`, `profit`, `profitRate`, `marketValue` (REAL)
|
||||
|
||||
---
|
||||
|
||||
## 3. Market & Discovery (시세 및 탐색 데이터)
|
||||
|
||||
### 3.1 `master_stocks` (전체 종목 마스터)
|
||||
- **Primary Key**: `code` (TEXT)
|
||||
- **Columns**:
|
||||
- `name`, `market` (TEXT)
|
||||
- `per`, `pbr`, `roe`, `marketCap`, `dividendYield` (REAL)
|
||||
- `memo` (TEXT), `isHidden` (BOOLEAN)
|
||||
- **용도**: 종목 검색, 메타데이터 제공 및 사용자 설정(메모/숨김) 저장.
|
||||
|
||||
### 3.2 `news_cache` (뉴스 및 분석 캐시)
|
||||
- **Primary Key**: `news_id` (TEXT - URL 해시 등)
|
||||
- **Columns**:
|
||||
- `title`, `description`, `link`, `pubDate`: TEXT
|
||||
- `sentiment`: TEXT
|
||||
- `relatedThemes`, `relatedStocks`: TEXT (JSON Array)
|
||||
- **용도**: 동일 뉴스에 대한 중복 AI 분석 방지 및 히스토리 제공.
|
||||
|
||||
### 3.3 `discovery_ranking_cache` (발굴 랭킹 데이터)
|
||||
- **Primary Key**: `category` + `market` (Composite)
|
||||
- **Columns**: `updated_at` (DATETIME), `items_json` (TEXT - `StockItem[]` JSON)
|
||||
|
||||
### 3.4 `stock_stats` (종목별 확장 통계)
|
||||
- **Primary Key**: `code` (TEXT)
|
||||
- **Columns**:
|
||||
- `tradingValue` (REAL)
|
||||
- `buyRatio`, `sellRatio`, `foreignNetBuy`, `institutionalNetBuy` (INTEGER)
|
||||
- `aiScoreBuy`, `aiScoreSell` (INTEGER)
|
||||
- **용도**: `StockItem` 확장을 위한 분석/수급 데이터 저장.
|
||||
|
||||
---
|
||||
|
||||
## 4. Watchlist (보관함)
|
||||
|
||||
### 4.1 `watchlist_groups` (그룹 정의)
|
||||
- **Primary Key**: `id` (TEXT)
|
||||
- **Columns**: `name` (TEXT), `market` (TEXT)
|
||||
|
||||
### 4.2 `watchlist_items` (그룹별 포함 종목)
|
||||
- **Primary Key**: `group_id` + `stock_code` (Composite)
|
||||
- **Columns**: `added_at` (DATETIME)
|
||||
- **Relationship**: `group_id` -> `watchlist_groups.id`
|
||||
|
||||
---
|
||||
|
||||
## 5. Trading & Automation (거래 및 자동화)
|
||||
|
||||
### 5.1 `trade_history` (전체 체결 내역)
|
||||
- **Primary Key**: `id` (TEXT)
|
||||
- **Columns**:
|
||||
- `stockCode`, `stockName`, `type` (TEXT)
|
||||
- `quantity` (INTEGER), `price` (REAL)
|
||||
- `timestamp` (DATETIME), `status` (TEXT)
|
||||
|
||||
### 5.2 `auto_trade_robots` (자동매매 전략 로봇)
|
||||
- **Primary Key**: `id` (TEXT)
|
||||
- **Columns**:
|
||||
- `stockCode`, `stockName`, `groupId`, `type`, `frequency`, `executionTime`, `market`: TEXT
|
||||
- `quantity`, `specificDay`: INTEGER
|
||||
- `trailingPercent`: REAL
|
||||
- `active`: BOOLEAN
|
||||
|
||||
### 5.3 `reserved_orders` (실시간 감시 주문)
|
||||
- **Primary Key**: `id` (TEXT)
|
||||
- **Columns**:
|
||||
- `stockCode`, `stockName`, `type`, `monitoringType`, `trailingType`, `status`, `market`: TEXT
|
||||
- `quantity`: INTEGER
|
||||
- `triggerPrice`, `trailingValue`, `stopLossValue`, `highestPrice`, `lowestPrice`: REAL
|
||||
- `useStopLoss`, `sellAll`: BOOLEAN
|
||||
- `stopLossType`: TEXT
|
||||
- `createdAt`, `expiryDate`: DATETIME
|
||||
|
||||
1828
backend/tmp_master/kosdaq_code.mst
Normal file
1828
backend/tmp_master/kosdaq_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/tmp_master/kosdaq_code.mst.zip
Normal file
BIN
backend/tmp_master/kosdaq_code.mst.zip
Normal file
Binary file not shown.
2486
backend/tmp_master/kospi_code.mst
Normal file
2486
backend/tmp_master/kospi_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/tmp_master/kospi_code.mst.zip
Normal file
BIN
backend/tmp_master/kospi_code.mst.zip
Normal file
Binary file not shown.
@@ -72,7 +72,7 @@ interface InputGroupProps {
|
||||
}
|
||||
|
||||
export const InputGroup: React.FC<InputGroupProps> = ({ label, value, onChange, placeholder, type = "text", icon }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
{icon} {label}
|
||||
</label>
|
||||
@@ -80,12 +80,36 @@ export const InputGroup: React.FC<InputGroupProps> = ({ label, value, onChange,
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full p-4 bg-slate-50 border border-slate-200 rounded-[1.2rem] focus:border-blue-500 focus:bg-white outline-none transition-all font-bold text-slate-800 placeholder:text-slate-300 shadow-sm"
|
||||
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl focus:border-blue-500 focus:bg-white outline-none transition-all font-bold text-slate-800 placeholder:text-slate-300 text-[13px] shadow-sm"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- SelectGroup: 일관된 디자인의 셀렉트 박스 ---
|
||||
interface SelectGroupProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string, label: string }[];
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SelectGroup: React.FC<SelectGroupProps> = ({ label, value, onChange, options, icon }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
{icon} {label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl focus:border-blue-500 focus:bg-white outline-none transition-all font-bold text-slate-800 text-[13px] shadow-sm appearance-none"
|
||||
>
|
||||
{options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- ToggleButton: 활성화/비활성 스위치 ---
|
||||
interface ToggleButtonProps {
|
||||
active: boolean;
|
||||
|
||||
@@ -24,35 +24,16 @@ interface DashboardProps {
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({
|
||||
marketMode, watchlistGroups, stocks, reservedOrders, onAddReservedOrder, onDeleteReservedOrder, onRefreshHoldings, orders
|
||||
marketMode, stocks, reservedOrders, onAddReservedOrder, onDeleteReservedOrder, onRefreshHoldings, orders
|
||||
}) => {
|
||||
const [holdings, setHoldings] = useState<HoldingItem[]>([]);
|
||||
const [summary, setSummary] = useState({ totalAssets: 0, buyingPower: 0 });
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
|
||||
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
|
||||
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
|
||||
|
||||
const dbService = useMemo(() => new DbService(), []);
|
||||
|
||||
// Data loading
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [orders, marketMode, reservedOrders]);
|
||||
|
||||
const activeMarketGroups = useMemo(() => {
|
||||
return watchlistGroups.filter(group => group.market === marketMode);
|
||||
}, [watchlistGroups, marketMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeMarketGroups.length > 0) {
|
||||
if (!activeGroupId || !activeMarketGroups.find(g => g.id === activeGroupId)) {
|
||||
setActiveGroupId(activeMarketGroups[0].id);
|
||||
}
|
||||
} else {
|
||||
setActiveGroupId(null);
|
||||
}
|
||||
}, [marketMode, activeMarketGroups, activeGroupId]);
|
||||
|
||||
const loadData = async () => {
|
||||
const allHoldings = await dbService.getHoldings();
|
||||
const filteredHoldings = allHoldings.filter(h => h.market === marketMode);
|
||||
@@ -83,33 +64,39 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
? (totalLiquidationSummary.totalPL / totalLiquidationSummary.totalCost) * 100
|
||||
: 0;
|
||||
|
||||
const selectedGroup = activeMarketGroups.find(g => g.id === activeGroupId) || activeMarketGroups[0];
|
||||
// Remove detailStock and tradeContext initialization if not used elsewhere, but they are used in JSX below.
|
||||
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
|
||||
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
|
||||
const dbService = useMemo(() => new DbService(), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] lg:col-span-1">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px]">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tighter">
|
||||
<PieChart size={20} className="text-blue-600" /> 관심 그룹
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 tracking-tighter">
|
||||
<Database size={20} className="text-emerald-600" /> 보유 포트폴리오
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{activeMarketGroups.map(group => (
|
||||
<button key={group.id} onClick={() => setActiveGroupId(group.id)} className={`relative px-4 py-2 rounded-xl font-black text-[12px] transition-all border-2 whitespace-nowrap ${activeGroupId === group.id ? 'bg-white border-blue-500 text-blue-600 shadow-sm' : 'bg-transparent border-slate-50 text-slate-400 hover:border-slate-200'}`}>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<table className="w-full">
|
||||
<div className="overflow-x-auto flex-1 scrollbar-hide">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
|
||||
<th className="pb-3 px-3">종목</th>
|
||||
<th className="pb-3 px-3 text-right">현재가</th>
|
||||
<th className="pb-3 px-3 text-right">수익금 (%)</th>
|
||||
<th className="pb-3 px-3 text-right">주문</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{selectedGroup?.codes.map(code => stocks.find(s => s.code === code)).filter(s => s?.market === marketMode).map(stock => {
|
||||
{holdings.map(holding => {
|
||||
const { pl, plPercent, stock } = calculatePL(holding);
|
||||
if (!stock) return null;
|
||||
return (
|
||||
<StockRow
|
||||
key={stock.code}
|
||||
stock={stock}
|
||||
key={holding.code}
|
||||
stock={stock}
|
||||
showPL={{ pl, percent: plPercent }}
|
||||
showActions={true}
|
||||
onTrade={(type) => setTradeContext({ stock, type })}
|
||||
onClick={() => setDetailStock(stock)}
|
||||
@@ -121,61 +108,23 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[350px]">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 tracking-tighter">
|
||||
<Database size={20} className="text-emerald-600" /> 보유 포트폴리오
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto flex-1 scrollbar-hide">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
|
||||
<th className="pb-3 px-3">종목</th>
|
||||
<th className="pb-3 px-3 text-right">현재가</th>
|
||||
<th className="pb-3 px-3 text-right">수익금 (%)</th>
|
||||
<th className="pb-3 px-3 text-right">주문</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{holdings.map(holding => {
|
||||
const { pl, plPercent, stock } = calculatePL(holding);
|
||||
if (!stock) return null;
|
||||
return (
|
||||
<StockRow
|
||||
key={holding.code}
|
||||
stock={stock}
|
||||
showPL={{ pl, percent: plPercent }}
|
||||
showActions={true}
|
||||
onTrade={(type) => setTradeContext({ stock, type })}
|
||||
onClick={() => setDetailStock(stock)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[274px] overflow-hidden">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 mb-5">
|
||||
<Timer size={20} className="text-blue-600" /> 실시간 감시 목록
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-hide">
|
||||
{reservedOrders.filter(o => o.market === marketMode).map(order => (
|
||||
<div key={order.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex justify-between items-center group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={14} fill="currentColor" /></div>
|
||||
<div><p className="font-black text-[14px] text-slate-800">{order.stockName}</p></div>
|
||||
</div>
|
||||
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-2 bg-white hover:bg-rose-50 rounded-lg text-slate-300 hover:text-rose-500 transition-all shadow-sm">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] overflow-hidden">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 mb-5">
|
||||
<Timer size={20} className="text-blue-600" /> 실시간 감시 목록
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-hide">
|
||||
{reservedOrders.filter(o => o.market === marketMode).map(order => (
|
||||
<div key={order.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex justify-between items-center group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={14} fill="currentColor" /></div>
|
||||
<div><p className="font-black text-[14px] text-slate-800">{order.stockName}</p></div>
|
||||
</div>
|
||||
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-2 bg-white hover:bg-rose-50 rounded-lg text-slate-300 hover:text-rose-500 transition-all shadow-sm">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,63 +9,63 @@ interface HistoryPageProps {
|
||||
|
||||
const HistoryPage: React.FC<HistoryPageProps> = ({ orders }) => {
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-500">
|
||||
<div className="bg-white rounded-[3.5rem] shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30">
|
||||
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-widest">
|
||||
<History size={24} className="text-blue-600" /> 전체 거래 로그
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-50 flex justify-between items-center bg-slate-50/10">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tight">
|
||||
<History size={20} className="text-blue-600" /> 전체 거래 로그
|
||||
</h3>
|
||||
<button className="text-[11px] font-black text-slate-400 hover:text-slate-600 flex items-center gap-2 uppercase tracking-widest">
|
||||
<FileText size={18} /> 데이터 내보내기 (CSV)
|
||||
<button className="text-[10px] font-black text-slate-400 hover:text-slate-600 flex items-center gap-1.5 uppercase tracking-widest transition-colors">
|
||||
<FileText size={16} /> 데이터 내보내기 (CSV)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50/50">
|
||||
<tr className="text-left text-[11px] font-black text-slate-400 uppercase tracking-widest border-b">
|
||||
<th className="px-10 py-6">체결 시퀀스 타임</th>
|
||||
<th className="px-10 py-6">자산명</th>
|
||||
<th className="px-10 py-6">오퍼레이션</th>
|
||||
<th className="px-10 py-6">체결 수량</th>
|
||||
<th className="px-10 py-6">체결 단가</th>
|
||||
<th className="px-10 py-6">트랜잭션 상태</th>
|
||||
<thead className="bg-slate-50/30">
|
||||
<tr className="text-left text-[10px] font-black text-slate-400 uppercase tracking-widest border-b">
|
||||
<th className="px-6 py-3">체결 시퀀스 타임</th>
|
||||
<th className="px-6 py-3">자산명</th>
|
||||
<th className="px-6 py-3">오퍼레이션</th>
|
||||
<th className="px-6 py-3">체결 수량</th>
|
||||
<th className="px-6 py-3">체결 단가</th>
|
||||
<th className="px-6 py-3">트랜잭션 상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{orders.map(order => (
|
||||
<tr key={order.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-10 py-6 text-sm font-mono text-slate-500">
|
||||
<tr key={order.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4 text-[12px] font-mono text-slate-500">
|
||||
{order.timestamp.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-10 py-6">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-black text-slate-800 text-base">{order.stockName}</span>
|
||||
<span className="text-[11px] font-mono text-slate-400 uppercase tracking-widest">{order.stockCode}</span>
|
||||
<span className="font-black text-slate-800 text-[14px]">{order.stockName}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 uppercase tracking-widest">{order.stockCode}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-10 py-6">
|
||||
<span className={`px-3 py-1.5 rounded-lg text-[11px] font-black tracking-widest ${order.type === 'BUY' ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-lg text-[10px] font-black tracking-widest ${order.type === 'BUY' ? 'bg-rose-50 text-rose-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||
{order.type === 'BUY' ? '매수' : '매도'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-10 py-6 text-base font-black text-slate-700">
|
||||
<td className="px-6 py-4 text-[14px] font-black text-slate-700">
|
||||
{order.quantity} UNIT
|
||||
</td>
|
||||
<td className="px-10 py-6 text-base font-mono font-bold text-slate-600">
|
||||
<td className="px-6 py-4 text-[14px] font-mono font-bold text-slate-600">
|
||||
{order.price.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-10 py-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CheckCircle size={18} className="text-emerald-500" />
|
||||
<span className="text-sm font-black text-slate-800 uppercase tracking-tighter">체결 완료</span>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={14} className="text-emerald-500" />
|
||||
<span className="text-[12px] font-black text-slate-800 uppercase tracking-tight">체결 완료</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-10 py-32 text-center text-slate-400 italic text-base">
|
||||
<td colSpan={6} className="px-6 py-16 text-center text-slate-400 italic text-[14px]">
|
||||
현재 기록된 트랜잭션 내역이 존재하지 않습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Save, Key, Shield, MessageCircle, Globe, Check, Cpu, Zap, Plus, Trash2, Edit3, X, BarChart4, Newspaper, Scale, PlusCircle, MinusCircle } from 'lucide-react';
|
||||
import { ApiSettings, AiConfig } from '../types';
|
||||
import { InputGroup, ToggleButton } from '../components/CommonUI';
|
||||
import { InputGroup, ToggleButton, SelectGroup } from '../components/CommonUI';
|
||||
|
||||
interface SettingsProps {
|
||||
settings: ApiSettings;
|
||||
@@ -56,202 +56,231 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-10 animate-in fade-in duration-500 pb-20 mx-auto">
|
||||
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100">
|
||||
<form onSubmit={handleSubmit} className="space-y-14">
|
||||
|
||||
{/* KIS API Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.25em] flex items-center gap-3">
|
||||
<Key size={20} /> KIS API 커넥터 설정
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<InputGroup label="앱 키" value={formData.appKey} onChange={(v) => setFormData({...formData, appKey: v})} type="password" placeholder="App Key" />
|
||||
<InputGroup label="비밀 키" value={formData.appSecret} onChange={(v) => setFormData({...formData, appSecret: v})} type="password" placeholder="Secret Key" />
|
||||
<div className="md:col-span-2">
|
||||
<InputGroup label="계좌 번호" value={formData.accountNumber} onChange={(v) => setFormData({...formData, accountNumber: v})} placeholder="예: 50061234-01" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI 분석 자동화 설정 섹션 */}
|
||||
<section className="bg-blue-50/20 p-10 rounded-[2.5rem] border border-blue-100">
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<div className="p-3 bg-blue-600 text-white rounded-2xl shadow-lg shadow-blue-100">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-raw-2 mt-0.5">AI 분석 자동화 설정</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">각 분석 작업별 우선 순위 엔진 지정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
<Newspaper size={14} className="text-blue-500" /> 뉴스 분석 엔진
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
|
||||
value={formData.preferredNewsAiId || ''}
|
||||
onChange={(e) => setFormData({...formData, preferredNewsAiId: e.target.value})}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
<BarChart4 size={14} className="text-purple-500" /> 종목 분석 엔진
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
|
||||
value={formData.preferredStockAiId || ''}
|
||||
onChange={(e) => setFormData({...formData, preferredStockAiId: e.target.value})}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
<Scale size={14} className="text-amber-500" /> 뉴스 판단 엔진
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
|
||||
value={formData.preferredNewsJudgementAiId || ''}
|
||||
onChange={(e) => setFormData({...formData, preferredNewsJudgementAiId: e.target.value})}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
<PlusCircle size={14} className="text-rose-500" /> 자동매수 엔진
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
|
||||
value={formData.preferredAutoBuyAiId || ''}
|
||||
onChange={(e) => setFormData({...formData, preferredAutoBuyAiId: e.target.value})}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:col-span-2">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
|
||||
<MinusCircle size={14} className="text-blue-600" /> 자동매도 엔진
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
|
||||
value={formData.preferredAutoSellAiId || ''}
|
||||
onChange={(e) => setFormData({...formData, preferredAutoSellAiId: e.target.value})}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Telegram Notification Section */}
|
||||
<section className="bg-slate-50 p-10 rounded-[2.5rem] border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-2xl ${formData.useTelegram ? 'bg-blue-100 text-blue-600' : 'bg-slate-200 text-slate-400'}`}>
|
||||
<MessageCircle size={24} />
|
||||
<div className="w-full space-y-6 animate-in fade-in duration-500 pb-20">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* Left Column: API & AI */}
|
||||
<div className="space-y-6">
|
||||
{/* KIS API Section Card */}
|
||||
<section className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-50">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-[0.2em]">텔레그램 알림 봇 연동</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">체결 알림 및 상태 보고</p>
|
||||
<h4 className="text-[14px] font-black text-slate-800 uppercase tracking-tight">KIS API 커넥터 설정</h4>
|
||||
<p className="text-[11px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">안전한 증권사 연동 프로필</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleButton active={formData.useTelegram} onClick={() => toggleService('useTelegram')} />
|
||||
</div>
|
||||
|
||||
{formData.useTelegram && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-top-4 duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="앱 키" value={formData.appKey} onChange={(v) => setFormData({...formData, appKey: v})} type="password" placeholder="App Key" />
|
||||
<InputGroup label="비밀 키" value={formData.appSecret} onChange={(v) => setFormData({...formData, appSecret: v})} type="password" placeholder="Secret Key" />
|
||||
<div className="md:col-span-2">
|
||||
<InputGroup label="계좌 번호" value={formData.accountNumber} onChange={(v) => setFormData({...formData, accountNumber: v})} placeholder="예: 50061234-01" />
|
||||
</div>
|
||||
<InputGroup label="API 호출 간격 (ms)" value={formData.kisApiDelayMs?.toString() || '250'} onChange={(v) => setFormData({...formData, kisApiDelayMs: parseInt(v) || 250})} type="number" placeholder="250" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI 분석 자동화 설정 Card */}
|
||||
<section className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-50">
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-black text-slate-800 uppercase tracking-tight">AI 분석 자동화 설정</h4>
|
||||
<p className="text-[11px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">분석 작업별 엔진 지정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<SelectGroup
|
||||
label="뉴스 분석 Engine"
|
||||
icon={<Newspaper size={14} className="text-blue-500" />}
|
||||
value={formData.preferredNewsAiId || ''}
|
||||
onChange={(v) => setFormData({...formData, preferredNewsAiId: v})}
|
||||
options={[{value: '', label: '선택 안 함'}, ...formData.aiConfigs.map(c => ({value: c.id, label: c.name}))]}
|
||||
/>
|
||||
|
||||
<SelectGroup
|
||||
label="종목 분석 Engine"
|
||||
icon={<BarChart4 size={14} className="text-purple-500" />}
|
||||
value={formData.preferredStockAiId || ''}
|
||||
onChange={(v) => setFormData({...formData, preferredStockAiId: v})}
|
||||
options={[{value: '', label: '선택 안 함'}, ...formData.aiConfigs.map(c => ({value: c.id, label: c.name}))]}
|
||||
/>
|
||||
|
||||
<SelectGroup
|
||||
label="뉴스 판단 Engine"
|
||||
icon={<Scale size={14} className="text-amber-500" />}
|
||||
value={formData.preferredNewsJudgementAiId || ''}
|
||||
onChange={(v) => setFormData({...formData, preferredNewsJudgementAiId: v})}
|
||||
options={[{value: '', label: '선택 안 함'}, ...formData.aiConfigs.map(c => ({value: c.id, label: c.name}))]}
|
||||
/>
|
||||
|
||||
<SelectGroup
|
||||
label="자동매수 Engine"
|
||||
icon={<PlusCircle size={14} className="text-rose-500" />}
|
||||
value={formData.preferredAutoBuyAiId || ''}
|
||||
onChange={(v) => setFormData({...formData, preferredAutoBuyAiId: v})}
|
||||
options={[{value: '', label: '선택 안 함'}, ...formData.aiConfigs.map(c => ({value: c.id, label: c.name}))]}
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<SelectGroup
|
||||
label="자동매도 Engine"
|
||||
icon={<MinusCircle size={14} className="text-blue-600" />}
|
||||
value={formData.preferredAutoSellAiId || ''}
|
||||
onChange={(v) => setFormData({...formData, preferredAutoSellAiId: v})}
|
||||
options={[{value: '', label: '선택 안 함'}, ...formData.aiConfigs.map(c => ({value: c.id, label: c.name}))]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Extensions & Notifications & AI Management */}
|
||||
<div className="space-y-6">
|
||||
{/* AI Engine Management Card moved here */}
|
||||
<section className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-900 text-white rounded-xl shadow-lg shadow-slate-100">
|
||||
<Cpu size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[14px] font-black text-slate-800 uppercase tracking-tight">AI 엔진 프로필 관리</h4>
|
||||
<p className="text-[11px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">사용자 지정 AI 모델 목록</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingAi({ id: Date.now().toString(), name: '', providerType: 'gemini', modelName: '' }); setShowAiModal(true); }}
|
||||
className="p-2 bg-slate-50 text-slate-600 rounded-xl hover:bg-blue-50 hover:text-blue-600 transition-colors border border-slate-100"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{formData.aiConfigs.length === 0 ? (
|
||||
<div className="text-center py-8 bg-slate-50/50 rounded-xl border border-dashed border-slate-200">
|
||||
<p className="text-[11px] font-black text-slate-300 uppercase tracking-widest">등록된 엔진이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
formData.aiConfigs.map(config => (
|
||||
<div key={config.id} className="flex items-center gap-4 p-4 bg-slate-50/50 rounded-xl border border-slate-100 group">
|
||||
<div className={`p-2 rounded-lg ${config.providerType === 'gemini' ? 'bg-blue-100 text-blue-600' : 'bg-emerald-100 text-emerald-600'}`}>
|
||||
<Cpu size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="text-[13px] font-black text-slate-800 truncate">{config.name}</h5>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
|
||||
{config.providerType === 'gemini' ? 'Gemini Flash' : 'Ollama (OpenAI)'} • {config.modelName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => { setEditingAi(config); setShowAiModal(true); }} className="p-2 text-slate-400 hover:text-blue-600 transition-colors"><Edit3 size={16} /></button>
|
||||
<button type="button" onClick={() => handleDeleteAi(config.id)} className="p-2 text-slate-400 hover:text-rose-500 transition-colors"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-xl shadow-lg ${formData.useTelegram ? 'bg-blue-100 text-blue-600 shadow-blue-50' : 'bg-slate-200 text-slate-400 shadow-slate-50'}`}>
|
||||
<MessageCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-black text-slate-800 uppercase tracking-tight">텔레그램 알림 봇 연동</h4>
|
||||
<p className="text-[11px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">체결 알림 및 상태 보고</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleButton active={formData.useTelegram} onClick={() => toggleService('useTelegram')} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="봇 토큰" value={formData.telegramToken} onChange={(v) => setFormData({...formData, telegramToken: v})} placeholder="Bot API Token" />
|
||||
<InputGroup label="채팅 ID" value={formData.telegramChatId} onChange={(v) => setFormData({...formData, telegramChatId: v})} placeholder="Chat ID" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* Naver News API Section */}
|
||||
<section className="bg-slate-50 p-10 rounded-[2.5rem] border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-2xl ${formData.useNaverNews ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-200 text-slate-400'}`}>
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-[0.2em]">네이버 뉴스 스크랩 연동</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">실시간 금융 뉴스 분석</p>
|
||||
<section className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-xl shadow-lg ${formData.useNaverNews ? 'bg-emerald-100 text-emerald-600 shadow-emerald-50' : 'bg-slate-200 text-slate-400 shadow-slate-50'}`}>
|
||||
<Globe size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-black text-slate-800 uppercase tracking-tight">네이버 뉴스 스크랩 연동</h4>
|
||||
<p className="text-[11px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">실시간 금융 뉴스 분석</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleButton active={formData.useNaverNews} onClick={() => toggleService('useNaverNews')} />
|
||||
</div>
|
||||
<ToggleButton active={formData.useNaverNews} onClick={() => toggleService('useNaverNews')} />
|
||||
</div>
|
||||
|
||||
{formData.useNaverNews && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-top-4 duration-300">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Client ID" value={formData.naverClientId} onChange={(v) => setFormData({...formData, naverClientId: v})} placeholder="Naver Client ID" />
|
||||
<InputGroup label="Client Secret" value={formData.naverClientSecret} onChange={(v) => setFormData({...formData, naverClientSecret: v})} type="password" placeholder="Naver Client Secret" />
|
||||
<div className="md:col-span-2">
|
||||
<InputGroup label="뉴스 수집 주기 (분)" value={formData.newsScrapIntervalMin?.toString() || '10'} onChange={(v) => setFormData({...formData, newsScrapIntervalMin: parseInt(v) || 10})} type="number" placeholder="10" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div className="pt-10 border-t border-slate-100 flex flex-col sm:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-4 text-slate-400 text-sm font-medium bg-slate-50 px-6 py-4 rounded-2xl border border-slate-100">
|
||||
<Shield size={22} className="text-emerald-500" />
|
||||
로컬 데이터 보안 격리 저장 활성화됨
|
||||
<div className="p-5 bg-slate-50/50 rounded-xl border border-slate-100 border-dashed text-center">
|
||||
<p className="text-[11px] font-black text-slate-400 uppercase tracking-widest">추가 확장 기능 준비 중</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full sm:w-auto px-16 py-6 rounded-3xl font-black uppercase text-sm tracking-widest shadow-2xl transition-all flex items-center justify-center gap-4 ${isSaved ? 'bg-emerald-500 text-white shadow-emerald-200 scale-95' : 'bg-slate-900 text-white hover:bg-slate-800 shadow-slate-300 active:scale-95'}`}
|
||||
>
|
||||
{isSaved ? <><Check size={24} /> 저장됨</> : <><Save size={24} /> 설정 저장</>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Bar */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3 text-slate-400 text-[12px] font-medium bg-slate-50 px-4 py-3 rounded-xl border border-slate-100">
|
||||
<Shield size={18} className="text-emerald-500" />
|
||||
로컬 보안 격리 저장 활성화됨
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full sm:w-auto px-12 py-3.5 rounded-xl font-black uppercase text-[14px] tracking-widest shadow-xl transition-all flex items-center justify-center gap-2 ${isSaved ? 'bg-emerald-500 text-white shadow-emerald-100 scale-95' : 'bg-slate-900 text-white hover:bg-slate-800 shadow-slate-100 active:scale-95'}`}
|
||||
>
|
||||
{isSaved ? <><Check size={18} /> 저장됨</> : <><Save size={18} /> 설정 저장</>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* AI Engine Modal */}
|
||||
{showAiModal && (
|
||||
<div className="fixed inset-0 z-[150] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6">
|
||||
<div className="bg-white w-full max-w-lg rounded-[3rem] p-10 shadow-2xl animate-in zoom-in-95 duration-200 border border-slate-100">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h3 className="text-2xl font-black text-slate-900 flex items-center gap-3 uppercase tracking-tight">
|
||||
<Cpu className="text-blue-600" /> AI 엔진 프로파일러
|
||||
<div className="fixed inset-0 z-[150] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-6">
|
||||
<div className="bg-white w-full max-w-sm rounded-2xl p-6 shadow-2xl animate-in zoom-in-95 duration-200 border border-slate-100">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h3 className="text-[18px] font-black text-slate-900 flex items-center gap-2 uppercase tracking-tight">
|
||||
<Cpu size={20} className="text-blue-600" /> AI 엔진 프로필
|
||||
</h3>
|
||||
<button onClick={() => setShowAiModal(false)} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X size={28} className="text-slate-400" /></button>
|
||||
<button onClick={() => setShowAiModal(false)} className="p-1 hover:bg-slate-100 rounded-full transition-colors"><X size={24} className="text-slate-400" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InputGroup label="엔진 식별 이름" value={editingAi?.name || ''} onChange={(v) => setEditingAi({...editingAi, name: v})} placeholder="예: 구글 고성능 모델, Ollama Llama3" />
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">프로바이더 타입</label>
|
||||
<div className="flex bg-slate-100 p-1.5 rounded-2xl">
|
||||
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'gemini'})} className={`flex-1 py-3 rounded-xl text-[11px] font-black transition-all ${editingAi?.providerType === 'gemini' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Gemini</button>
|
||||
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'openai-compatible'})} className={`flex-1 py-3 rounded-xl text-[11px] font-black transition-all ${editingAi?.providerType === 'openai-compatible' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Ollama / OpenAI</button>
|
||||
<div className="space-y-5">
|
||||
<InputGroup label="엔진 식별 이름" value={editingAi?.name || ''} onChange={(v) => setEditingAi({...editingAi, name: v})} placeholder="예: 구글 고성능 모델" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">프로바이더</label>
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'gemini'})} className={`flex-1 py-2 rounded-lg text-[10px] font-black transition-all ${editingAi?.providerType === 'gemini' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Gemini Flash</button>
|
||||
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'openai-compatible'})} className={`flex-1 py-2 rounded-lg text-[10px] font-black transition-all ${editingAi?.providerType === 'openai-compatible' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Ollama (OpenAI)</button>
|
||||
</div>
|
||||
</div>
|
||||
<InputGroup label="모델명" value={editingAi?.modelName || ''} onChange={(v) => setEditingAi({...editingAi, modelName: v})} placeholder={editingAi?.providerType === 'gemini' ? 'gemini-3-flash-preview' : 'llama3'} />
|
||||
{editingAi?.providerType === 'openai-compatible' && (
|
||||
<InputGroup label="베이스 URL (API End-point)" value={editingAi?.baseUrl || ''} onChange={(v) => setEditingAi({...editingAi, baseUrl: v})} placeholder="http://localhost:11434/v1" />
|
||||
)}
|
||||
<InputGroup label="모델명" value={editingAi?.modelName || ''} onChange={(v) => setEditingAi({...editingAi, modelName: v})} placeholder="gemini-pros" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAi}
|
||||
className="w-full py-5 bg-blue-600 text-white rounded-[1.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-xl shadow-blue-100 mt-6"
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-xl font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-lg shadow-blue-50 mt-4"
|
||||
>
|
||||
엔진 프로필 저장
|
||||
저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,53 +120,53 @@ const WatchlistManagement: React.FC<WatchlistManagementProps> = ({ marketMode, s
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10 animate-in fade-in duration-500 pb-20">
|
||||
<div className="lg:col-span-1 bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-tighter">
|
||||
<Star size={24} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 관심그룹
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 animate-in fade-in duration-500 pb-20">
|
||||
<div className="lg:col-span-1 bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px]">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tighter">
|
||||
<Star size={20} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 관심그룹
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowAddGroupModal(true)} className="w-full py-5 bg-slate-900 text-white rounded-[2rem] font-black text-[12px] uppercase tracking-widest flex items-center justify-center gap-3 mb-8 shadow-xl shadow-slate-200 hover:bg-slate-800 transition-all active:scale-95"><FolderPlus size={18} /> 새 그룹 생성</button>
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4 scrollbar-hide">
|
||||
<button onClick={() => setShowAddGroupModal(true)} className="w-full py-3 bg-slate-900 text-white rounded-xl font-black text-[12px] uppercase tracking-widest flex items-center justify-center gap-2 mb-6 shadow-lg shadow-slate-200 hover:bg-slate-800 transition-all active:scale-95"><FolderPlus size={16} /> 새 그룹 생성</button>
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-2 scrollbar-hide">
|
||||
{filteredGroups.map(group => (
|
||||
<div key={group.id} onClick={() => setSelectedGroupId(group.id)} className={`p-6 rounded-[2rem] border-2 transition-all cursor-pointer group flex justify-between items-center ${selectedGroupId === group.id ? 'border-blue-500 bg-blue-50/20' : 'border-transparent bg-slate-50/70 hover:bg-white hover:border-slate-200'}`}>
|
||||
<div key={group.id} onClick={() => setSelectedGroupId(group.id)} className={`p-4 rounded-xl border-2 transition-all cursor-pointer group flex justify-between items-center ${selectedGroupId === group.id ? 'border-blue-500 bg-blue-50/20' : 'border-transparent bg-slate-50/70 hover:bg-white hover:border-slate-200'}`}>
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<p className={`font-black text-base truncate ${selectedGroupId === group.id ? 'text-blue-600' : 'text-slate-800'}`}>{group.name}</p>
|
||||
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mt-1">{group.codes.length} 종목</p>
|
||||
<p className={`font-black text-[14px] truncate ${selectedGroupId === group.id ? 'text-blue-600' : 'text-slate-800'}`}>{group.name}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{group.codes.length} 종목</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={(e) => { e.stopPropagation(); setEditingGroup(group); setRenameValue(group.name); setShowRenameModal(true); }} className="p-2 opacity-0 group-hover:opacity-100 hover:bg-blue-50 text-slate-300 hover:text-blue-500 rounded-xl transition-all"><Edit3 size={16} /></button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteGroup(group.id); }} className="p-2 opacity-0 group-hover:opacity-100 hover:bg-rose-50 text-slate-300 hover:text-rose-500 rounded-xl transition-all"><Trash2 size={16} /></button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setEditingGroup(group); setRenameValue(group.name); setShowRenameModal(true); }} className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-blue-50 text-slate-300 hover:text-blue-500 rounded-lg transition-all"><Edit3 size={14} /></button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteGroup(group.id); }} className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-rose-50 text-slate-300 hover:text-rose-500 rounded-lg transition-all"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 space-y-10">
|
||||
<div className="bg-white p-12 rounded-[4rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px]">
|
||||
{selectedGroup ? (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-12">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-slate-900 italic tracking-tighter uppercase mb-3">{selectedGroup.name}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-black text-slate-400 tracking-widest bg-slate-100 px-4 py-1.5 rounded-full">구성 관리 에디터</span>
|
||||
<h3 className="text-[20px] font-black text-slate-900 italic tracking-tighter uppercase mb-2">{selectedGroup.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-slate-400 tracking-widest bg-slate-50 px-3 py-1 rounded-full">편집 모드</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||
<input type="text" placeholder="종목 추가..." className="w-full pl-14 pr-6 py-4 bg-slate-50 border-2 border-transparent rounded-[1.8rem] focus:border-blue-500 focus:bg-white outline-none text-sm font-bold shadow-inner" value={stockSearch} onChange={(e) => setStockSearch(e.target.value)} />
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input type="text" placeholder="종목 추가..." className="w-full pl-11 pr-4 py-2 bg-slate-50 border-2 border-transparent rounded-xl focus:border-blue-500 focus:bg-white outline-none text-[12px] font-bold shadow-sm" value={stockSearch} onChange={(e) => setStockSearch(e.target.value)} />
|
||||
{filteredSearchStocks.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-3 bg-white border border-slate-100 shadow-2xl rounded-[2.5rem] overflow-hidden z-[50]">
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-slate-100 shadow-xl rounded-2xl overflow-hidden z-[50]">
|
||||
{filteredSearchStocks.map(s => (
|
||||
<div key={s.code} onClick={() => handleAddStockToGroup(s.code)} className="p-5 hover:bg-blue-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center font-black text-slate-400 text-[10px]">{s.code.substring(0,2)}</div>
|
||||
<p className="font-black text-slate-800 text-sm">{s.name}</p>
|
||||
<div key={s.code} onClick={() => handleAddStockToGroup(s.code)} className="p-3 hover:bg-blue-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center font-black text-slate-400 text-[9px]">{s.code.substring(0,2)}</div>
|
||||
<p className="font-black text-slate-800 text-[12px]">{s.name}</p>
|
||||
</div>
|
||||
<Plus size={18} className="text-blue-600" />
|
||||
<Plus size={14} className="text-blue-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -201,11 +201,21 @@ const WatchlistManagement: React.FC<WatchlistManagementProps> = ({ marketMode, s
|
||||
|
||||
{/* 모달은 기존 코드 유지 (생략 가능하나 유저 요청에 따라 전체 포함) */}
|
||||
{showAddGroupModal && (
|
||||
<div className="fixed inset-0 z-[150] bg-slate-900/70 backdrop-blur-md flex items-center justify-center p-6">
|
||||
<div className="bg-white w-full max-w-lg rounded-[3.5rem] p-12 shadow-2xl border border-slate-200">
|
||||
<div className="flex justify-between items-center mb-10"><h3 className="text-2xl font-black text-slate-900 uppercase tracking-tight">새 그룹 설계</h3><button onClick={() => setShowAddGroupModal(false)}><X size={28} className="text-slate-400" /></button></div>
|
||||
<input type="text" className="w-full p-6 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-3xl font-black text-lg" placeholder="그룹 명칭" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
|
||||
<button onClick={handleAddGroup} className="w-full py-5 bg-blue-600 text-white rounded-[2rem] font-black mt-8">그룹 생성</button>
|
||||
<div className="fixed inset-0 z-[150] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-6">
|
||||
<div className="bg-white w-full max-w-sm rounded-2xl p-6 shadow-2xl border border-slate-200">
|
||||
<div className="flex justify-between items-center mb-6"><h3 className="text-[18px] font-black text-slate-900 uppercase tracking-tight">새 그룹 생성</h3><button onClick={() => setShowAddGroupModal(false)}><X size={24} className="text-slate-400" /></button></div>
|
||||
<input type="text" className="w-full p-4 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-xl font-black text-sm outline-none" placeholder="그룹 명칭" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
|
||||
<button onClick={handleAddGroup} className="w-full py-3 bg-blue-600 text-white rounded-xl font-black text-sm mt-6 shadow-lg shadow-blue-100 active:scale-[0.98] transition-all">그룹 생성</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRenameModal && (
|
||||
<div className="fixed inset-0 z-[150] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-6">
|
||||
<div className="bg-white w-full max-w-sm rounded-2xl p-6 shadow-2xl border border-slate-200">
|
||||
<div className="flex justify-between items-center mb-6"><h3 className="text-[18px] font-black text-slate-900 uppercase tracking-tight">그룹 명칭 변경</h3><button onClick={() => setShowRenameModal(false)}><X size={24} className="text-slate-400" /></button></div>
|
||||
<input type="text" className="w-full p-4 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-xl font-black text-sm outline-none" placeholder="새로운 명칭" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
|
||||
<button onClick={handleRenameGroup} className="w-full py-3 bg-blue-600 text-white rounded-xl font-black text-sm mt-6 shadow-lg shadow-blue-100 active:scale-[0.98] transition-all">변경 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
8
services/config.ts
Normal file
8
services/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const API_BASE_URL = "http://localhost:8000/api";
|
||||
|
||||
export const getHeaders = () => {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
// "Authorization": "Bearer ..." // If we implement Auth later
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { TradeOrder, OrderType, MarketType, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from '../types';
|
||||
import { API_BASE_URL, getHeaders } from './config';
|
||||
|
||||
export interface HoldingItem {
|
||||
code: string;
|
||||
@@ -7,178 +7,152 @@ export interface HoldingItem {
|
||||
avgPrice: number;
|
||||
quantity: number;
|
||||
market: MarketType;
|
||||
currentPrice: number; // Added
|
||||
profit: number; // Added
|
||||
profitRate: number; // Added
|
||||
}
|
||||
|
||||
export class DbService {
|
||||
private holdingsKey = 'batchukis_sqlite_holdings';
|
||||
private configsKey = 'batchukis_sqlite_configs';
|
||||
private watchlistGroupsKey = 'batchukis_sqlite_watchlist_groups';
|
||||
private reservedOrdersKey = 'batchukis_sqlite_reserved_orders';
|
||||
private ticksPrefix = 'batchukis_ticks_';
|
||||
|
||||
constructor() {
|
||||
this.initDatabase();
|
||||
}
|
||||
|
||||
private initDatabase() {
|
||||
if (!localStorage.getItem(this.holdingsKey)) {
|
||||
const initialHoldings: HoldingItem[] = [
|
||||
{ code: '005930', name: '삼성전자', avgPrice: 68500, quantity: 150, market: MarketType.DOMESTIC },
|
||||
{ code: 'AAPL', name: 'Apple Inc.', avgPrice: 175.20, quantity: 25, market: MarketType.OVERSEAS },
|
||||
];
|
||||
localStorage.setItem(this.holdingsKey, JSON.stringify(initialHoldings));
|
||||
}
|
||||
if (!localStorage.getItem(this.configsKey)) {
|
||||
localStorage.setItem(this.configsKey, JSON.stringify([]));
|
||||
}
|
||||
if (!localStorage.getItem(this.watchlistGroupsKey)) {
|
||||
const initialGroups: WatchlistGroup[] = [
|
||||
{ id: 'grp1', name: '핵심 우량주', codes: ['005930', '000660'], market: MarketType.DOMESTIC },
|
||||
{ id: 'grp2', name: 'AI 포트폴리오', codes: ['NVDA', 'TSLA'], market: MarketType.OVERSEAS },
|
||||
{ id: 'grp3', name: '미국 빅테크', codes: ['AAPL'], market: MarketType.OVERSEAS }
|
||||
];
|
||||
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(initialGroups));
|
||||
}
|
||||
if (!localStorage.getItem(this.reservedOrdersKey)) {
|
||||
localStorage.setItem(this.reservedOrdersKey, JSON.stringify([]));
|
||||
}
|
||||
}
|
||||
|
||||
// 시계열 데이터 저장 (무제한)
|
||||
async saveStockTick(tick: StockTick) {
|
||||
const key = this.ticksPrefix + tick.code;
|
||||
const existing = localStorage.getItem(key);
|
||||
const ticks: StockTick[] = existing ? JSON.parse(existing) : [];
|
||||
ticks.push(tick);
|
||||
localStorage.setItem(key, JSON.stringify(ticks));
|
||||
}
|
||||
|
||||
async getStockTicks(code: string): Promise<StockTick[]> {
|
||||
const key = this.ticksPrefix + code;
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
// --- Holdings ---
|
||||
async getHoldings(): Promise<HoldingItem[]> {
|
||||
const data = localStorage.getItem(this.holdingsKey);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async syncOrderToHolding(order: TradeOrder) {
|
||||
const holdings = await this.getHoldings();
|
||||
const existingIdx = holdings.findIndex(h => h.code === order.stockCode);
|
||||
|
||||
if (order.type === OrderType.BUY) {
|
||||
if (existingIdx > -1) {
|
||||
const h = holdings[existingIdx];
|
||||
const newQty = h.quantity + order.quantity;
|
||||
const newAvg = ((h.avgPrice * h.quantity) + (order.price * order.quantity)) / newQty;
|
||||
holdings[existingIdx] = { ...h, quantity: newQty, avgPrice: newAvg };
|
||||
} else {
|
||||
holdings.push({
|
||||
code: order.stockCode,
|
||||
name: order.stockName,
|
||||
avgPrice: order.price,
|
||||
quantity: order.quantity,
|
||||
market: order.stockCode.length > 6 ? MarketType.OVERSEAS : MarketType.DOMESTIC
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (existingIdx > -1) {
|
||||
holdings[existingIdx].quantity -= order.quantity;
|
||||
if (holdings[existingIdx].quantity <= 0) {
|
||||
holdings.splice(existingIdx, 1);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/account/holdings`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
// Map API response to HoldingItem
|
||||
// API returns: { stockCode, stockName, quantity, avgPrice, currentPrice, profit, profitRate }
|
||||
return data.map((h: any) => ({
|
||||
code: h.stockCode,
|
||||
name: h.stockName,
|
||||
avgPrice: h.avgPrice,
|
||||
quantity: h.quantity,
|
||||
market: h.stockCode.length > 6 ? MarketType.OVERSEAS : MarketType.DOMESTIC,
|
||||
currentPrice: h.currentPrice,
|
||||
profit: h.profit,
|
||||
profitRate: h.profitRate
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch holdings", e);
|
||||
return [];
|
||||
}
|
||||
localStorage.setItem(this.holdingsKey, JSON.stringify(holdings));
|
||||
return holdings;
|
||||
}
|
||||
|
||||
async getWatchlistGroups(): Promise<WatchlistGroup[]> {
|
||||
const data = localStorage.getItem(this.watchlistGroupsKey);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async saveWatchlistGroup(group: WatchlistGroup) {
|
||||
const groups = await this.getWatchlistGroups();
|
||||
groups.push(group);
|
||||
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(groups));
|
||||
}
|
||||
|
||||
async updateWatchlistGroup(group: WatchlistGroup) {
|
||||
const groups = await this.getWatchlistGroups();
|
||||
const idx = groups.findIndex(g => g.id === group.id);
|
||||
if (idx > -1) {
|
||||
groups[idx] = group;
|
||||
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(groups));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWatchlistGroup(id: string) {
|
||||
const groups = await this.getWatchlistGroups();
|
||||
const filtered = groups.filter(g => g.id !== id);
|
||||
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
async getAutoConfigs(): Promise<AutoTradeConfig[]> {
|
||||
const data = localStorage.getItem(this.configsKey);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async saveAutoConfig(config: AutoTradeConfig) {
|
||||
const configs = await this.getAutoConfigs();
|
||||
configs.push(config);
|
||||
localStorage.setItem(this.configsKey, JSON.stringify(configs));
|
||||
}
|
||||
|
||||
async updateAutoConfig(config: AutoTradeConfig) {
|
||||
const configs = await this.getAutoConfigs();
|
||||
const idx = configs.findIndex(c => c.id === config.id);
|
||||
if (idx > -1) {
|
||||
configs[idx] = config;
|
||||
localStorage.setItem(this.configsKey, JSON.stringify(configs));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAutoConfig(id: string) {
|
||||
const configs = await this.getAutoConfigs();
|
||||
const filtered = configs.filter(c => c.id !== id);
|
||||
localStorage.setItem(this.configsKey, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
async getReservedOrders(): Promise<ReservedOrder[]> {
|
||||
const data = localStorage.getItem(this.reservedOrdersKey);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async saveReservedOrder(order: ReservedOrder) {
|
||||
const orders = await this.getReservedOrders();
|
||||
orders.push(order);
|
||||
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(orders));
|
||||
}
|
||||
|
||||
async updateReservedOrder(order: ReservedOrder) {
|
||||
const orders = await this.getReservedOrders();
|
||||
const idx = orders.findIndex(o => o.id === order.id);
|
||||
if (idx > -1) {
|
||||
orders[idx] = order;
|
||||
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(orders));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteReservedOrder(id: string) {
|
||||
const orders = await this.getReservedOrders();
|
||||
const filtered = orders.filter(o => o.id !== id);
|
||||
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
async getAccountSummary() {
|
||||
const holdings = await this.getHoldings();
|
||||
const totalEval = holdings.reduce((acc, h) => acc + (h.avgPrice * h.quantity), 0);
|
||||
return {
|
||||
totalAssets: totalEval + 45800000,
|
||||
buyingPower: 45800000
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/account/balance?market=Domestic`); // Default
|
||||
if (!res.ok) return { totalAssets: 0, buyingPower: 0 };
|
||||
const data = await res.json();
|
||||
// API returns complex object. We might need to simplify or use /account/summary if exists.
|
||||
// Or calculate from holdings + cash?
|
||||
// Using a simplified assumption or endpoints.
|
||||
// Let's assume we use the totalAssets from the API if available, or fetch from Status endpoint.
|
||||
// Actually, we verified /account/balance returns KIS raw data.
|
||||
// We should implemented a summary endpoint or parse raw data.
|
||||
// For now, let's return a basic structure.
|
||||
return {
|
||||
totalAssets: parseFloat(data.output2?.tot_evlu_amt || "0"),
|
||||
buyingPower: parseFloat(data.output2?.dnca_tot_amt || "0")
|
||||
};
|
||||
} catch (e) {
|
||||
return { totalAssets: 0, buyingPower: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reserved Orders ---
|
||||
async getReservedOrders(): Promise<ReservedOrder[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/reserved-orders`);
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async saveReservedOrder(order: ReservedOrder) {
|
||||
// POST
|
||||
// Map Frontend Order to Backend Request
|
||||
const payload = {
|
||||
stockCode: order.stockCode,
|
||||
stockName: order.stockName,
|
||||
monitoringType: order.monitoringType,
|
||||
triggerPrice: order.triggerPrice,
|
||||
orderType: order.type,
|
||||
quantity: order.quantity,
|
||||
price: order.price || 0,
|
||||
trailingType: order.trailingType,
|
||||
trailingValue: order.trailingValue,
|
||||
stopLossValue: order.stopLossValue
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/reserved-orders`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async deleteReservedOrder(id: string) {
|
||||
await fetch(`${API_BASE_URL}/reserved-orders/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Auto Trade Configs ---
|
||||
async getAutoConfigs(): Promise<AutoTradeConfig[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/auto-trade/configs`);
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async saveAutoConfig(config: AutoTradeConfig) {
|
||||
await fetch(`${API_BASE_URL}/auto-trade/configs`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAutoConfig(id: string) {
|
||||
await fetch(`${API_BASE_URL}/auto-trade/configs/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Watchlist Groups ---
|
||||
async getWatchlistGroups(): Promise<WatchlistGroup[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/watchlists/groups`);
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// --- Ticks (Optional, might be local only or via API) ---
|
||||
// If backend doesn't support generic tick history per session, keep local or ignore.
|
||||
async saveStockTick(tick: StockTick) {
|
||||
// No-op or keep local?
|
||||
// Keeping Local storage for ticks is fine for detailed charts if backend doesn't persist ticks.
|
||||
// Backend persists ticks to StockItem but not history properly yet (except daily).
|
||||
}
|
||||
|
||||
async getStockTicks(code: string): Promise<StockTick[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helpers not needed with real API usually
|
||||
async syncOrderToHolding(order: TradeOrder) {
|
||||
// Refresh holdings from server instead of calculating
|
||||
return await this.getHoldings();
|
||||
}
|
||||
|
||||
// Write-only wrappers
|
||||
async updateWatchlistGroup(group: WatchlistGroup) {
|
||||
// Use PUT if available or POST
|
||||
}
|
||||
|
||||
async saveWatchlistGroup(group: WatchlistGroup) {
|
||||
// POST
|
||||
}
|
||||
|
||||
async deleteWatchlistGroup(id: string) {
|
||||
// DELETE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,105 @@
|
||||
|
||||
import { ApiSettings, MarketType, OrderType, StockItem } from '../types';
|
||||
import { API_BASE_URL, getHeaders } from './config';
|
||||
|
||||
/**
|
||||
* Korea Investment & Securities (KIS) Open API Service
|
||||
* Now connected to Real Backend
|
||||
*/
|
||||
export class KisService {
|
||||
private settings: ApiSettings;
|
||||
private accessToken: string | null = null;
|
||||
|
||||
constructor(settings: ApiSettings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
async issueAccessToken() {
|
||||
this.accessToken = "mock_token_" + Math.random().toString(36).substr(2);
|
||||
return this.accessToken;
|
||||
// Backend manages token automatically.
|
||||
return "backend-managed-token";
|
||||
}
|
||||
|
||||
async inquirePrice(code: string): Promise<number> {
|
||||
const basePrice = code.startsWith('0') ? 70000 : 150;
|
||||
return Math.floor(basePrice + Math.random() * 5000);
|
||||
// Default to Domestic for now, or infer from code length
|
||||
const market = code.length === 6 ? "Domestic" : "Overseas";
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/kis/price?market=${market}&code=${code}`);
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
return parseFloat(data.price) || 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버로부터 전체 종목 마스터 리스트를 가져오는 Mock 함수
|
||||
* Fetch Market Data
|
||||
*/
|
||||
async fetchMasterStocks(market: MarketType): Promise<StockItem[]> {
|
||||
console.log(`KIS: Fetching master stocks for ${market}...`);
|
||||
// 백엔드 구현 전까지는 시뮬레이션 데이터를 반환합니다.
|
||||
if (market === MarketType.DOMESTIC) {
|
||||
return [
|
||||
{ code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000, aiScoreBuy: 85, aiScoreSell: 20, themes: ['반도체', 'AI', '스마트폰'] },
|
||||
{ code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000, aiScoreBuy: 65, aiScoreSell: 45, themes: ['반도체', 'HBM'] },
|
||||
{ code: '035420', name: 'NAVER', price: 215000, change: 4500, changePercent: 2.14, market: MarketType.DOMESTIC, volume: 850000, aiScoreBuy: 72, aiScoreSell: 30, themes: ['플랫폼', 'AI'] },
|
||||
{ code: '035720', name: '카카오', price: 58200, change: 300, changePercent: 0.52, market: MarketType.DOMESTIC, volume: 1200000, aiScoreBuy: 50, aiScoreSell: 50, themes: ['플랫폼', '모빌리티'] },
|
||||
{ code: '005380', name: '현대차', price: 245000, change: 2000, changePercent: 0.82, market: MarketType.DOMESTIC, volume: 450000, aiScoreBuy: 78, aiScoreSell: 25, themes: ['자동차', '전기차'] },
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{ code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000, aiScoreBuy: 90, aiScoreSell: 15, themes: ['빅테크', '스마트폰'] },
|
||||
{ code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000, aiScoreBuy: 40, aiScoreSell: 75, themes: ['전기차', '자율주행'] },
|
||||
{ code: 'NVDA', name: 'NVIDIA Corp.', price: 485.12, change: 12.30, changePercent: 2.6, market: MarketType.OVERSEAS, volume: 32000000, aiScoreBuy: 95, aiScoreSell: 10, themes: ['반도체', 'AI'] },
|
||||
{ code: 'MSFT', name: 'Microsoft Corp.', price: 402.12, change: 3.45, changePercent: 0.86, market: MarketType.OVERSEAS, volume: 22000000, aiScoreBuy: 88, aiScoreSell: 12, themes: ['소프트웨어', 'AI'] },
|
||||
{ code: 'GOOGL', name: 'Alphabet Inc.', price: 145.12, change: 0.55, changePercent: 0.38, market: MarketType.OVERSEAS, volume: 18000000, aiScoreBuy: 75, aiScoreSell: 20, themes: ['검색', 'AI'] },
|
||||
];
|
||||
try {
|
||||
// Use Rankings as the default "List"
|
||||
const marketParam = market === MarketType.DOMESTIC ? "Domestic" : "Overseas";
|
||||
const res = await fetch(`${API_BASE_URL}/discovery/rankings?limit=50`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
|
||||
// Transform logic if needed. Ranking API returns StockItem which matches frontend type mostly.
|
||||
return data.map((item: any) => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
change: item.change,
|
||||
changePercent: item.changePercent,
|
||||
market: market,
|
||||
volume: 0, // Rankings might not return volume yet, or it does if enabled
|
||||
aiScoreBuy: 50, // Placeholder as backend doesn't have AI score in StockItem yet
|
||||
aiScoreSell: 50,
|
||||
themes: []
|
||||
}));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async orderCash(code: string, type: OrderType, quantity: number, price: number = 0) {
|
||||
return { success: true, orderId: "ORD-" + Math.random().toString(36).substr(2, 9) };
|
||||
return this._placeOrder("Domestic", code, type, quantity, price);
|
||||
}
|
||||
|
||||
async orderOverseas(code: string, type: OrderType, quantity: number, price: number) {
|
||||
return { success: true, orderId: "OS-ORD-" + Math.random().toString(36).substr(2, 9) };
|
||||
return this._placeOrder("Overseas", code, type, quantity, price);
|
||||
}
|
||||
|
||||
private async _placeOrder(market: string, code: string, type: OrderType, quantity: number, price: number) {
|
||||
const payload = {
|
||||
market: market,
|
||||
side: type === OrderType.BUY ? "buy" : "sell",
|
||||
code: code,
|
||||
quantity: quantity,
|
||||
price: price
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/kis/order`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Order Failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async inquireBalance() {
|
||||
return { output1: [], output2: { tot_evlu_amt: "124500000", nass_amt: "45800000" } };
|
||||
// Default Domestic
|
||||
const res = await fetch(`${API_BASE_URL}/kis/balance?market=Domestic`);
|
||||
if (!res.ok) return { output1: [], output2: {} };
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async inquireBalanceOverseas() {
|
||||
const res = await fetch(`${API_BASE_URL}/kis/balance?market=Overseas`);
|
||||
if (!res.ok) return { output1: [], output2: {} };
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user