Compare commits

...

19 Commits

Author SHA1 Message Date
86c1c54126 "동적_서비스_감시자_구현_및_각종_오류_수정_완료" 2026-02-03 01:05:12 +09:00
b364e41e46 "실시간_설정_감시_Watchdog_기능_구현" 2026-02-03 01:04:23 +09:00
675026d51e "텔레그램_연동_기능_구현_및_설정_스키마_수정" 2026-02-03 01:02:04 +09:00
3ef50f9b3a "main.py_run_startup_sequence_임포트_누락_수정" 2026-02-03 00:57:18 +09:00
1a25974b10 "kis.py_구문_오류_수정" 2026-02-03 00:56:44 +09:00
63fcc434ba "models.py_StockItem_추가_및_ImportError_수정" 2026-02-03 00:56:03 +09:00
1114cc14a8 "scheduler.py_인덴트_오류_수정" 2026-02-03 00:54:49 +09:00
eeddc62089 "백엔드_핵심_로직_구현_프론트엔드_연동_및_도커_배포_최적화_완료" 2026-02-03 00:52:54 +09:00
ed8fc0943b 보안 강화: DB 자격증명(AppKey, Secret) 및 세션토큰(Access Token) 암호화 저장 구현 (AES-GCM/CBC), .env 정리 2026-02-03 00:08:15 +09:00
4f0cc05f39 백엔드 전체 구현 완료: 내부 서비스(Auth, Client, Realtime), API 엔드포인트 및 스케줄러 구현 2026-02-02 23:55:07 +09:00
03027d2206 .. 2026-02-02 23:30:59 +09:00
6d673e06ce 문서작업 2026-02-02 23:13:28 +09:00
84746d41b8 디자인업데이트 2026-02-02 22:20:08 +09:00
3c2f4a0371 .. 2026-02-01 20:24:04 +09:00
498bddb4fe .. 2026-02-01 15:30:32 +09:00
35dfce6818 .. 2026-02-01 15:25:08 +09:00
01acc19401 .. 2026-02-01 00:56:54 +09:00
83491c08b9 .. 2026-01-31 23:55:41 +09:00
f25b6f538f . 2026-01-31 23:46:19 +09:00
88 changed files with 8694 additions and 998 deletions

40
App.tsx
View File

@@ -15,7 +15,9 @@ import {
Star,
LayoutGrid,
RotateCcw,
Compass
Compass,
ArrowUpRight,
ArrowDownRight
} from 'lucide-react';
import { ApiSettings, StockItem, OrderType, MarketType, TradeOrder, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from './types';
import { MOCK_STOCKS } from './constants';
@@ -38,14 +40,39 @@ interface LogEntry {
timestamp: Date;
}
const TopNavItem: React.FC<{ to: string, icon: React.ReactNode, label: string, active: boolean }> = ({ to, icon, label, active }) => (
<Link to={to} className={`flex items-center gap-2 px-3 py-2 rounded-xl font-black text-[11px] uppercase tracking-wider transition-all ${active ? 'bg-blue-600 text-white shadow-md' : 'text-slate-400 hover:text-blue-600 hover:bg-blue-50'}`}>
{/* Fix for line 43: Use React.isValidElement and cast to React.ReactElement<any> to allow the size prop for Lucide icons */}
const TopNavItem: React.FC<{ to: string, icon: React.ReactNode, label: string, active: boolean, className?: string }> = ({ to, icon, label, active, className = "" }) => (
<Link to={to} className={`flex items-center gap-2 px-3 py-2 rounded-xl font-black text-[12px] uppercase tracking-wider transition-all ${active ? 'bg-blue-600 text-white shadow-md' : 'text-slate-400 hover:text-blue-600 hover:bg-blue-50'} ${className}`}>
{React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement<any>, { size: 16 }) : icon}
<span>{label}</span>
</Link>
);
const IndexTicker = () => {
const indices = [
{ name: '코스피', value: '2,561.32', change: '+12.45', percent: '0.49%', isUp: true },
{ name: '코스닥', value: '842.11', change: '-3.21', percent: '0.38%', isUp: false },
{ name: '나스닥', value: '15,628.95', change: '+215.12', percent: '1.40%', isUp: true },
{ name: 'S&P 500', value: '4,850.12', change: '+45.23', percent: '0.94%', isUp: true },
{ name: 'USD/KRW', value: '1,324.50', change: '+2.10', percent: '0.16%', isUp: true },
];
return (
<div className="h-10 bg-white border-b border-slate-100 flex items-center px-6 gap-8 overflow-x-auto scrollbar-hide z-40">
{indices.map((idx, i) => (
<div key={i} className="flex items-center gap-2 group cursor-pointer whitespace-nowrap">
<span className="text-[11px] font-black text-slate-400 uppercase tracking-tighter">{idx.name}</span>
<span className="text-[13px] font-black text-slate-900 font-mono">{idx.value}</span>
<div className={`flex items-center gap-0.5 text-[11px] font-bold ${idx.isUp ? 'text-rose-500' : 'text-blue-600'}`}>
{idx.isUp ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
<span>{idx.percent}</span>
</div>
{i < indices.length - 1 && <div className="w-[1px] h-3 bg-slate-100 ml-4" />}
</div>
))}
</div>
);
};
const AppContent: React.FC = () => {
const [settings, setSettings] = useState<ApiSettings>(() => {
const saved = localStorage.getItem('trader_settings');
@@ -173,13 +200,13 @@ const AppContent: React.FC = () => {
</div>
<nav className="flex items-center gap-0.5">
<TopNavItem to="/" icon={<LayoutDashboard />} label="대시보드" active={isActive('/')} />
<TopNavItem to="/discovery" icon={<Compass />} label="종목발굴" active={isActive('/discovery')} />
<TopNavItem to="/stocks" icon={<LayoutGrid />} label="종목" active={isActive('/stocks')} />
<TopNavItem to="/auto" icon={<Cpu />} label="자동매매" active={isActive('/auto')} />
<TopNavItem to="/watchlist" icon={<Star />} label="관심종목" active={isActive('/watchlist')} />
<TopNavItem to="/news" icon={<Newspaper />} label="뉴스" active={isActive('/news')} />
<TopNavItem to="/history" icon={<History />} label="기록" active={isActive('/history')} />
<TopNavItem to="/settings" icon={<SettingsIcon />} label="설정" active={isActive('/settings')} />
<TopNavItem to="/discovery" icon={<Compass />} label="종목발굴" active={isActive('/discovery')} className="hidden xl:flex" />
</nav>
</div>
<div className="flex items-center gap-4">
@@ -191,10 +218,11 @@ const AppContent: React.FC = () => {
<div className="w-8 h-8 rounded-xl bg-slate-900 flex items-center justify-center text-white shadow-md"><ShieldCheck size={16} /></div>
</div>
</header>
<IndexTicker />
<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(); }} />} />

View File

@@ -1,26 +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)
# 2단계: 실행 (Node.js)
FROM node:20-alpine
# 2. Backend & Serving Stage
FROM python:3.9-slim
# Set working directory
WORKDIR /app
# 프로덕션 의존성만 설치
COPY package*.json ./
RUN npm install --omit=dev
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# 서버 파일 및 빌드 결과물 복사
COPY server.js ./
COPY --from=build /app/dist ./dist
# 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
# 서버 실행
CMD ["node", "server.js"]
# 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
View 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

View File

@@ -1,15 +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. **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
View 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` 비동기 처리.

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

16
backend/app/api/api.py Normal file
View 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"])

View 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

View 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"}

View 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"}

View 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))

View 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

View 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

View 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"}

View 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"}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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()

View 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]"

View 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()

View 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()

View 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 ===")

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
View 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
View 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
View 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}")

View 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}")

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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

Binary file not shown.

View File

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

@@ -0,0 +1 @@
uvicorn app.main:app

View File

@@ -2,70 +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` 테이블을 직접 확장하거나 별도 통계 테이블로 관리하여 발굴 데이터 조회 성능 최적화.
### 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -11,18 +11,18 @@ interface StatCardProps {
}
export const StatCard: React.FC<StatCardProps> = ({ title, value, change, isUp, icon }) => (
<div className="bg-white p-8 rounded-[3rem] shadow-sm border border-slate-100 flex items-start justify-between group hover:border-blue-100 transition-all">
<div>
<p className="text-[11px] font-black text-slate-400 mb-3 uppercase tracking-widest">{title}</p>
<h4 className="text-2xl font-black text-slate-900 leading-none">{value}</h4>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex items-center justify-between group hover:border-blue-100 transition-all">
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-slate-400 mb-1 uppercase tracking-widest truncate">{title}</p>
<h4 className="text-[18px] font-black text-slate-900 leading-none truncate">{value}</h4>
{change && (
<p className={`text-[11px] font-black mt-4 flex items-center gap-2 ${isUp ? 'text-emerald-500' : 'text-rose-500'}`}>
<p className={`text-[10px] font-black mt-2 flex items-center gap-1.5 ${isUp ? 'text-emerald-500' : 'text-rose-500'}`}>
{change}
</p>
)}
</div>
<div className="bg-slate-50 p-5 rounded-3xl group-hover:bg-blue-50 transition-colors">
{icon}
<div className="bg-slate-50 p-3 rounded-xl group-hover:bg-blue-50 transition-colors shrink-0 ml-3">
{React.cloneElement(icon as React.ReactElement, { size: 18 })}
</div>
</div>
);
@@ -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;

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { Zap, ShoppingCart, Star, TrendingUp, TrendingDown } from 'lucide-react';
import { StockItem, MarketType, OrderType } from '../types';
interface StockMasterRowProps {
stock: StockItem;
rank?: number;
isWatchlisted?: boolean;
onClick?: () => void;
onTrade?: (type: OrderType) => void;
onToggleWatchlist?: () => void;
}
export const StockMasterRow: React.FC<StockMasterRowProps> = ({
stock, rank, isWatchlisted, onClick, onTrade, onToggleWatchlist
}) => {
const formatValue = (val?: number) => {
if (val === undefined) return '-';
return stock.market === MarketType.DOMESTIC ? val.toLocaleString() : '$' + val;
};
const formatVolume = (vol: number) => {
if (vol >= 1000000) return (vol / 1000000).toFixed(1) + 'M';
if (vol >= 1000) return (vol / 1000).toFixed(1) + 'K';
return vol.toString();
};
return (
<tr
onClick={onClick}
className="group cursor-pointer transition-colors hover:bg-blue-50/40 border-b border-slate-50 last:border-0"
>
{/* 1. 번호 (순위) */}
<td className="px-5 py-4 font-mono font-bold text-slate-300 group-hover:text-blue-500 text-[11px]">
{rank !== undefined ? rank.toString().padStart(2, '0') : '-'}
</td>
{/* 2. 종목 기본 정보 */}
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-600 text-[10px] font-black group-hover:bg-slate-900 group-hover:text-white transition-all">
{stock.name[0]}
</div>
<div className="flex flex-col min-w-0">
<span className="font-black text-slate-800 text-[13px] truncate tracking-tight">{stock.name}</span>
<span className="text-[10px] text-slate-400 font-mono font-bold">{stock.code}</span>
</div>
</div>
</td>
{/* 3. 현재가 및 등락 */}
<td className="px-5 py-4">
<div className="flex flex-col items-end">
<span className="font-mono font-black text-slate-900 text-[13px]">{formatValue(stock.price)}</span>
<div className={`flex items-center gap-1 text-[10px] font-bold ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.changePercent >= 0 ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
{Math.abs(stock.changePercent)}%
</div>
</div>
</td>
{/* 4. 시가 / 고가 / 저가 */}
<td className="px-5 py-4 text-right">
<div className="flex flex-col">
<span className="text-[10px] text-slate-400 font-bold">: {formatValue(stock.openPrice)}</span>
<div className="flex gap-2 justify-end text-[10px] font-bold">
<span className="text-rose-400">: {formatValue(stock.highPrice)}</span>
<span className="text-blue-400">: {formatValue(stock.lowPrice)}</span>
</div>
</div>
</td>
{/* 5. 거래량 및 거래대금 */}
<td className="px-5 py-4 text-right">
<div className="flex flex-col">
<span className="text-[11px] font-black text-slate-700 font-mono">{formatVolume(stock.volume)}</span>
{stock.tradingValue && (
<span className="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">
{stock.market === MarketType.DOMESTIC ? (stock.tradingValue / 100000000).toFixed(1) + '억' : '$' + (stock.tradingValue / 1000000).toFixed(1) + 'M'}
</span>
)}
</div>
</td>
{/* 6. 기업 건강상태 (Fundamental) */}
<td className="px-5 py-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center gap-2">
<span className="text-[9px] font-black text-slate-400 uppercase">PER/PBR</span>
<span className="text-[11px] font-mono font-bold text-slate-700">{stock.per || '-'} / {stock.pbr || '-'}</span>
</div>
<div className="flex justify-between items-center gap-2">
<span className="text-[9px] font-black text-slate-400 uppercase">ROE/DY</span>
<span className="text-[11px] font-mono font-bold text-slate-700">{stock.roe || '-'}% / {stock.dividendYield || '-'}%</span>
</div>
</div>
</td>
{/* 7. AI 스코어 (매수/매도) */}
<td className="px-5 py-4">
<div className="flex items-center justify-end gap-3">
<div className="flex flex-col items-center">
<span className="text-[8px] font-black text-rose-300 uppercase">Buy</span>
<span className="text-[14px] font-black text-rose-500 font-mono italic">{stock.aiScoreBuy}</span>
</div>
<div className="w-[1px] h-6 bg-slate-100" />
<div className="flex flex-col items-center">
<span className="text-[8px] font-black text-blue-300 uppercase">Sell</span>
<span className="text-[14px] font-black text-blue-500 font-mono italic">{stock.aiScoreSell}</span>
</div>
</div>
</td>
{/* 7. 액션 버튼 */}
<td className="px-5 py-4 text-right">
<div className="flex gap-2 justify-end">
<button
onClick={(e) => { e.stopPropagation(); onToggleWatchlist?.(); }}
className={`p-2 rounded-xl transition-all ${isWatchlisted ? 'bg-amber-50 text-amber-500' : 'bg-slate-50 text-slate-300 hover:text-amber-500'}`}
>
<Star size={14} fill={isWatchlisted ? "currentColor" : "none"} />
</button>
<div className="w-[1px] h-4 bg-slate-200 self-center mx-1" />
<button
onClick={(e) => { e.stopPropagation(); onTrade?.(OrderType.BUY); }}
className="px-3 py-1.5 bg-slate-900 text-white rounded-xl hover:bg-rose-600 transition-all text-[11px] font-black flex items-center gap-1.5"
>
<Zap size={10} fill="currentColor" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onTrade?.(OrderType.SELL); }}
className="px-3 py-1.5 border-2 border-slate-900 text-slate-900 rounded-xl hover:bg-blue-600 hover:border-blue-600 hover:text-white transition-all text-[11px] font-black flex items-center gap-1.5"
>
<ShoppingCart size={10} />
</button>
</div>
</td>
</tr>
);
};

View File

@@ -25,11 +25,11 @@ export const StockRow: React.FC<StockRowProps> = ({
className="group cursor-pointer transition-colors hover:bg-slate-50/70"
>
{showRank && (
<td className="pl-6 py-3 font-mono font-black text-slate-400 group-hover:text-blue-600 transition-colors">
<td className="pl-4 py-2 font-mono font-black text-slate-400 group-hover:text-blue-600 transition-colors text-[13px]">
{rank}
</td>
)}
<td className="px-4 py-3">
<td className="px-3 py-2">
<div className="flex items-center gap-3">
{onToggleWatchlist && (
<button
@@ -39,38 +39,38 @@ export const StockRow: React.FC<StockRowProps> = ({
<Star size={16} fill={isWatchlisted ? "currentColor" : "none"} className={isWatchlisted ? "text-amber-500" : ""} />
</button>
)}
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-white text-[10px] font-black shadow-sm overflow-hidden">
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-white text-[11px] font-black shadow-sm overflow-hidden">
{stock.name[0]}
</div>
<div className="flex flex-col">
<span className="font-black text-slate-900 text-[13px] tracking-tight group-hover:text-blue-600">{stock.name}</span>
<span className="text-[9px] text-slate-400 font-mono font-bold">{stock.code}</span>
<span className="font-black text-slate-900 text-[13.5px] tracking-tight group-hover:text-blue-600 leading-tight">{stock.name}</span>
<span className="text-[10px] text-slate-400 font-mono font-bold">{stock.code}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-right font-mono font-black text-slate-800 text-[13px]">
<td className="px-3 py-2 text-right font-mono font-black text-slate-800 text-[13.5px]">
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() + '원' : '$' + stock.price}
</td>
<td className="px-4 py-3 text-right">
<td className="px-3 py-2 text-right">
{showPL ? (
<div className={showPL.pl >= 0 ? 'text-rose-500' : 'text-blue-600'}>
<p className="font-black text-[13px]">{showPL.pl.toLocaleString()}</p>
<p className="text-[10px] font-bold">({showPL.percent.toFixed(2)}%)</p>
<p className="font-black text-[13.5px] leading-tight">{showPL.pl.toLocaleString()}</p>
<p className="text-[10px] font-bold opacity-80 mt-0.5">({showPL.percent.toFixed(2)}%)</p>
</div>
) : (
<span className={`font-black text-[13px] ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
<span className={`font-black text-[13.5px] ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent}%
</span>
)}
</td>
{showRatioBar && (
<td className="px-4 py-3">
<td className="px-3 py-2">
<div className="flex flex-col items-end gap-0.5">
<div className="w-full h-1 rounded-full bg-slate-100 overflow-hidden flex">
<div className="h-full bg-rose-400" style={{ width: `${stock.buyRatio || 50}%` }} />
<div className="h-full bg-blue-400" style={{ width: `${stock.sellRatio || 50}%` }} />
</div>
<div className="flex justify-between w-full text-[8px] font-black font-mono">
<div className="flex justify-between w-full text-[8.5px] font-black font-mono opacity-60">
<span className="text-rose-500">{stock.buyRatio || 50}</span>
<span className="text-blue-500">{stock.sellRatio || 50}</span>
</div>
@@ -78,10 +78,10 @@ export const StockRow: React.FC<StockRowProps> = ({
</td>
)}
{showActions && onTrade && (
<td className="px-4 py-3 text-right">
<div className="flex gap-1.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); onTrade(OrderType.BUY); }} className="p-1.5 bg-rose-50 text-rose-500 rounded-lg hover:bg-rose-500 hover:text-white transition-all"><Zap size={12} fill="currentColor" /></button>
<button onClick={(e) => { e.stopPropagation(); onTrade(OrderType.SELL); }} className="p-1.5 bg-blue-50 text-blue-500 rounded-lg hover:bg-blue-500 hover:text-white transition-all"><ShoppingCart size={12} /></button>
<td className="px-3 py-2 text-right">
<div className="flex gap-1 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); onTrade(OrderType.BUY); }} className="p-1 px-1.5 bg-rose-50 text-rose-500 rounded-md hover:bg-rose-500 hover:text-white transition-all"><Zap size={10} fill="currentColor" /></button>
<button onClick={(e) => { e.stopPropagation(); onTrade(OrderType.SELL); }} className="p-1 px-1.5 bg-blue-50 text-blue-500 rounded-md hover:bg-blue-500 hover:text-white transition-all"><ShoppingCart size={10} /></button>
</div>
</td>
)}

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, Star, Globe, Building2, TrendingUp, TrendingDown, ChevronRight } from 'lucide-react';
import { StockItem, WatchlistGroup, MarketType } from '../types';
import { DbService } from '../services/dbService';
import { MOCK_STOCKS } from '../constants';
interface StockSearchModalProps {
onClose: () => void;
onSelect: (stock: StockItem) => void;
currentMarket: MarketType;
}
const StockSearchModal: React.FC<StockSearchModalProps> = ({ onClose, onSelect, currentMarket }) => {
const [searchQuery, setSearchQuery] = useState('');
const [groups, setGroups] = useState<WatchlistGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | 'ALL'>('ALL');
const dbService = useMemo(() => new DbService(), []);
useEffect(() => {
const fetchGroups = async () => {
const data = await dbService.getWatchlistGroups();
setGroups(data.filter(g => g.market === currentMarket));
};
fetchGroups();
}, [dbService, currentMarket]);
const filteredStocks = useMemo(() => {
let base = MOCK_STOCKS.filter(s => s.market === currentMarket);
if (selectedGroupId !== 'ALL') {
const group = groups.find(g => g.id === selectedGroupId);
if (group) {
base = base.filter(s => group.codes.includes(s.code));
}
}
if (searchQuery) {
const lowerQuery = searchQuery.toLowerCase();
base = base.filter(s =>
s.name.toLowerCase().includes(lowerQuery) ||
s.code.toLowerCase().includes(lowerQuery)
);
}
return base;
}, [searchQuery, selectedGroupId, groups, currentMarket]);
return (
<div className="fixed inset-0 z-[400] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white w-full max-w-3xl h-[600px] rounded-3xl shadow-2xl flex overflow-hidden border border-slate-200 animate-in zoom-in-95 duration-200">
{/* 사이드바: 그룹 목록 */}
<div className="w-56 bg-slate-50 border-r border-slate-100 flex flex-col">
<div className="p-5 border-b border-slate-100">
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Star size={14} className="text-amber-500" />
</h4>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
<button
onClick={() => setSelectedGroupId('ALL')}
className={`w-full text-left px-4 py-2.5 rounded-xl text-[13px] font-bold transition-all flex items-center justify-between group ${selectedGroupId === 'ALL' ? 'bg-white text-slate-900 shadow-sm border border-slate-100' : 'text-slate-500 hover:bg-slate-100'}`}
>
<ChevronRight size={14} className={`transition-transform ${selectedGroupId === 'ALL' ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100'}`} />
</button>
{groups.map(group => (
<button
key={group.id}
onClick={() => setSelectedGroupId(group.id)}
className={`w-full text-left px-4 py-2.5 rounded-xl text-[13px] font-bold transition-all flex items-center justify-between group ${selectedGroupId === group.id ? 'bg-white text-slate-900 shadow-sm border border-slate-100' : 'text-slate-500 hover:bg-slate-100'}`}
>
<span className="truncate">{group.name}</span>
<span className="text-[10px] bg-slate-200 text-slate-500 px-1.5 rounded-md">{group.codes.length}</span>
</button>
))}
</div>
</div>
{/* 메인: 검색 및 목록 */}
<div className="flex-1 flex flex-col bg-white">
<div className="p-4 border-b border-slate-100 flex items-center justify-between gap-4">
<div className="flex-1 relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-blue-500 transition-colors" size={18} />
<input
autoFocus
type="text"
placeholder="종목명 또는 코드 입력..."
className="w-full bg-slate-50 border-none rounded-2xl py-2.5 pl-10 pr-4 text-[14px] font-bold outline-none ring-2 ring-transparent focus:ring-blue-500/10 focus:bg-white transition-all"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
{filteredStocks.length > 0 ? (
filteredStocks.map(stock => (
<div
key={stock.code}
onClick={() => onSelect(stock)}
className="p-3 rounded-2xl border border-slate-50 hover:border-blue-100 hover:bg-blue-50/30 cursor-pointer transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center text-slate-400 font-bold group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
{stock.name[0]}
</div>
<div>
<h5 className="text-[14px] font-black text-slate-800">{stock.name}</h5>
<span className="text-[11px] font-bold text-slate-400 tracking-wider">{stock.code}</span>
</div>
</div>
<div className="text-right">
<p className="text-[14px] font-black font-mono text-slate-900">
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`}
</p>
<div className={`text-[11px] font-bold flex items-center justify-end gap-1 ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.changePercent >= 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{Math.abs(stock.changePercent)}%
</div>
</div>
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-300 space-y-3">
<Search size={48} strokeWidth={1} />
<p className="text-[13px] font-bold"> .</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default StockSearchModal;

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, RotateCcw, ChevronDown, Zap, Plus, Minus, Calendar, ToggleLeft, ToggleRight, CheckSquare, Square, TrendingUp, TrendingDown, Wallet, Target, ShieldAlert, BadgePercent, Save, PlayCircle, Info, BarChart3, Maximize2, Circle, CheckCircle2 } from 'lucide-react';
import { X, RotateCcw, ChevronDown, Zap, Plus, Minus, Calendar, ToggleLeft, ToggleRight, CheckSquare, Square, TrendingUp, TrendingDown, Wallet, Target, ShieldAlert, BadgePercent, Save, PlayCircle, Info, BarChart3, Maximize2, Circle, CheckCircle2, Search } from 'lucide-react';
import { StockItem, OrderType, MarketType, ReservedOrder, TradeOrder } from '../types';
import { DbService, HoldingItem } from '../services/dbService';
import StockSearchModal from './StockSearchModal';
interface TradeModalProps {
stock: StockItem;
@@ -14,7 +15,9 @@ interface TradeModalProps {
type StrategyType = 'NONE' | 'PROFIT' | 'LOSS' | 'TRAILING_STOP';
const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClose, onExecute, onImmediateOrder }) => {
const TradeModal: React.FC<TradeModalProps> = ({ stock: initialStock, type: initialType, onClose, onExecute, onImmediateOrder }) => {
const [localStock, setLocalStock] = useState<StockItem>(initialStock);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [orderType, setOrderType] = useState<OrderType>(initialType);
const isBuyMode = orderType === OrderType.BUY;
@@ -25,21 +28,21 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
useEffect(() => {
const fetchData = async () => {
const holdings = await dbService.getHoldings();
const currentHolding = holdings.find(h => h.code === stock.code) || null;
const currentHolding = holdings.find(h => h.code === localStock.code) || null;
setHolding(currentHolding);
const summary = await dbService.getAccountSummary();
setBuyingPower(summary.buyingPower);
};
fetchData();
}, [stock.code, dbService, orderType]);
}, [localStock.code, dbService, orderType]);
const plInfo = useMemo(() => {
if (!holding) return null;
const pl = (stock.price - holding.avgPrice) * holding.quantity;
const percent = ((stock.price - holding.avgPrice) / holding.avgPrice) * 100;
const pl = (localStock.price - holding.avgPrice) * holding.quantity;
const percent = ((localStock.price - holding.avgPrice) / holding.avgPrice) * 100;
return { pl, percent };
}, [holding, stock.price]);
}, [holding, localStock.price]);
const [monitoringEnabled, setMonitoringEnabled] = useState(false);
const [immediateStart, setImmediateStart] = useState(true);
@@ -54,7 +57,7 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
// TS 내부 감시 시작 조건 (Trigger)
const [triggerEnabled, setTriggerEnabled] = useState(false);
const [triggerType, setTriggerType] = useState<'CURRENT' | 'HIGH' | 'LOW' | 'VOLUME'>('CURRENT');
const [triggerValue, setTriggerValue] = useState<number>(stock.price);
const [triggerValue, setTriggerValue] = useState<number>(localStock.price);
const [monCondition, setMonCondition] = useState<'ABOVE' | 'BELOW'>(isBuyMode ? 'BELOW' : 'ABOVE');
// 자동 조건 결정 로직
@@ -80,13 +83,13 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
}
applyAutoCondition(triggerType, isBuyMode);
if (triggerType !== 'VOLUME') {
setTriggerValue(stock.price);
setTriggerValue(localStock.price);
}
}, [isBuyMode, stock.price]);
}, [isBuyMode, localStock.price]);
const [monTargetUnit, setMonTargetUnit] = useState<'PRICE' | 'PERCENT' | 'AMOUNT'>('PRICE');
const [profitValue, setProfitValue] = useState<number>(stock.price * 1.1);
const [lossValue, setLossValue] = useState<number>(stock.price * 0.9);
const [profitValue, setProfitValue] = useState<number>(localStock.price * 1.1);
const [lossValue, setLossValue] = useState<number>(localStock.price * 0.9);
const [expiryDays, setExpiryDays] = useState<number>(1);
const [customExpiryDate, setCustomExpiryDate] = useState<string>(
@@ -99,14 +102,14 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
const [quantity, setQuantity] = useState<number>(1);
const [quantityRatio, setQuantityRatio] = useState<number>(isBuyMode ? 10 : 100);
const currencySymbol = stock.market === MarketType.DOMESTIC ? '원' : '$';
const currencySymbol = localStock.market === MarketType.DOMESTIC ? '원' : '$';
const handleMaxQuantity = () => {
if (quantityMode === 'RATIO') {
setQuantityRatio(100);
} else {
if (isBuyMode) {
const maxQty = Math.floor(buyingPower / stock.price);
const maxQty = Math.floor(buyingPower / localStock.price);
setQuantity(maxQty > 0 ? maxQty : 1);
} else if (holding) {
setQuantity(holding.quantity);
@@ -120,7 +123,7 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
setActiveStrategy(isBuyMode ? 'TRAILING_STOP' : 'NONE');
setTriggerEnabled(false);
setTriggerType('CURRENT');
setTriggerValue(stock.price);
setTriggerValue(localStock.price);
applyAutoCondition('CURRENT', isBuyMode);
setPriceMethod('CURRENT');
setTickOffset(0);
@@ -133,13 +136,65 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
if (quantityMode === 'RATIO') {
if (isBuyMode) {
const targetBudget = buyingPower * (quantityRatio / 100);
return Math.floor(targetBudget / stock.price) || 0;
return Math.floor(targetBudget / localStock.price) || 0;
} else if (holding) {
return Math.floor(holding.quantity * (quantityRatio / 100));
}
}
return quantity;
}, [isBuyMode, quantityMode, quantity, quantityRatio, holding, buyingPower, stock.price]);
}, [isBuyMode, quantityMode, quantity, quantityRatio, holding, buyingPower, localStock.price]);
const summaryMessage = useMemo(() => {
const action = isBuyMode ? '매수' : '매도';
const quantityText = quantityMode === 'RATIO'
? `${finalQuantity}주(${isBuyMode ? '예수금' : '보유수량'}대비 ${quantityRatio}%)`
: `${finalQuantity}`;
const priceMethodMap = {
CURRENT: '현재가',
MARKET: '시장가',
HIGH: '고가',
LOW: '저가'
};
const tickText = tickOffset === 0 ? '+0틱' : (tickOffset > 0 ? `+${tickOffset}` : `${tickOffset}`);
const priceText = priceMethod === 'MARKET' ? '시장가' : `${priceMethodMap[priceMethod]}${tickText}`;
if (!monitoringEnabled) {
return `${priceText}${quantityText}${action}합니다.`;
}
let strategyDesc = '';
if (activeStrategy === 'TRAILING_STOP') {
const triggerTypeMap = {
CURRENT: '현재가',
HIGH: '고가',
LOW: '저가',
VOLUME: '거래량'
};
const triggerLabel = triggerTypeMap[triggerType];
const josa = triggerType === 'VOLUME' ? '이' : '가';
const triggerDesc = triggerEnabled
? `${triggerLabel}${josa} ${triggerValue.toLocaleString()}${triggerType === 'VOLUME' ? '주' : currencySymbol} ${monCondition === 'ABOVE' ? '이상' : '이하'} 도달 후`
: '현재 시점부터';
const tsUnitText = tsUnit === 'PERCENT' ? '%' : tsUnit === 'TICK' ? '틱' : currencySymbol;
const turnAction = isBuyMode ? '반등' : '하락';
strategyDesc = `${triggerDesc} ${tsValue}${tsUnitText} ${turnAction}`;
} else if (activeStrategy === 'PROFIT') {
const unitLabel = monTargetUnit === 'PRICE' ? '현재가' : monTargetUnit === 'PERCENT' ? '수익률(%)' : '수익금액';
const unit = monTargetUnit === 'PERCENT' ? '%' : currencySymbol;
strategyDesc = `(이익실현) ${unitLabel} ${profitValue.toLocaleString()}${unit} 이상 도달 시`;
} else if (activeStrategy === 'LOSS') {
const unitLabel = monTargetUnit === 'PRICE' ? '현재가' : monTargetUnit === 'PERCENT' ? '수익률(%)' : '수익금액';
const unit = monTargetUnit === 'PERCENT' ? '%' : currencySymbol;
strategyDesc = `(손절제한) ${unitLabel} ${lossValue.toLocaleString()}${unit} 이하 도달 시`;
} else {
strategyDesc = '조건 만족 시';
}
return `${strategyDesc} (${priceText})으로 ${quantityText}${action}합니다.`;
}, [isBuyMode, monitoringEnabled, activeStrategy, triggerEnabled, triggerType, triggerValue, monCondition, tsValue, tsUnit, priceMethod, tickOffset, finalQuantity, quantityMode, quantityRatio, profitValue, lossValue, monTargetUnit, currencySymbol]);
const handleExecute = () => {
if (monitoringEnabled) {
@@ -148,24 +203,24 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
else finalExpiry = new Date(customExpiryDate);
onExecute({
stockCode: stock.code,
stockName: stock.name,
stockCode: localStock.code,
stockName: localStock.name,
type: orderType,
quantity: finalQuantity,
monitoringType: activeStrategy === 'TRAILING_STOP' ? 'TRAILING_STOP' : 'PRICE_TRIGGER',
triggerPrice: triggerEnabled ? triggerValue : stock.price,
triggerPrice: triggerEnabled ? triggerValue : localStock.price,
trailingType: tsUnit === 'PERCENT' ? 'PERCENT' : 'AMOUNT',
trailingValue: tsValue,
market: stock.market,
market: localStock.market,
expiryDate: finalExpiry
});
} else {
if (onImmediateOrder) {
onImmediateOrder({
stockCode: stock.code,
stockName: stock.name,
stockCode: localStock.code,
stockName: localStock.name,
type: orderType,
price: priceMethod === 'MARKET' ? 0 : stock.price,
price: priceMethod === 'MARKET' ? 0 : localStock.price,
quantity: finalQuantity
});
}
@@ -178,12 +233,12 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
return (
<div
onClick={() => setActiveStrategy(type)}
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer flex items-center justify-between ${isSelected ? 'bg-amber-50/40 border-amber-300 shadow-sm' : 'bg-transparent border-slate-100 opacity-60'}`}
className={`p-3 rounded-2xl border-2 transition-all cursor-pointer flex items-center justify-between ${isSelected ? 'bg-amber-50/40 border-amber-300 shadow-sm' : 'bg-transparent border-slate-100 opacity-60'}`}
>
<div className="flex items-center gap-3">
{isSelected ? <CheckCircle2 className="text-amber-600" size={18} /> : <Circle className="text-slate-300" size={18} />}
<Icon className={isSelected ? 'text-amber-600' : 'text-slate-300'} size={18} />
<span className="text-[13px] font-black text-slate-800 uppercase tracking-tight">{label}</span>
<div className="flex items-center gap-2">
{isSelected ? <CheckCircle2 className="text-amber-600" size={16} /> : <Circle className="text-slate-300" size={16} />}
<Icon className={isSelected ? 'text-amber-600' : 'text-slate-300'} size={16} />
<span className="text-[12px] font-black text-slate-800 uppercase tracking-tight">{label}</span>
</div>
{isSelected && type === 'TRAILING_STOP' && (
@@ -216,10 +271,10 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
return (
<div className="fixed inset-0 z-[300] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4">
<div className="bg-white w-full max-w-5xl rounded-3xl shadow-2xl animate-in zoom-in-95 duration-200 flex flex-col max-h-[90vh] overflow-hidden border border-slate-200">
<div className="bg-white w-full max-w-4xl rounded-3xl shadow-2xl animate-in zoom-in-95 duration-200 flex flex-col max-h-[90vh] overflow-hidden border border-slate-200">
{/* 헤더 */}
<div className="px-6 py-3 flex justify-between items-center bg-white border-b border-slate-100 shrink-0">
<div className="px-5 py-2 flex justify-between items-center bg-white border-b border-slate-100 shrink-0">
<button onClick={handleReset} className="flex items-center gap-1.5 text-slate-400 hover:text-slate-600 transition-colors">
<RotateCcw size={16} />
<span className="text-[11px] font-black uppercase tracking-wider"> </span>
@@ -235,65 +290,86 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
<div className="flex-1 overflow-y-auto custom-scrollbar bg-slate-50/20">
{/* 상단 통합 정보 */}
<div className="px-8 py-6 bg-white border-b border-slate-100 grid grid-cols-1 lg:grid-cols-2 gap-6 items-center">
<div className="flex items-center gap-6">
<div className="w-16 h-16 bg-slate-900 rounded-2xl flex items-center justify-center text-white font-black italic text-[20px] shadow-lg">{stock.name[0]}</div>
<div className="space-y-0.5">
<h3 className="text-xl font-black text-slate-900 tracking-tighter flex items-center gap-1.5">{stock.name} <ChevronDown size={18} className="text-slate-200" /></h3>
<div className="flex items-center gap-2.5 text-[11px] font-black uppercase text-slate-400">
<span className="bg-slate-100 px-2 py-0.5 rounded-md border border-slate-200 text-slate-600">{stock.market === MarketType.DOMESTIC ? 'KRX' : 'NYSE'}</span>
<span className="tracking-widest">{stock.code}</span>
<div className="px-5 py-3 bg-white border-b border-slate-100 flex items-center justify-between gap-4">
{/* 1. 종목 정보 */}
<div className="flex items-center gap-3 min-w-0 group relative">
<button
onClick={() => setIsSearchOpen(true)}
className="w-10 h-10 bg-slate-900 rounded-xl flex items-center justify-center text-white font-black italic text-[14px] shrink-0 shadow-md transition-all hover:bg-slate-800 relative group-hover:bg-blue-600"
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Search size={18} />
</div>
</div>
<div className="ml-4 border-l border-slate-100 pl-6 space-y-0.5 flex flex-col justify-center">
<p className={`text-2xl font-black font-mono tracking-tighter leading-none ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`}
</p>
<div className={`text-[12px] font-black flex items-center gap-1.5 ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.changePercent >= 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
{Math.abs(stock.changePercent)}%
<span className="group-hover:opacity-0 transition-opacity">{localStock.name[0]}</span>
</button>
<div className="min-w-0 cursor-pointer" onClick={() => setIsSearchOpen(true)}>
<h3 className="text-[15px] font-black text-slate-900 truncate flex items-center gap-1 leading-tight group-hover:text-blue-600 transition-colors">
{localStock.name} <ChevronDown size={14} className="text-slate-300" />
</h3>
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase text-slate-400 mt-0.5">
<span className="bg-slate-100 px-1 py-0.5 rounded text-slate-600 border border-slate-200/50">{localStock.market === MarketType.DOMESTIC ? 'KRX' : 'NYSE'}</span>
<span className="tracking-widest">{localStock.code}</span>
</div>
</div>
<div className="ml-4 border-l border-slate-100 pl-6 flex flex-col justify-center gap-1">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none"></p>
<p className="text-[14px] font-black font-mono text-slate-900 leading-none">{stock.volume.toLocaleString()}</p>
<div className="flex items-center gap-1 text-[10px] font-black text-rose-500">
<TrendingUp size={10} /> +5.2%
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 bg-slate-50 p-4 rounded-2xl border border-slate-100 shadow-sm">
<div className="space-y-0.5 pr-4 border-r border-slate-200">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><Wallet size={12} className="text-blue-500" /> </p>
<p className="text-[14px] font-black font-mono text-slate-900">{buyingPower.toLocaleString()}{currencySymbol}</p>
{/* 2. 시세 및 계좌 정보 (가로 나열) */}
<div className="flex items-center gap-4 shrink-0">
{/* 시세 */}
<div className="border-l border-slate-100 pl-4 flex flex-col justify-center">
<p className={`text-[17px] font-black font-mono leading-none ${localStock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{localStock.market === MarketType.DOMESTIC ? localStock.price.toLocaleString() : `$${localStock.price}`}
</p>
<div className={`text-[10px] font-black flex items-center gap-1 mt-1 ${localStock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{localStock.changePercent >= 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{Math.abs(localStock.changePercent)}%
</div>
</div>
{holding ? (
<div className="flex flex-col gap-1.5">
<div className="flex justify-between items-center">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></p>
<p className="text-[12px] font-black font-mono text-slate-900">{holding.quantity} <span className="text-[9px] text-slate-400">EA</span></p>
{/* 거래량 */}
<div className="border-l border-slate-100 pl-4 flex flex-col justify-center">
<p className="text-[8px] font-black text-slate-400 uppercase tracking-widest leading-none mb-1"></p>
<p className="text-[12px] font-black font-mono text-slate-900 leading-none">{localStock.volume.toLocaleString()}</p>
<div className="flex items-center gap-1 text-[9px] font-black text-rose-500 mt-1">
<TrendingUp size={10} /> +5.2%
</div>
</div>
{/* 예수금 */}
<div className="border-l border-slate-100 pl-4 flex flex-col justify-center">
<p className="text-[8px] font-black text-slate-400 uppercase tracking-widest leading-none mb-1 flex items-center gap-1"><Wallet size={11} className="text-blue-500" /> </p>
<p className="text-[13px] font-black font-mono text-slate-900 leading-none">{buyingPower.toLocaleString()}{currencySymbol}</p>
</div>
{/* 보유/평가 */}
<div className="border-l border-slate-100 pl-4 flex flex-col justify-center min-w-[110px]">
{holding ? (
<div className="space-y-1">
<div className="flex justify-between items-center gap-3">
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest leading-none"></span>
<span className="text-[11px] font-black font-mono text-slate-900 leading-none">{holding.quantity} <span className="text-[8px] text-slate-300">EA</span></span>
</div>
<div className="flex justify-between items-center gap-3">
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest leading-none"></span>
<span className={`text-[11px] font-black font-mono leading-none ${plInfo!.pl >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{plInfo!.percent.toFixed(1)}%
</span>
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></p>
<p className={`text-[12px] font-black font-mono ${plInfo!.pl >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{plInfo!.pl > 0 ? '+' : ''}{plInfo!.pl.toLocaleString()} <span className="text-[10px]">({plInfo!.percent.toFixed(2)}%)</span>
</p>
) : (
<div className="py-1 px-2 bg-slate-50 rounded border border-dashed border-slate-200 text-center">
<p className="text-[9px] font-black text-slate-300 uppercase tracking-widest"></p>
</div>
</div>
) : (
<div className="flex items-center justify-center opacity-20">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]"> </p>
</div>
)}
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-px bg-slate-200">
<div className={`bg-white p-8 space-y-6 transition-all duration-300 ${!monitoringEnabled ? 'opacity-40 grayscale-[0.5]' : ''}`}>
<div className={`bg-white p-5 space-y-5 transition-all duration-300 ${!monitoringEnabled ? 'opacity-40 grayscale-[0.5]' : ''}`}>
<div className="flex items-center justify-between">
<h4 className="text-[14px] font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<ShieldAlert size={18} className={monitoringEnabled ? 'text-rose-500' : 'text-slate-300'} />
<h4 className="text-[13px] font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<ShieldAlert size={16} className={monitoringEnabled ? 'text-rose-500' : 'text-slate-300'} />
1.
</h4>
<button
@@ -403,13 +479,13 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
</div>
</div>
<div className="bg-white p-8 space-y-6">
<h4 className="text-[14px] font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Zap size={18} className="text-blue-500" />
<div className="bg-white p-5 space-y-5">
<h4 className="text-[13px] font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Zap size={16} className="text-blue-500" />
2.
</h4>
<div className="space-y-8">
<div className="space-y-6">
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest pl-1"> </label>
<div className="grid grid-cols-4 gap-2 bg-slate-50 p-1 rounded-2xl border border-slate-100">
@@ -468,9 +544,9 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
</div>
</div>
<div className="p-4 bg-slate-900/5 rounded-2xl border border-slate-100 text-center">
<div className="p-3 bg-slate-900/5 rounded-2xl border border-slate-100 text-center">
<p className="text-[12px] font-black text-slate-600 leading-tight">
<span className={`${isBuyMode ? 'text-rose-500' : 'text-blue-600'}`}>{finalQuantity}</span> {isBuyMode ? '매수' : '매도'}.
{summaryMessage}
</p>
</div>
</div>
@@ -479,7 +555,7 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
</div>
{/* 푸터 */}
<div className="px-8 py-6 bg-white border-t border-slate-100 flex items-center justify-between shrink-0 gap-6">
<div className="px-6 py-4 bg-white border-t border-slate-100 flex items-center justify-between shrink-0 gap-4">
{monitoringEnabled && (
<div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex flex-col">
@@ -502,6 +578,21 @@ const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClo
{monitoringEnabled ? <><Save className="w-5 h-5" /> {immediateStart ? '즉시 활성화' : '대기 저장'}</> : <><Zap size={22} fill="currentColor" /> {isBuyMode ? '매수' : '매도'} </>}
</button>
</div>
{isSearchOpen && (
<StockSearchModal
currentMarket={localStock.market}
onClose={() => setIsSearchOpen(false)}
onSelect={(newStock) => {
setLocalStock(newStock);
setIsSearchOpen(false);
// 초기화 로직 (트리거 등 새 종목 가격 연동)
setTriggerValue(newStock.price);
setProfitValue(newStock.price * 1.1);
setLossValue(newStock.price * 0.9);
}}
/>
)}
</div>
</div>
);

View File

@@ -14,36 +14,42 @@ export const MOCK_STOCKS: StockItem[] = [
code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000,
per: 15.2, pbr: 1.1, roe: 12.5, marketCap: 4370000, dividendYield: 2.1,
aiScoreBuy: 85, aiScoreSell: 20,
openPrice: 72500, highPrice: 73500, lowPrice: 72400,
themes: ['반도체', 'AI/인공지능', '스마트폰', 'HBM']
},
{
code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000,
per: 22.4, pbr: 1.5, roe: 8.2, marketCap: 906000, dividendYield: 1.2,
aiScoreBuy: 65, aiScoreSell: 45,
openPrice: 126000, highPrice: 126500, lowPrice: 124000,
themes: ['반도체', 'HBM', '엔비디아 관련주']
},
{
code: '035420', name: 'NAVER', price: 215000, change: 4500, changePercent: 2.14, market: MarketType.DOMESTIC, volume: 850000,
per: 35.1, pbr: 2.3, roe: 15.8, marketCap: 352000, dividendYield: 0.5,
aiScoreBuy: 72, aiScoreSell: 30,
openPrice: 211000, highPrice: 216500, lowPrice: 210500,
themes: ['플랫폼', '생성형AI', '광고/커머스']
},
{
code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000,
per: 31.5, pbr: 48.2, roe: 160.1, marketCap: 3020000, dividendYield: 0.5,
aiScoreBuy: 90, aiScoreSell: 15,
openPrice: 188.50, highPrice: 190.20, lowPrice: 188.10,
themes: ['빅테크', '스마트폰', '자율주행']
},
{
code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000,
per: 78.2, pbr: 15.1, roe: 22.4, marketCap: 745000, dividendYield: 0,
aiScoreBuy: 40, aiScoreSell: 75,
openPrice: 238.10, highPrice: 239.50, lowPrice: 233.80,
themes: ['전기차', '자율주행', '에너지저장장치']
},
{
code: 'NVDA', name: 'NVIDIA Corp.', price: 485.12, change: 12.30, changePercent: 2.6, market: MarketType.OVERSEAS, volume: 32000000,
per: 65.4, pbr: 35.2, roe: 91.5, marketCap: 1200000, dividendYield: 0.1,
aiScoreBuy: 95, aiScoreSell: 10,
openPrice: 480.00, highPrice: 488.50, lowPrice: 479.20,
themes: ['반도체', 'AI/인공지능', '데이터센터']
}
];

View File

@@ -63,85 +63,85 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
const filteredGroups = groups.filter(g => g.codes.some(code => stocks.find(s => s.code === code)?.market === marketMode));
return (
<div className="space-y-12 animate-in slide-in-from-bottom-6 duration-500 pb-24">
<div className="flex justify-between items-center bg-white p-12 rounded-[4rem] shadow-sm border border-slate-100">
<div className="space-y-6 animate-in slide-in-from-bottom-6 duration-500 pb-20 pr-4">
<div className="flex justify-between items-center bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div>
<h3 className="text-3xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-5">
<Cpu className="text-emerald-500" size={36} /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
<Cpu className="text-emerald-500" size={24} /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
</h3>
<p className="text-base font-bold text-slate-400 uppercase tracking-widest mt-3 flex items-center gap-3">
<span className="relative flex h-3 w-3">
<p className="text-[12px] font-black text-slate-400 uppercase tracking-widest mt-1 flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-400' : 'bg-slate-300'}`}></span>
<span className={`relative inline-flex rounded-full h-3 w-3 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-500' : 'bg-slate-400'}`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-500' : 'bg-slate-400'}`}></span>
</span>
{configs.filter(c => c.active && c.market === marketMode).length}
{configs.filter(c => c.active && c.market === marketMode).length}
</p>
</div>
<button
onClick={() => { setShowAddModal(true); setTargetType('SINGLE'); }}
className="bg-slate-900 text-white px-12 py-6 rounded-[2.5rem] font-black text-base uppercase tracking-widest flex items-center gap-4 hover:bg-slate-800 transition-all shadow-2xl shadow-slate-300 active:scale-95"
className="bg-slate-900 text-white px-6 py-2.5 rounded-xl font-black text-[12px] uppercase tracking-widest flex items-center gap-2 hover:bg-slate-800 transition-all shadow-md active:scale-95"
>
<Plus size={24} />
<Plus size={18} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configs.filter(c => c.market === marketMode).map(config => (
<div key={config.id} className={`bg-white p-12 rounded-[4rem] shadow-sm border transition-all relative overflow-hidden group hover:shadow-2xl ${config.active ? 'border-emerald-200' : 'border-slate-100 grayscale-[0.5]'}`}>
<div className={`absolute top-0 left-0 w-full h-2.5 transition-colors ${config.active ? (config.groupId ? 'bg-indigo-500' : (config.type === 'ACCUMULATION' ? 'bg-blue-500' : 'bg-orange-500')) : 'bg-slate-300'}`}></div>
<div key={config.id} className={`bg-white p-5 rounded-2xl shadow-sm border transition-all relative overflow-hidden group hover:shadow-md ${config.active ? 'border-emerald-200' : 'border-slate-100 grayscale-[0.5]'}`}>
<div className={`absolute top-0 left-0 w-full h-1.5 transition-colors ${config.active ? (config.groupId ? 'bg-indigo-500' : (config.type === 'ACCUMULATION' ? 'bg-blue-500' : 'bg-orange-500')) : 'bg-slate-300'}`}></div>
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-6">
<div className={`p-5 rounded-3xl shadow-sm transition-colors ${config.active ? (config.groupId ? 'bg-indigo-50 text-indigo-600' : (config.type === 'ACCUMULATION' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600')) : 'bg-slate-100 text-slate-400'}`}>
{config.groupId ? <Layers size={32} /> : <Activity size={32} />}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl shadow-sm transition-colors ${config.active ? (config.groupId ? 'bg-indigo-50 text-indigo-600' : (config.type === 'ACCUMULATION' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600')) : 'bg-slate-100 text-slate-400'}`}>
{config.groupId ? <Layers size={20} /> : <Activity size={20} />}
</div>
<div>
<h4 className={`font-black text-2xl leading-none mb-2 transition-colors ${config.active ? 'text-slate-900' : 'text-slate-400'}`}>{config.stockName}</h4>
<p className="text-[12px] text-slate-400 font-mono font-bold tracking-widest uppercase">{config.groupId ? 'GROUP AGENT' : config.stockCode}</p>
<h4 className={`font-black text-[16px] leading-tight mb-0.5 transition-colors ${config.active ? 'text-slate-900' : 'text-slate-400'}`}>{config.stockName}</h4>
<p className="text-[10px] text-slate-400 font-mono font-bold tracking-widest uppercase">{config.groupId ? 'GROUP AGENT' : config.stockCode}</p>
</div>
</div>
{/* 활성화 토글 스위치 */}
<button
onClick={() => onToggleConfig(config.id)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all focus:outline-none ${config.active ? 'bg-emerald-500' : 'bg-slate-200'}`}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-all focus:outline-none ${config.active ? 'bg-emerald-500' : 'bg-slate-200'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${config.active ? 'translate-x-7' : 'translate-x-2'}`} />
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm transition-transform ${config.active ? 'translate-x-4.5' : 'translate-x-1'}`} />
</button>
</div>
<div className="space-y-6">
<div className={`p-8 rounded-[3rem] space-y-5 border transition-colors ${config.active ? 'bg-slate-50/80 border-slate-100' : 'bg-slate-50/30 border-transparent'}`}>
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
<div className="space-y-4">
<div className={`p-4 rounded-xl space-y-3 border transition-colors ${config.active ? 'bg-slate-50/80 border-slate-100' : 'bg-slate-50/30 border-transparent'}`}>
<div className="flex justify-between items-center text-[11px] font-black uppercase tracking-widest">
<span className="text-slate-400"></span>
<span className={config.active ? (config.groupId ? 'text-indigo-600' : 'text-slate-600') : 'text-slate-300'}>{config.groupId ? '그룹 일괄' : '개별 자산'}</span>
</div>
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
<div className="flex justify-between items-center text-[11px] font-black uppercase tracking-widest">
<span className="text-slate-400"> </span>
<span className={`px-3 py-1 rounded-lg transition-colors ${config.active ? (config.type === 'ACCUMULATION' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600') : 'bg-slate-100 text-slate-300'}`}>
<span className={`px-2 py-0.5 rounded-lg transition-colors ${config.active ? (config.type === 'ACCUMULATION' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600') : 'bg-slate-100 text-slate-300'}`}>
{config.type === 'ACCUMULATION' ? '적립식 매수' : 'TS 자동매매'}
</span>
</div>
<div className="flex justify-between items-center text-base font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[12px]"></span>
<span className={`flex items-center gap-3 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Calendar size={20} className="text-slate-400" /> {getDayLabel(config)}</span>
<div className="flex justify-between items-center text-[12px] font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[11px]"></span>
<span className={`flex items-center gap-2 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Calendar size={14} className="text-slate-400" /> {getDayLabel(config)}</span>
</div>
<div className="flex justify-between items-center text-base font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[12px]"></span>
<span className={`flex items-center gap-3 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Clock size={20} className="text-slate-400" /> {config.executionTime} / {config.quantity}</span>
<div className="flex justify-between items-center text-[12px] font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[11px]"></span>
<span className={`flex items-center gap-2 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Clock size={14} className="text-slate-400" /> {config.executionTime} / {config.quantity}</span>
</div>
</div>
<div className="pt-8 border-t border-slate-100 flex justify-between items-center">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full transition-colors ${config.active ? 'bg-emerald-500' : 'bg-slate-300'}`}></span>
<span className="text-[12px] font-black text-slate-400 uppercase tracking-widest">{config.active ? '에이전트 운용 중' : '일시 중단됨'}</span>
<div className="pt-4 border-t border-slate-100 flex justify-between items-center">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full transition-colors ${config.active ? 'bg-emerald-500' : 'bg-slate-300'}`}></span>
<span className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{config.active ? '에이전트 운용 중' : '일시 중단됨'}</span>
</div>
<button
onClick={() => onDeleteConfig(config.id)}
className="p-4 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-[1.5rem] transition-all"
className="p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all"
>
<Trash2 size={24} />
<Trash2 size={18} />
</button>
</div>
</div>
@@ -149,44 +149,43 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
))}
</div>
{/* 전략 추가 모달 (기존 동일) */}
{showAddModal && (
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6">
<div className="bg-white w-full max-w-2xl rounded-[4rem] p-16 shadow-2xl animate-in zoom-in-95 duration-300 border border-slate-100 overflow-hidden">
<div className="flex justify-between items-center mb-12">
<h3 className="text-4xl font-black text-slate-900 uppercase tracking-tight flex items-center gap-5">
<Zap className="text-yellow-400 fill-yellow-400" />
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6">
<div className="bg-white w-full max-w-xl rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-300 border border-slate-100 overflow-hidden">
<div className="flex justify-between items-center mb-8">
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter flex items-center gap-3">
<Zap className="text-yellow-400 fill-yellow-400" size={24} />
</h3>
<button onClick={() => setShowAddModal(false)} className="p-4 hover:bg-slate-100 rounded-full transition-colors"><X size={32} className="text-slate-400" /></button>
<button onClick={() => setShowAddModal(false)} className="p-2 hover:bg-slate-100 rounded-lg transition-colors"><X size={20} className="text-slate-400" /></button>
</div>
<div className="space-y-10">
<div className="space-y-5">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<div className="flex bg-slate-100 p-2.5 rounded-[2.5rem] shadow-inner">
<div className="space-y-6">
<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-xl shadow-inner">
<button
onClick={() => setTargetType('SINGLE')}
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'SINGLE' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
className={`flex-1 py-2.5 rounded-lg text-[12px] font-black transition-all ${targetType === 'SINGLE' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
</button>
<button
onClick={() => setTargetType('GROUP')}
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'GROUP' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
className={`flex-1 py-2.5 rounded-lg text-[12px] font-black transition-all ${targetType === 'GROUP' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
{targetType === 'SINGLE' ? '자산 선택' : '그룹 선택'}
</label>
{targetType === 'SINGLE' ? (
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
onChange={(e) => setNewConfig({...newConfig, stockCode: e.target.value})}
value={newConfig.stockCode || ''}
>
@@ -195,7 +194,7 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
</select>
) : (
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
onChange={(e) => setNewConfig({...newConfig, groupId: e.target.value})}
value={newConfig.groupId || ''}
>
@@ -204,20 +203,20 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
</select>
)}
</div>
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1"> </label>
<input
type="number"
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-black text-slate-800 text-center text-2xl shadow-sm"
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-black text-slate-800 text-center text-[18px] shadow-sm"
value={newConfig.quantity}
onChange={(e) => setNewConfig({...newConfig, quantity: parseInt(e.target.value)})}
/>
</div>
</div>
<div className="space-y-5">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1"> </label>
<div className="grid grid-cols-3 gap-3">
{[
{ val: 'DAILY', label: '매일' },
{ val: 'WEEKLY', label: '매주' },
@@ -226,7 +225,7 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
<button
key={freq.val}
onClick={() => setNewConfig({...newConfig, frequency: freq.val as any, specificDay: freq.val === 'DAILY' ? undefined : 1})}
className={`py-5 rounded-[2rem] text-[12px] font-black transition-all border-2 ${newConfig.frequency === freq.val ? 'bg-slate-900 text-white border-slate-900 shadow-2xl' : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'}`}
className={`py-3 rounded-xl text-[12px] font-black transition-all border-2 ${newConfig.frequency === freq.val ? 'bg-slate-900 text-white border-slate-900 shadow-md' : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'}`}
>
{freq.label}
</button>
@@ -234,14 +233,14 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="grid grid-cols-2 gap-6">
{newConfig.frequency !== 'DAILY' && (
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
{newConfig.frequency === 'WEEKLY' ? '요일' : '날짜'}
</label>
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 shadow-sm"
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
value={newConfig.specificDay}
onChange={(e) => setNewConfig({...newConfig, specificDay: parseInt(e.target.value)})}
>
@@ -253,27 +252,27 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
</select>
</div>
)}
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">퀀 </label>
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">퀀 </label>
<input
type="time"
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 shadow-sm"
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
value={newConfig.executionTime}
onChange={(e) => setNewConfig({...newConfig, executionTime: e.target.value})}
/>
</div>
</div>
<div className="flex gap-8 pt-10">
<div className="flex gap-4 pt-6">
<button
onClick={() => setShowAddModal(false)}
className="flex-1 py-6 bg-slate-100 text-slate-400 rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-slate-200 transition-all"
className="flex-1 py-3 bg-slate-100 text-slate-400 rounded-xl font-black uppercase text-[12px] tracking-widest hover:bg-slate-200 transition-all"
>
</button>
<button
onClick={handleAdd}
className="flex-1 py-6 bg-blue-600 text-white rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-2xl shadow-blue-100"
className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-md"
>
</button>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp, Wallet, Activity, Briefcase, PieChart, Database, Zap, Timer, Trash2
TrendingUp, TrendingDown, Wallet, Activity, Briefcase, PieChart, Database, Zap, Timer, Trash2, Globe, ArrowUpRight, ArrowDownRight, ChevronRight
} from 'lucide-react';
import { StockItem, TradeOrder, MarketType, WatchlistGroup, OrderType, AutoTradeConfig, ReservedOrder } from '../types';
import { DbService, HoldingItem } from '../services/dbService';
@@ -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,40 +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-12 animate-in fade-in duration-500 pb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<StatCard title={`총 자산 (${marketMode === MarketType.DOMESTIC ? '원' : '달러'})`} value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.totalAssets.toLocaleString()}`} change="+4.2%" isUp={true} icon={<Wallet className="text-blue-500" />} />
<StatCard title="총 평가손익" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${totalLiquidationSummary.totalPL.toLocaleString()}`} change={`${aggregatePLPercent.toFixed(2)}%`} isUp={totalLiquidationSummary.totalPL >= 0} icon={<TrendingUp className={totalLiquidationSummary.totalPL >= 0 ? "text-emerald-500" : "text-rose-500"} />} />
<StatCard title="보유 종목수" value={`${holdings.length}`} change="마켓 필터" isUp={true} icon={<Briefcase className="text-orange-500" />} />
<StatCard title="예수금" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.buyingPower.toLocaleString()}`} change="인출 가능" isUp={true} icon={<Activity className="text-purple-500" />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[800px] lg:col-span-1">
<div className="flex justify-between items-center mb-8">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-tighter">
<PieChart size={28} className="text-blue-600" />
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
<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 tracking-tighter">
<Database size={20} className="text-emerald-600" />
</h3>
</div>
<div className="flex gap-3 mb-10 overflow-x-auto pb-4 scrollbar-hide">
{activeMarketGroups.map(group => (
<button key={group.id} onClick={() => setActiveGroupId(group.id)} className={`relative px-7 py-3 rounded-full font-black text-[12px] transition-all border-2 whitespace-nowrap ${activeGroupId === group.id ? 'bg-white border-blue-500 text-blue-600 shadow-md' : 'bg-transparent border-slate-100 text-slate-400'}`}>
{group.name}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto pr-2 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)}
@@ -128,61 +108,23 @@ const Dashboard: React.FC<DashboardProps> = ({
</div>
</div>
<div className="lg:col-span-2 space-y-10">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[450px]">
<div className="flex justify-between items-center mb-8">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 tracking-tighter">
<Database size={28} 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-[0.25em] border-b">
<th className="pb-5 px-6"></th>
<th className="pb-5 px-6 text-right"></th>
<th className="pb-5 px-6 text-right"> (%)</th>
<th className="pb-5 px-6 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-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[350px] overflow-hidden">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 mb-8">
<Timer size={28} className="text-blue-600" />
</h3>
<div className="flex-1 overflow-y-auto space-y-4 scrollbar-hide">
{reservedOrders.filter(o => o.market === marketMode).map(order => (
<div key={order.id} className="bg-slate-50 p-6 rounded-[2.5rem] border border-slate-100 flex justify-between items-center group">
<div className="flex items-center gap-5">
<div className={`p-4 rounded-2xl ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={20} fill="currentColor" /></div>
<div><p className="font-black text-lg text-slate-800">{order.stockName}</p></div>
</div>
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-3 bg-white hover:bg-rose-50 rounded-2xl text-slate-300 hover:text-rose-500 transition-all shadow-sm">
<Trash2 size={22} />
</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>

View File

@@ -19,15 +19,34 @@ interface DiscoveryProps {
settings: ApiSettings;
}
const DISCOVERY_CATEGORIES = [
{ id: 'trading_value', name: '거래대금 상위', icon: <Flame size={16} /> },
{ id: 'gainers', name: '급상승 종목', icon: <Zap size={16} /> },
{ id: 'continuous_rise', name: '연속 상승세', icon: <Trophy size={16} />, isPending: true },
{ id: 'undervalued_growth', name: '저평가 성장주', icon: <Sparkles size={16} />, isPending: true },
{ id: 'cheap_value', name: '아직 저렴한 가치주', icon: <Sparkles size={16} />, isPending: true },
{ id: 'stable_dividends', name: '꾸준한 배당주', icon: <Sparkles size={16} />, isPending: true },
{ id: 'profitable_companies', name: '돈 잘버는 회사 찾기', icon: <Sparkles size={16} />, isPending: true },
{ id: 'undervalued_recovery', name: '저평가 탈출', icon: <Sparkles size={16} />, isPending: true },
{ id: 'future_dividend_kings', name: '미래의 배당왕 찾기', icon: <Sparkles size={16} />, isPending: true },
{ id: 'growth_prospects', name: '성장 기대주', icon: <Sparkles size={16} />, isPending: true },
{ id: 'buy_at_cheap', name: '싼값에 매수', icon: <Sparkles size={16} />, isPending: true },
{ id: 'high_yield_undervalued', name: '고수익 저평가', icon: <Sparkles size={16} />, isPending: true },
{ id: 'popular_growth', name: '인기 성장주', icon: <Sparkles size={16} />, isPending: true },
];
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
const [activeTab, setActiveTab] = useState<'realtime' | 'category' | 'investor'>('realtime');
const [activeCategoryId, setActiveCategoryId] = useState<string>('trading_value');
const [marketFilter, setMarketFilter] = useState<'all' | 'domestic' | 'overseas'>('all');
const [sortType, setSortType] = useState<'value' | 'volume' | 'gain' | 'loss'>('value');
const [selectedStockCode, setSelectedStockCode] = useState<string>(stocks[0]?.code || '');
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
const activeCategory = useMemo(() =>
DISCOVERY_CATEGORIES.find(c => c.id === activeCategoryId) || DISCOVERY_CATEGORIES[0]
, [activeCategoryId]);
const selectedStock = useMemo(() => {
return stocks.find(s => s.code === selectedStockCode) || null;
}, [stocks, selectedStockCode]);
@@ -56,13 +75,11 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
if (marketFilter === 'overseas') return s.market === MarketType.OVERSEAS;
return true;
}).sort((a, b) => {
if (sortType === 'value') return (b.tradingValue || 0) - (a.tradingValue || 0);
if (sortType === 'volume') return b.volume - a.volume;
if (sortType === 'gain') return b.changePercent - a.changePercent;
if (sortType === 'loss') return a.changePercent - b.changePercent;
return 0;
// 메뉴별 기본 정렬 (사용자가 나중에 필터링 로직 제공하기 전까지는 기존 로직 활용)
if (activeCategoryId === 'gainers') return b.changePercent - a.changePercent;
return (b.tradingValue || 0) - (a.tradingValue || 0);
});
}, [stocks, marketFilter, sortType]);
}, [stocks, marketFilter, activeCategoryId]);
const handleSaveMemo = () => {
if (selectedStock) {
@@ -101,30 +118,65 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
};
return (
<div className="max-w-[1500px] mx-auto flex flex-col lg:flex-row gap-6 animate-in fade-in duration-700 pb-10">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-8 border-b border-slate-100 pb-1 overflow-x-auto scrollbar-hide">
<TabButton active={activeTab === 'realtime'} onClick={() => setActiveTab('realtime')} icon={<Trophy size={18}/>} label="실시간 차트" />
<TabButton active={activeTab === 'category'} onClick={() => setActiveTab('category')} icon={<Flame size={18}/>} label="인기 테마" />
<TabButton active={activeTab === 'investor'} onClick={() => setActiveTab('investor')} icon={<Users size={18}/>} label="투자자 동향" />
<div className="w-full h-full flex flex-col lg:flex-row gap-6 animate-in fade-in duration-700 pb-10 pr-2">
{/* 1. 좌측 사이드바 메뉴 */}
<div className="w-full lg:w-[240px] shrink-0 space-y-8">
<div>
<h3 className="text-[12px] font-black text-slate-400 uppercase tracking-widest px-4 mb-4"> </h3>
<div className="space-y-1">
<p className="text-[11px] font-black text-slate-300 px-4 mb-2 uppercase italic"> </p>
<button disabled className="w-full flex items-center gap-3 px-4 py-2 text-slate-300 font-bold text-[14px] cursor-not-allowed rounded-xl transition-all">
<div className="w-6 h-6 rounded-lg bg-slate-50 flex items-center justify-center">+</div>
()
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-1.5">
<div>
<p className="text-[11px] font-black text-slate-300 px-4 mb-3 uppercase italic"> </p>
<div className="space-y-0.5">
{DISCOVERY_CATEGORIES.map(cat => (
<button
key={cat.id}
disabled={cat.isPending}
onClick={() => !cat.isPending && setActiveCategoryId(cat.id)}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-xl transition-all group ${activeCategoryId === cat.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-50'} ${cat.isPending ? 'opacity-40 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<span className={`${activeCategoryId === cat.id ? 'text-blue-400' : 'text-slate-400 group-hover:text-slate-900'}`}>{cat.icon}</span>
<span className="text-[14px] font-black tracking-tight">{cat.name}</span>
</div>
{cat.isPending ? (
<span className="text-[9px] px-1.5 py-0.5 rounded-md font-black bg-slate-100 text-slate-400 uppercase"></span>
) : cat.badge ? (
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-black italic ${activeCategoryId === cat.id ? 'bg-blue-500 text-white' : 'bg-blue-50 text-blue-500'}`}>
{cat.badge}
</span>
) : null}
</button>
))}
</div>
</div>
</div>
{/* 2. 중앙 목록 영역 */}
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-slate-900 italic tracking-tighter">{activeCategory.name}</h2>
<p className="text-[12px] font-bold text-slate-400 mt-1 uppercase tracking-tight"> .</p>
</div>
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
<FilterChip active={marketFilter === 'domestic'} onClick={() => setMarketFilter('domestic')} label="국내" />
<FilterChip active={marketFilter === 'overseas'} onClick={() => setMarketFilter('overseas')} label="해외" />
</div>
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
<FilterChip active={sortType === 'value'} onClick={() => setSortType('value')} label="거래대금" />
<FilterChip active={sortType === 'gain'} onClick={() => setSortType('gain')} label="급상승" />
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="text-[10px] font-black text-slate-400 uppercase tracking-widest border-b bg-slate-50/50">
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b bg-slate-50/50">
<th className="pl-6 py-4 w-12 text-center"></th>
<th className="px-4 py-4"></th>
<th className="px-4 py-4 text-right"></th>
@@ -148,36 +200,37 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
</div>
</div>
<div className="w-full lg:w-[380px]">
{/* 3. 우측 상세 패널 */}
<div className="w-full lg:w-[340px] shrink-0">
{selectedStock && (
<div className="bg-white p-6 rounded-3xl shadow-lg border border-slate-100 flex flex-col gap-6 sticky top-24 max-h-[88vh] overflow-y-auto scrollbar-hide">
<div className="bg-white p-5 rounded-2xl shadow-lg border border-slate-100 flex flex-col gap-5 sticky top-24 max-h-[85vh] overflow-y-auto scrollbar-hide">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-slate-900 rounded-xl flex items-center justify-center text-white shadow-md text-sm">{selectedStock.name[0]}</div>
<div>
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
<p className="text-[11px] font-black text-slate-400">{selectedStock.code}</p>
<div className="min-w-0">
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight truncate" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
<p className="text-[12px] font-black text-slate-400">{selectedStock.code}</p>
</div>
</div>
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all ${selectedStock.isHidden ? 'bg-rose-50 text-rose-500' : 'bg-slate-50 text-slate-400 hover:text-rose-500'}`}>
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all shrink-0 ${selectedStock.isHidden ? 'bg-rose-50 text-rose-500' : 'bg-slate-50 text-slate-400 hover:text-rose-500'}`}>
{selectedStock.isHidden ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
</div>
<div className="flex gap-3">
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.BUY})} className="flex-1 py-3 bg-rose-500 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5"></button>
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.SELL})} className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5"></button>
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.BUY})} className="flex-1 py-3 bg-rose-500 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5 hover:bg-rose-600 transition-all"></button>
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.SELL})} className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5 hover:bg-blue-600 transition-all"></button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
<StickyNote size={14} className="text-amber-500" />
</div>
<button onClick={handleSaveMemo} className="text-[10px] font-black text-blue-600 hover:underline"></button>
<button onClick={handleSaveMemo} className="text-[11px] font-black text-blue-600 hover:underline"></button>
</div>
<textarea
className="w-full h-20 p-4 bg-slate-50 border border-slate-100 rounded-2xl text-[12px] text-slate-600 font-medium resize-none focus:bg-white outline-none transition-all"
className="w-full h-20 p-4 bg-slate-50 border border-slate-100 rounded-2xl text-[13px] text-slate-600 font-medium resize-none focus:bg-white outline-none transition-all"
placeholder="메모를 입력하세요..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
@@ -185,7 +238,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
</div>
<div className="space-y-3 pt-4 border-t border-slate-50">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
<HistoryIcon size={14} className="text-blue-500" />
</div>
<div className="space-y-2">
@@ -193,39 +246,39 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
stockOrders.slice(0, 2).map((order) => (
<div key={order.id} className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-500'}`}>
<div className={`p-1.5 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}>
{order.type === OrderType.BUY ? <ArrowDownRight size={12} /> : <ArrowUpRight size={12} />}
</div>
<div>
<p className="text-[11px] font-black text-slate-800">{order.type === OrderType.BUY ? '매수' : '매도'} {order.quantity}</p>
<p className="text-[9px] text-slate-400 font-bold">{new Date(order.timestamp).toLocaleDateString()}</p>
<p className="text-[12px] font-black text-slate-800">{order.type === OrderType.BUY ? '매수' : '매도'} {order.quantity}</p>
<p className="text-[10px] text-slate-400 font-bold">{new Date(order.timestamp).toLocaleDateString()}</p>
</div>
</div>
<p className="text-[11px] font-black text-slate-900">{order.price.toLocaleString()}</p>
<p className="text-[12px] font-black text-slate-900">{order.price.toLocaleString()}</p>
</div>
))
) : (
<p className="text-[10px] font-black text-slate-300 text-center py-4 italic"> </p>
<p className="text-[11px] font-black text-slate-300 text-center py-4 italic"> </p>
)}
</div>
</div>
<div className="space-y-3 pt-4 border-t border-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
<Sparkles size={14} className="text-purple-500" /> AI
</div>
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg shrink-0">
<RefreshCw size={12} className={isAnalyzing ? 'animate-spin' : ''} />
</button>
</div>
{selectedStock.aiAnalysis ? (
<div className="bg-slate-900 p-4 rounded-2xl text-slate-100 text-[11px] leading-relaxed font-medium italic">
<div className="bg-slate-900 p-4 rounded-2xl text-slate-100 text-[12px] leading-relaxed font-medium italic">
{selectedStock.aiAnalysis}
</div>
) : (
<div className="p-6 border border-dashed border-slate-100 rounded-2xl flex flex-col items-center gap-2 opacity-30 text-center">
<p className="text-[10px] font-black uppercase"> </p>
<p className="text-[11px] font-black uppercase"> </p>
</div>
)}
</div>
@@ -233,6 +286,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
)}
</div>
{/* 4. 모달들 */}
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert('주문 예약됨')} />}
</div>

View File

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

View File

@@ -71,8 +71,34 @@ const News: React.FC<NewsProps> = ({ settings }) => {
const headlines = news.slice(0, 10).map(n => n.title);
try {
const result = await AiService.analyzeNewsSentiment(config, headlines);
setAnalysisResult(result);
const fullResult = await AiService.analyzeNewsSentiment(config, headlines);
const [report, metadataStr] = fullResult.split('---METADATA---');
setAnalysisResult(report.trim());
if (metadataStr) {
try {
const metadata = JSON.parse(metadataStr.trim());
if (Array.isArray(metadata)) {
setNews(prev => {
const updated = [...prev];
metadata.forEach(item => {
if (updated[item.index]) {
updated[item.index] = {
...updated[item.index],
relatedThemes: item.themes,
relatedStocks: item.stocks,
sentiment: item.sentiment
};
}
});
return updated;
});
}
} catch (e) {
console.error("메타데이터 파싱 실패:", e);
}
}
} catch (e) {
setAnalysisResult("AI 분석 중 오류가 발생했습니다. 설정을 확인해 주세요.");
} finally {
@@ -86,30 +112,30 @@ const News: React.FC<NewsProps> = ({ settings }) => {
);
return (
<div className="space-y-12 max-w-6xl mx-auto animate-in slide-in-from-right-4 duration-500 pb-20">
<div className="space-y-6 w-full animate-in slide-in-from-right-4 duration-500 pb-20 pr-4">
{/* 뉴스 스크랩 비활성 알림 */}
{!settings.useNaverNews && (
<div className="bg-amber-50 border border-amber-100 p-8 rounded-[2.5rem] flex items-start gap-5 shadow-sm">
<AlertCircle className="text-amber-500 shrink-0" size={28} />
<div className="bg-amber-50 border border-amber-100 p-5 rounded-2xl flex items-start gap-3 shadow-sm">
<AlertCircle className="text-amber-500 shrink-0" size={20} />
<div>
<h5 className="font-bold text-amber-900 mb-2 text-lg"> </h5>
<p className="text-base text-amber-700"> . Naver Client ID를 .</p>
<h5 className="font-black text-amber-900 mb-1 text-sm uppercase tracking-tight"> </h5>
<p className="text-[14px] text-amber-700 font-medium"> . Naver Client ID를 .</p>
</div>
</div>
)}
{/* 분석 결과 리포트 */}
{analysisResult && (
<div className="bg-white p-10 rounded-[3rem] shadow-xl border-l-8 border-l-blue-600 animate-in fade-in zoom-in duration-300 relative group">
<button onClick={() => setAnalysisResult(null)} className="absolute top-6 right-6 p-2 hover:bg-slate-50 rounded-full text-slate-300 hover:text-slate-600 transition-all"><X size={20}/></button>
<div className="flex items-start gap-6">
<div className="p-4 bg-blue-50 text-blue-600 rounded-2xl">
<MessageSquareQuote size={28} />
<div className="bg-white p-6 rounded-2xl shadow-lg border-l-4 border-l-blue-600 animate-in fade-in zoom-in duration-300 relative group">
<button onClick={() => setAnalysisResult(null)} className="absolute top-4 right-4 p-1.5 hover:bg-slate-50 rounded-lg text-slate-300 hover:text-slate-600 transition-all"><X size={16}/></button>
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-50 text-blue-600 rounded-xl">
<MessageSquareQuote size={20} />
</div>
<div className="space-y-4 pr-10">
<h4 className="text-[11px] font-black text-blue-600 uppercase tracking-[0.2em]">AI Intelligence Report</h4>
<div className="text-slate-800 text-lg font-bold leading-relaxed whitespace-pre-wrap">
<div className="space-y-2 pr-6">
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em]">AI Intelligence Report</h4>
<div className="text-slate-800 text-[15px] font-bold leading-relaxed whitespace-pre-wrap">
{analysisResult}
</div>
</div>
@@ -118,61 +144,78 @@ const News: React.FC<NewsProps> = ({ settings }) => {
)}
{/* 툴바: 검색 및 실행 버튼 */}
<div className="flex flex-col md:flex-row gap-8 items-center justify-between">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="relative w-full md:flex-1">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" size={24} />
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="뉴스 검색..."
className="w-full pl-16 pr-6 py-5 bg-white border border-slate-200 rounded-[2rem] shadow-sm focus:ring-4 focus:ring-blue-50 outline-none text-base font-bold text-slate-800"
className="w-full pl-12 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-100 outline-none text-[14px] font-black text-slate-800"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="flex gap-4 w-full md:w-auto">
<div className="flex gap-2 w-full md:w-auto">
<button
onClick={handleAnalyze}
disabled={isAnalyzing || news.length === 0}
className={`flex-1 md:shrink-0 px-8 py-5 bg-blue-600 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-blue-700 flex items-center justify-center gap-4 transition-all active:scale-95 disabled:opacity-50`}
className={`flex-1 md:shrink-0 px-5 py-2.5 bg-blue-600 text-white rounded-xl shadow-md font-black text-[12px] uppercase tracking-wider hover:bg-blue-700 flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50`}
>
<Sparkles size={22} className={isAnalyzing ? 'animate-pulse' : ''} />
{isAnalyzing ? 'AI 분석 중...' : 'AI 브리핑'}
<Sparkles size={16} className={isAnalyzing ? 'animate-pulse' : ''} />
{isAnalyzing ? '분석 중...' : 'AI 브리핑'}
</button>
<button
onClick={handleRefresh}
disabled={loading}
className={`flex-1 md:shrink-0 px-8 py-5 bg-slate-900 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-slate-800 flex items-center justify-center gap-4 transition-all active:scale-95 ${loading ? 'opacity-50' : ''}`}
className={`flex-1 md:shrink-0 px-5 py-2.5 bg-slate-900 text-white rounded-xl shadow-md font-black text-[12px] uppercase tracking-wider hover:bg-slate-800 flex items-center justify-center gap-2 transition-all active:scale-95 ${loading ? 'opacity-50' : ''}`}
>
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* 뉴스 리스트 */}
<div className="space-y-8">
<div className="grid grid-cols-1 gap-3">
{filteredNews.map((item, idx) => (
<article key={idx} className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col md:flex-row gap-10 hover:shadow-2xl transition-all group">
<div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center flex-shrink-0 text-slate-900 group-hover:bg-slate-900 group-hover:text-white transition-all shadow-sm">
<Newspaper size={40} />
<article key={idx} className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex items-start gap-4 hover:border-blue-200 hover:shadow-md transition-all group">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-all ${
item.sentiment === 'POSITIVE' ? 'bg-rose-50 text-rose-500' :
item.sentiment === 'NEGATIVE' ? 'bg-blue-50 text-blue-500' :
'bg-slate-50 text-slate-400 group-hover:bg-slate-900 group-hover:text-white'
}`}>
<Newspaper size={18} />
</div>
<div className="flex-1 space-y-4">
<div className="flex justify-between items-start">
<span className="text-[11px] font-black text-blue-600 uppercase tracking-[0.2em] bg-blue-50 px-4 py-1.5 rounded-full">Financial Market</span>
<span className="text-sm text-slate-400 font-bold">{new Date(item.pubDate).toLocaleDateString('ko-KR')}</span>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] font-black text-blue-500 uppercase tracking-widest">Financial Market</span>
<span className="text-[11px] text-slate-400 font-bold">{new Date(item.pubDate).toLocaleDateString('ko-KR')}</span>
</div>
<h3 className="text-2xl font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors">
<h3 className="text-[16px] font-black text-slate-900 leading-tight mb-1 group-hover:text-blue-600 transition-colors truncate">
{item.title}
</h3>
<p className="text-slate-500 text-base font-medium leading-relaxed line-clamp-2 opacity-80">
<p className="text-slate-500 text-[13px] font-medium leading-normal line-clamp-1 opacity-70">
{item.description}
</p>
<div className="pt-6 flex items-center gap-8">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="text-[12px] font-black uppercase text-slate-900 hover:text-blue-600 flex items-center gap-2.5 tracking-tighter transition-colors">
<ExternalLink size={18} />
{/* AI 분석 인사이트 태그 */}
{(item.relatedThemes || item.relatedStocks) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.relatedThemes?.map(theme => (
<span key={theme} className="text-[10px] font-black text-blue-600 bg-blue-50/50 px-1.5 py-0.5 rounded-md leading-none border border-blue-100/50">#{theme}</span>
))}
{item.relatedStocks?.map(stock => (
<span key={stock} className="text-[10px] font-black text-slate-500 bg-slate-50 px-1.5 py-0.5 rounded-md leading-none border border-slate-200/50">{stock}</span>
))}
</div>
)}
<div className="mt-2 flex items-center gap-4">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="text-[11px] font-black uppercase text-blue-600 hover:underline flex items-center gap-1.5 tracking-tight transition-colors">
<ExternalLink size={12} />
</a>
<button className="text-slate-300 hover:text-amber-500 transition-colors">
<Bookmark size={22} />
<Bookmark size={14} />
</button>
</div>
</div>

View File

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

View File

@@ -5,6 +5,7 @@ import { StockItem, MarketType, OrderType } from '../types';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { StockRow } from '../components/StockRow';
import { StockMasterRow } from '../components/StockMasterRow';
interface StocksProps {
marketMode: MarketType;
@@ -30,8 +31,8 @@ const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, w
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
);
result.sort((a, b) => {
const valA = a[sortField] || 0;
const valB = b[sortField] || 0;
const valA = (a[sortField] as number) || 0;
const valB = (b[sortField] as number) || 0;
return sortOrder === 'asc' ? (valA > valB ? 1 : -1) : (valB > valA ? 1 : -1);
});
return result;
@@ -43,49 +44,84 @@ const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, w
};
return (
<div className="space-y-10 animate-in fade-in duration-500 pb-20">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col lg:flex-row gap-8 items-center justify-between">
<div className="flex items-center gap-6">
<div className="p-5 bg-blue-50 text-blue-600 rounded-3xl"><Filter size={28} /></div>
<div><h3 className="text-3xl font-black text-slate-900 tracking-tight"> </h3></div>
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex flex-col lg:flex-row gap-6 items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-4 bg-blue-50 text-blue-600 rounded-2xl shadow-sm"><Filter size={20} /></div>
<div>
<h3 className="text-[20px] font-black text-slate-900 tracking-tighter uppercase"> </h3>
<p className="text-[11px] font-bold text-slate-400"> </p>
</div>
</div>
<div className="flex gap-4">
<input type="text" placeholder="검색..." className="p-4 bg-slate-50 rounded-2xl outline-none border-2 border-transparent focus:border-blue-500" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={onSync} className="p-4 bg-slate-900 text-white rounded-2xl flex items-center gap-2"><RotateCw size={18} /> </button>
<div className="flex gap-3 w-full lg:w-auto">
<div className="relative flex-1 lg:w-64">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={16} />
<input
type="text"
placeholder="종목명 또는 코드 검색..."
className="w-full pl-11 pr-4 py-3 bg-slate-50 rounded-xl outline-none border-2 border-transparent focus:border-blue-500 focus:bg-white transition-all text-sm font-bold"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<button onClick={onSync} className="px-5 py-3 bg-slate-900 text-white rounded-xl flex items-center gap-2 hover:bg-slate-800 transition-all text-sm font-black shadow-lg">
<RotateCw size={16} />
</button>
</div>
</div>
<div className="bg-white rounded-[3.5rem] shadow-sm border border-slate-100 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400 uppercase tracking-widest">
<tr>
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('name')}> <ArrowUpDown size={12} className="inline" /></th>
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('price')}> <ArrowUpDown size={12} className="inline" /></th>
<th className="px-8 py-6">AI </th>
<th className="px-8 py-6 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredStocks.map(stock => {
const isWatch = watchlistCodes.includes(stock.code);
return (
<StockRow
key={stock.code}
stock={stock}
isWatchlisted={isWatch}
showActions={true}
onTrade={(type) => setTradeContext({ stock, type })}
onToggleWatchlist={() => onAddToWatchlist(stock)}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div className="overflow-x-auto scrollbar-hide">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
<tr>
<th className="px-5 py-4 w-12 text-center">No</th>
<th className="px-5 py-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('name')}>
<ArrowUpDown size={10} className="inline ml-1" />
</th>
<th className="px-5 py-4 text-right cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('price')}>
<ArrowUpDown size={10} className="inline ml-1" />
</th>
<th className="px-5 py-4 text-right">//</th>
<th className="px-5 py-4 text-right cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('volume')}>
<ArrowUpDown size={10} className="inline ml-1" />
</th>
<th className="px-5 py-4"> (P/R/E)</th>
<th className="px-5 py-4 text-center cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('aiScoreBuy')}>
AI SCORE <ArrowUpDown size={10} className="inline ml-1" />
</th>
<th className="px-5 py-4 text-right"> </th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredStocks.map((stock, idx) => {
const isWatch = watchlistCodes.includes(stock.code);
return (
<StockMasterRow
key={stock.code}
rank={idx + 1}
stock={stock}
isWatchlisted={isWatch}
onTrade={(type) => setTradeContext({ stock, type })}
onToggleWatchlist={() => onAddToWatchlist(stock)}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
</div>
</div>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert("주문 예약됨")} />}
{tradeContext && (
<TradeModal
stock={tradeContext.stock}
type={tradeContext.type}
onClose={() => setTradeContext(null)}
onExecute={async (o) => alert("주문 예약됨")}
/>
)}
</div>
);
};

View File

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

View File

@@ -7,11 +7,21 @@ export class AiService {
* 뉴스 기사들을 바탕으로 시장 심리 및 인사이트 분석
*/
static async analyzeNewsSentiment(config: AiConfig, newsHeadlines: string[]): Promise<string> {
const prompt = `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인들을 분석하여 시장의 심리(상승/하락/중립)와 투자자가 주목해야 할 핵심 포인트 3가지를 한국어로 요약해 주세요.
뉴스 헤드라인:
${newsHeadlines.join('\n')}
`;
const isBatch = newsHeadlines.length > 1;
const prompt = isBatch
? `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인들을 분석하여 시장의 전반적인 심리와 투자 핵심 포인트를 한국어로 리포트 형식으로 작성해 주세요.
또한, 반드시 리포트 내용이 끝난 뒤에 "---METADATA---" 라는 구분선을 넣고, 그 바로 뒤에 각 뉴스 인덱스별로 연관된 테마와 종목 정보를 JSON 배열 형식으로 포함해 주세요.
JSON 형식 예시: [{"index": 0, "themes": ["반도체", "AI"], "stocks": ["삼성전자"], "sentiment": "POSITIVE"}, ...]
분석할 뉴스 헤드라인:
${newsHeadlines.map((h, i) => `${i}. ${h}`).join('\n')}
`
: `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인을 분석하여 시장의 심리(상승/하락/중립)와 투자자가 주목해야 할 핵심 포인트를 한국어로 요약해 주세요.
분석할 뉴스 헤드라인:
${newsHeadlines.join('\n')}
`;
if (config.providerType === 'gemini') {
return this.callGemini(config.modelName, prompt);

8
services/config.ts Normal file
View 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
};
};

View File

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

View File

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

View File

@@ -29,6 +29,8 @@ export interface ApiSettings {
useNaverNews: boolean;
naverClientId: string;
naverClientSecret: string;
kisApiDelayMs?: number;
newsScrapIntervalMin?: number;
aiConfigs: AiConfig[];
preferredNewsAiId?: string;
preferredStockAiId?: string;
@@ -61,6 +63,9 @@ export interface StockItem {
dividendYield?: number;
aiScoreBuy: number;
aiScoreSell: number;
openPrice?: number; // 시가
highPrice?: number; // 고가
lowPrice?: number; // 저가
themes?: string[];
// --- New Fields ---
memo?: string; // 사용자 메모
@@ -128,4 +133,7 @@ export interface NewsItem {
description: string;
link: string;
pubDate: string;
relatedThemes?: string[];
relatedStocks?: string[];
sentiment?: 'POSITIVE' | 'NEUTRAL' | 'NEGATIVE';
}