initial commit

This commit is contained in:
2026-01-31 22:34:57 +09:00
commit f1301de543
875 changed files with 196598 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

239
App.tsx Normal file
View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { HashRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Settings as SettingsIcon,
Newspaper,
Cpu,
History,
Terminal,
ChevronDown,
ShieldCheck,
Server,
Zap,
Star,
LayoutGrid,
RotateCcw,
Compass
} from 'lucide-react';
import { ApiSettings, StockItem, OrderType, MarketType, TradeOrder, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from './types';
import { MOCK_STOCKS } from './constants';
import { KisService } from './services/kisService';
import { TelegramService } from './services/telegramService';
import { DbService } from './services/dbService';
import Dashboard from './pages/Dashboard';
import AutoTrading from './pages/AutoTrading';
import News from './pages/News';
import Settings from './pages/Settings';
import HistoryPage from './pages/History';
import WatchlistManagement from './pages/WatchlistManagement';
import Stocks from './pages/Stocks';
import Discovery from './pages/Discovery';
interface LogEntry {
id: string;
type: 'info' | 'error' | 'success' | 'warn';
message: string;
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 */}
{React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement<any>, { size: 16 }) : icon}
<span>{label}</span>
</Link>
);
const AppContent: React.FC = () => {
const [settings, setSettings] = useState<ApiSettings>(() => {
const saved = localStorage.getItem('trader_settings');
return saved ? JSON.parse(saved) : {
appKey: '', appSecret: '', accountNumber: '',
useTelegram: false, telegramToken: '', telegramChatId: '',
useNaverNews: false, naverClientId: '', naverClientSecret: '',
aiConfigs: [
{ id: 'def_gemini', name: 'Gemini 기본 분석기', providerType: 'gemini', modelName: 'gemini-3-flash-preview' }
],
preferredNewsAiId: 'def_gemini',
preferredStockAiId: 'def_gemini',
preferredNewsJudgementAiId: 'def_gemini',
preferredAutoBuyAiId: 'def_gemini',
preferredAutoSellAiId: 'def_gemini'
};
});
const [marketMode, setMarketMode] = useState<MarketType>(MarketType.DOMESTIC);
const [masterStocks, setMasterStocks] = useState<StockItem[]>(() => {
const saved = localStorage.getItem('batchukis_master_stocks');
return saved ? JSON.parse(saved) : MOCK_STOCKS;
});
const [watchlistGroups, setWatchlistGroups] = useState<WatchlistGroup[]>([]);
const [orders, setOrders] = useState<TradeOrder[]>([]);
const [reservedOrders, setReservedOrders] = useState<ReservedOrder[]>([]);
const [autoTrades, setAutoTrades] = useState<AutoTradeConfig[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const location = useLocation();
const kisService = useMemo(() => new KisService(settings), [settings]);
const dbService = useMemo(() => new DbService(), []);
const visibleStocks = useMemo(() => {
return masterStocks.filter(s => !s.isHidden);
}, [masterStocks]);
useEffect(() => {
localStorage.setItem('trader_settings', JSON.stringify(settings));
}, [settings]);
useEffect(() => {
localStorage.setItem('batchukis_master_stocks', JSON.stringify(masterStocks));
}, [masterStocks]);
useEffect(() => {
syncFromDb();
}, [dbService]);
const addLog = useCallback((message: string, type: LogEntry['type'] = 'info') => {
setLogs(prev => [{
id: Math.random().toString(36).substr(2, 9),
type, message, timestamp: new Date()
}, ...prev].slice(0, 100));
}, []);
const handleUpdateStockMetadata = (code: string, updates: Partial<StockItem>) => {
setMasterStocks(prev => prev.map(s => s.code === code ? { ...s, ...updates } : s));
};
const syncFromDb = async () => {
const configs = await dbService.getAutoConfigs();
const groups = await dbService.getWatchlistGroups();
const resOrders = await dbService.getReservedOrders();
setAutoTrades(configs);
setWatchlistGroups(groups);
setReservedOrders(resOrders);
};
const handleSyncStocks = async () => {
addLog(`${marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 종목 마스터 동기화 시작...`, 'info');
try {
const serverStocks = await kisService.fetchMasterStocks(marketMode);
setMasterStocks(prev => {
const existingCodes = new Set(prev.map(s => s.code));
const newStocks = serverStocks.filter(s => !existingCodes.has(s.code));
if (newStocks.length === 0) return prev;
addLog(`${newStocks.length}개의 신규 종목이 마스터 리스트에 추가되었습니다.`, 'success');
return [...prev, ...newStocks];
});
} catch (e) {
addLog("종목 동기화 실패", "error");
}
};
const handleManualOrder = async (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => {
const res = await (order.stockCode.length > 6
? kisService.orderOverseas(order.stockCode, order.type, order.quantity, order.price)
: kisService.orderCash(order.stockCode, order.type, order.quantity, order.price)
);
if (res.success) {
const newOrder: TradeOrder = { id: res.orderId, ...order, status: 'COMPLETED', timestamp: new Date() } as TradeOrder;
await dbService.syncOrderToHolding(newOrder);
setOrders(prev => [newOrder, ...prev]);
addLog(`${order.stockName} ${order.type === OrderType.BUY ? '매수' : '매도'} 완료`, 'success');
}
};
const handleAddToWatchlist = async (stock: StockItem) => {
const groups = await dbService.getWatchlistGroups();
const targetGroup = groups.find(g => g.market === stock.market);
if (targetGroup) {
const updated = targetGroup.codes.includes(stock.code)
? { ...targetGroup, codes: targetGroup.codes.filter(c => c !== stock.code) }
: { ...targetGroup, codes: [...targetGroup.codes, stock.code] };
await dbService.updateWatchlistGroup(updated);
syncFromDb();
}
};
const watchlistCodes = useMemo(() => watchlistGroups.flatMap(g => g.codes), [watchlistGroups]);
const isActive = (path: string) => location.pathname === path;
return (
<div className="flex flex-col h-screen bg-[#F8FAFC] overflow-hidden font-sans text-slate-900">
<header className="h-16 bg-white/80 backdrop-blur-xl border-b border-slate-100 flex items-center justify-between px-6 z-50 sticky top-0 shadow-sm">
<div className="flex items-center gap-6">
<div className="flex flex-col">
<h1 className="text-xl font-black italic tracking-tighter text-blue-600 flex items-center gap-1.5">
<Zap className="fill-blue-600" size={18} /> BATCHUKIS
</h1>
<span className="text-[8px] font-black text-slate-400 uppercase tracking-[0.2em] -mt-1">Trading Bot Engine</span>
</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')} />
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex bg-slate-100 p-0.5 rounded-xl border border-slate-200">
<button onClick={() => setMarketMode(MarketType.DOMESTIC)} className={`px-3 py-1.5 rounded-lg text-[10px] font-black transition-all ${marketMode === MarketType.DOMESTIC ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setMarketMode(MarketType.OVERSEAS)} className={`px-3 py-1.5 rounded-lg text-[10px] font-black transition-all ${marketMode === MarketType.OVERSEAS ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
</div>
<button onClick={() => setIsConsoleOpen(!isConsoleOpen)} className={`p-2 rounded-xl transition-all border ${isConsoleOpen ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-400 border-slate-200 hover:text-blue-600'}`}><Terminal size={18} /></button>
<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>
<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="/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(); }} />} />
<Route path="/watchlist" element={<WatchlistManagement marketMode={marketMode} stocks={visibleStocks} groups={watchlistGroups} onRefresh={syncFromDb} />} />
<Route path="/news" element={<News settings={settings} />} />
<Route path="/history" element={<HistoryPage orders={orders} />} />
<Route path="/settings" element={<Settings settings={settings} onSave={setSettings} />} />
</Routes>
</main>
{isConsoleOpen && (
<div className="fixed bottom-0 left-0 right-0 bg-slate-950 text-slate-300 transition-all duration-500 z-[100] border-t border-slate-800 h-64 flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-6 py-2.5 border-b border-slate-800 bg-slate-950">
<div className="flex items-center gap-2"><Server size={12} className="text-blue-500" /><span className="text-[9px] font-black uppercase tracking-[0.15em]">Execution Console</span></div>
<div className="flex gap-4 items-center">
<button onClick={() => setLogs([])} className="text-[9px] font-black text-slate-500 hover:text-white flex items-center gap-1"><RotateCcw size={10} /> CLEAR</button>
<button onClick={() => setIsConsoleOpen(false)} className="text-slate-500 hover:text-white"><ChevronDown size={14}/></button>
</div>
</div>
<div className="p-4 flex-1 overflow-y-auto font-mono text-[10px] space-y-1.5 custom-scrollbar">
{logs.map(log => (
<div key={log.id} className="flex gap-3 animate-in slide-in-from-left-2">
<span className="text-slate-600 shrink-0">[{log.timestamp.toLocaleTimeString()}]</span>
<span className={`font-bold ${log.type === 'error' ? 'text-rose-500' : log.type === 'success' ? 'text-emerald-500' : log.type === 'warn' ? 'text-amber-500' : 'text-blue-400'}`}>{log.type.toUpperCase()}</span>
<span className="text-slate-300">{log.message}</span>
</div>
))}
{logs.length === 0 && <div className="text-slate-600 italic">No logs available. System standby...</div>}
</div>
</div>
)}
</div>
);
};
const App: React.FC = () => (
<HashRouter>
<AppContent />
</HashRouter>
);
export default App;

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# 1단계: 빌드 (Node.js)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN rm -f package-lock.json && npm install
COPY . .
RUN npm run build
# 2단계: 실행 (Nginx)
# 2단계: 실행 (Node.js)
FROM node:20-alpine
WORKDIR /app
# 프로덕션 의존성만 설치
COPY package*.json ./
RUN npm install --omit=dev
# 서버 파일 및 빌드 결과물 복사
COPY server.js ./
COPY --from=build /app/dist ./dist
# 환경변수 포트 노출
EXPOSE 80
# 서버 실행
CMD ["node", "server.js"]

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1T7Zhx4xShoPXEOK6c7cUt1CYpmHfxKKq
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

15
backend/ReadMe.md Normal file
View File

@@ -0,0 +1,15 @@
# BatchuKis 백엔드 독립 실행 엔진 및 API 통합 사양서 (v1.6)
본 문서는 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 분석 및 뉴스 요청 중계.
## 2. 상세 명세 가이드
- **DB 스키마**: [tables.md](./tables.md) 참조.
- **API 엔드포인트**: [api.md](./api.md) 참조.
- **데이터 구조(JSON)**: [models.md](./models.md) 참조.

91
backend/api.md Normal file
View File

@@ -0,0 +1,91 @@
# BatchuKis API Specification
이 문서는 프론트엔드와 백엔드 간의 통신을 위한 API 인터페이스 명세서입니다.
**데이터 모델(JSON 구조)의 상세 정의는 [models.md](./models.md)를 참조하십시오.**
---
## 1. 설정 및 시스템 (Settings)
### 1.1 전체 설정 가져오기
- **URL**: `GET /api/settings`
- **Response**: `ApiSettings` (See models.md)
### 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 }`
---
## 2. 자산 및 잔고 (Portfolio)
### 2.1 보유 종목 리스트
- **URL**: `GET /api/holdings`
- **Query**: `?market=Domestic|Overseas`
- **Response**: `HoldingItem[]`
### 2.2 계좌 요약 (자산/예수금)
- **URL**: `GET /api/account/summary`
- **Response**: `{ "totalAssets": number, "buyingPower": number }`
---
## 3. 자동매매 전략 (Auto Trading)
### 3.1 로봇 리스트 조회
- **URL**: `GET /api/auto-trades`
- **Response**: `AutoTradeConfig[]`
### 3.2 로봇 등록/수정
- **URL**: `POST /api/auto-trades`
- **Body**: `AutoTradeConfig` (ID가 없으면 생성, 있으면 수정)
- **Response**: `{ "id": string }`
---
## 4. 실시간 감시 주문 (Reserved Orders)
### 4.1 감시 목록 조회
- **URL**: `GET /api/reserved-orders`
- **Response**: `ReservedOrder[]`
### 4.2 감시 등록
- **URL**: `POST /api/reserved-orders`
- **Body**: `Omit<ReservedOrder, 'id' | 'status' | 'createdAt'>`
- **Response**: `{ "id": string }`
---
## 5. 종목 및 시세 (Market Data & Discovery)
### 5.1 마스터 종목 리스트 (동기화용)
- **URL**: `GET /api/kis/master-stocks`
- **Query**: `?market=Domestic|Overseas`
- **Response**: `StockItem[]`
### 5.2 개별 종목 시세 차트 데이터
- **URL**: `GET /api/kis/ticks/:code`
- **Query**: `?limit=100`
- **Response**: `StockTick[]`
### 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) 및 수급 데이터가 포함됩니다.
### 5.4 [NEW] 실시간 커뮤니티 심리 요약 (AI 전용)
- **URL**: `GET /api/discovery/sentiment/:code`
- **Response**: `{ "insights": string[], "sentimentScore": number }`
- **설명**: 특정 종목에 대한 AI의 실시간 커뮤니티/뉴스 요약 정보를 반환합니다.

126
backend/models.md Normal file
View File

@@ -0,0 +1,126 @@
# BatchuKis Shared Data Models (JSON Schema)
이 문서는 시스템 전반에서 사용되는 데이터 객체의 구조를 상세히 정의합니다. 백엔드 개발 시 이 필드명과 타입을 반드시 준수해야 합니다.
## 1. Enums & Types (공통 타입)
### 1.1 MarketType
- **Type**: `string`
- **Values**: `"Domestic"` (국내), `"Overseas"` (해외)
### 1.2 OrderType
- **Type**: `string`
- **Values**: `"BUY"` (매수), `"SELL"` (매도)
### 1.3 AiProviderType
- **Type**: `string`
- **Values**: `"gemini"`, `"openai-compatible"`
---
## 2. Core Models (핵심 객체)
### 2.1 StockItem (종목 정보 - 확장됨)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| code | string | Y | 종목 코드 (예: "005930", "NVDA") |
| name | string | Y | 종목 명칭 |
| price | number | Y | 현재가 |
| change | number | Y | 전일 대비 등락 금액 |
| changePercent | number | Y | 전일 대비 등락률 (%) |
| market | MarketType | Y | 소속 시장 |
| volume | number | Y | 거래량 |
| 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 | 자기자본이익률 |
| marketCap | number | N | 시가총액 |
| dividendYield | number | N | 배당수익률 |
| aiScoreBuy | number | Y | AI 매수 점수 (0~100) |
| aiScoreSell | number | Y | AI 매도 점수 (0~100) |
| themes | string[] | N | 연관 테마 리스트 (JSON Array) |
### 2.2 ApiSettings (시스템 설정)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| appKey | string | Y | KIS API App Key |
| appSecret | string | Y | KIS API Secret Key |
| accountNumber | string | Y | 계좌번호 |
| useTelegram | boolean | Y | 텔레그램 알림 사용 여부 |
| telegramToken | string | N | 텔레그램 봇 토큰 |
| telegramChatId | string | N | 텔레그램 채팅 ID |
| useNaverNews | boolean | Y | 네이버 뉴스 스크랩 여부 |
| naverClientId | string | N | 네이버 Client ID |
| naverClientSecret | string | N | 네이버 Client Secret |
| aiConfigs | AiConfig[] | Y | 등록된 AI 엔진 목록 |
| preferredNewsAiId | string | N | 뉴스 분석용 AI ID (FK) |
| preferredStockAiId | string | N | 종목 분석용 AI ID (FK) |
| preferredNewsJudgementAiId | string | N | 뉴스 판단용 AI ID (FK) |
| preferredAutoBuyAiId | string | N | 자동매수용 AI ID (FK) |
| preferredAutoSellAiId | string | N | 자동매도용 AI ID (FK) |
### 2.3 AiConfig (AI 프로필)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| id | string | Y | 고유 식별자 |
| name | string | Y | 엔진 별칭 |
| providerType | AiProviderType | Y | 제공자 유형 |
| modelName | string | Y | 모델 명칭 (예: "gemini-3-flash-preview") |
| baseUrl | string | N | API 엔드포인트 URL (Ollama 등) |
### 2.4 AutoTradeConfig (자동매매 로봇)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| id | string | Y | 식별자 |
| stockCode | string | N | 종목 코드 (개별 종목 대상인 경우) |
| stockName | string | 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 날짜) |
| executionTime | string | Y | 실행 시각 ("HH:mm") |
| trailingPercent | number | N | TS 사용 시 퍼센트 |
| active | boolean | Y | 활성화 상태 |
| market | MarketType | Y | 마켓 구분 |
### 2.5 ReservedOrder (감시 주문)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| id | string | Y | 식별자 |
| stockCode | string | Y | 종목 코드 |
| stockName | string | Y | 종목 명칭 |
| type | OrderType | Y | 매수/매도 구분 |
| quantity | number | Y | 주문 수량 |
| monitoringType | string | Y | "PRICE_TRIGGER" \| "TRAILING_STOP" |
| triggerPrice | number | Y | 감시 기준 가격 |
| trailingType | string | Y | "PERCENT" \| "AMOUNT" |
| trailingValue | number | Y | 간격 값 |
| status | string | Y | "WAITING" \| "MONITORING" \| "TRIGGERED" \| "CANCELLED" |
| createdAt | string | Y | 생성 일시 (ISO 8601) |
| expiryDate | string | Y | 만료 일시 (ISO 8601) |
### 2.6 WatchlistGroup (관심 종목 그룹)
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| id | string | Y | 식별자 |
| name | string | Y | 그룹명 |
| codes | string[] | Y | 포함된 종목 코드 배열 |
| market | MarketType | Y | 마켓 구분 |
## 3. Discovery Models (종목 발굴용 전용 모델)
### 3.1 RankingCategory
- **Type**: `string`
- **Values**: `"VOLUME"` (거래량), `"VALUE"` (거래대금), `"GAIN"` (상승), `"LOSS"` (하락), `"FOREIGN_BUY"` (외인매수), `"INSTITUTION_BUY"` (기관매수)
### 3.2 DiscoveryRankingResponse
| 필드명 | 타입 | 필수 | 설명 |
| :--- | :--- | :--- | :--- |
| category | RankingCategory | Y | 랭킹 카테고리 |
| updatedAt | string | Y | 랭킹 갱신 시각 (ISO 8601) |
| items | StockItem[] | Y | 순위별 종목 리스트 |

71
backend/tables.md Normal file
View File

@@ -0,0 +1,71 @@
# BatchuKis Database Schema Definition
이 문서는 데이터베이스 설계를 위한 테이블 명세서입니다. 모든 컬럼명은 [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)
## 3. holdings (현재 보유 종목)
- `code`: TEXT (PK)
- `name`: TEXT
- `avgPrice`: REAL
- `quantity`: INTEGER
- `market`: TEXT (Domestic | Overseas)
## 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
## 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
## 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로 제공.
## 8. [NEW] stock_stats (종목별 확장 통계)
- `code`: TEXT (PK)
- `tradingValue`: REAL
- `buyRatio`: INTEGER
- `sellRatio`: INTEGER
- `foreignNetBuy`: INTEGER
- `institutionalNetBuy`: INTEGER
- **용도**: `StockItem` 테이블을 직접 확장하거나 별도 통계 테이블로 관리하여 발굴 데이터 조회 성능 최적화.

103
components/CommonUI.tsx Normal file
View File

@@ -0,0 +1,103 @@
import React from 'react';
// --- StatCard: 대시보드 및 자산 요약용 ---
interface StatCardProps {
title: string;
value: string;
change?: string;
isUp?: boolean;
icon: React.ReactNode;
}
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>
{change && (
<p className={`text-[11px] font-black mt-4 flex items-center gap-2 ${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>
</div>
);
// --- FilterChip: 시장/정렬/카테고리 필터용 ---
interface FilterChipProps {
active: boolean;
onClick: () => void;
label: string;
}
export const FilterChip: React.FC<FilterChipProps> = ({ active, onClick, label }) => (
<button
onClick={onClick}
className={`px-5 py-2 rounded-full text-[12px] font-black transition-all ${active ? 'bg-slate-900 text-white shadow-lg' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
{label}
</button>
);
// --- TabButton: 대분류 섹션 전환용 ---
interface TabButtonProps {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}
export const TabButton: React.FC<TabButtonProps> = ({ active, onClick, icon, label }) => (
<button
onClick={onClick}
className={`flex items-center gap-3 pb-4 whitespace-nowrap transition-all border-b-2 ${active ? 'border-blue-600 text-blue-600 font-black' : 'border-transparent text-slate-400 font-bold hover:text-slate-600'}`}
>
{icon}
<span className="text-base tracking-tight">{label}</span>
</button>
);
// --- InputGroup: 설정창 및 폼 입력용 ---
interface InputGroupProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
type?: string;
icon?: React.ReactNode;
}
export const InputGroup: React.FC<InputGroupProps> = ({ label, value, onChange, placeholder, type = "text", icon }) => (
<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">
{icon} {label}
</label>
<input
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"
placeholder={placeholder}
/>
</div>
);
// --- ToggleButton: 활성화/비활성 스위치 ---
interface ToggleButtonProps {
active: boolean;
onClick: () => void;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({ active, onClick }) => (
<button
type="button"
onClick={onClick}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all focus:outline-none ${active ? 'bg-emerald-500' : 'bg-slate-300'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${active ? 'translate-x-7' : 'translate-x-2'}`} />
</button>
);

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface InsightBubbleProps {
user: string;
time: string;
content: string;
}
export const InsightBubble: React.FC<InsightBubbleProps> = ({ user, time, content }) => (
<div className="p-5 bg-slate-50/70 rounded-[1.5rem] border border-slate-100 space-y-2 hover:bg-white hover:shadow-md transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-100" />
<span className="text-[11px] font-black text-slate-700">{user}</span>
</div>
<span className="text-[10px] font-bold text-slate-400">{time}</span>
</div>
<p className="text-[13px] font-medium text-slate-600 leading-relaxed">{content}</p>
</div>
);

View File

@@ -0,0 +1,183 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
X, TrendingUp, BarChart4, PieChart, Info, Building2, Layers, RotateCw
} from 'lucide-react';
import { StockItem, MarketType, StockTick } from '../types';
import { DbService } from '../services/dbService';
interface StockDetailModalProps {
stock: StockItem;
onClose: () => void;
}
const StockDetailModal: React.FC<StockDetailModalProps> = ({ stock, onClose }) => {
const [ticks, setTicks] = useState<StockTick[]>([]);
const dbService = useMemo(() => new DbService(), []);
useEffect(() => {
const refreshTicks = async () => {
const history = await dbService.getStockTicks(stock.code);
setTicks(history);
};
refreshTicks();
const interval = setInterval(refreshTicks, 5000);
return () => clearInterval(interval);
}, [stock.code, dbService]);
return (
<div className="fixed inset-0 z-[300] bg-slate-900/40 backdrop-blur-xl flex items-center justify-center p-6 animate-in fade-in duration-300">
<div className="bg-white w-full max-w-7xl rounded-[4rem] h-[85vh] shadow-2xl flex flex-col overflow-hidden border border-slate-100 animate-in slide-in-from-bottom-10">
{/* Header */}
<div className="px-16 py-10 flex justify-between items-center border-b border-slate-50">
<div className="flex items-center gap-8">
<div className="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center text-white shadow-xl">
<BarChart4 size={40} />
</div>
<div>
<div className="flex items-center gap-4 mb-2">
<h2 className="text-4xl font-black text-slate-900 tracking-tighter italic">{stock.name}</h2>
<span className={`px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.2em] ${stock.market === MarketType.DOMESTIC ? 'bg-red-50 text-red-500' : 'bg-blue-50 text-blue-600'}`}>
{stock.market}
</span>
</div>
<p className="text-base text-slate-400 font-mono font-bold tracking-[0.3em] uppercase">{stock.code}</p>
</div>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<p className="text-5xl font-black text-slate-900 tracking-tighter mb-2">
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`}
</p>
<p className={`text-xl font-bold ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'} flex items-center justify-end gap-2`}>
{stock.changePercent >= 0 ? <TrendingUp size={24}/> : <TrendingUp size={24} className="rotate-180" />}
{Math.abs(stock.changePercent)}%
</p>
</div>
<button onClick={onClose} className="p-4 bg-slate-50 hover:bg-slate-100 text-slate-400 rounded-full transition-all">
<X size={32} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Chart */}
<div className="flex-[1.5] p-16 border-r border-slate-50 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-10">
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] flex items-center gap-3">
<PieChart size={20} className="text-blue-500" /> ()
</h4>
<span className="text-[10px] bg-slate-900 text-white px-4 py-1.5 rounded-lg font-black uppercase tracking-widest flex items-center gap-2">
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></span>
</span>
</div>
<div className="flex-1 relative bg-slate-50/50 rounded-[3rem] border border-slate-100 overflow-hidden group">
{ticks.length > 0 ? (
<div className="w-full h-full p-10">
<SimpleLineChart data={ticks} />
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-300 gap-6">
<RotateCw size={48} className="animate-spin" />
<p className="text-sm font-black uppercase tracking-widest"> ...</p>
</div>
)}
</div>
</div>
{/* Right: Info */}
<div className="flex-1 p-16 overflow-y-auto scrollbar-hide space-y-12">
<section>
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
<Layers size={20} className="text-blue-500" />
</h4>
<div className="flex flex-wrap gap-3">
{stock.themes?.map(theme => (
<span key={theme} className="px-6 py-3 bg-blue-50 text-blue-600 rounded-2xl text-[12px] font-black uppercase tracking-widest shadow-sm border border-blue-100">
#{theme}
</span>
))}
</div>
</section>
<section>
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
<Building2 size={20} className="text-purple-500" />
</h4>
<div className="grid grid-cols-2 gap-6">
<DataCard label="PER" value={stock.per} desc="Price Earning Ratio" />
<DataCard label="PBR" value={stock.pbr} desc="Price Book-value Ratio" />
<DataCard label="ROE" value={stock.roe} suffix="%" desc="Return On Equity" />
<DataCard label="시가총액" value={stock.marketCap} suffix="억" desc="Market Cap" />
</div>
</section>
<section className="bg-slate-900 p-10 rounded-[2.5rem] shadow-2xl">
<h4 className="text-[11px] font-black text-blue-400 uppercase tracking-[0.25em] mb-6">AI Strategy Insight</h4>
<div className="space-y-6">
<ScoreBar label="BUY" score={stock.aiScoreBuy} color="blue" />
<ScoreBar label="SELL" score={stock.aiScoreSell} color="rose" />
</div>
</section>
</div>
</div>
</div>
</div>
);
};
// --- Helpers ---
const SimpleLineChart: React.FC<{ data: StockTick[] }> = ({ data }) => {
const prices = data.map(d => d.price);
const min = Math.min(...prices) * 0.9995;
const max = Math.max(...prices) * 1.0005;
const range = max - min || 1;
const width = 800;
const height = 400;
if (data.length <= 1) return <div className="flex items-center justify-center h-full text-slate-400 uppercase font-black text-xs tracking-widest"> </div>;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((d.price - min) / range) * height;
return `${x},${y}`;
}).join(' ');
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible">
<polyline fill="none" stroke="#3b82f6" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" points={points} />
<polygon points={`0,${height} ${points} ${width},${height}`} fill="url(#grad-dash)" />
<circle cx={width} cy={height - ((data[data.length-1].price - min) / range) * height} r="6" fill="#3b82f6" className="animate-pulse" />
</svg>
);
};
const DataCard: React.FC<{ label: string, value?: number, suffix?: string, desc: string }> = ({ label, value, suffix = '', desc }) => (
<div className="bg-white border border-slate-100 p-8 rounded-[2rem] shadow-sm hover:border-blue-100 transition-all group">
<div className="flex justify-between items-start mb-2">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{label}</span>
<Info size={14} className="text-slate-200 group-hover:text-blue-400 transition-colors" />
</div>
<div className="flex items-baseline gap-1 mb-1">
<span className="text-2xl font-black text-slate-900 leading-none">{value !== undefined ? value.toLocaleString() : 'N/A'}</span>
<span className="text-[12px] font-black text-slate-400">{suffix}</span>
</div>
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-widest">{desc}</p>
</div>
);
const ScoreBar: React.FC<{ label: string, score: number, color: 'rose' | 'blue' }> = ({ label, score, color }) => (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-[10px] font-black">
<span className={color === 'rose' ? 'text-rose-500' : 'text-blue-600'}>{label}</span>
<span className="text-slate-400">{score}pt</span>
</div>
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
<div className={`h-full transition-all duration-1000 ${color === 'rose' ? 'bg-rose-500' : 'bg-blue-600'}`} style={{ width: `${score}%` }} />
</div>
</div>
);
export default StockDetailModal;

90
components/StockRow.tsx Normal file
View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Zap, ShoppingCart, Star } from 'lucide-react';
import { StockItem, MarketType, OrderType } from '../types';
interface StockRowProps {
stock: StockItem;
rank?: number;
showRank?: boolean;
showActions?: boolean;
showRatioBar?: boolean;
showPL?: { pl: number; percent: number };
isWatchlisted?: boolean;
onClick?: () => void;
onTrade?: (type: OrderType) => void;
onToggleWatchlist?: () => void;
}
export const StockRow: React.FC<StockRowProps> = ({
stock, rank, showRank, showActions, showRatioBar, showPL, isWatchlisted, onClick, onTrade, onToggleWatchlist
}) => {
return (
<tr
onClick={onClick}
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">
{rank}
</td>
)}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{onToggleWatchlist && (
<button
onClick={(e) => { e.stopPropagation(); onToggleWatchlist(); }}
className="text-slate-300 hover:text-amber-500 transition-colors"
>
<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">
{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>
</div>
</div>
</td>
<td className="px-4 py-3 text-right font-mono font-black text-slate-800 text-[13px]">
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() + '원' : '$' + stock.price}
</td>
<td className="px-4 py-3 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>
</div>
) : (
<span className={`font-black text-[13px] ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent}%
</span>
)}
</td>
{showRatioBar && (
<td className="px-4 py-3">
<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">
<span className="text-rose-500">{stock.buyRatio || 50}</span>
<span className="text-blue-500">{stock.sellRatio || 50}</span>
</div>
</div>
</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>
</div>
</td>
)}
</tr>
);
};

510
components/TradeModal.tsx Normal file
View File

@@ -0,0 +1,510 @@
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 { StockItem, OrderType, MarketType, ReservedOrder, TradeOrder } from '../types';
import { DbService, HoldingItem } from '../services/dbService';
interface TradeModalProps {
stock: StockItem;
type: OrderType;
onClose: () => void;
onExecute: (order: Omit<ReservedOrder, 'id' | 'status' | 'createdAt'>) => Promise<void>;
onImmediateOrder?: (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => Promise<void>;
}
type StrategyType = 'NONE' | 'PROFIT' | 'LOSS' | 'TRAILING_STOP';
const TradeModal: React.FC<TradeModalProps> = ({ stock, type: initialType, onClose, onExecute, onImmediateOrder }) => {
const [orderType, setOrderType] = useState<OrderType>(initialType);
const isBuyMode = orderType === OrderType.BUY;
const [holding, setHolding] = useState<HoldingItem | null>(null);
const [buyingPower, setBuyingPower] = useState(0);
const dbService = useMemo(() => new DbService(), []);
useEffect(() => {
const fetchData = async () => {
const holdings = await dbService.getHoldings();
const currentHolding = holdings.find(h => h.code === stock.code) || null;
setHolding(currentHolding);
const summary = await dbService.getAccountSummary();
setBuyingPower(summary.buyingPower);
};
fetchData();
}, [stock.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;
return { pl, percent };
}, [holding, stock.price]);
const [monitoringEnabled, setMonitoringEnabled] = useState(false);
const [immediateStart, setImmediateStart] = useState(true);
// 전략 선택 (라디오 버튼 방식)
const [activeStrategy, setActiveStrategy] = useState<StrategyType>(isBuyMode ? 'TRAILING_STOP' : 'NONE');
// TS 설정
const [tsValue, setTsValue] = useState<number>(3);
const [tsUnit, setTsUnit] = useState<'PERCENT' | 'TICK' | 'AMOUNT'>('PERCENT');
// TS 내부 감시 시작 조건 (Trigger)
const [triggerEnabled, setTriggerEnabled] = useState(false);
const [triggerType, setTriggerType] = useState<'CURRENT' | 'HIGH' | 'LOW' | 'VOLUME'>('CURRENT');
const [triggerValue, setTriggerValue] = useState<number>(stock.price);
const [monCondition, setMonCondition] = useState<'ABOVE' | 'BELOW'>(isBuyMode ? 'BELOW' : 'ABOVE');
// 자동 조건 결정 로직
const applyAutoCondition = (type: string, buyMode: boolean) => {
if (!buyMode) {
setMonCondition('ABOVE'); // 매도시에는 현재/고/저/거래량 모두 이상
} else {
// 매수시
if (type === 'CURRENT' || type === 'HIGH') {
setMonCondition('BELOW'); // 현재가, 고가는 이하
} else {
setMonCondition('ABOVE'); // 저가, 거래량은 이상
}
}
};
// 모드 변경 시 전략 및 조건 초기화
useEffect(() => {
if (isBuyMode) {
setActiveStrategy('TRAILING_STOP');
} else {
setActiveStrategy('NONE');
}
applyAutoCondition(triggerType, isBuyMode);
if (triggerType !== 'VOLUME') {
setTriggerValue(stock.price);
}
}, [isBuyMode, stock.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 [expiryDays, setExpiryDays] = useState<number>(1);
const [customExpiryDate, setCustomExpiryDate] = useState<string>(
new Date(Date.now() + 86400000).toISOString().split('T')[0]
);
const [priceMethod, setPriceMethod] = useState<'CURRENT' | 'MARKET' | 'HIGH' | 'LOW'>('CURRENT');
const [tickOffset, setTickOffset] = useState<number>(0);
const [quantityMode, setQuantityMode] = useState<'DIRECT' | 'RATIO'>('DIRECT');
const [quantity, setQuantity] = useState<number>(1);
const [quantityRatio, setQuantityRatio] = useState<number>(isBuyMode ? 10 : 100);
const currencySymbol = stock.market === MarketType.DOMESTIC ? '원' : '$';
const handleMaxQuantity = () => {
if (quantityMode === 'RATIO') {
setQuantityRatio(100);
} else {
if (isBuyMode) {
const maxQty = Math.floor(buyingPower / stock.price);
setQuantity(maxQty > 0 ? maxQty : 1);
} else if (holding) {
setQuantity(holding.quantity);
}
}
};
const handleReset = () => {
setMonitoringEnabled(false);
setImmediateStart(true);
setActiveStrategy(isBuyMode ? 'TRAILING_STOP' : 'NONE');
setTriggerEnabled(false);
setTriggerType('CURRENT');
setTriggerValue(stock.price);
applyAutoCondition('CURRENT', isBuyMode);
setPriceMethod('CURRENT');
setTickOffset(0);
setQuantity(1);
setQuantityRatio(isBuyMode ? 10 : 100);
setExpiryDays(1);
};
const finalQuantity = useMemo(() => {
if (quantityMode === 'RATIO') {
if (isBuyMode) {
const targetBudget = buyingPower * (quantityRatio / 100);
return Math.floor(targetBudget / stock.price) || 0;
} else if (holding) {
return Math.floor(holding.quantity * (quantityRatio / 100));
}
}
return quantity;
}, [isBuyMode, quantityMode, quantity, quantityRatio, holding, buyingPower, stock.price]);
const handleExecute = () => {
if (monitoringEnabled) {
let finalExpiry = new Date();
if (expiryDays > 0) finalExpiry.setDate(finalExpiry.getDate() + expiryDays);
else finalExpiry = new Date(customExpiryDate);
onExecute({
stockCode: stock.code,
stockName: stock.name,
type: orderType,
quantity: finalQuantity,
monitoringType: activeStrategy === 'TRAILING_STOP' ? 'TRAILING_STOP' : 'PRICE_TRIGGER',
triggerPrice: triggerEnabled ? triggerValue : stock.price,
trailingType: tsUnit === 'PERCENT' ? 'PERCENT' : 'AMOUNT',
trailingValue: tsValue,
market: stock.market,
expiryDate: finalExpiry
});
} else {
if (onImmediateOrder) {
onImmediateOrder({
stockCode: stock.code,
stockName: stock.name,
type: orderType,
price: priceMethod === 'MARKET' ? 0 : stock.price,
quantity: finalQuantity
});
}
}
onClose();
};
const StrategyRadio = ({ type, label, icon: Icon }: { type: StrategyType, label: string, icon: any }) => {
const isSelected = activeStrategy === type;
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'}`}
>
<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>
{isSelected && type === 'TRAILING_STOP' && (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<input type="number" className="w-16 p-2 bg-white rounded-xl text-center font-black text-[14px] border border-amber-200 shadow-inner outline-none" value={tsValue} onChange={(e) => setTsValue(Number(e.target.value))} />
<select value={tsUnit} onChange={(e) => setTsUnit(e.target.value as any)} className="bg-transparent font-black text-[12px] text-amber-600 outline-none">
<option value="PERCENT">%</option>
<option value="TICK"></option>
<option value="AMOUNT">{currencySymbol}</option>
</select>
</div>
)}
{isSelected && type === 'PROFIT' && (
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<input type="number" className="w-20 p-2 bg-white rounded-xl text-center font-black text-[14px] border border-rose-200 shadow-inner outline-none" value={profitValue} onChange={e => setProfitValue(Number(e.target.value))} />
<span className="text-[12px] font-black text-rose-500">{monTargetUnit === 'PERCENT' ? '%' : currencySymbol}</span>
</div>
)}
{isSelected && type === 'LOSS' && (
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<input type="number" className="w-20 p-2 bg-white rounded-xl text-center font-black text-[14px] border border-blue-200 shadow-inner outline-none" value={lossValue} onChange={e => setLossValue(Number(e.target.value))} />
<span className="text-[12px] font-black text-blue-500">{monTargetUnit === 'PERCENT' ? '%' : currencySymbol}</span>
</div>
)}
</div>
);
};
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="px-6 py-3 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>
</button>
<div className="flex bg-slate-100 p-1 rounded-xl border border-slate-200 shadow-inner">
<button onClick={() => setOrderType(OrderType.BUY)} className={`px-10 py-1.5 rounded-lg text-[11px] font-black transition-all ${isBuyMode ? 'bg-rose-500 text-white shadow-md' : 'text-slate-400 hover:text-slate-600'}`}></button>
<button onClick={() => setOrderType(OrderType.SELL)} className={`px-10 py-1.5 rounded-lg text-[11px] font-black transition-all ${!isBuyMode ? 'bg-blue-600 text-white shadow-md' : 'text-slate-400 hover:text-slate-600'}`}></button>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-slate-100 rounded-full transition-colors text-slate-400"><X size={24} /></button>
</div>
<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>
</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)}%
</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>
</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>
<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>
</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 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="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'} />
1.
</h4>
<button
onClick={() => setMonitoringEnabled(!monitoringEnabled)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-[10px] font-black transition-all border shadow-sm ${monitoringEnabled ? 'bg-slate-900 border-slate-900 text-white' : 'bg-white border-slate-200 text-slate-400 hover:border-slate-300'}`}
>
{monitoringEnabled ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
{monitoringEnabled ? '감시 중' : '비활성'}
</button>
</div>
<div className="space-y-6">
{!isBuyMode ? (
<div className="space-y-4">
<div className="flex items-center justify-between pl-1">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5">
<TrendingUp size={14} /> /
</label>
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
{['PRICE', 'PERCENT', 'AMOUNT'].map(unit => (
<button key={unit} onClick={() => setMonTargetUnit(unit as any)} className={`text-[9px] font-black px-2 py-1 rounded-md transition-all ${monTargetUnit === unit ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}>
{unit === 'PRICE' ? '현재가' : unit === 'PERCENT' ? '수익률(%)' : '수익금액'}
</button>
))}
</div>
</div>
<div className="space-y-3">
<StrategyRadio type="PROFIT" label="이익실현 (TAKE PROFIT)" icon={Target} />
<StrategyRadio type="LOSS" label="손실제한 (STOP LOSS)" icon={ShieldAlert} />
<StrategyRadio type="TRAILING_STOP" label="반등 시 주문 (TRAILING STOP)" icon={PlayCircle} />
</div>
</div>
) : (
<div className="space-y-3">
<StrategyRadio type="TRAILING_STOP" label="반등 시 주문 (TRAILING STOP)" icon={PlayCircle} />
</div>
)}
{/* TS 내부 감시 시작 조건 설정 (TS 선택 시에만 표시) */}
{activeStrategy === 'TRAILING_STOP' && (
<div className="mt-3 p-4 rounded-2xl border border-amber-100 bg-amber-50/20 space-y-3 animate-in slide-in-from-top-1">
<div className="flex items-center justify-between">
<div
onClick={() => setTriggerEnabled(!triggerEnabled)}
className="flex items-center gap-2 cursor-pointer group"
>
{triggerEnabled ? <CheckSquare size={16} className="text-amber-600" /> : <Square size={16} className="text-slate-300" />}
<span className="text-[11px] font-black text-amber-700/80 uppercase tracking-widest"> </span>
</div>
</div>
{triggerEnabled && (
<div className="flex items-center gap-1.5 bg-white/70 p-2 rounded-xl border border-amber-100 shadow-sm overflow-hidden">
<select
value={triggerType}
onChange={(e) => {
const newType = e.target.value as any;
setTriggerType(newType);
applyAutoCondition(newType, isBuyMode);
if (newType === 'VOLUME') {
setTriggerValue(100000);
} else {
setTriggerValue(stock.price);
}
}}
className="bg-white p-2 rounded-lg font-black text-[10px] outline-none text-slate-700 w-32 border border-slate-100 shadow-sm shrink-0"
>
<option value="CURRENT">(\)</option>
<option value="HIGH">(\)</option>
<option value="LOW">(\)</option>
<option value="VOLUME">()</option>
</select>
<input
type="number"
className="flex-1 min-w-0 p-2 rounded-lg font-black text-[14px] text-center outline-none bg-white border border-slate-100 focus:border-amber-300 transition-all shadow-sm"
value={triggerValue}
onChange={(e) => setTriggerValue(Number(e.target.value))}
/>
<select
value={monCondition}
onChange={(e) => setMonCondition(e.target.value as any)}
className={`p-2 rounded-lg font-black text-[10px] outline-none border border-slate-100 shadow-sm w-16 shrink-0 transition-colors ${monCondition === 'ABOVE' ? 'bg-blue-50 text-blue-600' : 'bg-rose-50 text-rose-500'}`}
>
<option value="ABOVE"></option>
<option value="BELOW"></option>
</select>
</div>
)}
{!triggerEnabled && (
<p className="text-[10px] font-bold text-amber-600/60 pl-1 italic"> .</p>
)}
</div>
)}
<div className="flex items-center gap-4 pt-4 border-t border-slate-100">
<span className="text-[11px] font-black text-slate-400 uppercase tracking-widest whitespace-nowrap"></span>
<div className="flex flex-1 gap-2">
{[1, 5, 30].map(d => (
<button key={d} onClick={() => setExpiryDays(d)} className={`flex-1 py-2 rounded-xl text-[11px] font-black transition-all ${expiryDays === d ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 border border-slate-200 text-slate-400 hover:border-slate-300'}`}>
{d}
</button>
))}
</div>
</div>
</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" />
2.
</h4>
<div className="space-y-8">
<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">
{['CURRENT', 'MARKET', 'HIGH', 'LOW'].map((method) => (
<button key={method} onClick={() => { setPriceMethod(method as any); if (method === 'MARKET') setTickOffset(0); }} className={`py-2 rounded-xl text-[11px] font-black transition-all border ${priceMethod === method ? 'bg-white text-slate-900 border-slate-200 shadow-md' : 'bg-transparent text-slate-400 border-transparent hover:text-slate-600'}`}>
{method === 'CURRENT' ? '현재' : method === 'MARKET' ? '시장' : method === 'HIGH' ? '고가' : '저가'}
</button>
))}
</div>
</div>
<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="flex items-center gap-4 bg-slate-50 p-1.5 rounded-2xl border border-slate-100">
<button disabled={priceMethod === 'MARKET'} onClick={() => setTickOffset(prev => prev - 1)} className="p-2.5 bg-white rounded-xl text-slate-400 hover:text-blue-500 disabled:opacity-20 shadow-sm transition-all"><Minus size={18} /></button>
<div className="flex-1 text-center font-black font-mono text-[18px] text-slate-800">{tickOffset > 0 ? `+${tickOffset}` : tickOffset}</div>
<button disabled={priceMethod === 'MARKET'} onClick={() => setTickOffset(prev => prev + 1)} className="p-2.5 bg-white rounded-xl text-slate-400 hover:text-blue-500 disabled:opacity-20 shadow-sm transition-all"><Plus size={18} /></button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between pl-1">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest"> </label>
<span className="text-[9px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full">
{isBuyMode ? `예수금` : `보유수량`}
</span>
</div>
<div className="bg-slate-50 p-3 rounded-2xl border border-slate-100 space-y-4">
<div className="flex items-center gap-1.5 bg-white/60 p-1 rounded-xl border border-slate-100 shadow-inner">
<button onClick={() => setQuantityMode('DIRECT')} className={`flex-1 py-2 rounded-lg text-[10px] font-black transition-all ${quantityMode === 'DIRECT' ? 'bg-white text-slate-900 shadow-sm border border-slate-200' : 'text-slate-400 hover:text-slate-600'}`}></button>
<button onClick={() => setQuantityMode('RATIO')} className={`flex-1 py-2 rounded-lg text-[10px] font-black transition-all ${quantityMode === 'RATIO' ? 'bg-white text-slate-900 shadow-sm border border-slate-200' : 'text-slate-400 hover:text-slate-600'}`}>(%)</button>
</div>
<div className="flex items-center gap-3 px-2 pb-1 relative">
{quantityMode === 'DIRECT' ? (
<div className="flex items-center w-full gap-2 group">
<input type="number" min="1" className="flex-1 bg-transparent text-center font-black text-[24px] outline-none placeholder:text-slate-200 text-slate-900 min-w-0" placeholder="0" value={quantity} onChange={e => setQuantity(Math.max(1, parseInt(e.target.value) || 0))} />
<div className="flex flex-col items-center gap-1">
<button onClick={handleMaxQuantity} className="px-3 py-1 bg-blue-600 text-white rounded-lg font-black text-[9px] uppercase tracking-tighter hover:bg-blue-700 transition-all shadow-md">MAX</button>
<div className="px-2 py-1 bg-slate-900 text-white rounded-lg font-black text-[9px] uppercase tracking-wider shrink-0">EA / UNIT</div>
</div>
</div>
) : (
<div className="flex items-center w-full gap-2">
<input type="number" min="0" max="100" className="flex-1 bg-transparent text-center font-black text-[24px] outline-none text-slate-900 min-w-0" value={quantityRatio} onChange={e => setQuantityRatio(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} />
<div className="flex flex-col items-center gap-1">
<button onClick={handleMaxQuantity} className="px-3 py-1 bg-blue-600 text-white rounded-lg font-black text-[9px] uppercase tracking-tighter hover:bg-blue-700 transition-all shadow-md">MAX</button>
<div className={`px-2 py-1 text-white rounded-lg font-black text-[9px] uppercase tracking-wider flex items-center gap-1 shrink-0 ${isBuyMode ? 'bg-rose-500' : 'bg-blue-600'}`}>
<BadgePercent size={12} /> PERCENT
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="p-4 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 ? '매수' : '매도'}.
</p>
</div>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="px-8 py-6 bg-white border-t border-slate-100 flex items-center justify-between shrink-0 gap-6">
{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">
<span className="text-[10px] font-black text-slate-800 uppercase tracking-tight leading-none"> </span>
<span className="text-[8px] text-slate-400 font-bold leading-none mt-1"> </span>
</div>
<button
onClick={() => setImmediateStart(!immediateStart)}
className={`relative inline-flex h-6 w-10 items-center rounded-full transition-all focus:outline-none shadow-inner ${immediateStart ? 'bg-emerald-500' : 'bg-slate-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow-md ${immediateStart ? 'translate-x-5' : 'translate-x-1'}`} />
</button>
</div>
)}
<button
onClick={handleExecute}
className={`flex-1 py-4 rounded-2xl font-black text-[16px] text-white shadow-xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 ${monitoringEnabled ? (isBuyMode ? 'bg-rose-500' : 'bg-blue-600') : 'bg-slate-900 hover:bg-slate-800'}`}
>
{monitoringEnabled ? <><Save className="w-5 h-5" /> {immediateStart ? '즉시 활성화' : '대기 저장'}</> : <><Zap size={22} fill="currentColor" /> {isBuyMode ? '매수' : '매도'} </>}
</button>
</div>
</div>
</div>
);
};
export default TradeModal;

49
constants.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { MarketType, StockItem } from './types';
export const COLORS = {
up: 'text-red-500',
down: 'text-blue-500',
neutral: 'text-slate-500',
primary: 'blue-600',
accent: 'indigo-600'
};
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,
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,
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,
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,
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,
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,
themes: ['반도체', 'AI/인공지능', '데이터센터']
}
];

41
index.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BatchuKis</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark-glass-panel {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"react-router-dom": "https://esm.sh/react-router-dom@^7.13.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"@google/genai": "https://esm.sh/@google/genai@^1.39.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "BatchuKis",
"description": "A sophisticated stock auto-trading platform supporting domestic and international markets with AI-driven insights, KIS API integration, and Telegram notifications.",
"requestFramePermissions": []
}

2783
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "batchukis",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-router-dom": "^7.13.0",
"react-dom": "^19.2.4",
"@google/genai": "^1.39.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

308
pages/AiInsights.tsx Normal file
View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import {
BrainCircuit,
Sparkles,
RefreshCw,
Search,
TrendingUp,
MessageSquareQuote,
Zap,
ChevronRight,
Target,
BarChart4,
Cpu,
ShieldCheck,
AlertCircle
} from 'lucide-react';
import { ApiSettings, StockItem, NewsItem, AiConfig } from '../types';
import { NaverService } from '../services/naverService';
import { AiService } from '../services/aiService';
interface AiInsightsProps {
stocks: StockItem[];
settings: ApiSettings;
onUpdateSettings: (settings: ApiSettings) => void;
}
const AiInsights: React.FC<AiInsightsProps> = ({ stocks, settings, onUpdateSettings }) => {
// 뉴스 분석 상태
const [news, setNews] = useState<NewsItem[]>([]);
const [isNewsLoading, setIsNewsLoading] = useState(false);
const [selectedNewsAiId, setSelectedNewsAiId] = useState<string>(settings.preferredNewsAiId || settings.aiConfigs[0]?.id || 'none');
const [newsAnalysis, setNewsAnalysis] = useState<string | null>(null);
const [isNewsAnalyzing, setIsNewsAnalyzing] = useState(false);
// 종목 분석 상태
const [search, setSearch] = useState('');
const [selectedStock, setSelectedStock] = useState<StockItem | null>(null);
const [selectedStockAiId, setSelectedStockAiId] = useState<string>(settings.preferredStockAiId || settings.aiConfigs[0]?.id || 'none');
const [stockAnalysis, setStockAnalysis] = useState<string | null>(null);
const [isStockAnalyzing, setIsStockAnalyzing] = useState(false);
const naverService = new NaverService(settings);
useEffect(() => {
fetchInitialNews();
}, []);
const fetchInitialNews = async () => {
setIsNewsLoading(true);
if (settings.useNaverNews) {
const result = await naverService.fetchNews("주식 시장 전망");
setNews(result);
}
setIsNewsLoading(false);
};
const handleNewsAnalysis = async () => {
const config = settings.aiConfigs.find(c => c.id === selectedNewsAiId);
if (!config) return;
setIsNewsAnalyzing(true);
const headlines = news.map(n => n.title);
try {
const result = await AiService.analyzeNewsSentiment(config, headlines);
setNewsAnalysis(result);
// 선호 엔진 업데이트
onUpdateSettings({ ...settings, preferredNewsAiId: selectedNewsAiId });
} catch (e) {
setNewsAnalysis("분석 실패: API 설정을 확인해주세요.");
} finally {
setIsNewsAnalyzing(false);
}
};
const handleStockAnalysis = async () => {
if (!selectedStock) return;
const config = settings.aiConfigs.find(c => c.id === selectedStockAiId);
if (!config) return;
setIsStockAnalyzing(true);
const context = `종목명: ${selectedStock.name}, 현재가: ${selectedStock.price}, 등락률: ${selectedStock.changePercent}%, PER: ${selectedStock.per}, PBR: ${selectedStock.pbr}, ROE: ${selectedStock.roe}%`;
const prompt = `주식 전문가로서 다음 데이터를 바탕으로 ${selectedStock.name}에 대한 매수/매도 의견과 향후 일주일간의 대응 전략을 아주 구체적으로 제안해 주세요. 데이터: ${context}`;
try {
// AiService에 범용 호출 메서드가 없으므로 analyzeNewsSentiment를 차용하거나 직접 fetch 가능
// 여기서는 규칙에 따라 aiService의 call 메카니즘을 활용하도록 시뮬레이션
const result = await AiService.analyzeNewsSentiment(config, [prompt]);
setStockAnalysis(result);
onUpdateSettings({ ...settings, preferredStockAiId: selectedStockAiId });
} catch (e) {
setStockAnalysis("분석 실패: API 연결을 확인하세요.");
} finally {
setIsStockAnalyzing(false);
}
};
const filteredStocks = stocks.filter(s =>
s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase())
).slice(0, 5);
return (
<div className="max-w-7xl mx-auto space-y-12 pb-32 animate-in fade-in duration-700">
{/* Header Banner */}
<div className="relative bg-slate-900 rounded-[4rem] p-16 overflow-hidden border border-white/5 shadow-2xl">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-gradient-to-br from-blue-600/20 to-purple-600/20 rounded-full blur-[100px] -mr-64 -mt-64"></div>
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-10">
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-4">
<div className="p-4 bg-blue-600 text-white rounded-[1.5rem] shadow-lg shadow-blue-500/30">
<BrainCircuit size={40} />
</div>
<h2 className="text-4xl font-black text-white italic tracking-tighter">AI INTELLIGENCE CENTER</h2>
</div>
<p className="text-lg text-slate-400 font-medium leading-relaxed">
AI <br/>
.
</p>
<div className="flex items-center gap-6 pt-4">
<div className="flex items-center gap-2 px-5 py-2.5 bg-white/5 rounded-full border border-white/10">
<ShieldCheck className="text-emerald-500" size={18} />
<span className="text-[11px] font-black text-slate-300 uppercase tracking-widest">Secure AI Tunneling</span>
</div>
<div className="flex items-center gap-2 px-5 py-2.5 bg-white/5 rounded-full border border-white/10">
<Cpu className="text-blue-400" size={18} />
<span className="text-[11px] font-black text-slate-300 uppercase tracking-widest">{settings.aiConfigs.length} Engines Connected</span>
</div>
</div>
</div>
<div className="hidden lg:block relative">
<div className="w-64 h-64 bg-slate-800 rounded-[3rem] border border-white/5 flex items-center justify-center animate-pulse">
<Sparkles size={80} className="text-blue-500/40" />
</div>
<div className="absolute -top-4 -right-4 p-4 bg-blue-600 rounded-2xl shadow-xl">
<Target size={24} className="text-white" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* News Intelligence Panel */}
<div className="space-y-8">
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100 h-full flex flex-col">
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-5">
<div className="p-4 bg-emerald-50 text-emerald-600 rounded-2xl">
<TrendingUp size={28} />
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">MARKET SENTIMENT</h3>
<p className="text-[11px] text-slate-400 font-black uppercase tracking-widest mt-1"> </p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={selectedNewsAiId}
onChange={(e) => setSelectedNewsAiId(e.target.value)}
className="p-3.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-black outline-none focus:border-blue-500 transition-all cursor-pointer"
>
{settings.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button
onClick={handleNewsAnalysis}
disabled={isNewsAnalyzing || news.length === 0}
className="p-3.5 bg-slate-900 text-white rounded-xl hover:bg-slate-800 transition-all disabled:opacity-30"
>
{isNewsAnalyzing ? <RefreshCw size={18} className="animate-spin" /> : <Sparkles size={18} />}
</button>
</div>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{newsAnalysis ? (
<div className="p-8 bg-slate-900 rounded-[2.5rem] text-white shadow-2xl animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3 mb-6">
<MessageSquareQuote size={24} className="text-emerald-400" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Analysis Result</span>
</div>
<div className="text-base font-medium leading-relaxed opacity-90 whitespace-pre-wrap font-sans">
{newsAnalysis}
</div>
</div>
) : (
<div className="space-y-4 opacity-50">
{news.slice(0, 3).map((n, i) => (
<div key={i} className="p-6 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between">
<p className="font-bold text-slate-800 truncate pr-4">{n.title}</p>
<ChevronRight size={16} className="text-slate-300 shrink-0" />
</div>
))}
{news.length === 0 && (
<div className="py-20 text-center flex flex-col items-center gap-4">
<AlertCircle className="text-slate-200" size={48} />
<p className="text-sm font-black text-slate-300 uppercase"> <br/> .</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Stock Deep Analysis Panel */}
<div className="space-y-8">
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100 h-full flex flex-col">
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-5">
<div className="p-4 bg-purple-50 text-purple-600 rounded-2xl">
<BarChart4 size={28} />
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">STOCK DEEP DIVE</h3>
<p className="text-[11px] text-slate-400 font-black uppercase tracking-widest mt-1"> </p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={selectedStockAiId}
onChange={(e) => setSelectedStockAiId(e.target.value)}
className="p-3.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-black outline-none focus:border-blue-500 transition-all cursor-pointer"
>
{settings.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="relative mb-8">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" size={24} />
<input
type="text"
placeholder="분석할 종목 검색..."
className="w-full pl-16 pr-6 py-5 bg-slate-50 border-2 border-transparent rounded-[2rem] focus:border-blue-500 focus:bg-white outline-none text-base font-bold text-slate-800 transition-all shadow-inner"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && filteredStocks.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]">
{filteredStocks.map(s => (
<div
key={s.code}
onClick={() => { setSelectedStock(s); setSearch(''); setStockAnalysis(null); }}
className="p-6 hover:bg-purple-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50 group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center font-black text-slate-400 text-xs">{s.code.substring(0,2)}</div>
<div>
<p className="font-black text-slate-800 text-base">{s.name}</p>
<p className="text-[11px] font-mono text-slate-400 font-bold tracking-widest">{s.code}</p>
</div>
</div>
<ChevronRight className="text-slate-200 group-hover:text-purple-500 transition-colors" />
</div>
))}
</div>
)}
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{selectedStock ? (
<div className="space-y-8 animate-in slide-in-from-bottom-4 duration-500">
<div className="p-8 bg-purple-50 rounded-[2.5rem] border border-purple-100 flex items-center justify-between">
<div className="flex items-center gap-5">
<div className="w-16 h-16 bg-white rounded-3xl flex items-center justify-center text-purple-600 shadow-sm">
<Zap size={32} fill="currentColor" />
</div>
<div>
<h4 className="text-2xl font-black text-slate-900 tracking-tighter italic">{selectedStock.name}</h4>
<p className="text-sm font-mono text-slate-500 font-bold">{selectedStock.code}</p>
</div>
</div>
<button
onClick={handleStockAnalysis}
disabled={isStockAnalyzing}
className="px-8 py-4 bg-purple-600 text-white rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-purple-700 transition-all shadow-xl shadow-purple-200"
>
{isStockAnalyzing ? <RefreshCw className="animate-spin" size={20} /> : "전략 분석 요청"}
</button>
</div>
{stockAnalysis && (
<div className="p-10 bg-white border-2 border-purple-100 rounded-[3rem] shadow-xl animate-in fade-in zoom-in-95 duration-500 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5">
<BrainCircuit size={120} />
</div>
<div className="relative z-10 text-slate-800 text-base font-medium leading-relaxed whitespace-pre-wrap">
{stockAnalysis}
</div>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center py-20 opacity-30">
<Search size={64} strokeWidth={1} className="text-slate-300 mb-6" />
<p className="text-sm font-black text-slate-400 uppercase tracking-widest"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default AiInsights;

289
pages/AutoTrading.tsx Normal file
View File

@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import { Cpu, Plus, Calendar, Zap, Trash2, Activity, Clock, LayoutGrid, Layers, X } from 'lucide-react';
import { StockItem, AutoTradeConfig, MarketType, WatchlistGroup } from '../types';
interface AutoTradingProps {
marketMode: MarketType;
stocks: StockItem[];
configs: AutoTradeConfig[];
groups: WatchlistGroup[];
onAddConfig: (config: Omit<AutoTradeConfig, 'id' | 'active'>) => void;
onToggleConfig: (id: string) => void;
onDeleteConfig: (id: string) => void;
}
const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs, groups, onAddConfig, onToggleConfig, onDeleteConfig }) => {
const [showAddModal, setShowAddModal] = useState(false);
const [targetType, setTargetType] = useState<'SINGLE' | 'GROUP'>('SINGLE');
const [newConfig, setNewConfig] = useState<Partial<AutoTradeConfig>>({
type: 'ACCUMULATION',
frequency: 'DAILY',
quantity: 1,
executionTime: '09:00',
specificDay: 1
});
const handleAdd = () => {
if (newConfig.type) {
if (targetType === 'SINGLE' && !newConfig.stockCode) return;
if (targetType === 'GROUP' && !newConfig.groupId) return;
const stockName = targetType === 'SINGLE'
? stocks.find(s => s.code === newConfig.stockCode)?.name || '알 수 없음'
: groups.find(g => g.id === newConfig.groupId)?.name || '알 수 없는 그룹';
onAddConfig({
stockCode: targetType === 'SINGLE' ? newConfig.stockCode : undefined,
groupId: targetType === 'GROUP' ? newConfig.groupId : undefined,
stockName: stockName,
type: newConfig.type as 'ACCUMULATION' | 'TRAILING_STOP',
quantity: newConfig.quantity || 1,
frequency: newConfig.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY',
specificDay: newConfig.specificDay,
executionTime: newConfig.executionTime || '09:00',
trailingPercent: newConfig.trailingPercent,
market: marketMode
});
setShowAddModal(false);
}
};
const getDayLabel = (config: AutoTradeConfig) => {
if (config.frequency === 'DAILY') return '매일';
if (config.frequency === 'WEEKLY') {
const days = ['일', '월', '화', '수', '목', '금', '토'];
return `매주 ${days[config.specificDay || 0]}요일`;
}
if (config.frequency === 'MONTHLY') return `매월 ${config.specificDay}`;
return '';
};
const filteredStocks = stocks.filter(s => s.market === marketMode);
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>
<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>
<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">
<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>
{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"
>
<Plus size={24} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{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 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>
<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>
</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'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${config.active ? 'translate-x-7' : 'translate-x-2'}`} />
</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]">
<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]">
<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'}`}>
{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>
<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>
</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>
<button
onClick={() => onDeleteConfig(config.id)}
className="p-4 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-[1.5rem] transition-all"
>
<Trash2 size={24} />
</button>
</div>
</div>
</div>
))}
</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" />
</h3>
<button onClick={() => setShowAddModal(false)} className="p-4 hover:bg-slate-100 rounded-full transition-colors"><X size={32} 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">
<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'}`}
>
</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'}`}
>
</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">
{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"
onChange={(e) => setNewConfig({...newConfig, stockCode: e.target.value})}
value={newConfig.stockCode || ''}
>
<option value=""> </option>
{filteredStocks.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</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"
onChange={(e) => setNewConfig({...newConfig, groupId: e.target.value})}
value={newConfig.groupId || ''}
>
<option value=""> </option>
{filteredGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
)}
</div>
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </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"
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">
{[
{ val: 'DAILY', label: '매일' },
{ val: 'WEEKLY', label: '매주' },
{ val: 'MONTHLY', label: '매월' }
].map(freq => (
<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'}`}
>
{freq.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-8">
{newConfig.frequency !== 'DAILY' && (
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
{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"
value={newConfig.specificDay}
onChange={(e) => setNewConfig({...newConfig, specificDay: parseInt(e.target.value)})}
>
{newConfig.frequency === 'WEEKLY' ? (
['일', '월', '화', '수', '목', '금', '토'].map((d, i) => <option key={d} value={i}>{d}</option>)
) : (
Array.from({length: 31}, (_, i) => i + 1).map(d => <option key={d} value={d}>{d}</option>)
)}
</select>
</div>
)}
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">퀀 </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"
value={newConfig.executionTime}
onChange={(e) => setNewConfig({...newConfig, executionTime: e.target.value})}
/>
</div>
</div>
<div className="flex gap-8 pt-10">
<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"
>
</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"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AutoTrading;

195
pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp, Wallet, Activity, Briefcase, PieChart, Database, Zap, Timer, Trash2
} from 'lucide-react';
import { StockItem, TradeOrder, MarketType, WatchlistGroup, OrderType, AutoTradeConfig, ReservedOrder } from '../types';
import { DbService, HoldingItem } from '../services/dbService';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { StatCard } from '../components/CommonUI';
import { StockRow } from '../components/StockRow';
interface DashboardProps {
marketMode: MarketType;
watchlistGroups: WatchlistGroup[];
stocks: StockItem[];
orders: TradeOrder[];
reservedOrders: ReservedOrder[];
autoTrades: AutoTradeConfig[];
onManualOrder: (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => Promise<void>;
onAddReservedOrder: (order: Omit<ReservedOrder, 'id' | 'status' | 'createdAt'>) => Promise<void>;
onDeleteReservedOrder: (id: string) => Promise<void>;
onRefreshHoldings: () => void;
}
const Dashboard: React.FC<DashboardProps> = ({
marketMode, watchlistGroups, 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(), []);
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);
const accSummary = await dbService.getAccountSummary();
setHoldings(filteredHoldings);
setSummary(accSummary);
};
const calculatePL = (holding: HoldingItem) => {
const currentStock = stocks.find(s => s.code === holding.code);
const currentPrice = currentStock ? currentStock.price : holding.avgPrice;
const pl = (currentPrice - holding.avgPrice) * holding.quantity;
const plPercent = ((currentPrice - holding.avgPrice) / holding.avgPrice) * 100;
const value = currentPrice * holding.quantity;
return { pl, plPercent, currentPrice, value, stock: currentStock };
};
const totalLiquidationSummary = holdings.reduce((acc, h) => {
const plData = calculatePL(h);
return {
totalValue: acc.totalValue + plData.value,
totalPL: acc.totalPL + plData.pl,
totalCost: acc.totalCost + (h.avgPrice * h.quantity)
};
}, { totalValue: 0, totalPL: 0, totalCost: 0 });
const aggregatePLPercent = totalLiquidationSummary.totalCost > 0
? (totalLiquidationSummary.totalPL / totalLiquidationSummary.totalCost) * 100
: 0;
const selectedGroup = activeMarketGroups.find(g => g.id === activeGroupId) || activeMarketGroups[0];
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" />
</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">
<tbody className="divide-y divide-slate-50">
{selectedGroup?.codes.map(code => stocks.find(s => s.code === code)).filter(s => s?.market === marketMode).map(stock => {
if (!stock) return null;
return (
<StockRow
key={stock.code}
stock={stock}
showActions={true}
onTrade={(type) => setTradeContext({ stock, type })}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
</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>
</div>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={onAddReservedOrder} />}
</div>
);
};
export default Dashboard;

242
pages/Discovery.tsx Normal file
View File

@@ -0,0 +1,242 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
Trophy, Flame, Users, Search, Info, MessageSquare, Sparkles, Zap,
Save, EyeOff, Eye, RefreshCw, FileText, StickyNote, History as HistoryIcon,
ArrowUpRight, ArrowDownRight
} from 'lucide-react';
import { StockItem, MarketType, OrderType, ApiSettings, TradeOrder } from '../types';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { FilterChip, TabButton } from '../components/CommonUI';
import { StockRow } from '../components/StockRow';
import { AiService } from '../services/aiService';
interface DiscoveryProps {
stocks: StockItem[];
orders: TradeOrder[];
onUpdateStock: (code: string, updates: Partial<StockItem>) => void;
settings: ApiSettings;
}
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
const [activeTab, setActiveTab] = useState<'realtime' | 'category' | 'investor'>('realtime');
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 selectedStock = useMemo(() => {
return stocks.find(s => s.code === selectedStockCode) || null;
}, [stocks, selectedStockCode]);
const stockOrders = useMemo(() => {
return orders.filter(o => o.stockCode === selectedStockCode).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}, [orders, selectedStockCode]);
const [memo, setMemo] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
useEffect(() => {
if (selectedStock) {
setMemo(selectedStock.memo || '');
}
}, [selectedStockCode, stocks]);
const enrichedStocks = useMemo(() => {
return stocks.filter(s => !s.isHidden).map((s) => ({
...s,
tradingValue: (s.volume * s.price) / (s.market === MarketType.DOMESTIC ? 100000000 : 1000000),
buyRatio: 50 + Math.floor(Math.random() * 45),
sellRatio: 100 - (50 + Math.floor(Math.random() * 45))
})).filter(s => {
if (marketFilter === 'domestic') return s.market === MarketType.DOMESTIC;
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;
});
}, [stocks, marketFilter, sortType]);
const handleSaveMemo = () => {
if (selectedStock) {
onUpdateStock(selectedStock.code, { memo });
alert('메모가 저장되었습니다.');
}
};
const handleToggleHide = () => {
if (selectedStock) {
const newHidden = !selectedStock.isHidden;
onUpdateStock(selectedStock.code, { isHidden: newHidden });
if (newHidden) {
alert(`${selectedStock.name} 종목이 숨김 처리되었습니다.`);
const next = enrichedStocks.find(s => s.code !== selectedStock.code);
if (next) setSelectedStockCode(next.code);
}
}
};
const handleGenerateAnalysis = async () => {
if (!selectedStock) return;
const config = settings.aiConfigs.find(c => c.id === settings.preferredStockAiId) || settings.aiConfigs[0];
if (!config) return;
setIsAnalyzing(true);
try {
const prompt = `주식 전문가로서 ${selectedStock.name}(${selectedStock.code}) 리포트를 작성해줘.`;
const result = await AiService.analyzeNewsSentiment(config, [prompt]);
onUpdateStock(selectedStock.code, { aiAnalysis: result });
} catch (e) {
alert('AI 분석 생성 실패');
} finally {
setIsAnalyzing(false);
}
};
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>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-1.5">
<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">
<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>
<th className="px-4 py-4 text-right"></th>
<th className="px-4 py-4 text-right w-36">/ </th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{enrichedStocks.map((stock, idx) => (
<StockRow
key={stock.code}
stock={stock}
rank={idx + 1}
showRank={true}
showRatioBar={true}
onClick={() => setSelectedStockCode(stock.code)}
/>
))}
</tbody>
</table>
</div>
</div>
<div className="w-full lg:w-[380px]">
{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="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>
</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'}`}>
{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>
</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">
<StickyNote size={14} className="text-amber-500" />
</div>
<button onClick={handleSaveMemo} className="text-[10px] 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"
placeholder="메모를 입력하세요..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</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">
<HistoryIcon size={14} className="text-blue-500" />
</div>
<div className="space-y-2">
{stockOrders.length > 0 ? (
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'}`}>
{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>
</div>
</div>
<p className="text-[11px] 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>
)}
</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">
<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">
<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">
{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>
</div>
)}
</div>
</div>
)}
</div>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert('주문 예약됨')} />}
</div>
);
};
export default Discovery;

81
pages/History.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React from 'react';
import { History, CheckCircle, Clock, XCircle, FileText } from 'lucide-react';
import { TradeOrder } from '../types';
interface HistoryPageProps {
orders: TradeOrder[];
}
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" />
</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>
</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>
</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">
{order.timestamp.toLocaleString()}
</td>
<td className="px-10 py-6">
<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>
</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'}`}>
{order.type === 'BUY' ? '매수' : '매도'}
</span>
</td>
<td className="px-10 py-6 text-base font-black text-slate-700">
{order.quantity} UNIT
</td>
<td className="px-10 py-6 text-base 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>
</div>
</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={6} className="px-10 py-32 text-center text-slate-400 italic text-base">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default HistoryPage;

186
pages/News.tsx Normal file
View File

@@ -0,0 +1,186 @@
import React, { useState, useEffect } from 'react';
import { Newspaper, ExternalLink, RefreshCw, Bookmark, Search, AlertCircle, Sparkles, X, MessageSquareQuote } from 'lucide-react';
import { NewsItem, ApiSettings } from '../types';
import { NaverService } from '../services/naverService';
import { AiService } from '../services/aiService';
interface NewsProps {
settings: ApiSettings;
}
const MOCK_NEWS: NewsItem[] = [
{
title: "BatchuKis 마켓 브리핑: AI 트레이딩의 미래",
description: "딥러닝 알고리즘을 활용한 자동매매 시장이 국내에서도 빠르게 성장하고 있습니다. 개인 투자자들의 접근성이 확대되며...",
link: "#",
pubDate: new Date().toISOString()
},
{
title: "KIS API 연동 가이드: 보안 설정과 데이터 암호화",
description: "한국투자증권 오픈 API를 활용한 안전한 트레이딩 환경 구축을 위해 사용자는 반드시 앱키 노출에 주의해야 하며...",
link: "#",
pubDate: new Date().toISOString()
}
];
const News: React.FC<NewsProps> = ({ settings }) => {
const [loading, setLoading] = useState(false);
const [news, setNews] = useState<NewsItem[]>(MOCK_NEWS);
const [filter, setFilter] = useState('');
// AI 분석 관련 상태
const [analysisResult, setAnalysisResult] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const naverService = new NaverService(settings);
useEffect(() => {
if (settings.useNaverNews) {
handleRefresh();
}
}, [settings.useNaverNews]);
const handleRefresh = async () => {
setLoading(true);
if (settings.useNaverNews) {
const fetchedNews = await naverService.fetchNews();
if (fetchedNews.length > 0) {
setNews([...fetchedNews, ...MOCK_NEWS]);
}
}
setTimeout(() => setLoading(false), 800);
};
const handleAnalyze = async () => {
const aiId = settings.preferredNewsAiId;
if (!aiId) {
alert("설정 페이지에서 '뉴스 분석 엔진'을 먼저 지정해주세요.");
return;
}
const config = settings.aiConfigs.find(c => c.id === aiId);
if (!config) {
alert("지정된 AI 엔진 설정을 찾을 수 없습니다.");
return;
}
setIsAnalyzing(true);
setAnalysisResult(null);
const headlines = news.slice(0, 10).map(n => n.title);
try {
const result = await AiService.analyzeNewsSentiment(config, headlines);
setAnalysisResult(result);
} catch (e) {
setAnalysisResult("AI 분석 중 오류가 발생했습니다. 설정을 확인해 주세요.");
} finally {
setIsAnalyzing(false);
}
};
const filteredNews = news.filter(n =>
n.title.toLowerCase().includes(filter.toLowerCase()) ||
n.description.toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="space-y-12 max-w-6xl mx-auto animate-in slide-in-from-right-4 duration-500 pb-20">
{/* 뉴스 스크랩 비활성 알림 */}
{!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>
<h5 className="font-bold text-amber-900 mb-2 text-lg"> </h5>
<p className="text-base text-amber-700"> . 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>
<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">
{analysisResult}
</div>
</div>
</div>
</div>
)}
{/* 툴바: 검색 및 실행 버튼 */}
<div className="flex flex-col md:flex-row gap-8 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} />
<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"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="flex gap-4 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`}
>
<Sparkles size={22} className={isAnalyzing ? 'animate-pulse' : ''} />
{isAnalyzing ? 'AI 분석 중...' : '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' : ''}`}
>
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* 뉴스 리스트 */}
<div className="space-y-8">
{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} />
</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>
<h3 className="text-2xl font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors">
{item.title}
</h3>
<p className="text-slate-500 text-base font-medium leading-relaxed line-clamp-2 opacity-80">
{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} />
</a>
<button className="text-slate-300 hover:text-amber-500 transition-colors">
<Bookmark size={22} />
</button>
</div>
</div>
</article>
))}
</div>
</div>
);
};
export default News;

264
pages/Settings.tsx Normal file
View File

@@ -0,0 +1,264 @@
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';
interface SettingsProps {
settings: ApiSettings;
onSave: (settings: ApiSettings) => void;
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave }) => {
const [formData, setFormData] = useState<ApiSettings>(settings);
const [isSaved, setIsSaved] = useState(false);
const [showAiModal, setShowAiModal] = useState(false);
const [editingAi, setEditingAi] = useState<Partial<AiConfig> | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
};
const toggleService = (field: keyof Pick<ApiSettings, 'useTelegram' | 'useNaverNews'>) => {
setFormData(prev => ({ ...prev, [field]: !prev[field] }));
};
const handleAddAi = () => {
setEditingAi({
id: 'ai_' + Date.now(),
name: '',
providerType: 'gemini',
modelName: 'gemini-3-flash-preview',
baseUrl: ''
});
setShowAiModal(true);
};
const handleSaveAi = () => {
if (!editingAi?.name) return;
const newConfigs = [...formData.aiConfigs];
const index = newConfigs.findIndex(c => c.id === editingAi.id);
if (index > -1) {
newConfigs[index] = editingAi as AiConfig;
} else {
newConfigs.push(editingAi as AiConfig);
}
setFormData({ ...formData, aiConfigs: newConfigs });
setShowAiModal(false);
setEditingAi(null);
};
const handleDeleteAi = (id: string) => {
setFormData({ ...formData, aiConfigs: formData.aiConfigs.filter(c => c.id !== id) });
};
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>
<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>
</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">
<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>
{/* 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>
</div>
</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">
<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>
)}
</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>
<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>
{/* 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
</h3>
<button onClick={() => setShowAiModal(false)} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X size={28} 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>
</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" />
)}
<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"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Settings;

93
pages/Stocks.tsx Normal file
View File

@@ -0,0 +1,93 @@
import React, { useState, useMemo } from 'react';
import { Search, Filter, ArrowUpDown, RotateCw } from 'lucide-react';
import { StockItem, MarketType, OrderType } from '../types';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { StockRow } from '../components/StockRow';
interface StocksProps {
marketMode: MarketType;
stocks: StockItem[];
onTrade: (stock: StockItem, type: OrderType) => void;
onAddToWatchlist: (stock: StockItem) => void;
watchlistCodes: string[];
onSync: () => Promise<void>;
onAddReservedOrder?: (order: any) => Promise<void>;
}
const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, watchlistCodes, onSync }) => {
const [search, setSearch] = useState('');
const [sortField, setSortField] = useState<keyof StockItem>('aiScoreBuy');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
const filteredStocks = useMemo(() => {
let result = stocks.filter(s =>
s.market === marketMode &&
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
);
result.sort((a, b) => {
const valA = a[sortField] || 0;
const valB = b[sortField] || 0;
return sortOrder === 'asc' ? (valA > valB ? 1 : -1) : (valB > valA ? 1 : -1);
});
return result;
}, [stocks, search, marketMode, sortField, sortOrder]);
const handleSort = (field: keyof StockItem) => {
if (sortField === field) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortOrder('desc'); }
};
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>
<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>
</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>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert("주문 예약됨")} />}
</div>
);
};
export default Stocks;

89
pages/Trading.tsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Search, ArrowRightLeft, ShieldCheck, Wallet, History, TrendingUp, Info } from 'lucide-react';
import { StockItem, OrderType, MarketType, TradeOrder } from '../types';
interface TradingProps {
marketMode: MarketType;
stocks: StockItem[];
onOrder: (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => void;
}
const Trading: React.FC<TradingProps> = ({ marketMode, stocks, onOrder }) => {
const [search, setSearch] = useState('');
const [selectedStock, setSelectedStock] = useState<StockItem | null>(null);
const [orderAmount, setOrderAmount] = useState<number>(1);
const [orderType, setOrderType] = useState<OrderType>(OrderType.BUY);
// 모드 변경 시 수량 초기화
useEffect(() => {
setOrderAmount(1);
}, [orderType]);
// 시장 변경 시 선택 해제
useEffect(() => {
setSelectedStock(null);
}, [marketMode]);
const filteredStocks = useMemo(() => {
return stocks.filter(s =>
s.market === marketMode &&
!s.isHidden &&
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
);
}, [stocks, search, marketMode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedStock) return;
const confirmMsg = `${selectedStock.name} 종목을 ${orderAmount}${orderType === OrderType.BUY ? '매수' : '매도'}하시겠습니까?`;
if(window.confirm(confirmMsg)) {
onOrder({
stockCode: selectedStock.code,
stockName: selectedStock.name,
type: orderType,
price: selectedStock.price,
quantity: orderAmount
});
}
};
const isBuyMode = orderType === OrderType.BUY;
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 animate-in fade-in duration-500 pb-20 max-w-[1600px] mx-auto">
{/* Left: Inventory List */}
<div className="lg:col-span-7 space-y-8">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
<div className="flex flex-col sm:flex-row gap-8 justify-between items-center mb-10">
<div>
<h3 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-3">
<Wallet size={24} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
</h3>
</div>
<div className="relative w-full sm:w-[350px]">
<Search size={20} className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" />
<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 transition-all"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4">
{filteredStocks.map(stock => (
<div
key={stock.code}
onClick={() => setSelectedStock(stock)}
className={`p-6 rounded-[2.5rem] border-2 transition-all cursor-pointer group flex items-center justify-between ${selectedStock?.code === stock.code ? 'border-blue-500 bg-blue-50/20 shadow-lg' : 'border-transparent bg-slate-50/60 hover:bg-white hover:border-slate-200'}`}
>
<div className="flex items-center gap-5">
<div className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center font-black text-white text-[12px] uppercase">
{stock.name[0]}
</div>
<div>
<h4 className="font-black text-slate

View File

@@ -0,0 +1,216 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Star, Plus, Trash2, Search, X, FolderPlus, ArrowRight, LayoutGrid, Globe, Edit3, Save, GripVertical } from 'lucide-react';
import { StockItem, WatchlistGroup, MarketType } from '../types';
import { DbService } from '../services/dbService';
import { StockRow } from '../components/StockRow';
interface WatchlistManagementProps {
marketMode: MarketType;
stocks: StockItem[];
groups: WatchlistGroup[];
onRefresh: () => void;
}
const WatchlistManagement: React.FC<WatchlistManagementProps> = ({ marketMode, stocks, groups, onRefresh }) => {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showAddGroupModal, setShowAddGroupModal] = useState(false);
const [showRenameModal, setShowRenameModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<WatchlistGroup | null>(null);
const [newGroupName, setNewGroupName] = useState('');
const [renameValue, setRenameValue] = useState('');
const [stockSearch, setStockSearch] = useState('');
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
const dbService = useMemo(() => new DbService(), []);
const filteredGroups = useMemo(() => {
return groups.filter(g => g.market === marketMode);
}, [groups, marketMode]);
useEffect(() => {
if (filteredGroups.length > 0) {
if (!selectedGroupId || !filteredGroups.find(g => g.id === selectedGroupId)) {
setSelectedGroupId(filteredGroups[0].id);
}
} else {
setSelectedGroupId(null);
}
}, [marketMode, filteredGroups, selectedGroupId]);
const selectedGroup = useMemo(() =>
filteredGroups.find(g => g.id === selectedGroupId),
[filteredGroups, selectedGroupId]
);
const filteredSearchStocks = useMemo(() => {
if (!stockSearch) return [];
return stocks.filter(s =>
s.market === marketMode &&
(s.name.includes(stockSearch) || s.code.toLowerCase().includes(stockSearch.toLowerCase())) &&
!(selectedGroup?.codes.includes(s.code))
).slice(0, 5);
}, [stocks, stockSearch, marketMode, selectedGroup]);
const handleAddGroup = async () => {
if (!newGroupName.trim()) return;
const newGroup: WatchlistGroup = {
id: 'grp_' + Date.now(),
name: newGroupName,
codes: [],
market: marketMode
};
await dbService.saveWatchlistGroup(newGroup);
setNewGroupName('');
setShowAddGroupModal(false);
onRefresh();
setSelectedGroupId(newGroup.id);
};
const handleRenameGroup = async () => {
if (!editingGroup || !renameValue.trim()) return;
const updated = { ...editingGroup, name: renameValue };
await dbService.updateWatchlistGroup(updated);
setEditingGroup(null);
setRenameValue('');
setShowRenameModal(false);
onRefresh();
};
const handleDeleteGroup = async (id: string) => {
if (!confirm('그룹을 삭제하시겠습니까?')) return;
await dbService.deleteWatchlistGroup(id);
onRefresh();
if (selectedGroupId === id) setSelectedGroupId(null);
};
const handleAddStockToGroup = async (code: string) => {
if (!selectedGroup) return;
const updated = { ...selectedGroup, codes: [...selectedGroup.codes, code] };
await dbService.updateWatchlistGroup(updated);
setStockSearch('');
onRefresh();
};
const handleRemoveStockFromGroup = async (code: string) => {
if (!selectedGroup) return;
const stock = stocks.find(s => s.code === code);
const stockName = stock ? stock.name : code;
if (!confirm(`'${stockName}' 종목을 그룹에서 삭제하시겠습니까?`)) return;
const updated = { ...selectedGroup, codes: selectedGroup.codes.filter(c => c !== code) };
await dbService.updateWatchlistGroup(updated);
onRefresh();
};
const onDragStart = (e: React.DragEvent, index: number) => {
setDraggedItemIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onDrop = async (e: React.DragEvent, targetIndex: number) => {
if (!selectedGroup || draggedItemIndex === null || draggedItemIndex === targetIndex) return;
const newCodes = [...selectedGroup.codes];
const [movedItem] = newCodes.splice(draggedItemIndex, 1);
newCodes.splice(targetIndex, 0, movedItem);
const updated = { ...selectedGroup, codes: newCodes };
await dbService.updateWatchlistGroup(updated);
setDraggedItemIndex(null);
onRefresh();
};
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 ? '국내' : '해외'}
</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">
{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 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>
</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>
</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]">
{selectedGroup ? (
<>
<div className="flex justify-between items-center mb-12">
<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>
</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)} />
{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]">
{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>
<Plus size={18} className="text-blue-600" />
</div>
))}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto pr-3 scrollbar-hide">
<table className="w-full text-left">
<tbody className="divide-y divide-slate-50">
{selectedGroup.codes.map((code, idx) => {
const stock = stocks.find(s => s.code === code);
if (!stock) return null;
return (
<StockRow
key={code}
stock={stock}
onTrade={() => {}}
onToggleWatchlist={() => handleRemoveStockFromGroup(code)}
isWatchlisted={true}
/>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full opacity-20"><Star size={80} strokeWidth={1} /><p className="text-lg font-black uppercase tracking-widest mt-6"> </p></div>
)}
</div>
</div>
{/* 모달은 기존 코드 유지 (생략 가능하나 유저 요청에 따라 전체 포함) */}
{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>
</div>
)}
</div>
);
};
export default WatchlistManagement;

65
services/aiService.ts Normal file
View File

@@ -0,0 +1,65 @@
import { GoogleGenAI } from "@google/genai";
import { AiConfig } from "../types";
export class AiService {
/**
* 뉴스 기사들을 바탕으로 시장 심리 및 인사이트 분석
*/
static async analyzeNewsSentiment(config: AiConfig, newsHeadlines: string[]): Promise<string> {
const prompt = `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인들을 분석하여 시장의 심리(상승/하락/중립)와 투자자가 주목해야 할 핵심 포인트 3가지를 한국어로 요약해 주세요.
뉴스 헤드라인:
${newsHeadlines.join('\n')}
`;
if (config.providerType === 'gemini') {
return this.callGemini(config.modelName, prompt);
} else {
return this.callOpenAiCompatible(config, prompt);
}
}
private static async callGemini(modelName: string, prompt: string): Promise<string> {
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const response = await ai.models.generateContent({
model: modelName || 'gemini-3-flash-preview',
contents: prompt,
});
return response.text || "분석 결과를 생성할 수 없습니다.";
} catch (error) {
console.error("Gemini 분석 오류:", error);
return `Gemini 분석 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}`;
}
}
private static async callOpenAiCompatible(config: AiConfig, prompt: string): Promise<string> {
if (!config.baseUrl) return "API 베이스 URL이 설정되지 않았습니다.";
try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Ollama나 로컬 엔진은 보통 키가 필요 없거나 커스텀하게 처리하므로 일단 비워둡니다.
},
body: JSON.stringify({
model: config.modelName,
messages: [{ role: 'user', content: prompt }],
temperature: 0.7
})
});
if (!response.ok) {
throw new Error(`HTTP 오류! 상태 코드: ${response.status}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || "분석 결과를 생성할 수 없습니다.";
} catch (error) {
console.error("OpenAI 호환 API 분석 오류:", error);
return `AI 엔진(${config.name}) 분석 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}`;
}
}
}

184
services/dbService.ts Normal file
View File

@@ -0,0 +1,184 @@
import { TradeOrder, OrderType, MarketType, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from '../types';
export interface HoldingItem {
code: string;
name: string;
avgPrice: number;
quantity: number;
market: MarketType;
}
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) : [];
}
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);
}
}
}
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
};
}
}

33
services/geminiService.ts Normal file
View File

@@ -0,0 +1,33 @@
import { GoogleGenAI } from "@google/genai";
export async function analyzeMarketSentiment(news: string[]) {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const prompt = `Based on the following news headlines, provide a concise market sentiment summary (Bullish/Bearish/Neutral) and 3 key takeaways for a stock trader. News: ${news.join('. ')}`;
try {
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
});
return response.text;
} catch (error) {
console.error("Gemini Error:", error);
return "AI insight currently unavailable.";
}
}
export async function getStockInsight(stockName: string, recentTrend: string) {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const prompt = `Analyze the stock ${stockName} which has been ${recentTrend}. Suggest a potential strategy for an automated trading bot (e.g., Trailing Stop percentage, Accumulation frequency).`;
try {
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview',
contents: prompt,
});
return response.text;
} catch (error) {
return "Detailed analysis unavailable at this moment.";
}
}

61
services/kisService.ts Normal file
View File

@@ -0,0 +1,61 @@
import { ApiSettings, MarketType, OrderType, StockItem } from '../types';
/**
* Korea Investment & Securities (KIS) Open API Service
*/
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;
}
async inquirePrice(code: string): Promise<number> {
const basePrice = code.startsWith('0') ? 70000 : 150;
return Math.floor(basePrice + Math.random() * 5000);
}
/**
* 서버로부터 전체 종목 마스터 리스트를 가져오는 Mock 함수
*/
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'] },
];
}
}
async orderCash(code: string, type: OrderType, quantity: number, price: number = 0) {
return { success: true, orderId: "ORD-" + Math.random().toString(36).substr(2, 9) };
}
async orderOverseas(code: string, type: OrderType, quantity: number, price: number) {
return { success: true, orderId: "OS-ORD-" + Math.random().toString(36).substr(2, 9) };
}
async inquireBalance() {
return { output1: [], output2: { tot_evlu_amt: "124500000", nass_amt: "45800000" } };
}
}

38
services/naverService.ts Normal file
View File

@@ -0,0 +1,38 @@
import { ApiSettings, NewsItem } from '../types';
export class NaverService {
private settings: ApiSettings;
constructor(settings: ApiSettings) {
this.settings = settings;
}
async fetchNews(query: string = "주식"): Promise<NewsItem[]> {
if (!this.settings.useNaverNews || !this.settings.naverClientId) {
console.log("Naver News: Service disabled or configuration missing.");
return [];
}
console.log(`Naver News: Fetching for "${query}"...`);
// 실제 API 연동 시 백엔드 프록시 호출 권장
// const response = await fetch(`/api/naver-news?query=${encodeURIComponent(query)}`);
// return await response.json();
return [
{
title: `[속보] ${query} 관련 글로벌 거시 경제 영향 분석 보고서`,
description: "현재 시장 상황에서 해당 섹터의 성장이 두드러지고 있으며 기관 투자자들의 매수세가 이어지고 있습니다.",
link: "https://news.naver.com",
pubDate: new Date().toISOString()
},
{
title: `${query} 실시간 수급 현황 및 외인 매수세 유입`,
description: "외국인 투자자들이 3거래일 연속 순매수를 기록하며 주가 상승을 견인하고 있습니다.",
link: "https://news.naver.com",
pubDate: new Date().toISOString()
}
];
}
}

View File

@@ -0,0 +1,49 @@
import { ApiSettings } from '../types';
/**
* Telegram Notification Client Service
* 프론트엔드에서는 백엔드에 알림 요청만 보내며,
* 실제 봇 호출 및 메시지 구성은 서버(백엔드)에서 안전하게 처리합니다.
*/
export class TelegramService {
private settings: ApiSettings;
constructor(settings: ApiSettings) {
this.settings = settings;
}
/**
* 백엔드 알림 API를 호출합니다.
* 백엔드 엔진은 사용자가 브라우저를 닫아도 독립적으로 이 로직을 수행합니다.
*/
async sendMessage(message: string): Promise<boolean> {
if (!this.settings.useTelegram) return false;
console.log(`[Frontend] 요청된 알림 메시지: ${message}`);
try {
// 실제 환경에서는 백엔드 API 호출:
// const response = await fetch('/api/notify/send', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ message })
// });
// return response.ok;
return true; // 데모용 성공 반환
} catch (e) {
console.error("Telegram notification request failed", e);
return false;
}
}
/**
* 설정 페이지에서 알림 연결 테스트용
*/
async testConnection(token: string, chatId: string): Promise<{success: boolean, msg: string}> {
console.log("Telegram: Testing connection through backend...");
// 백엔드 /api/notify/test 호출 로직
return { success: true, msg: "테스트 메시지가 발송되었습니다." };
}
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

131
types.ts Normal file
View File

@@ -0,0 +1,131 @@
export enum MarketType {
DOMESTIC = 'Domestic',
OVERSEAS = 'Overseas'
}
export enum OrderType {
BUY = 'BUY',
SELL = 'SELL'
}
export type AiProviderType = 'gemini' | 'openai-compatible';
export interface AiConfig {
id: string;
name: string;
providerType: AiProviderType;
modelName: string;
baseUrl?: string;
}
export interface ApiSettings {
appKey: string;
appSecret: string;
accountNumber: string;
useTelegram: boolean;
telegramToken: string;
telegramChatId: string;
useNaverNews: boolean;
naverClientId: string;
naverClientSecret: string;
aiConfigs: AiConfig[];
preferredNewsAiId?: string;
preferredStockAiId?: string;
preferredNewsJudgementAiId?: string;
preferredAutoBuyAiId?: string;
preferredAutoSellAiId?: string;
}
export interface StockTick {
code: string;
price: number;
timestamp: number;
}
export interface StockItem {
code: string;
name: string;
price: number;
change: number;
changePercent: number;
market: MarketType;
volume: number;
tradingValue?: number; // 거래대금
buyRatio?: number; // 매수 비율 (0-100)
sellRatio?: number; // 매도 비율 (0-100)
per?: number;
pbr?: number;
roe?: number;
marketCap?: number;
dividendYield?: number;
aiScoreBuy: number;
aiScoreSell: number;
themes?: string[];
// --- New Fields ---
memo?: string; // 사용자 메모
aiAnalysis?: string; // AI 분석 리포트 텍스트
isHidden?: boolean; // 종목 숨김 여부
}
export interface TradeOrder {
id: string;
stockCode: string;
stockName: string;
type: OrderType;
price: number;
quantity: number;
status: 'PENDING' | 'COMPLETED' | 'CANCELLED';
timestamp: Date;
}
export interface ReservedOrder {
id: string;
stockCode: string;
stockName: string;
type: OrderType;
quantity: number;
monitoringType: 'PRICE_TRIGGER' | 'TRAILING_STOP';
triggerPrice: number;
trailingType: 'PERCENT' | 'AMOUNT';
trailingValue: number;
useStopLoss?: boolean;
stopLossType?: 'PERCENT' | 'AMOUNT';
stopLossValue?: number;
sellAll?: boolean;
highestPrice?: number;
lowestPrice?: number;
market: MarketType;
status: 'WAITING' | 'MONITORING' | 'TRIGGERED' | 'CANCELLED';
createdAt: Date;
expiryDate: Date;
}
export interface AutoTradeConfig {
id: string;
stockCode?: string;
stockName: string;
groupId?: string;
type: 'ACCUMULATION' | 'TRAILING_STOP';
quantity: number;
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY';
specificDay?: number;
executionTime: string;
trailingPercent?: number;
active: boolean;
market: MarketType;
}
export interface WatchlistGroup {
id: string;
name: string;
codes: string[];
market: MarketType;
}
export interface NewsItem {
title: string;
description: string;
link: string;
pubDate: string;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

167
한국투자증권(API)/.gitignore vendored Normal file
View File

@@ -0,0 +1,167 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
*.lock
*_logs/

View File

@@ -0,0 +1,77 @@
# Git 관련
.git
.gitignore
# Python 관련
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
# 개발 환경
.venv
venv/
ENV/
env/
.env
.env.*
!.env.live
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS 관련
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 프로젝트 특정
tmp/
logs/
*.tmp
*.log
# Docker 관련
Dockerfile
.dockerignore
docker-compose*.yml
# 문서
*.md
!README.md
# 테스트
test_*.py
*_test.py
tests/
# 기타
.env.example
env.example
*.csv
*.tmp

View File

@@ -0,0 +1,4 @@
MCP_TYPE=sse
MCP_HOST=0.0.0.0
MCP_PORT=3000
MCP_PATH=/sse

View File

@@ -0,0 +1,23 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.idea
.vscode
*.log
*_log/
*_logs/
tmp/
*.csv
!standalone_util/*.csv
*.tmp
*.db

View File

@@ -0,0 +1 @@
3.13

View File

@@ -0,0 +1,63 @@
# Python 3.13 slim 이미지 사용
FROM python:3.13-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치를 위한 uv 설치
RUN pip install uv
# pyproject.toml 복사 (uv.lock이 없을 수 있으므로)
COPY pyproject.toml ./
# uv.lock이 있으면 복사, 없으면 의존성만 설치
COPY uv.lock* ./
# 의존성 설치 (uv.lock이 있으면 frozen, 없으면 일반 설치)
RUN if [ -f uv.lock ]; then uv sync --frozen; else uv sync; fi
# 애플리케이션 코드 복사
COPY . .
# 환경변수 설정
ENV ENV=live
ENV PYTHONPATH=/app
# 포트 노출 (HTTP 서버용)
EXPOSE 3000
# 환경변수 정의 (런타임에 설정됨)
ENV KIS_APP_KEY=""
ENV KIS_APP_SECRET=""
ENV KIS_PAPER_APP_KEY=""
ENV KIS_PAPER_APP_SECRET=""
ENV KIS_HTS_ID=""
ENV KIS_ACCT_STOCK=""
ENV KIS_ACCT_FUTURE=""
ENV KIS_PAPER_STOCK=""
ENV KIS_PAPER_FUTURE=""
ENV KIS_PROD_TYPE=""
ENV KIS_URL_REST=""
ENV KIS_URL_REST_PAPER=""
ENV KIS_URL_WS=""
ENV KIS_URL_WS_PAPER=""
# 시작 스크립트 생성
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
echo "Starting KIS Trade MCP Server..."\n\
echo "Environment: $ENV"\n\
\n\
# MCP 서버 시작 (HTTP 모드)\n\
exec uv run python server.py\n\
' > /app/start.sh && chmod +x /app/start.sh
# 시작 스크립트 실행
CMD ["/app/start.sh"]

View File

@@ -0,0 +1,391 @@
# 중요 : MCP에 대한 내용을 완전히 숙지하신 뒤 사용해 주십시오.
# 이 프로그램을 실행하여 발생한 모든 책임은 사용자 본인에게 있습니다.
# 한국투자증권 OPEN API MCP 서버 - Docker 설치 가이드
한국투자증권의 다양한 금융 API를 Docker를 통해 Claude Desktop에서 쉽게 사용할 수 있도록 하는 설치 가이드입니다.
## 🚀 주요 기능
### 지원하는 API 카테고리
| 카테고리 | 개수 | 주요 기능 |
|---------|------|----------|
| 국내주식 | 74개 | 현재가, 호가, 차트, 잔고, 주문, 순위분석, 시세분석, 종목정보, 실시간시세 등 |
| 해외주식 | 34개 | 미국/아시아 주식 시세, 잔고, 주문, 체결내역, 거래량순위, 권리종합 등 |
| 국내선물옵션 | 20개 | 선물옵션 시세, 호가, 차트, 잔고, 주문, 야간거래, 실시간체결 등 |
| 해외선물옵션 | 19개 | 해외선물 시세, 주문내역, 증거금, 체결추이, 옵션호가 등 |
| 국내채권 | 14개 | 채권 시세, 호가, 발행정보, 잔고조회, 주문체결내역 등 |
| ETF/ETN | 2개 | NAV 비교추이, 현재가 등 |
| ELW | 1개 | ELW 거래량순위 |
**전체 API 총합계: 166개**
### 핵심 특징
- 🐳 **Docker 컨테이너화**: 완전 격리된 환경에서 안전한 실행
-**동적 코드 실행**: GitHub에서 실시간으로 API 코드를 다운로드하여 실행
- 🔧 **설정 기반**: JSON 파일로 API 설정 및 파라미터 관리
- 🛡️ **안전한 실행**: 격리된 임시 환경에서 코드 실행
- 🔍 **검증 기능**: API 상세 정보 조회로 파라미터 확인
- 🌍 **환경 지원**: 실전/모의 환경 구분 지원
- 🔐 **자동 설정**: 서버 시작 시 KIS 인증 설정 자동 생성
- 🖥️ **크로스 플랫폼**: Windows, macOS, Linux 모두 지원
## 📦 Docker 설치 및 설정
### 📋 Docker 설치
#### 🚀 빠른 설치 (권장)
**공식 Docker Desktop을 사용하세요:**
- [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop/)
- [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/)
- [Docker Engine for Linux](https://docs.docker.com/engine/install/)
#### 📋 OS별 간단 가이드
##### 🍎 **macOS**
```bash
# Homebrew 사용 (권장)
brew install --cask docker
# 또는 공식 인스톨러 다운로드
# https://www.docker.com/products/docker-desktop/
```
##### 🐧 **Linux (Ubuntu/Debian)**
```bash
# 공식 스크립트 사용 (권장)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 사용자를 docker 그룹에 추가
sudo usermod -aG docker $USER
```
##### 🪟 **Windows**
**⚠️ Windows는 추가 설정이 필요합니다:**
1. **시스템 요구사항 확인**
- Windows 10/11 Pro, Enterprise, Education
- WSL2 또는 Hyper-V 지원
2. **Docker Desktop 설치**
- [공식 사이트](https://www.docker.com/products/docker-desktop/)에서 다운로드
- 설치 중 "Use WSL 2" 옵션 선택 권장
3. **설치 후 확인**
```cmd
docker --version
docker run hello-world
```
**Windows 상세 설치 가이드**: [Docker 공식 문서](https://docs.docker.com/desktop/install/windows-install/) 참조
### 요구사항
- Docker 20.10+
- 한국투자증권 OPEN API 계정
### 📋 설치 및 설정 단계
#### **1단계: 프로젝트 클론**
```bash
# 프로젝트 클론
git clone https://github.com/koreainvestment/open-trading-api.git
cd "open-trading-api/MCP/Kis Trading MCP"
```
#### **2단계: 한국투자증권 API 정보 준비**
한국투자증권 개발자 센터에서 발급받은 정보를 준비하세요:
**필수 정보:**
- App Key (실전용)
- App Secret (실전용)
- 계좌 정보들
**선택 정보:**
- App Key (모의용)
- App Secret (모의용)
#### **3단계: Docker 이미지 빌드**
```bash
# Docker 이미지 빌드
docker build -t kis-trade-mcp .
# 또는 태그와 함께 빌드
docker build -t kis-trade-mcp:latest .
```
#### **4단계: Docker 컨테이너 실행**
**기본 실행:**
```bash
docker run -d \
--name kis-trade-mcp \
-p 3000:3000 \
-e KIS_APP_KEY="your_app_key" \
-e KIS_APP_SECRET="your_app_secret" \
-e KIS_PAPER_APP_KEY="your_paper_app_key" \
-e KIS_PAPER_APP_SECRET="your_paper_app_secret" \
-e KIS_HTS_ID="your_hts_id" \
-e KIS_ACCT_STOCK="12345678" \
-e KIS_ACCT_FUTURE="87654321" \
-e KIS_PAPER_STOCK="11111111" \
-e KIS_PAPER_FUTURE="22222222" \
-e KIS_PROD_TYPE="01" \
kis-trade-mcp
```
#### **5단계: 컨테이너 상태 확인**
```bash
# 컨테이너 상태 확인
docker ps
# 컨테이너 로그 확인
docker logs kis-trade-mcp
# 실시간 로그 확인
docker logs -f kis-trade-mcp
# HTTP 서버 접근 확인
curl http://localhost:3000/sse
```
#### **6단계: HTTP 서버 접근 확인**
컨테이너가 정상적으로 실행되면 HTTP 서버에 접근할 수 있습니다:
```bash
# 서버 상태 확인
curl http://localhost:3000/sse
# 또는 브라우저에서 접근
# http://localhost:3000/sse
```
### 🔗 Claude Desktop 연동 및 설정
#### 📝 Claude Desktop 설정
Claude Desktop 설정 파일에 MCP 서버를 등록하세요.
**설정 파일 위치:**
- **Linux/Mac**: `~/.claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
#### 🐧 Linux/Mac 설정
```json
{
"mcpServers": {
"kis-trade-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/sse"]
}
}
}
```
#### 🪟 Windows 설정
```json
{
"mcpServers": {
"kis-trade-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/sse"]
}
}
}
```
## 💬 사용법 및 질문 예시
### 기본 사용 패턴
1. **종목 검색**: 먼저 종목 코드를 찾습니다
2. **API 확인**: 사용할 API의 파라미터를 확인합니다
3. **API 호출**: 필요한 파라미터와 함께 API를 호출합니다
### 질문 예시
**주식 시세 조회:**
- "삼성전자(005930) 현재가 시세 조회해줘"
- "애플(AAPL) 해외주식 현재 체결가 알려줘"
- "삼성전자 종목코드 찾아줘"
**잔고 및 계좌:**
- "국내주식 잔고 조회해줘"
- "해외주식 잔고 확인해줘"
**채권 및 기타:**
- "국고채 3년물 호가 정보 조회하는 방법"
- "KODEX 200 ETF(069500) NAV 비교추이 확인해줘"
**모의투자:**
- "모의투자로 삼성전자 현재가 조회해줘"
- "데모 환경에서 애플 주식 시세 알려줘"
## 🔧 컨테이너 관리
### 컨테이너 제어
```bash
# 컨테이너 시작
docker start kis-trade-mcp
# 컨테이너 중지
docker stop kis-trade-mcp
# 컨테이너 재시작
docker restart kis-trade-mcp
# 컨테이너 제거
docker stop kis-trade-mcp
docker rm kis-trade-mcp
```
### 컨테이너 내부 접근
```bash
# 컨테이너 내부 bash 실행
docker exec -it kis-trade-mcp /bin/bash
# 환경변수 확인
docker exec kis-trade-mcp env | grep KIS
# 로그 실시간 확인
docker logs -f kis-trade-mcp
```
## 💡 사용 팁
1. **환경변수 관리**: 민감한 정보는 환경변수로 안전하게 관리
2. **로그 모니터링**: `docker logs -f`로 실시간 로그 확인
3. **리소스 모니터링**: `docker stats`로 컨테이너 리소스 사용량 확인
4. **백업 전략**: 중요한 설정 파일은 정기적으로 백업
5. **보안 관리**: 컨테이너 내부에서만 민감한 정보 처리
## 📝 로깅 및 모니터링
### 로그 확인
```bash
# 전체 로그
docker logs kis-trade-mcp
# 최근 100줄
docker logs --tail 100 kis-trade-mcp
# 실시간 로그
docker logs -f kis-trade-mcp
# 특정 시간대 로그
docker logs --since "2024-01-01T00:00:00" kis-trade-mcp
```
### 성능 모니터링
```bash
# 컨테이너 리소스 사용량
docker stats kis-trade-mcp
# 컨테이너 상세 정보
docker inspect kis-trade-mcp
# 프로세스 확인
docker exec kis-trade-mcp ps aux
```
## 🛠️ 문제 해결
### 일반적인 문제들
**1. 컨테이너가 시작되지 않는 경우**
```bash
# 로그 확인
docker logs kis-trade-mcp
# 환경변수 확인
docker exec kis-trade-mcp env | grep KIS
```
**2. 환경변수 누락**
```bash
# 컨테이너 재시작
docker restart kis-trade-mcp
# 환경변수 다시 설정하여 실행
docker run -d --name kis-trade-mcp -e KIS_APP_KEY="..." ...
```
**3. 메모리 부족**
```bash
# 메모리 사용량 확인
docker stats kis-trade-mcp
# 컨테이너 리소스 제한 설정
docker run -d --name kis-trade-mcp --memory="2g" --cpus="2" ...
```
**4. 네트워크 연결 문제**
```bash
# 포트 확인
docker port kis-trade-mcp
# 네트워크 연결 테스트
curl http://localhost:3000/sse
```
### 디버깅 명령어
```bash
# 컨테이너 내부 bash 접근
docker exec -it kis-trade-mcp /bin/bash
# Python 환경 확인
docker exec kis-trade-mcp uv run python -c "import sys; print(sys.path)"
# 의존성 확인
docker exec kis-trade-mcp uv pip list
# 네트워크 연결 확인
docker exec kis-trade-mcp ping github.com
```
## 🔒 보안 고려사항
- **컨테이너 격리**: 호스트 시스템과 완전히 분리된 환경에서 실행
- **환경변수 보안**: 민감한 정보는 환경변수로 전달, 코드에 하드코딩 금지
- **임시 파일 정리**: 각 API 호출 후 임시 파일 자동 삭제
- **네트워크 격리**: 필요한 경우 Docker 네트워크를 통한 추가 격리
## ⚠️ 제한사항 및 성능
### API 호출 제한
- 한국투자증권 API의 호출 제한을 준수해야 합니다
- 분당 호출 횟수 제한이 있을 수 있습니다
- 실전 환경에서는 더욱 신중한 사용이 필요합니다
### Docker 성능 고려사항
- **컨테이너 오버헤드**: Docker 컨테이너 실행으로 인한 약간의 성능 오버헤드
- **메모리 사용량**: SQLAlchemy와 pandas가 메모리를 많이 사용할 수 있음
- **네트워크 지연**: GitHub 다운로드 시 네트워크 지연 발생
### 다단계 타임아웃 설정
- 파일 다운로드: 30초 (GitHub 응답 대기)
- 코드 실행: 15초 (API 호출 및 결과 처리)
- 컨테이너 시작: 60초 (의존성 설치 및 초기화)
## 🔗 관련 링크
- [한국투자증권 개발자 센터](https://apiportal.koreainvestment.com/)
- [한국투자증권 OPEN API GitHub](https://github.com/koreainvestment/open-trading-api)
- [MCP (Model Context Protocol) 공식 문서](https://modelcontextprotocol.io/)
- [Docker 공식 문서](https://docs.docker.com/)
---
**주의**: 이 프로젝트는 한국투자증권 OPEN API를 사용합니다. 사용 전 반드시 [한국투자증권 개발자 센터](https://apiportal.koreainvestment.com/)에서 API 이용약관을 확인하시기 바랍니다.
## ⚠️ 투자 책임 고지
**본 MCP 서버는 한국투자증권 OPEN API를 활용한 도구일 뿐이며, 투자 조언이나 권유를 제공하지 않습니다.**
- 📈 **투자 결정 책임**: 모든 투자 결정과 그에 따른 손익은 전적으로 투자자 본인의 책임입니다
- 💰 **손실 위험**: 주식, 선물, 옵션 등 모든 금융상품 투자에는 원금 손실 위험이 있습니다
- 🔍 **정보 검증**: API를 통해 제공되는 정보의 정확성은 한국투자증권에 의존하며, 투자 전 반드시 정보를 검증하시기 바랍니다
- 🧠 **신중한 판단**: 충분한 조사와 신중한 판단 없이 투자하지 마시기 바랍니다
- 🎯 **모의투자 권장**: 실전 투자 전 반드시 모의투자를 통해 충분히 연습하시기 바랍니다
**투자는 본인의 판단과 책임 하에 이루어져야 하며, 본 도구 사용으로 인한 어떠한 손실에 대해서도 개발자는 책임지지 않습니다.**

View File

@@ -0,0 +1,105 @@
{
"tool_info": {
"introduce": "한국투자증권의 auth OPEN API를 활용합니다.",
"introduce_append": "",
"examples": [
{
"api_type": "auth_token",
"params": {
"grant_type": "client_credentials",
"env_dv": "real"
}
},
{
"api_type": "auth_ws_token",
"params": {
"grant_type": "client_credentials",
"env_dv": "real"
}
}
]
},
"apis": {
"auth_token": {
"category": "OAuth인증",
"name": "접근토큰발급(P)",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/auth/auth_token",
"method": "auth_token",
"api_path": "/oauth2/tokenP",
"params": {
"grant_type": {
"name": "grant_type",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 권한부여 Type (client_credentials)"
},
"appkey": {
"name": "appkey",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)"
},
"appsecret": {
"name": "appsecret",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)"
},
"env_dv": {
"name": "env_dv",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 환경구분 (real: 실전, demo: 모의)"
}
}
},
"auth_ws_token": {
"category": "OAuth인증",
"name": "실시간 (웹소켓) 접속키 발급",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/auth/auth_ws_token",
"method": "auth_ws_token",
"api_path": "/oauth2/Approval",
"params": {
"grant_type": {
"name": "grant_type",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 권한부여 Type (client_credentials)"
},
"appkey": {
"name": "appkey",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)"
},
"appsecret": {
"name": "appsecret",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)"
},
"env_dv": {
"name": "env_dv",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 환경구분 (real: 실전, demo: 모의)"
},
"token": {
"name": "token",
"type": "str",
"required": false,
"default_value": "",
"description": "접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
{
"tool_info": {
"introduce": "한국투자증권의 ELW OPEN API를 활용합니다.",
"introduce_append": "이 도구는 ELW 관련 시세 정보를 제공합니다.",
"examples": [
{
"api_type": "volume_rank",
"params": {
"fid_cond_mrkt_div_code": "W",
"fid_cond_scr_div_code": "20278",
"fid_unas_input_iscd": "000000",
"fid_input_iscd": "00000",
"fid_input_rmnn_dynu_1": "",
"fid_div_cls_code": "0",
"fid_input_price_1": "1000",
"fid_input_price_2": "5000",
"fid_input_vol_1": "100",
"fid_input_vol_2": "1000",
"fid_input_date_1": "20230101",
"fid_rank_sort_cls_code": "0",
"fid_blng_cls_code": "0",
"fid_input_iscd_2": "0000",
"fid_input_date_2": ""
}
}
]
},
"apis": {
"volume_rank": {
"category": "[국내주식] ELW시세",
"name": "ELW 거래량순위",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/elw/volume_rank",
"method": "volume_rank",
"api_path": "/uapi/elw/v1/ranking/volume-rank",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "조건시장분류코드"
},
"fid_cond_scr_div_code": {
"name": "fid_cond_scr_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "조건화면분류코드"
},
"fid_unas_input_iscd": {
"name": "fid_unas_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "기초자산입력종목코드"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "발행사"
},
"fid_input_rmnn_dynu_1": {
"name": "fid_input_rmnn_dynu_1",
"type": "str",
"required": true,
"default_value": null,
"description": "입력잔존일수"
},
"fid_div_cls_code": {
"name": "fid_div_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "콜풋구분코드"
},
"fid_input_price_1": {
"name": "fid_input_price_1",
"type": "str",
"required": true,
"default_value": null,
"description": "가격(이상)"
},
"fid_input_price_2": {
"name": "fid_input_price_2",
"type": "str",
"required": true,
"default_value": null,
"description": "가격(이하)"
},
"fid_input_vol_1": {
"name": "fid_input_vol_1",
"type": "str",
"required": true,
"default_value": null,
"description": "거래량(이상)"
},
"fid_input_vol_2": {
"name": "fid_input_vol_2",
"type": "str",
"required": true,
"default_value": null,
"description": "거래량(이하)"
},
"fid_input_date_1": {
"name": "fid_input_date_1",
"type": "str",
"required": true,
"default_value": null,
"description": "조회기준일"
},
"fid_rank_sort_cls_code": {
"name": "fid_rank_sort_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "순위정렬구분코드"
},
"fid_blng_cls_code": {
"name": "fid_blng_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "소속구분코드"
},
"fid_input_iscd_2": {
"name": "fid_input_iscd_2",
"type": "str",
"required": true,
"default_value": null,
"description": "LP발행사"
},
"fid_input_date_2": {
"name": "fid_input_date_2",
"type": "str",
"required": true,
"default_value": null,
"description": "만기일-최종거래일조회"
},
"tr_cont": {
"name": "tr_cont",
"type": "str",
"required": true,
"default_value": "",
"description": "연속 거래 여부"
},
"dataframe": {
"name": "dataframe",
"type": "pd.DataFrame",
"required": false,
"default_value": null,
"description": "누적 데이터프레임"
},
"depth": {
"name": "depth",
"type": "int",
"required": true,
"default_value": 0,
"description": "현재 재귀 깊이"
},
"max_depth": {
"name": "max_depth",
"type": "int",
"required": true,
"default_value": 10,
"description": "최대 재귀 깊이 (기본값: 10)"
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
{
"tool_info": {
"introduce": "한국투자증권의 ETF/ETN OPEN API를 활용합니다.",
"introduce_append": "",
"examples": [
{
"api_type": "inquire_price",
"params": {
"fid_cond_mrkt_div_code": "J",
"fid_input_iscd": "123456"
}
},
{
"api_type": "nav_comparison_trend",
"params": {
"fid_cond_mrkt_div_code": "J",
"fid_input_iscd": "069500"
}
}
]
},
"apis": {
"inquire_price": {
"category": "[국내주식] 기본시세",
"name": "ETF/ETN 현재가",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/etfetn/inquire_price",
"method": "inquire_price",
"api_path": "/uapi/etfetn/v1/quotations/inquire-price",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 입력 종목코드 (ex. 123456)"
}
}
},
"nav_comparison_trend": {
"category": "[국내주식] 기본시세",
"name": "NAV 비교추이(종목)",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/etfetn/nav_comparison_trend",
"method": "nav_comparison_trend",
"api_path": "/uapi/etfetn/v1/quotations/nav-comparison-trend",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 조건 시장 분류 코드 (ex. J)"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 입력 종목코드"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
from .base import Base
from .updated import Updated
# 툴별 마스터 모델들
from .domestic_stock import DomesticStockMaster
from .overseas_stock import OverseasStockMaster
from .domestic_futureoption import DomesticFutureoptionMaster
from .overseas_futureoption import OverseasFutureoptionMaster
from .domestic_bond import DomesticBondMaster
from .etfetn import EtfetnMaster
from .elw import ElwMaster
from .auth import AuthMaster
# 모든 모델들을 리스트로 제공
ALL_MODELS = [
# 툴별 마스터 모델들
DomesticStockMaster,
OverseasStockMaster,
DomesticFutureoptionMaster,
OverseasFutureoptionMaster,
DomesticBondMaster,
EtfetnMaster,
ElwMaster,
AuthMaster,
# 업데이트 상태 추적
Updated
]

View File

@@ -0,0 +1,11 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class AuthMaster(Base):
"""인증 마스터"""
__tablename__ = 'auth_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import declarative_base
# SQLAlchemy Base 클래스
Base = declarative_base()

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticBondMaster(Base):
"""국내채권 마스터"""
__tablename__ = 'domestic_bond_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticFutureoptionMaster(Base):
"""국내선물옵션 마스터"""
__tablename__ = 'domestic_futureoption_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticStockMaster(Base):
"""국내주식 마스터"""
__tablename__ = 'domestic_stock_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class ElwMaster(Base):
"""ELW 마스터"""
__tablename__ = 'elw_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class EtfetnMaster(Base):
"""ETF/ETN 마스터"""
__tablename__ = 'etfetn_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class OverseasFutureoptionMaster(Base):
"""해외선물옵션 마스터"""
__tablename__ = 'overseas_futureoption_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class OverseasStockMaster(Base):
"""해외주식 마스터"""
__tablename__ = 'overseas_stock_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, DateTime
from .base import Base
class Updated(Base):
"""마스터파일 업데이트 상태 추적 테이블"""
__tablename__ = 'updated'
id = Column(Integer, primary_key=True)
tool_name = Column(String(50), nullable=False, unique=True, index=True) # 툴명 (예: domestic_stock, overseas_stock)
updated_at = Column(DateTime, nullable=False) # 마지막 업데이트 시간
def __repr__(self):
return f"<Updated(tool_name='{self.tool_name}', updated_at='{self.updated_at}')>"

View File

@@ -0,0 +1,3 @@
from .decorator import singleton
from .plugin import setup_environment, EnvironmentConfig, setup_kis_config, MasterFileManager
from .middleware import EnvironmentMiddleware

View File

@@ -0,0 +1,32 @@
import threading
def singleton(cls):
"""
클래스 형태를 유지하는 싱글톤 데코레이터.
- 여러 번 호출해도 동일 인스턴스 반환
- __init__은 최초 1회만 실행
- 스레드-세이프
"""
cls.__singleton_lock__ = getattr(cls, "__singleton_lock__", threading.Lock())
cls.__singleton_instance__ = getattr(cls, "__singleton_instance__", None)
orig_init = cls.__init__
def __init__(self, *args, **kwargs):
# 최초 1회만 실제 __init__ 수행
if getattr(self, "__initialized__", False):
return
orig_init(self, *args, **kwargs)
setattr(self, "__initialized__", True)
def __new__(inner_cls, *args, **kwargs):
if inner_cls.__singleton_instance__ is None:
with inner_cls.__singleton_lock__:
if inner_cls.__singleton_instance__ is None:
inner_cls.__singleton_instance__ = object.__new__(inner_cls)
return inner_cls.__singleton_instance__
cls.__init__ = __init__
cls.__new__ = staticmethod(__new__)
return cls

View File

@@ -0,0 +1,6 @@
# 기존 컨텍스트 상수들
CONTEXT_REQUEST_ID = "context_request_id"
CONTEXT_ENVIRONMENT = "context_environment"
CONTEXT_STARTED_AT = "context_started_at"
CONTEXT_ENDED_AT = "context_ended_at"
CONTEXT_ELAPSED_SECONDS = "context_elapsed_seconds"

View File

@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
import time
from fastmcp.server.middleware import Middleware, MiddlewareContext
import module.factory as factory
# 기본 미들웨어
class EnvironmentMiddleware(Middleware):
def __init__(self, environment):
self.environment = environment
async def on_call_tool(self, context: MiddlewareContext, call_next):
ctx = context.fastmcp_context
# time counter start
t0 = time.perf_counter()
# started_at
started_dt = datetime.now()
ctx.set_state(factory.CONTEXT_STARTED_AT, started_dt.strftime("%Y-%m-%d %H:%M:%S"))
# request id
request_id = uuid.uuid4().hex
ctx.set_state(factory.CONTEXT_REQUEST_ID, request_id)
# context setup
ctx.set_state(factory.CONTEXT_ENVIRONMENT, self.environment)
try:
result = await call_next(context)
return result
except Exception as e:
raise e
finally:
# ended at
ended_at = datetime.now()
ctx.set_state(factory.CONTEXT_ENDED_AT, ended_at.strftime("%Y-%m-%d %H:%M:%S"))
# time counter end
elapsed_sec = time.perf_counter() - t0
ctx.set_state(factory.CONTEXT_ELAPSED_SECONDS, round(elapsed_sec, 2))

View File

@@ -0,0 +1,4 @@
from .kis import setup_kis_config
from .environment import setup_environment, EnvironmentConfig
from .master_file import MasterFileManager
from .database import DatabaseEngine, Database

View File

@@ -0,0 +1,540 @@
from typing import Any, Dict, List, Optional, Type, Union
from sqlalchemy import create_engine, Engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.exc import SQLAlchemyError
import logging
import os
from datetime import datetime
logger = logging.getLogger(__name__)
class DatabaseEngine:
"""1 SQLite 파일 : 1 엔진을 관리하는 클래스"""
def __init__(self, db_path: str, models: List[Type]):
"""
Args:
db_path: SQLite 파일 경로
models: 해당 데이터베이스에 포함될 모델 클래스들의 리스트
"""
self.db_path = db_path
self.models = models
self.engine: Optional[Engine] = None
self.SessionLocal: Optional[sessionmaker] = None
self._initialize_engine()
def _initialize_engine(self):
"""데이터베이스 엔진 초기화"""
try:
# SQLite 연결 문자열 생성
db_url = f"sqlite:///{self.db_path}"
# 엔진 생성
self.engine = create_engine(
db_url,
echo=False, # SQL 로그 출력 여부
pool_pre_ping=True, # 연결 상태 확인
connect_args={"check_same_thread": False} # SQLite 멀티스레드 지원
)
# 세션 팩토리 생성
self.SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=self.engine
)
# 테이블 생성
self._create_tables()
logger.info(f"Database engine initialized: {self.db_path}")
except Exception as e:
logger.error(f"Failed to initialize database engine {self.db_path}: {e}")
raise
def _create_tables(self):
"""모든 모델의 테이블 생성"""
try:
from model.base import Base
Base.metadata.create_all(bind=self.engine)
logger.info(f"Tables created for {self.db_path}")
except Exception as e:
logger.error(f"Failed to create tables for {self.db_path}: {e}")
raise
def get_session(self) -> Session:
"""새로운 데이터베이스 세션 반환"""
if not self.SessionLocal:
raise RuntimeError("Database engine not initialized")
return self.SessionLocal()
def insert(self, model_instance: Any) -> Any:
"""
모델 인스턴스를 데이터베이스에 삽입
Args:
model_instance: 삽입할 모델 인스턴스
Returns:
삽입된 모델 인스턴스 (ID 포함)
"""
session = self.get_session()
try:
session.add(model_instance)
session.commit()
session.refresh(model_instance)
logger.info(f"Inserted record: {type(model_instance).__name__}")
return model_instance
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to insert record: {e}")
raise
finally:
session.close()
def update(self, model_class: Type, record_id: int, update_data: Dict[str, Any]) -> Optional[Any]:
"""
ID로 레코드 업데이트
Args:
model_class: 업데이트할 모델 클래스
record_id: 업데이트할 레코드의 ID
update_data: 업데이트할 필드와 값의 딕셔너리
Returns:
업데이트된 모델 인스턴스 또는 None
"""
session = self.get_session()
try:
# 레코드 조회
record = session.query(model_class).filter(model_class.id == record_id).first()
if not record:
logger.warning(f"Record not found: {model_class.__name__} ID {record_id}")
return None
# 필드 업데이트
for field, value in update_data.items():
if hasattr(record, field):
setattr(record, field, value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
session.commit()
session.refresh(record)
logger.info(f"Updated record: {model_class.__name__} ID {record_id}")
return record
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to update record: {e}")
raise
finally:
session.close()
def delete(self, model_class: Type, record_id: int) -> bool:
"""
ID로 레코드 삭제
Args:
model_class: 삭제할 모델 클래스
record_id: 삭제할 레코드의 ID
Returns:
삭제 성공 여부
"""
session = self.get_session()
try:
# 레코드 조회
record = session.query(model_class).filter(model_class.id == record_id).first()
if not record:
logger.warning(f"Record not found: {model_class.__name__} ID {record_id}")
return False
session.delete(record)
session.commit()
logger.info(f"Deleted record: {model_class.__name__} ID {record_id}")
return True
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to delete record: {e}")
raise
finally:
session.close()
def list(self, model_class: Type, filters: Optional[Dict[str, Any]] = None,
limit: Optional[int] = None, offset: Optional[int] = None) -> List[Any]:
"""
조건에 맞는 레코드 목록 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
limit: 조회할 최대 개수
offset: 건너뛸 개수
Returns:
조회된 레코드 리스트
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
if filters:
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
# 페이징 적용
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
results = query.all()
logger.info(f"Listed {len(results)} records: {model_class.__name__}")
return results
except SQLAlchemyError as e:
logger.error(f"Failed to list records: {e}")
raise
finally:
session.close()
def get(self, model_class: Type, filters: Dict[str, Any]) -> Optional[Any]:
"""
조건에 맞는 첫 번째 레코드 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
Returns:
조회된 레코드 또는 None
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
result = query.first()
if result:
logger.info(f"Found record: {model_class.__name__}")
else:
logger.info(f"No record found: {model_class.__name__}")
return result
except SQLAlchemyError as e:
logger.error(f"Failed to get record: {e}")
raise
finally:
session.close()
def count(self, model_class: Type, filters: Optional[Dict[str, Any]] = None) -> int:
"""
조건에 맞는 레코드 개수 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
Returns:
레코드 개수
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
if filters:
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
count = query.count()
logger.info(f"Counted {count} records: {model_class.__name__}")
return count
except SQLAlchemyError as e:
logger.error(f"Failed to count records: {e}")
raise
finally:
session.close()
def bulk_replace_master_data(self, model_class: Type, data_list: List[Dict], master_name: str) -> int:
"""
마스터 데이터 추가 (INSERT만) - 카테고리 레벨에서 이미 삭제됨
Args:
model_class: 마스터 데이터 모델 클래스
data_list: 삽입할 데이터 리스트 (딕셔너리 리스트)
master_name: 마스터파일명 (로깅용)
Returns:
삽입된 레코드 수
"""
session = self.get_session()
try:
# 새 데이터 배치 삽입 (카테고리 레벨에서 이미 삭제되었으므로 INSERT만)
if data_list:
# 배치 크기 설정 (메모리 효율성을 위해 1000개씩)
batch_size = 1000
total_inserted = 0
for i in range(0, len(data_list), batch_size):
batch = data_list[i:i + batch_size]
batch_objects = []
for data in batch:
# 모든 값을 문자열로 강제 변환 (SQLAlchemy 타입 추론 방지)
str_data = {key: str(value) if value is not None else None for key, value in data.items()}
# 딕셔너리를 모델 인스턴스로 변환
obj = model_class(**str_data)
batch_objects.append(obj)
# 배치 삽입
session.bulk_save_objects(batch_objects)
total_inserted += len(batch_objects)
# 중간 커밋 (메모리 절약)
if i + batch_size < len(data_list):
session.commit()
logger.info(f"Inserted batch {i//batch_size + 1}: {len(batch_objects)} records")
# 최종 커밋
session.commit()
logger.info(f"Bulk replace completed: {total_inserted} records inserted into {model_class.__name__}")
return total_inserted
else:
logger.warning(f"No data to insert for {master_name}")
return 0
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to bulk replace master data for {master_name}: {e}")
raise
finally:
session.close()
def update_master_timestamp(self, tool_name: str, record_count: int = None) -> bool:
"""
마스터파일 업데이트 시간 기록
Args:
tool_name: 툴명 (예: domestic_stock, overseas_stock)
record_count: 레코드 수 (선택사항)
Returns:
업데이트 성공 여부
"""
from model.updated import Updated
session = self.get_session()
try:
# 기존 레코드 조회
existing_record = session.query(Updated).filter(Updated.tool_name == tool_name).first()
if existing_record:
# 기존 레코드 업데이트
existing_record.updated_at = datetime.now()
logger.info(f"Updated timestamp for {tool_name}")
else:
# 새 레코드 생성
new_record = Updated(
tool_name=tool_name,
updated_at=datetime.now()
)
session.add(new_record)
logger.info(f"Created new timestamp record for {tool_name}")
session.commit()
return True
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to update master timestamp for {tool_name}: {e}")
return False
finally:
session.close()
def get_master_update_time(self, tool_name: str) -> Optional[datetime]:
"""
마스터파일 마지막 업데이트 시간 조회
Args:
tool_name: 툴명 (예: domestic_stock, overseas_stock)
Returns:
마지막 업데이트 시간 또는 None
"""
from model.updated import Updated
session = self.get_session()
try:
record = session.query(Updated).filter(Updated.tool_name == tool_name).first()
if record:
logger.info(f"Found update time for {tool_name}: {record.updated_at}")
return record.updated_at
else:
logger.info(f"No update record found for {tool_name}")
return None
except SQLAlchemyError as e:
logger.error(f"Failed to get master update time for {tool_name}: {e}")
return None
finally:
session.close()
def is_master_data_available(self, model_class: Type) -> bool:
"""
마스터 데이터 존재 여부 확인
Args:
model_class: 마스터 데이터 모델 클래스
Returns:
데이터 존재 여부
"""
session = self.get_session()
try:
count = session.query(model_class).count()
available = count > 0
logger.info(f"Master data availability check for {model_class.__name__}: {available} ({count} records)")
return available
except SQLAlchemyError as e:
logger.error(f"Failed to check master data availability for {model_class.__name__}: {e}")
return False
finally:
session.close()
def close(self):
"""데이터베이스 연결 종료"""
if self.engine:
self.engine.dispose()
logger.info(f"Database engine closed: {self.db_path}")
def __repr__(self):
return f"DatabaseEngine(db_path='{self.db_path}', models={len(self.models)})"
class Database:
"""데이터베이스 엔진들을 관리하는 Singleton 클래스"""
_instance: Optional['Database'] = None
_initialized: bool = False
def __new__(cls) -> 'Database':
"""Singleton 패턴 구현"""
if cls._instance is None:
cls._instance = super(Database, cls).__new__(cls)
return cls._instance
def __init__(self):
"""초기화 (한 번만 실행)"""
if not self._initialized:
self.dbs: Dict[str, DatabaseEngine] = {}
self._initialized = True
logger.info("Database singleton instance created")
def new(self, db_dir: str = "configs/master") -> None:
"""
마스터 데이터베이스 엔진 초기화
Args:
db_dir: 데이터베이스 파일이 저장될 디렉토리
"""
try:
# 데이터베이스 디렉토리 생성
os.makedirs(db_dir, exist_ok=True)
# 하나의 통합 마스터 데이터베이스 엔진 생성
self._create_master_engine(db_dir)
logger.info(f"Master database engine initialized: '{db_dir}/master.db'")
logger.info(f"Available databases: {list(self.dbs.keys())}")
except Exception as e:
logger.error(f"Failed to initialize master database: {e}")
raise
def _create_master_engine(self, db_dir: str):
"""통합 마스터 데이터베이스 엔진 생성"""
from model import ALL_MODELS
db_path = os.path.join(db_dir, "master.db")
self.dbs["master"] = DatabaseEngine(db_path, ALL_MODELS)
logger.info("Created master database engine with all models")
def get_by_name(self, name: str) -> DatabaseEngine:
"""
이름으로 데이터베이스 엔진 조회
Args:
name: 데이터베이스 이름
Returns:
DatabaseEngine 인스턴스
Raises:
KeyError: 해당 이름의 데이터베이스가 없는 경우
"""
if name not in self.dbs:
available_dbs = list(self.dbs.keys())
raise KeyError(f"Database '{name}' not found. Available databases: {available_dbs}")
return self.dbs[name]
def get_available_databases(self) -> List[str]:
"""사용 가능한 데이터베이스 이름 목록 반환"""
return list(self.dbs.keys())
def is_initialized(self) -> bool:
"""데이터베이스가 초기화되었는지 확인"""
return len(self.dbs) > 0
def ensure_initialized(self, db_dir: str = "configs/master") -> bool:
"""데이터베이스가 초기화되지 않은 경우에만 초기화"""
if not self.is_initialized():
try:
self.new(db_dir)
logger.info("Database initialized on demand")
return True
except Exception as e:
logger.error(f"Failed to initialize database on demand: {e}")
return False
return True
def close_all(self):
"""모든 데이터베이스 연결 종료"""
for name, engine in self.dbs.items():
try:
engine.close()
logger.info(f"Closed database: {name}")
except Exception as e:
logger.error(f"Failed to close database {name}: {e}")
self.dbs.clear()
logger.info("All database connections closed")
def __repr__(self):
return f"Database(engines={len(self.dbs)}, names={list(self.dbs.keys())})"
def __del__(self):
"""소멸자 - 모든 연결 정리"""
if hasattr(self, 'dbs') and self.dbs:
self.close_all()

View File

@@ -0,0 +1,43 @@
import logging
import os
from collections import namedtuple
from dotenv import load_dotenv
# Environment 설정을 위한 namedtuple 정의
EnvironmentConfig = namedtuple('EnvironmentConfig', [
'mcp_type', 'mcp_host', 'mcp_port', 'mcp_path'
])
def setup_environment(env: str) -> EnvironmentConfig:
# get api env
if not env:
logging.error("Environment variable ENV not defined")
exit(1)
# load .env
dotenv_path = os.path.join(os.getcwd(), f".env.{env}")
if not os.path.isfile(dotenv_path):
logging.error(f"Environment variable file .env.{env} not found")
exit(1)
load_dotenv(dotenv_path=dotenv_path)
# return environment
# MCP_TYPE 검증 및 기본값 설정
mcp_type = os.getenv("MCP_TYPE", "stdio")
if mcp_type not in ['stdio', 'sse', 'streamable-http']:
logging.warning(f"Invalid MCP_TYPE: {mcp_type}, using default: stdio")
mcp_type = "stdio"
# MCP_PORT가 빈 문자열이면 기본값 사용
mcp_port_str = os.getenv("MCP_PORT", "8000")
mcp_port = int(mcp_port_str) if mcp_port_str.strip() else 8000
return EnvironmentConfig(
mcp_type=mcp_type,
mcp_host=os.getenv("MCP_HOST", "localhost"),
mcp_port=mcp_port,
mcp_path=os.getenv("MCP_PATH", "/mcp")
)

View File

@@ -0,0 +1,163 @@
import logging
import os
import requests
import yaml
def setup_kis_config(force_update=False):
"""KIS 설정 파일 자동 생성 (템플릿 다운로드 + 환경변수로 값 덮어쓰기)
Args:
force_update (bool): True면 기존 파일이 있어도 강제로 덮어쓰기
"""
# kis_auth.py와 동일한 경로 생성 방식 사용
kis_config_dir = os.path.join(os.path.expanduser("~"), "KIS", "config")
# KIS 설정 디렉토리 생성
os.makedirs(kis_config_dir, exist_ok=True)
# 설정 파일 경로
kis_config_path = os.path.join(kis_config_dir, "kis_devlp.yaml")
# 기존 파일 존재 확인
if os.path.exists(kis_config_path) and not force_update:
logging.info(f"✅ KIS 설정 파일이 이미 존재합니다: {kis_config_path}")
logging.info("기존 파일을 사용합니다. 강제 업데이트가 필요한 경우 force_update=True 옵션을 사용하세요.")
return True
# 1. kis_devlp.yaml 템플릿 다운로드
template_url = "https://raw.githubusercontent.com/koreainvestment/open-trading-api/refs/heads/main/kis_devlp.yaml"
try:
logging.info("KIS 설정 템플릿을 다운로드 중...")
response = requests.get(template_url, timeout=30)
response.raise_for_status()
# 원본 템플릿 텍스트 보존
template_content = response.text
logging.info("✅ KIS 설정 템플릿 다운로드 완료")
except Exception as e:
logging.error(f"❌ KIS 설정 템플릿 다운로드 실패: {e}")
return False
# 2. 환경변수로 민감한 정보 덮어쓰기
# 필수값 (누락 시 경고)
app_key = os.getenv("KIS_APP_KEY")
app_secret = os.getenv("KIS_APP_SECRET")
if not app_key or not app_secret:
logging.warning("⚠️ 필수 환경변수가 설정되지 않았습니다:")
if not app_key:
logging.warning(" - KIS_APP_KEY")
if not app_secret:
logging.warning(" - KIS_APP_SECRET")
logging.warning("실제 거래 API 사용이 불가능할 수 있습니다.")
# 선택적 값들 (누락 시 빈값 또는 기본값)
paper_app_key = os.getenv("KIS_PAPER_APP_KEY", "")
paper_app_secret = os.getenv("KIS_PAPER_APP_SECRET", "")
hts_id = os.getenv("KIS_HTS_ID", "")
acct_stock = os.getenv("KIS_ACCT_STOCK", "")
acct_future = os.getenv("KIS_ACCT_FUTURE", "")
paper_stock = os.getenv("KIS_PAPER_STOCK", "")
paper_future = os.getenv("KIS_PAPER_FUTURE", "")
prod_type = os.getenv("KIS_PROD_TYPE", "01") # 기본값: 종합계좌
url_rest = os.getenv("KIS_URL_REST", "")
url_rest_paper = os.getenv("KIS_URL_REST_PAPER", "")
url_ws = os.getenv("KIS_URL_WS", "")
url_ws_paper = os.getenv("KIS_URL_WS_PAPER", "")
# 3. YAML 파싱하여 값 업데이트
try:
# YAML 파싱 (주석 보존을 위해 ruamel.yaml 사용하거나, 간단히 pyyaml 사용)
config = yaml.safe_load(template_content)
# 환경변수 값이 있으면 해당 필드만 업데이트
if app_key:
config['my_app'] = app_key
logging.info(f"✅ 실전 App Key 설정 완료")
if app_secret:
config['my_sec'] = app_secret
logging.info(f"✅ 실전 App Secret 설정 완료")
if paper_app_key:
config['paper_app'] = paper_app_key
logging.info(f"✅ 모의 App Key 설정 완료")
if paper_app_secret:
config['paper_sec'] = paper_app_secret
logging.info(f"✅ 모의 App Secret 설정 완료")
if hts_id:
config['my_htsid'] = hts_id
logging.info(f"✅ HTS ID 설정 완료: {hts_id}")
else:
logging.warning("⚠️ KIS_HTS_ID 환경변수가 설정되지 않았습니다.")
if acct_stock:
config['my_acct_stock'] = acct_stock
logging.info(f"✅ 증권계좌 설정 완료")
if acct_future:
config['my_acct_future'] = acct_future
logging.info(f"✅ 선물옵션계좌 설정 완료")
if paper_stock:
config['my_paper_stock'] = paper_stock
logging.info(f"✅ 모의 증권계좌 설정 완료")
if paper_future:
config['my_paper_future'] = paper_future
logging.info(f"✅ 모의 선물옵션계좌 설정 완료")
if prod_type != "01": # 기본값이 아닌 경우만 업데이트
config['my_prod'] = prod_type
logging.info(f"✅ 계좌상품코드 설정 완료: {prod_type}")
# URL 설정 업데이트 (직접 필드)
if url_rest:
config['prod'] = url_rest
logging.info(f"✅ 실전 REST URL 설정 완료")
if url_rest_paper:
config['vps'] = url_rest_paper
logging.info(f"✅ 모의 REST URL 설정 완료")
if url_ws:
config['ops'] = url_ws
logging.info(f"✅ 실전 WebSocket URL 설정 완료")
if url_ws_paper:
config['vops'] = url_ws_paper
logging.info(f"✅ 모의 WebSocket URL 설정 완료")
# YAML로 다시 변환
updated_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
except yaml.YAMLError as e:
logging.error(f"❌ YAML 파싱 오류: {e}")
logging.info("문자열 치환 방식으로 대체합니다...")
# 실패 시 기존 문자열 치환 방식 사용
updated_content = template_content
if app_key:
updated_content = updated_content.replace('my_app: "앱키"', f'my_app: "{app_key}"')
if app_secret:
updated_content = updated_content.replace('my_sec: "앱키 시크릿"', f'my_sec: "{app_secret}"')
if hts_id:
updated_content = updated_content.replace('my_htsid: "사용자 HTS ID"', f'my_htsid: "{hts_id}"')
# ... 나머지 기존 로직
# 4. 수정된 설정을 파일로 저장 (원본 구조 보존)
try:
with open(kis_config_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
logging.info(f"✅ KIS 설정 파일이 생성되었습니다: {kis_config_path}")
# 설정 요약 출력
logging.info("📋 KIS 설정 요약:")
logging.info(f" - 실제 거래: {'' if app_key and app_secret else ''}")
logging.info(f" - 모의 거래: {'' if paper_app_key and paper_app_secret else ''}")
logging.info(f" - 계좌번호: {'' if any([acct_stock, acct_future, paper_stock, paper_future]) else ''}")
logging.info(f" - URL 설정: {'' if any([url_rest, url_rest_paper, url_ws, url_ws_paper]) else ''}")
return True
except Exception as e:
logging.error(f"❌ KIS 설정 파일 생성 실패: {e}")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
[project]
name = "korea-investment-api-mcp"
version = "0.1.0"
description = "한국투자증권 OPEN API MCP 서버 - LLM이 쉽게 사용할 수 있는 금융 API 래퍼"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastmcp>=2.11.2",
"pandas>=2.3.1",
"pycryptodome>=3.23.0",
"pydantic>=2.11.7",
"python-dotenv>=1.1.1",
"requests>=2.32.4",
"websockets>=15.0.1",
"PyYAML>=6.0.1",
"sqlalchemy>=2.0.43",
]

View File

@@ -0,0 +1,105 @@
import logging
import os
import platform
import sys
from fastmcp import FastMCP
from module import setup_environment, EnvironmentMiddleware, EnvironmentConfig, setup_kis_config
from module.plugin import Database
from tools import *
logging.basicConfig(
level=logging.DEBUG, # DEBUG 이상 (DEBUG, INFO, WARNING...) 모두 출력
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
def main():
env = os.getenv("ENV", None)
# 환경 설정
logging.info("setup environment ...")
env_config = setup_environment(env=env)
# KIS 설정 자동 생성 (템플릿 다운로드 + 값 덮어쓰기)
logging.info("setup KIS configuration ...")
if not setup_kis_config(force_update=env == "live"):
logging.warning("KIS 설정 파일 생성에 실패했습니다. 수동으로 설정해주세요.")
# 데이터베이스 초기화
logging.info("setup database ...")
db = None
db_exists = False
try:
db = Database()
db_exists = os.path.exists(os.path.join("configs/master", "master.db"))
db.new(db_dir="configs/master")
logging.info(f"📁 Available databases: {db.get_available_databases()}")
except Exception as e:
logging.error(f"❌ Database initialization failed: {e}")
sys.exit(1)
# MCP 서버 설정
mcp_server = FastMCP(
name="My Awesome MCP Server",
instructions="This is a server for a specific project.",
version="1.0.0",
stateless_http=False,
)
# middleware
mcp_server.add_middleware(EnvironmentMiddleware(environment=env_config))
# tools 등록
DomesticStockTool().register(mcp_server=mcp_server)
DomesticFutureOptionTool().register(mcp_server=mcp_server)
DomesticBondTool().register(mcp_server=mcp_server)
OverseasStockTool().register(mcp_server=mcp_server)
OverseasFutureOptionTool().register(mcp_server=mcp_server)
ElwTool().register(mcp_server=mcp_server)
EtfEtnTool().register(mcp_server=mcp_server)
AuthTool().register(mcp_server=mcp_server)
# MCP 서버 실행 방식 결정
logging.info(f"🚀 MCP 서버를 {env_config.mcp_type} 모드로 시작합니다...")
if env_config.mcp_type == "stdio":
# stdio 모드 (기본값)
logging.info("📝 stdio 모드로 MCP 서버를 시작합니다.")
mcp_server.run(
transport="stdio"
)
elif env_config.mcp_type == "sse":
# HTTP 모드로 실행
logging.info(f"🌐 Server Sent Event 모드로 MCP 서버를 시작합니다: {env_config.mcp_host}:{env_config.mcp_port}")
mcp_server.run(
transport="sse",
host=env_config.mcp_host,
port=env_config.mcp_port,
path=env_config.mcp_path,
)
elif env_config.mcp_type == "streamable-http":
# HTTP 모드로 실행
logging.info(f"🌐 HTTP 모드로 MCP 서버를 시작합니다: {env_config.mcp_host}:{env_config.mcp_port}")
mcp_server.run(
transport="streamable-http",
host=env_config.mcp_host,
port=env_config.mcp_port,
path=env_config.mcp_path,
)
else:
logging.error(f"❌ 지원하지 않는 MCP_TYPE: {env_config.mcp_type}")
sys.exit(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logging.info("🛑 Application interrupted by user (Ctrl+C)")

View File

@@ -0,0 +1,8 @@
from .domestic_bond import DomesticBondTool
from .domestic_futureoption import DomesticFutureOptionTool
from .domestic_stock import DomesticStockTool
from .elw import ElwTool
from .etfetn import EtfEtnTool
from .overseas_futureoption import OverseasFutureOptionTool
from .overseas_stock import OverseasStockTool
from .auth import AuthTool

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class AuthTool(BaseTool):
@property
def tool_name(self) -> str:
return "auth"

View File

@@ -0,0 +1,818 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, List
import json
import os
import time
import shutil
import subprocess
import requests
from fastmcp import FastMCP, Context
from module.plugin import MasterFileManager
from module.plugin.database import Database
import module.factory as factory
class ApiExecutor:
"""API 실행 클래스 - GitHub에서 코드를 다운로드하고 실행"""
def __init__(self, tool_name: str):
"""초기화"""
self.tool_name = tool_name
self.temp_base_dir = "./tmp"
# 절대 경로로 venv python 설정
self.venv_python = os.path.join(os.getcwd(), ".venv", "bin", "python")
# temp 디렉토리 생성
os.makedirs(self.temp_base_dir, exist_ok=True)
def _create_temp_directory(self, request_id: str) -> str:
"""임시 디렉토리 생성"""
timestamp = int(time.time() * 1_000_000) # 나노초 단위
temp_dir = os.path.join(self.temp_base_dir, f"{timestamp}_{request_id}")
os.makedirs(temp_dir, exist_ok=True)
return temp_dir
@classmethod
def _download_file(cls, url: str, file_path: str) -> bool:
"""파일 다운로드"""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
with open(file_path, 'w', encoding='utf-8') as f:
f.write(response.text)
return True
except Exception as e:
print(f"파일 다운로드 실패: {url}, 오류: {str(e)}")
return False
def _download_kis_auth(self, temp_dir: str) -> bool:
"""kis_auth.py 다운로드"""
kis_auth_url = "https://raw.githubusercontent.com/koreainvestment/open-trading-api/main/examples_llm/kis_auth.py"
kis_auth_path = os.path.join(temp_dir, "kis_auth.py")
return self._download_file(kis_auth_url, kis_auth_path)
def _download_api_code(self, github_url: str, temp_dir: str, api_type: str) -> str:
"""API 코드 다운로드"""
# GitHub URL을 raw URL로 변환하고 api_type/api_type.py를 붙여서 실제 파일 경로 생성
raw_url = github_url.replace('/tree/', '/').replace('github.com', 'raw.githubusercontent.com')
full_url = f"{raw_url}/{api_type}.py"
api_code_path = os.path.join(temp_dir, "api_code.py")
if self._download_file(full_url, api_code_path):
return api_code_path
else:
raise Exception(f"API 코드 다운로드 실패: {full_url}")
@classmethod
def _extract_trenv_params_from_example(cls, api_code_content: str) -> Dict[str, str]:
"""예제 파일에서 trenv 사용 패턴 완전 추출"""
import re
# 🎯 완전 자동화: param_name=xxx.my_attr 패턴 찾기 (변수명 무관)
trenv_mapping_pattern = r'(\w+)=\w*\.(my_\w+)'
matches = re.findall(trenv_mapping_pattern, api_code_content)
dynamic_mappings = {}
discovered_mappings = []
for param_name, trenv_attr in matches:
# 발견된 매핑을 그대로 사용 (완전 자동화!)
trenv_value = f'ka._TRENV.{trenv_attr}'
# 소문자 버전 (함수 파라미터)
dynamic_mappings[param_name] = trenv_value
# 대문자 버전 (API 파라미터)
dynamic_mappings[param_name.upper()] = trenv_value
discovered_mappings.append(f"{param_name}=xxx.{trenv_attr}")
if discovered_mappings:
print(f"[🎯자동발견] {len(discovered_mappings)}개 매핑: {', '.join(discovered_mappings)}")
print(f"[🎯자동생성] {len(dynamic_mappings)}개 파라미터: {list(dynamic_mappings.keys())}")
else:
print("[🎯자동발견] .my_xxx 패턴 없음 - 조회성 API로 추정")
return dynamic_mappings
@classmethod
def _modify_api_code(cls, api_code_path: str, params: Dict[str, Any], api_type: str) -> str:
"""API 코드 수정 (파라미터 적용)"""
try:
import re
with open(api_code_path, 'r', encoding='utf-8') as f:
code = f.read()
# 1. sys.path.extend 관련 코드 제거
code = re.sub(r"sys\.path\.extend\(\[.*?\]\)", "", code, flags=re.DOTALL)
code = re.sub(r"import sys\n", "", code) # import sys도 제거
# 2. 코드에서 함수명과 시그니처 추출
function_match = re.search(r'def\s+(\w+)\s*\((.*?)\):', code, re.DOTALL)
if not function_match:
raise Exception("코드에서 함수를 찾을 수 없습니다.")
function_name = function_match.group(1)
function_params = function_match.group(2)
# 3. 함수가 max_depth 파라미터를 받는지 확인
has_max_depth = 'max_depth' in function_params
# 4. 파라미터 조정
adjusted_params = params.copy()
# max_depth 파라미터 처리
if has_max_depth:
# 함수가 max_depth를 받는 경우에만 처리
if 'max_depth' not in adjusted_params:
adjusted_params['max_depth'] = 1
print(f"[기본값] {function_name} 함수에 max_depth=1 설정")
else:
print(f"[사용자 설정] {function_name} 함수에 max_depth={adjusted_params['max_depth']} 사용")
else:
# 함수가 max_depth를 받지 않는 경우 제거
if 'max_depth' in adjusted_params:
del adjusted_params['max_depth']
print(f"[제거] {function_name} 함수는 max_depth 파라미터를 지원하지 않아 제거함")
# 🆕 동적으로 trenv 패턴 추출
dynamic_mappings = cls._extract_trenv_params_from_example(code)
# 기본 매핑과 동적 매핑 결합
account_mappings = {
'cano': 'ka._TRENV.my_acct', # 종합계좌번호 (변수 접근)
'acnt_prdt_cd': 'ka._TRENV.my_prod', # 계좌상품코드 (변수 접근)
'my_htsid': 'ka._TRENV.my_htsid', # HTS ID (변수 접근)
'user_id': 'ka._TRENV.my_htsid', # domestic_stock에서 발견된 변형
**dynamic_mappings # 동적으로 발견된 매핑 추가
}
for param_name, correct_value in account_mappings.items():
if param_name in function_params:
if param_name in adjusted_params:
original_value = adjusted_params[param_name]
adjusted_params[param_name] = correct_value
print(f"[보안강제] {function_name} 함수의 {param_name}='{original_value}'{correct_value} (LLM값 무시)")
else:
adjusted_params[param_name] = correct_value
print(f"[자동설정] {function_name} 함수에 {param_name}={correct_value} 설정")
# 거래소ID구분코드 처리 (API 타입 기반 추론)
if 'excg_id_dvsn_cd' in function_params and 'excg_id_dvsn_cd' not in adjusted_params:
if api_type.startswith('domestic'):
adjusted_params['excg_id_dvsn_cd'] = '"KRX"'
print(f"[추론] 국내 API({api_type})로 판단하여 excg_id_dvsn_cd='KRX' 설정")
else:
print(f"[경고] {api_type} API에서 excg_id_dvsn_cd 파라미터가 필요합니다. (예: NASD, NYSE, KRX)")
# overseas_stock 등은 사용자가 명시적으로 제공해야 함
# 5. 함수 호출 코드 생성 (ka.auth() - env_dv에 따라 분기)
# env_dv 값에 따른 인증 방식 결정
env_dv = params.get('env_dv', 'real')
if env_dv == 'demo':
auth_code = 'ka.auth("vps")'
print(f"[모의투자] {function_name} 함수에 ka.auth(\"vps\") 적용")
else:
auth_code = 'ka.auth()'
print(f"[실전투자] {function_name} 함수에 ka.auth() 적용")
call_code = f"""
# API 함수 호출
if __name__ == "__main__":
try:
# 인증 초기화 (env_dv={env_dv})
{auth_code}
result = {function_name}({", ".join([f"{k}={v if isinstance(v, str) and v.startswith('ka._TRENV.') else repr(v)}" for k, v in adjusted_params.items()])})
except TypeError as e:
# 🚨 핵심 오류 메시지만 출력
print(f"❌ TypeError: {{str(e)}}")
print()
# 파라미터 오류 처리 - LLM 교육용 메시지
if 'stock_name' in {repr(list(params.keys()))}:
print("💡 해결방법: find_stock_code로 종목을 검색하세요.")
else:
print("💡 해결방법: find_api_detail로 API 상세 정보를 확인하세요")
import sys
sys.exit(1)
try:
# N개 튜플 반환 함수 처리 (예: inquire_balance는 (df1, df2) 반환)
if isinstance(result, tuple):
# 튜플인 경우 - N개의 DataFrame 처리
output = {{}}
for i, item in enumerate(result):
if hasattr(item, 'to_dict'):
# DataFrame인 경우
output[f"output{{i+1}}"] = item.to_dict('records') if not item.empty else []
else:
# 일반 객체인 경우
output[f"output{{i+1}}"] = str(item)
import json
print(json.dumps(output, ensure_ascii=False, indent=2))
elif hasattr(result, 'empty') and not result.empty:
print(result.to_json(orient='records', force_ascii=False))
elif isinstance(result, dict):
import json
print(json.dumps(result, ensure_ascii=False))
elif isinstance(result, (list, tuple)):
import json
print(json.dumps(result, ensure_ascii=False))
else:
print(str(result))
except Exception as e:
print(f"오류 발생: {{str(e)}}")
"""
# 6. 코드 끝에 함수 호출 추가
modified_code = code + call_code
# 7. 수정된 코드 저장
with open(api_code_path, 'w', encoding='utf-8') as f:
f.write(modified_code)
return api_code_path
except Exception as e:
raise Exception(f"코드 수정 실패: {str(e)}")
def _execute_code(self, temp_dir: str, timeout: int = 15) -> Dict[str, Any]:
"""코드 실행"""
try:
# 실행할 파일 경로 (상대 경로로 변경)
api_code_path = "api_code.py"
# subprocess로 코드 실행
result = subprocess.run(
[self.venv_python, api_code_path],
cwd=temp_dir,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode == 0:
# 성공 시 stdout을 결과로 반환
return {
"success": True,
"output": result.stdout,
"error": result.stderr
}
else:
# 실패 시 stderr와 stdout 모두 확인
error_message = result.stderr if result.stderr else result.stdout
return {
"success": False,
"output": result.stdout,
"error": error_message
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"실행 시간 초과 ({timeout}초)"
}
except Exception as e:
return {
"success": False,
"error": f"실행 중 오류: {str(e)}"
}
def _cleanup_temp_directory(self, temp_dir: str):
"""임시 디렉토리 정리"""
try:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
print(f"임시 디렉토리 정리 실패: {temp_dir}, 오류: {str(e)}")
async def execute_api(self, ctx: Context, api_type: str, params: Dict[str, Any], github_url: str) -> Dict[str, Any]:
"""API 실행 메인 함수"""
temp_dir = None
start_time = time.time()
try:
await ctx.info(f"API 실행 시작: {api_type}")
# 1. 임시 디렉토리 생성
# FastMCP Context에서 request_id 안전하게 가져오기
try:
request_id = ctx.get_state(factory.CONTEXT_REQUEST_ID)
except:
request_id = "unknown"
temp_dir = self._create_temp_directory(request_id)
# 2. kis_auth.py 다운로드
if not self._download_kis_auth(temp_dir):
raise Exception("kis_auth.py 다운로드 실패")
# 3. API 코드 다운로드
api_code_path = self._download_api_code(github_url, temp_dir, api_type)
# 4. 코드 수정
self._modify_api_code(api_code_path, params, api_type)
# 5. 코드 실행
execution_result = self._execute_code(temp_dir)
# 6. 실행 시간 계산
execution_time = time.time() - start_time
# 7. 결과 반환
result = {
"success": execution_result["success"],
"api_type": api_type,
"params": params,
"message": f"{self.tool_name} API 호출 완료",
"execution_time": f"{execution_time:.2f}s",
"temp_dir": temp_dir,
"venv_used": True,
"cleanup_success": True
}
if execution_result["success"]:
result["data"] = execution_result["output"]
else:
result["error"] = execution_result["error"]
return result
except Exception as e:
await ctx.error(f"API 실행 중 오류: {str(e)}")
return {
"success": False,
"api_type": api_type,
"params": params,
"error": str(e),
"execution_time": f"{time.time() - start_time:.2f}s",
"temp_dir": temp_dir,
"venv_used": True,
"cleanup_success": False
}
finally:
# 8. 임시 디렉토리 정리
if temp_dir:
self._cleanup_temp_directory(temp_dir)
class BaseTool(ABC):
"""MCP 도구 기본 클래스"""
def __init__(self):
"""도구 초기화"""
self._load_config()
self.api_executor = ApiExecutor(self.tool_name)
self.master_file_manager = MasterFileManager(self.tool_name)
self.db = Database()
# ========== Abstract Properties ==========
@property
@abstractmethod
def tool_name(self) -> str:
"""도구 이름 (하위 클래스에서 구현 필수)"""
pass
# ========== Public Properties ==========
@property
def description(self) -> str:
"""도구 설명 (분류.json에서 동적 생성)"""
return self._generate_description()
@property
def config_file(self) -> str:
"""JSON 설정 파일 경로 (tool_name 기반 자동 생성)"""
return f"./configs/{self.tool_name}.json"
# ========== Public Methods ==========
def register(self, mcp_server: FastMCP) -> None:
"""MCP 서버에 도구 등록"""
mcp_server.tool(
self._run,
name=self.tool_name,
description=self.description,
)
# ========== Protected Methods ==========
def _load_config(self) -> None:
"""JSON 설정 파일 로드"""
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
self.config = json.load(f)
except FileNotFoundError:
# 임시로 빈 설정으로 초기화
self.config = {"apis": {}}
def _generate_description(self) -> str:
"""분류.json에서 도구 설명 동적 생성"""
try:
config_json_path = f"./configs/{self.tool_name}.json"
with open(config_json_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
tool_info = config_data.get("tool_info")
apis = config_data.get("apis", {})
if not tool_info:
return f"{self.tool_name} 도구의 tool_info가 없습니다."
# description 문자열 구성
lines = [tool_info.get("introduce", "")]
# introduce_append가 있으면 추가
introduce_append = tool_info.get("introduce_append", "").strip()
if introduce_append:
lines.append(introduce_append)
lines.append("") # 빈 줄
lines.append("[지원 기능]")
# API 목록 추가
for api_type, api_info in apis.items():
lines.append(f"- {api_info['name']} (api_type: \"{api_type}\")")
lines.append("") # 빈 줄
# 개선된 구조 적용
lines.append("📋 사용 방법:")
lines.append("1. find_api_detail로 API 상세 정보를 확인하세요")
lines.append("2. api_type을 선택하고 params에 필요한 파라미터를 입력하세요")
lines.append("3. 종목명으로 검색할 경우: stock_name='종목명' 파라미터를 사용하세요")
lines.append("4. 모의투자 시에는 env_dv='demo'를 추가하세요")
lines.append("")
lines.append("🔧 특별한 api_type 및 예시:")
lines.append(f"- find_stock_code (종목번호 검색) : {self.tool_name}({{ \"api_type\": \"find_stock_code\", \"params\": {{ \"stock_name\": \"삼성전자\" }} }})")
lines.append(f"- find_api_detail (API 정보 조회) : {self.tool_name}({{ \"api_type\": \"find_api_detail\", \"params\": {{ \"api_type\": \"inquire_price\" }} }})")
lines.append("")
lines.append("🔍 종목명 사용: stock_name=\"삼성전자\" → 자동으로 종목번호 변환하여 실행")
lines.append(f"{self.tool_name}({{ \"api_type\": \"inquire_price\", \"params\": {{ \"stock_name\": \"삼성전자\" }} }})")
lines.append("")
lines.append("💡 주요 파라미터:")
if self.tool_name.startswith('domestic'):
lines.append("- 시장코드(fid_cond_mrkt_div_code)='J'(KRX)/'NX'(넥스트레이드)/'UN'(통합)")
lines.append("- 매매구분(ord_dv)='buy'(매수)/'sell'(매도)")
lines.append("- 실전모의구분(env_dv)='real'(실전)/'demo'(모의)")
lines.append("")
lines.append("⚠️ 중요: API 호출 시 필수 주의사항")
lines.append("**API 실행 전 반드시 API 상세 문서의 파라미터를 확인하세요. Request Query Params와 Request Body 입력 시 추측이나 과거 실행 값 사용 금지, 확인된 API 상세 문서의 값을 사용하세요.**")
lines.append("**파라미터 description에 '공란'이 있는 경우 기본적으로 빈값으로 처리하되, 아닌 경우에는 값을 넣어도 됩니다.**")
lines.append("**🎯 모의투자 관련: 사용자가 '모의', '모의투자', '데모', '테스트' 등의 용어를 언급하거나 모의투자 관련 요청을 할 경우, 반드시 env_dv 파라미터를 'demo'로 설정하여 API를 호출해야 합니다. env_dv 파라미터가 있는 모든 API에서 모의투자 시에는 env_dv='demo', 실전투자 시에는 env_dv='real'을 사용합니다. 기본값은 'real'이므로 모의투자 요청 시 반드시 env_dv='demo'를 명시적으로 설정해주세요.**")
lines.append("")
lines.append("🔒 자동 처리되는 파라미터 (제공하지 마세요):")
lines.append("• cano (계좌번호), acnt_prdt_cd (계좌상품코드), my_htsid (HTS ID) - 시스템 자동 설정")
if self.tool_name.startswith('domestic'):
lines.append("• excg_id_dvsn_cd (거래소구분) - 국내 API는 자동으로 KRX 설정")
lines.append("")
# 예시 호출 추가
examples = tool_info.get("examples", [])
if examples:
lines.append("💻 예시 호출:")
for example in examples:
params_str = json.dumps(example.get('params', {}), ensure_ascii=False)
lines.append(
f"{self.tool_name}({{ \"api_type\": \"{example['api_type']}\",\"params\": {params_str} }})")
return "\n".join(lines)
except Exception as e:
return f"{self.tool_name} 도구 설명 생성 중 오류: {str(e)}"
async def _run(self, ctx: Context, api_type: str, params: dict) -> Dict[str, Any]:
"""공통 실행 로직"""
try:
await ctx.info(f"{self.tool_name} running with api_type: {api_type}")
# 1) 인자 구조 검증(가볍게)
if not api_type or not isinstance(params, dict):
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": [k for k in ("api_type", "params") if
not (api_type if k == "api_type" else isinstance(params, dict))],
"invalid": [] if isinstance(params, dict) else [{"field": "params", "expected": "object"}],
}
# 2. 특별한 api_type 처리
if api_type == "find_stock_code":
return await self._handle_find_stock_code(ctx, params)
elif api_type == "find_api_detail":
return await self._handle_find_api_detail(ctx, params)
# 3. API 설정 조회
if api_type not in self.config['apis']:
return {"ok": False, "error": f"지원하지 않는 API 타입: {api_type}"}
# 4. 종목명 자동 처리 (stock_name이 있으면 자동으로 pdno 변환)
params = await self._process_stock_name(ctx, params)
# 5. 실제 실행 (래핑 함수 선택 → OPEN API 호출)
data = await self._run_api(ctx, api_type, params)
return {"ok": True, "data": data}
except Exception as e:
await ctx.error(f"실행 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}
async def _run_api(self, ctx: Context, api_type: str, params: Dict[str, Any]) -> Any:
"""API 실행 - ApiExecutor 사용"""
try:
api_info = self.config['apis'][api_type]
github_url = api_info.get('github_url')
if not github_url:
return {"error": f"GitHub URL이 없습니다: {api_type}"}
# ApiExecutor를 사용하여 API 실행
result = await self.api_executor.execute_api(
ctx=ctx,
api_type=api_type,
params=params,
github_url=github_url
)
return result
except Exception as e:
return {"error": f"API 실행 중 오류: {str(e)}"}
async def _process_stock_name(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""종목명/종목코드 자동 처리 (stock_name이 있으면 자동으로 pdno 변환)"""
try:
# 종목명으로 찾을 수 있는 파라미터들
stock_name_params = ["stock_name", "stock_name_kr", "korean_name", "company_name"]
# 파라미터에서 종목명/종목코드 찾기
search_value = None
for param_name in stock_name_params:
if param_name in params and params[param_name]:
search_value = params[param_name]
break
# 검색할 값이 없으면 그대로 반환
if not search_value:
return params
await ctx.info(f"검색값 발견: {search_value}, 자동 검색 시작")
# 종목명 또는 종목코드로 검색
result = await self._find_stock_by_name_or_code(ctx, search_value)
if result["found"]:
params["pdno"] = result["code"]
await ctx.info(f"종목번호 자동 찾기 성공: {search_value}{result['code']}")
# 원본 검색값 보존
params["_original_search_value"] = search_value
params["_resolved_stock_code"] = result["code"]
else:
await ctx.warning(f"종목을 찾을 수 없음: {search_value}")
# 종목을 찾지 못해도 원본 파라미터 유지
return params
except Exception as e:
await ctx.error(f"종목명 자동 처리 실패: {str(e)}")
return params
async def _find_stock_by_name_or_code(self, ctx: Context, search_value: str) -> Dict[str, Any]:
"""종목명 또는 종목코드로 종목번호 찾기"""
try:
# 검색어에서 띄어쓰기 제거
search_term = search_value.replace(" ", "")
# 데이터베이스 연결 확인
if not self.db.ensure_initialized():
return {"found": False, "message": "데이터베이스 초기화 실패"}
# 마스터 파일 업데이트 확인 (force_update=False로 필요시에만 업데이트)
try:
from module.plugin import MasterFileManager
master_file_manager = MasterFileManager(self.tool_name)
await master_file_manager.ensure_master_file_updated(ctx, force_update=False)
except Exception as e:
await ctx.warning(f"마스터 파일 업데이트 확인 중 오류: {str(e)}")
# DB 엔진
db_engine = self.db.get_by_name("master")
master_models = MasterFileManager.get_master_models_for_tool(self.tool_name)
if not master_models:
return {"found": False, "message": f"지원하지 않는 툴: {self.tool_name}"}
# 각 모델에서 우선순위별 검색
for model_class in master_models:
try:
# 1순위: 종목코드로 완전 매칭
code_results = db_engine.list(
model_class,
filters={"code": search_term},
limit=1
)
if code_results:
result = code_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "code_exact"
}
# 2순위: 종목명으로 완전 매칭
name_results = db_engine.list(
model_class,
filters={"name": search_term},
limit=1
)
if name_results:
result = name_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_exact"
}
# 3순위: 종목명으로 앞글자 매칭
prefix_results = db_engine.list(
model_class,
filters={"name": f"{search_term}%"},
limit=1
)
if prefix_results:
result = prefix_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_prefix"
}
# 4순위: 종목명으로 중간 매칭
contains_results = db_engine.list(
model_class,
filters={"name": f"%{search_term}%"},
limit=1
)
if contains_results:
result = contains_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_contains"
}
except Exception as e:
continue
return {"found": False, "message": f"종목을 찾을 수 없음: {search_value}"}
except Exception as e:
return {"found": False, "message": f"종목 검색 오류: {str(e)}"}
def get_api_info(self, api_type: str) -> Dict[str, Any]:
"""API 정보 조회 (리소스 기능 통합)"""
try:
# API 설정 조회
if api_type not in self.config['apis']:
return {
"error": f"지원하지 않는 API 타입: {api_type}",
"available_apis": list(self.config['apis'].keys()),
"api_type": api_type
}
# API 정보 반환
api_info = self.config['apis'][api_type]
# 파라미터 정보 정리
params = api_info.get("params", {})
param_details = {}
for param_name, param_info in params.items():
param_details[param_name] = {
"name": param_info.get("name", param_name),
"type": param_info.get("type", "str"),
"required": param_info.get("required", False),
"default_value": param_info.get("default_value"),
"description": param_info.get("description", "")
}
result = {
"tool_name": self.tool_name,
"api_type": api_type,
"name": api_info.get("name", ""),
"category_detail": api_info.get("category", ""),
"method": api_info.get("method", ""),
"api_path": api_info.get("api_path", ""),
"github_url": api_info.get("github_url", ""),
"params": param_details
}
return result
except Exception as e:
return {
"error": f"API 정보 조회 중 오류 발생: {str(e)}",
"tool_name": self.tool_name,
"api_type": api_type
}
async def _handle_find_stock_code(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""종목 검색 처리"""
try:
await ctx.info(f"종목 검색 요청: {self.tool_name}")
# stock_name 파라미터 확인
search_value = params.get("stock_name")
if not search_value:
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": ["stock_name"],
"message": "stock_name 파라미터가 필요합니다. (종목명 또는 종목코드 입력 가능)"
}
# 종목 검색 실행 (종목명 또는 종목코드)
result = await self._find_stock_by_name_or_code(ctx, search_value)
if result["found"]:
return {
"ok": True,
"data": {
"tool_name": self.tool_name,
"search_value": search_value,
"found": True,
"stock_code": result["code"],
"stock_name_found": result["name"],
"ex": result.get("ex"),
"match_type": result.get("match_type"),
"message": f"'{search_value}' 종목을 찾았습니다. 종목번호: {result['code']}",
"usage_guide": f"find_api_detail로 API상세정보를 확인하고 종목코드 '{result['code']}'를 해당 API의 종목코드 필드에 입력하여 실행하세요.",
"next_step": f"{self.tool_name} 툴에서 find_api_detail로 확인한 종목코드 필드에 '{result['code']}'를 입력하세요."
}
}
else:
return {
"ok": False,
"error": "STOCK_NOT_FOUND",
"message": f"'{search_value}' 종목을 찾을 수 없습니다.",
"suggestions": [
"종목명의 철자가 정확한지 확인",
"종목코드가 정확한지 확인",
"띄어쓰기나 특수문자가 있는지 확인",
"다른 검색어로 시도 (예: '삼성전자' 대신 '삼성' 또는 '005930')"
]
}
except Exception as e:
await ctx.error(f"종목 검색 처리 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}
async def _handle_find_api_detail(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""API 상세 정보 조회 처리"""
try:
await ctx.info(f"API 상세 정보 조회 요청: {self.tool_name}")
# api_type 파라미터 확인
target_api_type = params.get("api_type")
if not target_api_type:
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": ["api_type"],
"message": "api_type 파라미터가 필요합니다.",
"available_apis": list(self.config['apis'].keys())
}
# API 정보 조회
api_info = self.get_api_info(target_api_type)
if "error" in api_info:
return {
"ok": False,
"error": api_info["error"],
"available_apis": api_info.get("available_apis", [])
}
return {
"ok": True,
"data": api_info
}
except Exception as e:
await ctx.error(f"API 상세 정보 조회 처리 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}

View File

@@ -0,0 +1,10 @@
from .base import BaseTool
from module import singleton
@singleton
class DomesticBondTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_bond"

View File

@@ -0,0 +1,15 @@
from .base import BaseTool
from module import singleton
import pandas as pd
import urllib.request
import ssl
import zipfile
import os
@singleton
class DomesticFutureOptionTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_futureoption"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class DomesticStockTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_stock"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class ElwTool(BaseTool):
@property
def tool_name(self) -> str:
return "elw"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class EtfEtnTool(BaseTool):
@property
def tool_name(self) -> str:
return "etfetn"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class OverseasFutureOptionTool(BaseTool):
@property
def tool_name(self) -> str:
return "overseas_futureoption"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class OverseasStockTool(BaseTool):
@property
def tool_name(self) -> str:
return "overseas_stock"

View File

@@ -0,0 +1,383 @@
# MCP를 AI 도구에 연결하는 방법
### 한국투자증권 Open API를 활용하는 KIS Trade MCP와 KIS Code Assistant MCP를 AI 도구(Claude Desktop | Cursor)에 연결하는 설정 방법을 단계별로 안내합니다.
---
# 공통사항
한국투자증권 계좌와 한국투자증권 OpenAPI 홈페이지에서 인증정보(App Key, App Secret)를 준비해 주세요.
개발 환경 : Python 3.13 이상 권장
Claude Desktop 또는 Cursor와 같은 한국투자증권 MCP를 연결할 AI 도구를 설치해 주세요.
## **KIS Open API 신청 및 설정**
1. 한국투자증권 **계좌 개설 및 ID 연결**
2. 한국투자증권 홈페이지 or 앱에서 **Open API 서비스 신청**
3. **앱키(App Key)**, **앱시크릿(App Secret)** 발급
4. **모의투자** 및 **실전투자** 앱키 각각 준비
🍀 [서비스 신청 안내 바로가기](https://apiportal.koreainvestment.com/about-howto)
# 🔗 MCP(Model Context Protocol)란?
MCP는 Claude를 개발한 Anthropic에서 만든 프로토콜로, AI 모델이 외부 도구와 데이터에 안전하고 효율적으로 접근할 수 있게 해주는 표준화된 인터페이스입니다.
이제 한국투자증권이 만든 2개의 MCP를 통해 한국투자증권 Open API를 자연어로 쉽게 활용할 수 있습니다.
# 한국투자증권 MCP 소개
## KIS Trade MCP
### **특징 및 용도**
국내/해외주식, 선물·옵션, 채권, ETF/ETN, 인증 등 한국투자증권의 다양한 Open API를 **MCP 서버의 "도구"**로 래핑하였습니다. LLM이 바로 사용할 수 있도록 *API 스키마·파라미터*를 리소스로 제공하고, *모의/실전 환경*을 구분하여 안전하게 실행합니다.
### 설정 방법
(9월 중 공개 예정)
## KIS Code Assistant MCP
### 특징 및 용도
한국투자증권의 많은 Open API 중에서 **자연어 검색으로 관련 API를 찾고**, **호출 예제(파라미터 포함)까지 자동 구성**해주는 MCP 서버입니다. "무엇을 하고 싶은지"만 말하면, 관련 API를 추천하고 예시 호출 코드를 만들어 드립니다.
### 설정 방법
1. Claude Desktop
Link : [https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp)
<img width="2048" height="958" alt="image" src="https://github.com/user-attachments/assets/82aa8bc4-b112-482c-8e8d-34c41fb0ed76" />
<img width="2048" height="816" alt="image 1" src="https://github.com/user-attachments/assets/3404acc4-058a-4b41-a4d4-0d5aa62ddd3b" />
**AUTO / Claude Desktop** 선택 → Terminal 명령어 Copy 클릭
<img width="2048" height="884" alt="image 2" src="https://github.com/user-attachments/assets/a5852435-baa9-4fe0-a5e6-41929552b900" />
터미널에 명령어 붙여넣기하고 엔터 → 설치 완료 메시지 후 Claude 재시작 질문에는 Y 입력 후 엔터를 누르면 Claude Desktop 재시작
<img width="2048" height="1000" alt="image 3" src="https://github.com/user-attachments/assets/911b7818-bedf-4d04-8721-09cc4cf5409d" />
홈 화면 대화창 하단 **검색 및 도구** 버튼에서 설치 및 추가 확인 가능, `설정 → 개발자`에서도 확인 할 수 있습니다.
2. Cursor
Link : [https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp)
<img width="2048" height="988" alt="image 4" src="https://github.com/user-attachments/assets/5058bc1d-8046-47e4-9962-f7f1a5f3bcba" />
<img width="2048" height="988" alt="image 5" src="https://github.com/user-attachments/assets/6bb863b7-a8de-4435-8bdd-ef1deece02f0" />
**AUTO / Cursor** 선택 → **One-Click Install** 클릭
<img width="2048" height="958" alt="image 6" src="https://github.com/user-attachments/assets/f3e2f17b-f1b6-4b8f-a388-2990ef6f2a0e" />
Cursor에서 **Install** 클릭하면 완료
<img width="2048" height="958" alt="image 7" src="https://github.com/user-attachments/assets/a4fcdcdc-d83b-4187-946d-28160d7f65bf" />
KIS Code Assistant MCP가 연결되었는지 확인 (경로 : `Settings` > `MCP Servers`)
# 🚀 MCP기반 트레이딩 시스템 개발을 위한 환경 설정
트레이딩 시스템 개발을 시작하기 전에 필요한 Python 환경 구성부터 API 연결 테스트까지 개발 환경 설정 과정을 안내합니다.
### 1. 폴더 생성 및 파일 다운로드
트레이딩 시스템 개발을 위해 필요한 파일을 다운로드하고 폴더를 생성하고 경로를 지정하세요.
### **1-1. 보안 폴더 생성**
중요 정보를 저장하는 폴더와 실행 코드를 저장하는 폴더를 각각 생성합니다.
**맥/리눅스**:
```bash
mkdir -p ~/KIS/config
cd ~/KIS/config
```
**윈도우 PowerShell**:
```powershell
mkdir "$HOME\KIS\config"
cd "$HOME\KIS\config"
```
### **1-2. 프로젝트 폴더 생성**
**맥/리눅스**:
```bash
mkdir -p ~/자동매매
cd ~/자동매매
```
**윈도우 PowerShell**:
```powershell
mkdir "$HOME\자동매매"
cd "$HOME\자동매매"
```
### **1-3. GitHub에서 파일 다운로드**
한국투자증권 GitHub에서 세개 파일을 다운로드 받으세요.
**GitHub 링크**: https://github.com/koreainvestment/open-trading-api
1. **kis_devlp.yaml**`~/kis/config` 폴더에 저장 **(보안 정보로 별도 관리)**
https://github.com/koreainvestment/open-trading-api/blob/main/kis_devlp.yaml
2. **kis_auth.py**`~/자동매매/` 폴더에 저장
https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py
3. **pyproject.toml**`~/자동매매/` 폴더에 저장
https://github.com/koreainvestment/open-trading-api/blob/main/pyproject.toml
> 경로 표기 안내
문서에서 `~`는 **내 사용자 폴더(홈)**를 뜻합니다.
`~/{폴더명}`은 그 안의 `{폴더명}` 폴더라는 의미이며, 실제 입력은 `~/kis/config`처럼 중괄호 없이 적습니다.
(Windows PowerShell: `~``C:\Users\내이름`)
>
### **1-4. `중요`kis_devlp.yaml 설정**
`~/KIS/kis_devlp.yaml` 파일에 발급받은 App key, App Secret, 계좌정보 (실전, 모의)를 입력하세요
```yaml
#홈페이지에서 API서비스 신청시 발급 AppKey, AppSecret 값 설정
#실전투자
my_app: "발급받은_실제_APP_KEY" # 한국투자증권에서 발급받은 APP KEY 입력
my_sec: "발급받은_실제_APP_SECRET" # 한국투자증권에서 발급받은 APP SECRET 입력
#모의투자
paper_app: "발급받은_실제_APP_KEY" # 모의투자용 APP KEY (실전과 동일)
paper_sec: "발급받은_실제_APP_SECRET" # 모의투자용 APP SECRET (실전과 동일)
# HTS ID
my_htsid: "실제_HTS_ID" # 한국투자증권 HTS ID 입력
#계좌번호 및 8자리
my_acct_stock: "실제_계좌번호" # 주식 계좌번호 (예: 50068418)
my_acct_future: "실제_계좌번호" # 선물옵션 계좌번호 (주식과 동일 가능)
my_paper_stock: "모의투자_계좌번호" # 모의투자 주식 계좌번호
my_paper_future: "모의투자_계좌번호" # 모의투자 선물옵션 계좌번호 (주식과 동일 가능)
#계좌번호 뒤 2자리
my_prod: "01" # 01(종합계좌), 03(국내선물옵션), 08(해외선물옵션), 22(개인연금), 29(퇴직연금)
```
> **⚠️ 보안 주의사항:**
>
> - App Key/App Secret과 계좌번호는 절대 타인과 공유하지 마세요.
> - GitHub 공개 저장소와 같이 외부에 공개된 저장소에는 절대 업로드하지 마세요.
> - 트레이딩 시스템 폴더와 별도의 경로(~/KIS/config)에 보관하세요.
> - 정기적으로 API Key를 재발급하여 보안을 강화하세요
## 2. uv 설치 및 가상환경 설정
### **2-1. uv 설치**
**맥/리눅스**:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**윈도우**:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### **2-2. 가상환경 설정**
프로젝트 폴더로 이동 후 가상환경 생성:
```bash
cd ~/자동매매
uv sync
```
### **2-3. 가상환경 활성화**
**맥/리눅스**:
```bash
source .venv/bin/activate
```
**윈도우**:
```bash
.venv\Scripts\activate
```
## 3. 연결 테스트 (필수 검증)
### 3-1. 기본 연결 테스트
`~/자동매매/test_connection.py` 파일을 생성하고 해당 코드를 복사/붙여넣기 합니다.
(모의투자로 세팅되어 있습니다.)
```python
# test_connection.py
# KIS Open API 연결 테스트 및 기본 정보 확인 스크립트
import sys
import os
try:
from kis_auth import auth, getTREnv, getEnv, read_token
import kis_auth
# 설정 파일 확인
print("설정 파일 확인 중...")
cfg = getEnv()
print(f"앱키: {cfg.get('my_app', 'None')[:10]}...")
print(f"서버 URL: {cfg.get('prod', 'None')}")
# 인증 토큰 발급 테스트
print("토큰 발급 시도 중...")
try:
# 디버그 모드 활성화
kis_auth._DEBUG = True
auth(svr="vps") # 모의투자 토큰 발급 및 저장
print("토큰 발급 완료")
# 토큰이 제대로 설정되지 않은 경우 수동으로 설정
env = getTREnv()
if not env.my_token:
print("토큰이 환경에 설정되지 않음. 저장된 토큰을 확인합니다...")
saved_token = read_token()
if saved_token:
print("저장된 토큰을 찾았습니다. 환경에 설정합니다...")
# 토큰을 직접 설정
kis_auth._TRENV = kis_auth._TRENV._replace(my_token=saved_token)
kis_auth._base_headers["authorization"] = f"Bearer {saved_token}"
print("토큰 설정 완료")
else:
print("저장된 토큰도 없습니다.")
except Exception as auth_error:
print(f"토큰 발급 중 오류: {auth_error}")
import traceback
traceback.print_exc()
# 환경 정보 확인
env = getTREnv()
if hasattr(env, 'my_token') and env.my_token:
print("✅ API 연결 성공!")
print(f"토큰 앞 10자리: {env.my_token[:10]}...")
print(f"계좌번호: {env.my_acct}")
print(f"서버: {'모의투자' if env.my_url.find('vts') > 0 else '실전투자'}")
else:
print("❌ API 연결 실패 - 토큰이 없습니다")
print(f"토큰 속성 존재: {hasattr(env, 'my_token')}")
if hasattr(env, 'my_token'):
print(f"토큰 값: {env.my_token}")
print(f"토큰 길이: {len(env.my_token) if env.my_token else 0}")
except Exception as e:
print(f"❌ 오류 발생: {e}")
print("devlp.yaml 파일 경로와 설정을 확인해주세요")
```
### 3-2. 테스트 실행
테스트를 실행하고 결과를 확인하세요.
```bash
# 실행
cd ~/자동매매
python test_connection.py
# 결과
✅ API 연결 성공!
토큰 앞 10자리: asdfasdfas...
계좌번호: 12345678
서버: 모의투자
```
### 🛠️ 자주 발생하는 문제와 해결방법
1. MCP 연결 실패 시
- Claude Desktop/Cursor 재시작
- MCP 서버 URL 확인 ([https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp))
- 방화벽 설정 확인
- 인터넷 연결 확인
2. API 연결 오류 시
- App Key와 Secret이 발급 받은 것과 동일한지 확인
- kis_auth.py 의 내용이 다운로드 받은 파일과 동일한지 확인
- kis_devlp.yaml 파일이 “~/KIS/config/” 혹은 “$HOME/KIS/config”에 있는지 확인
- kis_devlp.yaml 파일에 작성한 개인정보가 정확한지 확인 (App Key/Secret, HTS ID, 계좌번호, 상품코드)
- kis_devlp.yaml 파일의 문법이 올바른지 확인 (YAML 문법, 들여쓰기 주의)
3. 가상환경 문제 시
- uv 버전 확인: `uv --version`
- pyproject.toml 의 내용이 다운로드 받은 파일과 동일한지 확인
- 프로그램 실행에 필요한 전체 패키지 재설치: `uv sync`
- 가상환경 재생성: `uv venv --force`
4. Python 모듈 import 오류 시
- 가상환경 활성화 확인
- 필요 패키지 설치: `uv add {패키지명}`
---
## 4. 최종 폴더 구조 확인
설정이 성공적으로 완료되면 폴더 구조는 다음과 같습니다.
```
~/KIS/
└── config/
└── devlp.yaml (보안 정보)
~/자동매매/
├── kis_auth.py
├── pyproject.toml
├── test_connection.py
├── .venv/ (uv sync 후 자동 생성)
└── uv.lock (uv sync 후 자동 생성)
```
---
## Next Step
설정이 완료되셨다면 이제 투자를 위한 전략를 구현하세요.
1. 🎯 MCP를 활용하여 개발 시작하기
- Cursor에서 KIS Code Assistant MCP를 활용하여 자동매매 시스템 개발
- 자연어로 '주식 현재가 조회 코드 보여줘' 같은 질문하기
2. 📊 모의투자 환경에서 충분한 테스트 진행
- 실제 거래 전 반드시 모의투자로 검증
- 손절/익절 로직 구현 및 테스트
3. 🔒 실전 투자 적용 시 보안과 리스크 관리 강화
- 포트폴리오 분산 투자 권장
- 정기적인 API 키 교체
🚀 고급 활용 팁
- 백테스팅을 통한 전략 검증
- 실시간 알림 시스템 구축
- 리스크 관리 자동화
---
한국투자증권은 기술을 통해 투자의 진입장벽을 낮추고, 투자자들이 더 나은 투자 경험을 할 수 있도록 MCP를 통해 복잡한 API 연동 등 개발환경을 개선하여 투자 전략 본질에 집중할 수 있도록 지원합니다.
AI와 함께하는 새로운 투자 시대, 여러분만의 성공 투자 스토리에 한국투자증권 MCP가 든든한 파트너가 되겠습니다.

View File

@@ -0,0 +1,78 @@
# 한국투자증권 MCP: 투자 코딩이 쉬워지는 순간
## 🚀 소개
한국투자증권에서 개발자들을 위한 혁신적인 도구를 공개합니다. **MCP(Model Context Protocol)**를 활용해 AI 모델이 증권 데이터에 직접 접근할 수 있는 두 가지 도구를 제공합니다:
1. **KIS Code Assistant MCP**: 한국투자증권 OpenAPI 사용법과 샘플코드를 AI 도구가 검색/제공
2. **KIS Trading MCP** : 한국투자증권 OpenAPI를 AI 도구에서 직접 호출
## 🔧 지원 기능
### KIS Code Assistant MCP
https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp
- **스마트 검색**: 자연어로 원하는 API 기능 검색
- **샘플코드 제공**: GitHub에서 실제 구현 코드 자동 검색
- **카테고리별 탐색**: 기능별 체계적인 API 분류
### KIS Trading MCP
- **시세 조회**: 국내/해외 주식, 선물/옵션, 채권, ETF/ELW
- **계좌 관리**: 잔고조회, 주문/체결내역, 손익현황
- **매매 주문**: 현물/신용/선물옵션 주문 및 정정취소
- **시장 분석**: 순위정보, 투자자별 매매동향, 프로그램매매
## 💡 활용 예시
### Claude와 함께 사용
```
사용자: "삼성전자 현재가와 호가창 정보 알려줘"
Claude: [KIS OpenAPI] → 실시간 데이터 제공
사용자: "내 계좌 잔고에서 수익률 높은 종목 5개 보여줘"
Claude: [잔고 조회 + 데이터 분석] → 맞춤형 포트폴리오 분석
```
### Cursor IDE에서 활용
```python
# AI가 자동으로 API 호출 코드 생성 및 실행
def get_stock_info(code):
"""삼성전자 주가 정보를 가져오는 함수를 만들어줘"""
# MCP를 통해 실제 API 호출 코드가 자동 생성됨
```
## 🌟 특징
- **데이터**: 지연 없는 시세 정보
- **완전한 API 커버리지**: 한투 OpenAPI의 모든 기능 지원
- **자연어 인터페이스**: 복잡한 API 문서 없이 대화로 사용
- **코드 자동 생성**: 샘플코드를 기반으로 한 맞춤형 구현
- **보안**: OAuth 토큰 기반 안전한 인증
## 🎯 사용 시나리오
### 개인 투자자
- "오늘 상승률 상위 10개 종목의 PER, PBR 비교해줘"
- "내 포트폴리오에서 손절매가 필요한 종목 찾아줘"
### 퀀트 개발자
- "볼린저밴드 돌파 전략으로 백테스팅 코드 만들어줘"
- "RSI와 MACD 조합 신호로 매매 로직 구현해줘"
### 핀테크 개발사
- "고객별 맞춤 포트폴리오 추천 시스템 개발"
- "실시간 리스크 모니터링 대시보드 구축"
## 📈 왜 MCP인가?
기존 REST API의 한계를 뛰어넘어, AI가 상황에 맞는 최적의 API를 선택하고 호출합니다. 개발자는 복잡한 API 문서를 읽을 필요 없이 자연어로 원하는 기능을 요청하면 됩니다.
---
*개발 지식이 있는 투자자라면 이제 아이디어만 있으면 됩니다. 복잡한 구현은 AI가, 실제 매매는 한투 API가 담당합니다. 더 스마트한 투자를 위한 새로운 도구를 경험해 보세요.*
---

View File

@@ -0,0 +1,314 @@
**[당사에서 제공하는 샘플코드에 대한 유의사항]**
- 샘플 코드는 한국투자증권 Open API(KIS Developers)를 연동하는 예시입니다. 고객님의 개발 부담을 줄이고자 참고용으로 제공되고 있습니다.
- 샘플 코드는 별도의 공지 없이 지속적으로 업데이트될 수 있습니다.
- 샘플 코드를 활용하여 제작한 고객님의 프로그램으로 인한 손해에 대해서는 당사에서 책임지지 않습니다.
# KIS Open API 샘플 코드 저장소 (LLM 지원)
## 1. 제작 의도 및 대상
### 🎯 제작 의도
이 저장소는 **ChatGPT, Claude 등 LLM(Large Language Model)** 기반 자동화 환경과 Python 개발자 모두가
**한국투자증권(Korea Investment & Securities) Open API를 쉽게 이해하고 활용**할 수 있도록 구성된 샘플 코드 모음입니다.
- `examples_llm/`: LLM이 단일 API 기능을 쉽게 탐색하고 호출할 수 있도록 구성된 기능 단위 샘플 코드
- `examples_user/`: 사용자가 실제 투자 및 자동매매 구현에 활용할 수 있도록 상품별로 통합된 API 호출 예제 코드
> AI와 사람이 모두 활용하기 쉬운 구조를 지향합니다.
[한국투자증권 Open API 포털 바로가기](https://apiportal.koreainvestment.com/)
### 👤 대상 사용자
- 한국투자증권 Open API를 처음 사용하는 Python 개발자
- 기존 Open API 사용자 중 코드 개선 및 구조 학습이 필요한 사용자
- LLM 기반 코드 에이전트를 활용해 종목 검색, 시세 분석, 자동매매 등을 구현하고자 하는 사용자
## 2. 폴더 구조 및 주요 파일 설명
### 2.1. 폴더 구조
```
# 프로젝트 구조
.
├── README.md # 프로젝트 설명서
├── docs/
│ └── convention.md # 코딩 컨벤션 가이드
├── examples_llm/ # LLM용 샘플 코드
│ ├── kis_auth.py # 인증 공통 함수
│ ├── domestic_bond # 국내채권
│ │ └── inquire_price # API 단일 기능별 폴더
│ │ ├── inquire_price.py # 한줄 호출 파일 (예: 채권 가격 조회)
│ │ └── chk_inquire_price.py # 테스트 파일 (예: 채권 가격 조회 결과 검증)
│ ├── domestic_futureoption # 국내선물옵션
│ ├── domestic_stock # 국내주식
│ ├── elw # ELW
│ ├── etfetn # ETF/ETN
│ ├── overseas_futureoption # 해외선물옵션
│ ├── overseas_price # 해외시세
│ └── overseas_stock # 해외주식
├── examples_user/ # user용 실제 사용 예제
│ ├── kis_auth.py # 인증 공통 함수
│ ├── domestic_bond # 국내채권
│ │ ├── domestic_bond_functions.py # (REST) 통합 함수 파일 (모든 API 함수 모음)
│ │ ├── domestic_bond_examples.py # (REST) 실행 예제 파일 (함수 사용법)
│ │ ├── domestic_bond_functions_ws.py # (Websocket) 통합 함수 파일
│ │ └── domestic_bond_examples_ws.py # (Websocket) 실행 예제 파일
│ ├── domestic_futureoption # 국내선물옵션
│ ├── domestic_stock # 국내주식
│ ├── elw # ELW
│ ├── etfetn # ETF/ETN
│ ├── overseas_futureoption # 해외선물옵션
│ ├── overseas_price # 해외시세
│ └── overseas_stock # 해외주식
├── legacy/ # 구 샘플코드 보관
├── stock_info/ # 종목정보파일 참고 데이터
├── kis_devlp.yaml # API 설정 파일 (개인정보 입력 필요)
├── pyproject.toml # (uv)프로젝트 의존성 관리
└── uv.lock # (uv)의존성 락 파일
```
### 2.2. 지원되는 주요 API 카테고리
- 아래 카테고리 및 폴더 구조는 examples_llm/, examples_user/ 폴더 모두 동일하게 적용됩니다.
| 카테고리 | 설명 | 폴더명 |
| --- | --- | --- |
| 국내주식 | 국내 주식 시세, 주문, 잔고 등 | `domestic_stock` |
| 국내채권 | 국내 채권 시세, 주문 등 | `domestic_bond` |
| 국내선물옵션 | 국내 파생상품 관련 | `domestic_futureoption` |
| 해외주식 | 해외 주식 시세, 주문 등 | `overseas_stock` |
| 해외선물옵션 | 해외 파생상품 관련 | `overseas_futureoption` |
| ELW | ELW 시세 API | `elw` |
| ETF/ETN | ETF, ETN 시세 API | `etfetn` |
### 2.3. 주요 파일 설명
### `examples_llm/` - llm용 기능 단위 샘플 코드
**API별 개별 폴더 구조**: 단일 API 기능을 독립 폴더로 분리하여, LLM이 관련 코드를 쉽게 탐색할 수 있도록 구성
- **한줄 호출 파일**: `[함수명].py` 단일 기능을 호출하는 최소 단위 코드 (예: `inquire_price.py`)
- **테스트 파일**: `chk_[함수명].py` 호출 결과를 검증하는 테스트 실행 코드 (예: `chk_inquire_price.py`)
### `examples_user/` - 사용자용 통합 예제 코드
**카테고리별 개별 폴더 구조**: 카테고리(상품)별로 모든 기능을 통합하여, 사용자가 쉽게 샘플 코드를 탐색하고 실행할 수 있도록 구성
- **통합 함수 파일**: `[카테고리]_functions.py` - 해당 카테고리의 모든 API 기능이 통합된 함수 모음
- **실행 예제 파일**: `[카테고리]_examples.py` - 실제 사용 예제를 기반으로 한 실행 코드
- **웹소켓 통합 함수 파일 및 실행 예제 파일**: `[카테고리]_functions_ws.py`, `[카테고리]_examples_ws.py`
### `kis_auth.py` - 인증 및 공통 기능
- 접근토큰 발급 및 관리
- API 호출 공통 함수
- 실전투자/모의투자 환경 전환 지원
- 웹소켓 연결 설정 기능 제공
## 3. 사전 환경설정 안내
### 3.1. Python 환경 요구사항
- **Python 3.9 이상** 필요
- **uv** **패키지 매니저 사용** 권장 (빠르고 간편한 의존성 관리)
### 3.2. uv 설치 방법
- 간편 설정을 위해 uv를 권장합니다
```bash
# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# 설치 확인
uv --version
# uv 0.x.x ... -> 설치 완료
```
### 3.3. 프로젝트 클론 및 환경 설정
```bash
# 저장소 클론
git clone https://github.com/koreainvestment/open-trading-api
cd open-trading-api/kis_github
# uv를 사용한 의존성 설치 - 한줄로 끝
uv sync
```
### 3.4. KIS Open API 신청 및 설정
🍀 [서비스 신청 안내 바로가기](https://apiportal.koreainvestment.com/about-howto)
1. 한국투자증권 **계좌 개설 및 ID 연결**
2. 한국투자증권 홈페이지 or 앱에서 **Open API 서비스 신청**
3. **앱키(App Key)**, **앱시크릿(App Secret)** 발급
4. **모의투자****실전투자** 앱키 각각 준비
### 3.5. kis_devlp.yaml 설정
- 본인의 계정 설정을 위해 `kis_devlp.yaml` 파일을 열어 다음과 같이 수정합니다.
1. **프로젝트 루트에 위치한** `kis_devlp.yaml` 파일 열기
2. **앱키와 앱시크릿** 정보 입력
3. **HTS ID** 정보 입력
4. **계좌번호** 정보 입력 (앞 8자리와 뒤 2자리 구분)
5. **저장** 후 닫기
```yaml
# 실전투자
my_app: "여기에 실전투자 앱키 입력"
my_sec: "여기에 실전투자 앱시크릿 입력"
# 모의투자
paper_app: "여기에 모의투자 앱키 입력"
paper_sec: "여기에 모의투자 앱시크릿 입력"
# HTS ID(KIS Developers 고객 ID) - 체결통보, 나의 조건 목록 확인 등에 사용됩니다.
my_htsid: "사용자 HTS ID"
# 계좌번호 앞 8자리
my_acct_stock: "증권계좌 8자리"
my_acct_future: "선물옵션계좌 8자리"
my_paper_stock: "모의투자 증권계좌 8자리"
my_paper_future: "모의투자 선물옵션계좌 8자리"
# 계좌번호 뒤 2자리
my_prod: "01" # 종합계좌
# my_prod: "03" # 국내선물옵션 계좌
# my_prod: "08" # 해외선물옵션 계좌
# my_prod: "22" # 연금저축 계좌
# my_prod: "29" # 퇴직연금 계좌
# User-Agent(기본값 사용 권장, 변경 불필요)
my_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
### 3.6. kis_auth.py 설정 경로 수정
- `kis_auth.py`의 config_root 경로를 본인 환경에 맞게 수정해줍니다. 발급된 토큰 파일이 저장될 경로로, 제3자가 찾기 어렵도록 설정하는것을 권장합니다.
```yaml
# kis_auth.py 39번째 줄
# windows - C:\Users\사용자이름\KIS\config
# Linux/macOS - /home/사용자이름/KIS/config
# config_root = os.path.join(os.path.expanduser("~"), "KIS", "config")
config_root = os.path.join(os.path.expanduser("~"), "폴더 경로", "config")
```
### 3.7. 실행파일 내 인증 설정 검토
- 실행하려는 파일에서 인증 관련 설정을 검토 혹은 변경해줍니다. 국내주식 기능 전체를 이용하시려면, `domestic_stock/domestic_stock_examples.py` 파일을 확인해주세요.
ka.auth() 함수의 svr, product 매개변수를 아래와 같이 수정하면 실전환경(prod)에서 위탁계좌(-01)로 매매 테스트가 가능합니다.
```python
import kis_auth as ka
# 실전투자 인증
ka.auth(svr="prod", product="01") # 모의투자: svr="vps"
```
## 4. 샘플 코드 실행
### 4.1. 샘플 코드 실행
- **examples_user 기준**
```bash
# 국내주식 샘플 코드 실행 (examples_user/domestic_stock/)
python domestic_stock_examples.py # REST 방식
python domestic_stock_examples_ws.py # Websocket 방식
```
domestic_stock_examples.py에는 여러 함수가 포함되어 있으므로, 사용하려는 함수만 남기고 나머지는 주석 처리한 후, 입력값을 수정하여 호출해 주세요.
- **examples_llm 기준**
```bash
# 국내주식 > 주식현재가 시세 샘플 코드 실행 (examples_llm/domestic_stock/inquire_price/)
python chk_inquire_price.py
```
examples_llm 은 각 기능별로 개별 실행 파일(chk_*.py)이 분리되어 있어, 특정 기능만 테스트하고자 할 때 유용합니다.
### 4.2. 예제 코드 샘플 (examples_user)
```python
# REST API 호출 예제 - domestic_stock_examples.py
import sys
import logging
import pandas as pd
sys.path.extend(['..', '.'])
import kis_auth as ka
from domestic_stock_functions import *
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 인증
ka.auth()
trenv = ka.getTREnv()
# 삼성전자 현재가 시세 조회
result = inquire_price(env_dv="real", fid_cond_mrkt_div_code="J", fid_input_iscd="005930")
print(result)
```
```python
# 웹소켓 호출 예제 - domestic_stock_examples_ws.py
import sys
import logging
import pandas as pd
sys.path.extend(['..', '.'])
import kis_auth as ka
from domestic_stock_functions_ws import *
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 인증
ka.auth()
ka.auth_ws()
trenv = ka.getTREnv()
# 웹소켓 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 삼성전자, sk하이닉스 실시간 호가 구독
kws.subscribe(request=asking_price_krx, data=["005930", "000660"])
```
## 5. 문제 해결 가이드
### 토큰 오류 시
```python
import kis_auth as ka
# 토큰 재발급 - 1분당 1회 발급됩니다.
ka.auth(svr="prod") # 또는 "vps"
```
### 설정 파일 오류 시
- `kis_devlp.yaml` 파일의 앱키, 앱시크릿이 올바른지 확인
- 계좌번호 형식이 맞는지 확인 (앞 8자리 + 뒤 2자리)
- 실시간 시세(WebSocket) 이용 중 No close frame received 오류가 발생하는 경우, `kis_devlp.yaml`에 입력하신 HTS ID가 정확한지 확인
### 의존성 오류 시
```bash
# 의존성 재설치
uv sync --reinstall
```
---
# 📧 문의사항
- [💬 한국투자증권 Open API 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts)에 언제든 궁금한 점을 물어보세요.

View File

@@ -0,0 +1,112 @@
# 한국투자증권 OpenAPI 코드 컨벤션
## 정의
- 한줄호출함수: 사용자 코드에 import 해서 한줄로 API를 실행할 수 있도록 만들어 주는 함수들이 담긴 파일이다
- 체크함수: 한줄호출함수 파일을 import하여, API를 실행한 후 결과 값을 출력하는 파일을 테스트 파일
## 단어에 대한 정의
- 1개의 용어에 대해서는 1개의 단어를 사용함으로써, 통일성을 유지하고, LLM이 혼란스럽지 않도록 사용한다.
- 동음이의어, 이음동의어와 같은 문맥상 파악이 필요한 단어를 최대한 지양한다.
## 네이밍 컨벤션
- 네이밍 파이썬 기본 규칙에 벗어나지 않으며, 역할과 의미가 명확해야 한다.
- 널리 알려진 축약어( URL,ID 등) 외에는 축약어와 모호한 이름은 사용하지 않는다.
- 형식
- 모듈 : snake_case
- 변수: snake_case
- 함수 : snake_case
- 클래스 : PascalCase
- 상수 : UPPER_SNAKE_CASE
## 폴더 명명 규칙
- API 주소에서 차용하여 생성
```text
url: /uapi/domestic-stock/v1/quotations/comp-program-trade-daily
폴더이름: domestic_stock
```
- 웹소켓은 URL 이 없으므로, 홈페이의 분류명에 맞추어 적용
## 파일 명명 규칙
1. Rest API 한줄호출함수 파일 명명 규칙
- 규칙 : API 주소에서 차용
- 예시
```text
url: /uapi/domestic-stock/v1/quotations/comp-program-trade-daily
폴더이름: comp_program_trade_daily
파일이름: comp_program_trade_daily.py
```
2. Websocket 한줄호출함수 파일 명명 규칙
- 규칙 : 웹소켓 함수 직역과 함께, 유사한 이름의 RestAPI를 참고하여 생성
3. 테스트 파일 명명 규칙
- 규칙 : "chk_한줄호출함수 파일이름.py"
- 예시
```text
한줄호출함수 파일이름: comp_program_trade_daily.py
체크함수 : chk_comp_program_trade_daily.py
```
## Docstring 작성
- 코드 블록의 목적, 인자, 반환 값, 예외등을 상세히 기술
- Google, Sphinx, NumPy 등 널리 사용되는 Docstring 형식을 따라야 한다.
- 예제 코드를 포함해야 한다.
## 주석
- 파일 상단에 모듈 전체의 목적과 주요 기능, 다른 모듈과의 관계, 주요 구성 요소와 그들간의 상호작용 방식을 설명하는 주석 필요 (인코딩, 작성 시각과 작성자)
- 자연어를 기반으로 학습되므로, 가급적 완전한 문장 형태의 자연스러운 설명을 사용 할것 (축약되거나 암호를 사용하지 말것)
- 코드 자체는 “무엇”을 하는지 자체로 명확해야하며, 주석은 “왜” 그렇게 작성했는지를 적어야함 (복잡한 로직, 비직관적인 해결 방법, 특정 디자인 결정의 배경 등)
- 함수/클래스 : 해당 코드 블록이 무엇을 하는지 설명
- 매개변수 : 이름,타입, 역할, 필수 여부, 기본 값 등을 상세 기술
- 반환 값 : 무엇을 반환하는지, 타입
- 예외처리 : 어떤 상황에서 어떤 예외가 발생할 수 있는지 명시
- 사용예시 : 실제 코드를 사용하여, 어떻게 사용하는지를 보여주는 예시
- 사전/사후 조건 : 함수 실행 전후에 보장되어야 하는 조건을 명시
- Input, Ouput 파라미터에 대체 가능한 옵션들에 대한 설명을 추가
- 파라미터는 Request Header/Query/Body, Response Header/Body 총 5개로 구성되어 있음
- 각각을 Class화하고 타입을 명시적으로 선언, 필수값은 일반 타입이지만, 선택 값은 Optional을 사용
- 코드가 변경되면 주석을 변경
- TODO, FIXME 와 같은 태그를 활용하여 개선 혹은 수정할 내용을 명시
## 코드의 모듈화 및 단일 책임 원칙
- 함수와 클래스는 가능한 한 작고, 하나의 명확한 기능만을 갖도록 설계
- 재사용성을 함께 제공하면서, LLM이 독립적으로 이해하는 데 도움이 된다.
- 긴 함수/클래스는 지양한다.
- 추상화 계층이나, 복잡한 디자인 패턴은 LLM이 이해하기 어렵게 만들 수 있으므로, 직관적인 코드를 넣을 것
- 장황하더라도 명확한 코드가 좋음 (한줄 마법은 지양)
## 설정 관리
- API 키, 경로, 임계 값등 자주 변경되거나, 환경에 따라 달라지는 설정은 코드에서 분리
- .env
- config.py
- etc..
## import 정리
- Wildcard는 지양하며, 필요한 것만 명시적으로 import 할 것
- 라이브러리 from에 따라 그룹화하고, 알파벳순으로 정렬
- 라이브러리 import 순서
1) 표준 라이브러리
2) 써드파티
3) 로컬 어플리케이션/Lib
## 변수 선언
- 변수의 타입을 명시적으로 선언한다.
- 함수의 파라미터, 리턴값, 변수의 타입을 명시적으로 선언해야 한다.
- 복잡한 타입 (List, Dict, tuple, Optional, Union, Callable 등)을 활용하여 데이터 구조도를 명확하게 표현한다.
## 에러 처리
- try-except 블록을 사용하되, 구체적인 예외 타입을 명시할 것
- 중요한 이벤트, 에러, 상태 변경 등을 “logging” 을 사용하여 기록 할 것

View File

@@ -0,0 +1,120 @@
# [인증] OAuth 접근토큰 발급
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import json
import logging
import sys
import pandas as pd
import requests
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] OAuth 접근토큰 발급
##############################################################################################
# 상수 정의
API_URL = "/oauth2/tokenP"
def auth_token(
grant_type: str,
appkey: str,
appsecret: str,
env_dv: str
) -> pd.DataFrame:
"""
OAuth 접근토큰 발급 API를 호출하여 DataFrame으로 반환합니다.
Args:
grant_type (str): [필수] 권한부여 Type (client_credentials)
appkey (str): [필수] 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)
appsecret (str): [필수] 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)
env_dv (str): [필수] 환경구분 (real: 실전, demo: 모의)
Returns:
pd.DataFrame: OAuth 토큰 발급 결과
Example:
>>> df = auth_token(
... grant_type="client_credentials",
... appkey=trenv.my_app,
... appsecret=trenv.my_sec,
... env_dv="real"
... )
>>> print(df)
"""
# 필수 파라미터 검증
if not grant_type:
logger.error("grant_type is required. (e.g. 'client_credentials')")
raise ValueError("grant_type is required. (e.g. 'client_credentials')")
if not appkey:
logger.error("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
raise ValueError("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
if not appsecret:
logger.error("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
raise ValueError("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
if not env_dv:
logger.error("env_dv is required. (real: 실전, demo: 모의)")
raise ValueError("env_dv is required. (real: 실전, demo: 모의)")
# 환경 구분에 따른 서버 URL 설정
config = ka.getEnv()
if env_dv == "real":
base_url = config.get("prod", "")
elif env_dv == "demo":
base_url = config.get("vps", "")
else:
logger.error("env_dv must be 'real' or 'demo'")
raise ValueError("env_dv must be 'real' or 'demo'")
url = f"{base_url}{API_URL}"
# 헤더 설정
headers = {
"Content-Type": "application/json",
"Accept": "text/plain",
"charset": "UTF-8"
}
# 요청 데이터
data = {
"grant_type": grant_type,
"appkey": appkey,
"appsecret": appsecret,
}
try:
# POST 방식으로 직접 API 호출
response = requests.post(url, data=json.dumps(data), headers=headers)
if response.status_code == 200:
# 응답 데이터를 DataFrame으로 반환 (1개 row)
response_data = response.json()
current_data = pd.DataFrame([response_data])
logger.info("OAuth 토큰 발급 성공")
return current_data
else:
logger.error("API call failed: %s - %s", response.status_code, response.text)
return pd.DataFrame()
except requests.RequestException as e:
logger.error("Request failed: %s", str(e))
return pd.DataFrame()
except json.JSONDecodeError as e:
logger.error("JSON decode failed: %s", str(e))
return pd.DataFrame()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from auth_token import auth_token
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] OAuth 접근토큰 발급 테스트
##############################################################################################
# 통합 컬럼 매핑
COLUMN_MAPPING = {
'access_token': '접근토큰',
'token_type': '접근토큰유형',
'expires_in': '접근토큰유효기간_초',
'access_token_token_expired': '접근토큰유효기간_일시표시'
}
def main():
"""
OAuth 접근토큰 발급 테스트 함수
Parameters:
- grant_type (str): 권한부여 Type (client_credentials)
- appkey (str): 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)
- appsecret (str): 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)
- env_dv (str): 환경구분 (real: 실전, demo: 모의)
Returns:
- pd.DataFrame: OAuth 토큰 발급 결과
Response Fields:
- access_token: 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
- token_type: 접근토큰유형 ("Bearer")
- expires_in: 접근토큰 유효기간(초)
- access_token_token_expired: 접근토큰 유효기간(일시표시)
Example:
>>> df = auth_token(grant_type="client_credentials", appkey=trenv.my_app, appsecret=trenv.my_sec, env_dv="real")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 환경 설정에서 앱키와 앱시크릿 가져오기
config = ka.getEnv()
# 실전투자용 앱키/앱시크릿 사용 (모의투자의 경우 paper_app, paper_sec 사용)
appkey = config.get("my_app", "")
appsecret = config.get("my_sec", "")
# 앱키와 앱시크릿이 설정되어 있는지 확인
if not appkey or not appsecret:
logger.error("앱키 또는 앱시크릿이 설정되지 않았습니다. kis_devlp.yaml 파일을 확인해주세요.")
logger.info("필요한 설정: my_app (앱키), my_sec (앱시크릿)")
return
# API 호출
logger.info("OAuth 접근토큰 발급 API 호출 시작")
result = auth_token(
grant_type="client_credentials",
appkey=appkey,
appsecret=appsecret,
env_dv="real" # 실전 환경으로 설정 (필요시 "demo"로 변경)
)
# 결과 확인
if result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 결과 처리
logger.info("=== OAuth 접근토큰 발급 결과 ===")
logger.info("사용 가능한 컬럼: %s", result.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result = result.rename(columns=COLUMN_MAPPING)
logger.info("결과:")
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
# [인증] WebSocket 웹소켓 접속키 발급
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import json
import logging
import sys
from typing import Optional
import pandas as pd
import requests
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] WebSocket 웹소켓 접속키 발급
##############################################################################################
# 상수 정의
API_URL = "/oauth2/Approval"
def auth_ws_token(
grant_type: str,
appkey: str,
appsecret: str,
env_dv: str,
token: Optional[str] = ""
) -> pd.DataFrame:
"""
WebSocket 웹소켓 접속키 발급 API를 호출하여 DataFrame으로 반환합니다.
Args:
grant_type (str): [필수] 권한부여 Type (client_credentials)
appkey (str): [필수] 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)
appsecret (str): [필수] 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)
env_dv (str): [필수] 환경구분 (real: 실전, demo: 모의)
token (Optional[str]): 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
Returns:
pd.DataFrame: WebSocket 접속키 발급 결과
Example:
>>> df = auth_ws_token(
... grant_type="client_credentials",
... appkey=trenv.my_app,
... appsecret=trenv.my_sec,
... env_dv="real"
... )
>>> print(df)
"""
# 필수 파라미터 검증
if not grant_type:
logger.error("grant_type is required. (e.g. 'client_credentials')")
raise ValueError("grant_type is required. (e.g. 'client_credentials')")
if not appkey:
logger.error("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
raise ValueError("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
if not appsecret:
logger.error("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
raise ValueError("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
if not env_dv:
logger.error("env_dv is required. (real: 실전, demo: 모의)")
raise ValueError("env_dv is required. (real: 실전, demo: 모의)")
# 환경 구분에 따른 서버 URL 설정
config = ka.getEnv()
if env_dv == "real":
base_url = config.get("prod", "")
elif env_dv == "demo":
base_url = config.get("vps", "")
else:
logger.error("env_dv must be 'real' or 'demo'")
raise ValueError("env_dv must be 'real' or 'demo'")
url = f"{base_url}{API_URL}"
# 헤더 설정
headers = {
"Content-Type": "application/json",
"Accept": "text/plain",
"charset": "UTF-8"
}
# 요청 데이터
data = {
"grant_type": grant_type,
"appkey": appkey,
"secretkey": appsecret,
}
# token이 있는 경우에만 data에 추가
if token:
data["token"] = token
try:
# POST 방식으로 직접 API 호출
response = requests.post(url, data=json.dumps(data), headers=headers)
if response.status_code == 200:
# 응답 데이터를 DataFrame으로 반환 (1개 row)
response_data = response.json()
current_data = pd.DataFrame([response_data])
logger.info("WebSocket 접속키 발급 성공")
return current_data
else:
logger.error("API call failed: %s - %s", response.status_code, response.text)
return pd.DataFrame()
except requests.RequestException as e:
logger.error("Request failed: %s", str(e))
return pd.DataFrame()
except json.JSONDecodeError as e:
logger.error("JSON decode failed: %s", str(e))
return pd.DataFrame()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from auth_ws_token import auth_ws_token
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] WebSocket 웹소켓 접속키 발급 테스트
##############################################################################################
# 통합 컬럼 매핑
COLUMN_MAPPING = {
'code': '응답코드',
'message': '응답메세지',
'approval_key': '웹소켓접속키'
}
def main():
"""
WebSocket 웹소켓 접속키 발급 테스트 함수
Parameters:
- grant_type (str): 권한부여 Type (client_credentials)
- appkey (str): 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)
- appsecret (str): 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)
- env_dv (str): 환경구분 (real: 실전, demo: 모의)
- token (str): 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
Returns:
- pd.DataFrame: WebSocket 접속키 발급 결과
Response Fields:
- code: 응답코드 (HTTP 응답코드)
- message: 응답메세지
Example:
>>> df = auth_ws_token(grant_type="client_credentials", appkey=trenv.my_app, appsecret=trenv.my_sec, env_dv="real")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# OAuth 토큰 발급 (WebSocket 접속키 발급에 필요)
logger.info("OAuth 토큰 발급 중...")
ka.auth() # 토큰 발급
logger.info("OAuth 토큰 발급 완료")
# 환경 설정에서 토큰, 앱키, 앱시크릿 가져오기
trenv = ka.getTREnv()
appkey = trenv.my_app
appsecret = trenv.my_sec
# 토큰 및 앱키가 설정되어 있는지 확인
if not appkey or not appsecret:
logger.error("앱키 또는 앱시크릿이 설정되지 않았습니다.")
return
# API 호출
logger.info("WebSocket 웹소켓 접속키 발급 API 호출 시작")
result = auth_ws_token(
grant_type="client_credentials",
appkey=appkey,
appsecret=appsecret,
env_dv="real", # 실전 환경으로 설정 (필요시 "demo"로 변경)
)
# 결과 확인
if result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 결과 처리
logger.info("=== WebSocket 웹소켓 접속키 발급 결과 ===")
logger.info("사용 가능한 컬럼: %s", result.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result = result.rename(columns=COLUMN_MAPPING)
logger.info("결과:")
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
# [장내채권] 기본시세 - 장내채권 평균단가조회
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import logging
import time
import sys
from typing import Optional, Tuple
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권 평균단가조회 [국내채권-158]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-bond/v1/quotations/avg-unit"
def avg_unit(
inqr_strt_dt: str, # 조회시작일자
inqr_end_dt: str, # 조회종료일자
pdno: str, # 상품번호
prdt_type_cd: str, # 상품유형코드
vrfc_kind_cd: str, # 검증종류코드
NK30: str = "", # 연속조회키30
FK100: str = "", # 연속조회검색조건100
dataframe1: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output1)
dataframe2: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output2)
dataframe3: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output3)
tr_cont: str = "",
depth: int = 0,
max_depth: int = 10
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""
[장내채권] 기본시세
장내채권 평균단가조회[국내주식-158]
장내채권 평균단가조회 API를 호출하여 DataFrame으로 반환합니다.
Args:
inqr_strt_dt (str): 조회 시작 일자 (예: '20230101')
inqr_end_dt (str): 조회 종료 일자 (예: '20230131')
pdno (str): 상품번호, 공백: 전체, 특정종목 조회시 : 종목코드
prdt_type_cd (str): 상품유형코드 (예: '302')
vrfc_kind_cd (str): 검증종류코드 (예: '00')
NK30 (str): 연속조회키30, 공백 허용
FK100 (str): 연속조회검색조건100, 공백 허용
dataframe1 (Optional[pd.DataFrame]): 누적 데이터프레임 (output1)
dataframe2 (Optional[pd.DataFrame]): 누적 데이터프레임 (output2)
dataframe3 (Optional[pd.DataFrame]): 누적 데이터프레임 (output3)
tr_cont (str): 연속 거래 여부
depth (int): 현재 재귀 깊이
max_depth (int): 최대 재귀 깊이 (기본값: 10)
Returns:
Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: 장내채권 평균단가조회 데이터
Example:
>>> df1, df2, df3 = avg_unit(
... inqr_strt_dt='20230101',
... inqr_end_dt='20230131',
... pdno='KR2033022D33',
... prdt_type_cd='302',
... vrfc_kind_cd='00',
... )
>>> print(df1)
>>> print(df2)
>>> print(df3)
"""
# 필수 파라미터 검증
if not inqr_strt_dt:
logger.error("inqr_strt_dt is required. (e.g. '20230101')")
raise ValueError("inqr_strt_dt is required. (e.g. '20230101')")
if not inqr_end_dt:
logger.error("inqr_end_dt is required. (e.g. '20230131')")
raise ValueError("inqr_end_dt is required. (e.g. '20230131')")
if not prdt_type_cd:
logger.error("prdt_type_cd is required. (e.g. '302')")
raise ValueError("prdt_type_cd is required. (e.g. '302')")
if not vrfc_kind_cd:
logger.error("vrfc_kind_cd is required. (e.g. '00')")
raise ValueError("vrfc_kind_cd is required. (e.g. '00')")
# 최대 재귀 깊이 체크
if depth >= max_depth:
logger.warning("Maximum recursion depth (%d) reached. Stopping further requests.", max_depth)
return (
dataframe1 if dataframe1 is not None else pd.DataFrame(),
dataframe2 if dataframe2 is not None else pd.DataFrame(),
dataframe3 if dataframe3 is not None else pd.DataFrame()
)
tr_id = "CTPF2005R"
params = {
"INQR_STRT_DT": inqr_strt_dt,
"INQR_END_DT": inqr_end_dt,
"PDNO": pdno,
"PRDT_TYPE_CD": prdt_type_cd,
"VRFC_KIND_CD": vrfc_kind_cd,
"CTX_AREA_NK30": NK30,
"CTX_AREA_FK100": FK100,
}
res = ka._url_fetch(API_URL, tr_id, tr_cont, params)
if res.isOK():
# 연속조회 정보 업데이트
tr_cont = res.getHeader().tr_cont
NK30 = res.getBody().ctx_area_nk30
FK100 = res.getBody().ctx_area_fk100
# output1 데이터 처리
current_data1 = pd.DataFrame(res.getBody().output1)
if dataframe1 is not None:
dataframe1 = pd.concat([dataframe1, current_data1], ignore_index=True)
else:
dataframe1 = current_data1
# output2 데이터 처리
current_data2 = pd.DataFrame(res.getBody().output2)
if dataframe2 is not None:
dataframe2 = pd.concat([dataframe2, current_data2], ignore_index=True)
else:
dataframe2 = current_data2
# output3 데이터 처리
current_data3 = pd.DataFrame(res.getBody().output3)
if dataframe3 is not None:
dataframe3 = pd.concat([dataframe3, current_data3], ignore_index=True)
else:
dataframe3 = current_data3
if tr_cont in ["M", "F"]: # 다음 페이지 존재
logger.info("Call Next page...")
ka.smart_sleep() # 시스템 안정적 운영을 위한 지연
return avg_unit(
inqr_strt_dt,
inqr_end_dt,
pdno,
prdt_type_cd,
vrfc_kind_cd,
NK30,
FK100,
dataframe1,
dataframe2,
dataframe3,
"N",
depth + 1,
max_depth
)
else:
logger.info("Data fetch complete.")
return dataframe1, dataframe2, dataframe3
else:
logger.error("API call failed: %s - %s", res.getErrorCode(), res.getErrorMessage())
res.printError(API_URL)
return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()

View File

@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from avg_unit import avg_unit
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권 평균단가조회 [국내채권-158]
##############################################################################################
# 통합 컬럼 매핑 (모든 output에서 공통 사용)
COLUMN_MAPPING = {
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_unpr': '한국신용평가단가',
'kbp_unpr': '한국채권평가단가',
'nice_evlu_unpr': '한국신용정보평가단가',
'fnp_unpr': '에프앤자산평가단가',
'avg_evlu_unpr': '평균평가단가',
'kis_crdt_grad_text': '한국신용평가신용등급내용',
'kbp_crdt_grad_text': '한국채권평가신용등급내용',
'nice_crdt_grad_text': '한국신용정보신용등급내용',
'fnp_crdt_grad_text': '에프앤자산평가신용등급내용',
'chng_yn': '변경여부',
'kis_erng_rt': '한국신용평가수익율',
'kbp_erng_rt': '한국채권평가수익율',
'nice_evlu_erng_rt': '한국신용정보평가수익율',
'fnp_erng_rt': '에프앤자산평가수익율',
'avg_evlu_erng_rt': '평균평가수익율',
'kis_rf_unpr': '한국신용평가RF단가',
'kbp_rf_unpr': '한국채권평가RF단가',
'nice_evlu_rf_unpr': '한국신용정보평가RF단가',
'avg_evlu_rf_unpr': '평균평가RF단가',
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_evlu_amt': '한국신용평가평가금액',
'kbp_evlu_amt': '한국채권평가평가금액',
'nice_evlu_amt': '한국신용정보평가금액',
'fnp_evlu_amt': '에프앤자산평가평가금액',
'avg_evlu_amt': '평균평가금액',
'chng_yn': '변경여부',
'output3': '응답상세',
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_crcy_cd': '한국신용평가통화코드',
'kis_evlu_unit_pric': '한국신용평가평가단위가격',
'kis_evlu_pric': '한국신용평가평가가격',
'kbp_crcy_cd': '한국채권평가통화코드',
'kbp_evlu_unit_pric': '한국채권평가평가단위가격',
'kbp_evlu_pric': '한국채권평가평가가격',
'nice_crcy_cd': '한국신용정보통화코드',
'nice_evlu_unit_pric': '한국신용정보평가단위가격',
'nice_evlu_pric': '한국신용정보평가가격',
'avg_evlu_unit_pric': '평균평가단위가격',
'avg_evlu_pric': '평균평가가격',
'chng_yn': '변경여부'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 기본시세
장내채권 평균단가조회[국내주식-158]
장내채권 평균단가조회 테스트 함수`
Parameters:
- inqr_strt_dt (str): 조회시작일자 (일자 ~)
- inqr_end_dt (str): 조회종료일자 (~ 일자)
- pdno (str): 상품번호 (공백: 전체, 특정종목 조회시 : 종목코드)
- prdt_type_cd (str): 상품유형코드 (Unique key(302))
- vrfc_kind_cd (str): 검증종류코드 (Unique key(00))
Returns:
- Tuple[DataFrame, ...]: 장내채권 평균단가조회 결과
Example:
>>> df1, df2, df3 = avg_unit(inqr_strt_dt="20250101", inqr_end_dt="20250131", pdno="KR2033022D33", prdt_type_cd="302", vrfc_kind_cd="00")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# API 호출
logger.info("API 호출 시작: 장내채권 평균단가조회")
result1, result2, result3 = avg_unit(
inqr_strt_dt="20240101", # 조회시작일자
inqr_end_dt="20250630", # 조회종료일자
pdno="KR103502GA34", # 상품번호
prdt_type_cd="302", # 상품유형코드
vrfc_kind_cd="00", # 검증종류코드
)
# 결과 확인
results = [result1, result2, result3]
if all(result is None or result.empty for result in results):
logger.warning("조회된 데이터가 없습니다.")
return
# output1 결과 처리
logger.info("=== output1 조회 ===")
if not result1.empty:
logger.info("사용 가능한 컬럼: %s", result1.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result1 = result1.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result1.columns:
result1[col] = pd.to_numeric(result1[col], errors='coerce').round(2)
logger.info("output1 결과:")
print(result1)
else:
logger.info("output1 데이터가 없습니다.")
# output2 결과 처리
logger.info("=== output2 조회 ===")
if not result2.empty:
logger.info("사용 가능한 컬럼: %s", result2.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result2 = result2.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result2.columns:
result2[col] = pd.to_numeric(result2[col], errors='coerce').round(2)
logger.info("output2 결과:")
print(result2)
else:
logger.info("output2 데이터가 없습니다.")
# output3 결과 처리
logger.info("=== output3 조회 ===")
if not result3.empty:
logger.info("사용 가능한 컬럼: %s", result3.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result3 = result3.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result3.columns:
result3[col] = pd.to_numeric(result3[col], errors='coerce').round(2)
logger.info("output3 결과:")
print(result3)
else:
logger.info("output3 데이터가 없습니다.")
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,97 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간호가 [실시간-053]
##############################################################################################
def bond_asking_price(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
일반채권 실시간호가[H0BJASP0]
일반채권 실시간호가 API를 통해 실시간 데이터를 구독합니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부를 나타냅니다.
tr_key (str): [필수] 종목코드. 빈 문자열일 수 없습니다.
Returns:
message (dict): 실시간 데이터 메시지.
columns (list[str]): 응답 데이터의 컬럼 정보.
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생합니다.
Example:
>>> msg, columns = bond_asking_price("1", "005930")
>>> print(msg, columns)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key is required and cannot be an empty string")
tr_id = "H0BJASP0"
params = {
"tr_key": tr_key,
}
# 데이터 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"stnd_iscd",
"stck_cntg_hour",
"askp_ert1",
"bidp_ert1",
"askp1",
"bidp1",
"askp_rsqn1",
"bidp_rsqn1",
"askp_ert2",
"bidp_ert2",
"askp2",
"bidp2",
"askp_rsqn2",
"bidp_rsqn2",
"askp_ert3",
"bidp_ert3",
"askp3",
"bidp3",
"askp_rsqn3",
"bidp_rsqn3",
"askp_ert4",
"bidp_ert4",
"askp4",
"bidp4",
"askp_rsqn4",
"bidp_rsqn4",
"askp_ert5",
"bidp_ert5",
"askp5",
"bidp5",
"askp_rsqn52",
"bidp_rsqn53",
"total_askp_rsqn",
"total_bidp_rsqn",
]
return msg, columns

View File

@@ -0,0 +1,133 @@
"""
Created on 2025-07-09
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
from bond_asking_price import bond_asking_price
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간호가 [실시간-053]
##############################################################################################
COLUMN_MAPPING = {
"stnd_iscd": "표준종목코드",
"stck_cntg_hour": "주식체결시간",
"askp_ert1": "매도호가수익률1",
"bidp_ert1": "매수호가수익률1",
"askp1": "매도호가1",
"bidp1": "매수호가1",
"askp_rsqn1": "매도호가잔량1",
"bidp_rsqn1": "매수호가잔량1",
"askp_ert2": "매도호가수익률2",
"bidp_ert2": "매수호가수익률2",
"askp2": "매도호가2",
"bidp2": "매수호가2",
"askp_rsqn2": "매도호가잔량2",
"bidp_rsqn2": "매수호가잔량2",
"askp_ert3": "매도호가수익률3",
"bidp_ert3": "매수호가수익률3",
"askp3": "매도호가3",
"bidp3": "매수호가3",
"askp_rsqn3": "매도호가잔량3",
"bidp_rsqn3": "매수호가잔량3",
"askp_ert4": "매도호가수익률4",
"bidp_ert4": "매수호가수익률4",
"askp4": "매도호가4",
"bidp4": "매수호가4",
"askp_rsqn4": "매도호가잔량4",
"bidp_rsqn4": "매수호가잔량4",
"askp_ert5": "매도호가수익률5",
"bidp_ert5": "매수호가수익률5",
"askp5": "매도호가5",
"bidp5": "매수호가5",
"askp_rsqn52": "매도호가잔량5",
"bidp_rsqn53": "매수호가잔량5",
"total_askp_rsqn": "총매도호가잔량",
"total_bidp_rsqn": "총매수호가잔량"
}
NUMERIC_COLUMNS = [
"매도호가수익률1", "매수호가수익률1", "매도호가1", "매수호가1", "매도호가잔량1", "매수호가잔량1",
"매도호가수익률2", "매수호가수익률2", "매도호가2", "매수호가2", "매도호가잔량2", "매수호가잔량2",
"매도호가수익률3", "매수호가수익률3", "매도호가3", "매수호가3", "매도호가잔량3", "매수호가잔량3",
"매도호가수익률4", "매수호가수익률4", "매도호가4", "매수호가4", "매도호가잔량4", "매수호가잔량4",
"매도호가수익률5", "매수호가수익률5", "매도호가5", "매수호가5", "매도호가잔량5", "매수호가잔량5",
"총매도호가잔량", "총매수호가잔량"
]
def main():
"""
일반채권 실시간호가
일반채권 실시간호가 API입니다.
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
[호출 데이터]
헤더와 바디 값을 합쳐 JSON 형태로 전송합니다.
[응답 데이터]
1. 정상 등록 여부 (JSON)
- JSON["body"]["msg1"] - 정상 응답 시, SUBSCRIBE SUCCESS
- JSON["body"]["output"]["iv"] - 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector)
- JSON["body"]["output"]["key"] - 실시간 결과 복호화에 필요한 AES256 Key
2. 실시간 결과 응답 ( | 로 구분되는 값)
ex) 0|H0STCNT0|004|005930^123929^73100^5^...
- 암호화 유무 : 0 암호화 되지 않은 데이터 / 1 암호화된 데이터
- TR_ID : 등록한 tr_id (ex. H0STCNT0)
- 데이터 건수 : (ex. 001 인 경우 데이터 건수 1건, 004인 경우 데이터 건수 4건)
- 응답 데이터 : 아래 response 데이터 참조 ( ^로 구분됨)
"""
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 인증 토큰 발급
ka.auth()
ka.auth_ws()
# 인증(auth_ws()) 이후에 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 조회
kws.subscribe(request=bond_asking_price, data=["KR103502GA34", "KR6095572D81"])
# 결과 표시
def on_result(ws, tr_id: str, result: pd.DataFrame, data_map: dict):
try:
# 컬럼 매핑
result.rename(columns=COLUMN_MAPPING, inplace=True)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
logging.info("결과:")
print(result)
except Exception as e:
logging.error(f"결과 처리 중 오류: {e}")
logging.error(f"받은 데이터: {result}")
kws.start(on_result=on_result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,85 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간체결가 [실시간-052]
##############################################################################################
def bond_ccnl(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
일반채권 실시간체결가[H0BJCNT0] 구독 함수
한국투자증권 웹소켓 API를 통해 일반채권의 실시간 체결가 데이터를 구독합니다.
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부
tr_key (str): [필수] 종목코드 (빈 문자열 불가)
Returns:
message (dict): 실시간 데이터 구독 결과 메시지
columns (list[str]): 응답 데이터의 컬럼 정보
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생
Example:
>>> msg, columns = bond_ccnl("1", "005930")
>>> print(msg, columns)
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key는 빈 문자열일 수 없습니다.")
tr_id = "H0BJCNT0"
params = {
"tr_key": tr_key,
}
# 데이터 구독 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"stnd_iscd", # 표준종목코드
"bond_isnm", # 채권종목명
"stck_cntg_hour", # 주식체결시간
"prdy_vrss_sign", # 전일대비부호
"prdy_vrss", # 전일대비
"prdy_ctrt", # 전일대비율
"stck_prpr", # 현재가
"cntg_vol", # 체결거래량
"stck_oprc", # 시가
"stck_hgpr", # 고가
"stck_lwpr", # 저가
"stck_prdy_clpr", # 전일종가
"bond_cntg_ert", # 현재수익률
"oprc_ert", # 시가수익률
"hgpr_ert", # 고가수익률
"lwpr_ert", # 저가수익률
"acml_vol", # 누적거래량
"prdy_vol", # 전일거래량
"cntg_type_cls_code", # 체결유형코드
]
return msg, columns

View File

@@ -0,0 +1,112 @@
"""
Created on 2025-07-09
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
from bond_ccnl import bond_ccnl
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간체결가 [실시간-052]
##############################################################################################
COLUMN_MAPPING = {
"stnd_iscd": "표준종목코드",
"bond_isnm": "채권종목명",
"stck_cntg_hour": "주식체결시간",
"prdy_vrss_sign": "전일대비부호",
"prdy_vrss": "전일대비",
"prdy_ctrt": "전일대비율",
"stck_prpr": "현재가",
"cntg_vol": "체결거래량",
"stck_oprc": "시가",
"stck_hgpr": "고가",
"stck_lwpr": "저가",
"stck_prdy_clpr": "전일종가",
"bond_cntg_ert": "현재수익률",
"oprc_ert": "시가수익률",
"hgpr_ert": "고가수익률",
"lwpr_ert": "저가수익률",
"acml_vol": "누적거래량",
"prdy_vol": "전일거래량",
"cntg_type_cls_code": "체결유형코드"
}
NUMERIC_COLUMNS = [
"전일대비", "전일대비율", "현재가", "체결거래량", "시가", "고가", "저가", "전일종가",
"현재수익률", "시가수익률", "고가수익률", "저가수익률", "누적거래량", "전일거래량"
]
def main():
"""
일반채권 실시간체결가
일반채권 실시간체결가 API입니다.
[호출 데이터]
헤더와 바디 값을 합쳐 JSON 형태로 전송합니다.
[응답 데이터]
1. 정상 등록 여부 (JSON)
- JSON["body"]["msg1"] - 정상 응답 시, SUBSCRIBE SUCCESS
- JSON["body"]["output"]["iv"] - 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector)
- JSON["body"]["output"]["key"] - 실시간 결과 복호화에 필요한 AES256 Key
2. 실시간 결과 응답 ( | 로 구분되는 값)
ex) 0|H0STCNT0|004|005930^123929^73100^5^...
- 암호화 유무 : 0 암호화 되지 않은 데이터 / 1 암호화된 데이터
- TR_ID : 등록한 tr_id (ex. H0STCNT0)
- 데이터 건수 : (ex. 001 인 경우 데이터 건수 1건, 004인 경우 데이터 건수 4건)
- 응답 데이터 : 아래 response 데이터 참조 ( ^로 구분됨)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 인증 토큰 발급
ka.auth()
ka.auth_ws()
# 인증(auth_ws()) 이후에 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 조회
kws.subscribe(request=bond_ccnl, data=["KR103502GA34", "KR6095572D81"])
# 결과 표시
def on_result(ws, tr_id: str, result: pd.DataFrame, data_map: dict):
try:
# 컬럼 매핑
result.rename(columns=COLUMN_MAPPING, inplace=True)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
logging.info("결과:")
print(result)
except Exception as e:
logging.error(f"결과 처리 중 오류: {e}")
logging.error(f"받은 데이터: {result}")
kws.start(on_result=on_result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,84 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 채권지수 실시간체결가 [실시간-060]
##############################################################################################
def bond_index_ccnl(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
채권지수 실시간체결가[H0BICNT0]
채권지수 실시간체결가 API를 통해 실시간 데이터를 구독합니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부를 나타냅니다.
tr_key (str): [필수] 구독할 종목코드. 빈 문자열이 아니어야 합니다.
Returns:
message (dict): 구독 요청에 대한 응답 메시지.
columns (list[str]): 실시간 데이터의 컬럼 정보.
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생합니다.
Example:
>>> msg, columns = bond_index_ccnl("1", "005930")
>>> print(msg, columns)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key is required and cannot be an empty string")
tr_id = "H0BICNT0"
params = {
"tr_key": tr_key,
}
# 데이터 구독 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"nmix_id", # 지수ID
"stnd_date1", # 기준일자1
"trnm_hour", # 전송시간
"totl_ernn_nmix_oprc", # 총수익지수시가지수
"totl_ernn_nmix_hgpr", # 총수익지수최고가
"totl_ernn_nmix_lwpr", # 총수익지수최저가
"totl_ernn_nmix", # 총수익지수
"prdy_totl_ernn_nmix", # 전일총수익지수
"totl_ernn_nmix_prdy_vrss", # 총수익지수전일대비
"totl_ernn_nmix_prdy_vrss_sign", # 총수익지수전일대비부호
"totl_ernn_nmix_prdy_ctrt", # 총수익지수전일대비율
"clen_prc_nmix", # 순가격지수
"mrkt_prc_nmix", # 시장가격지수
"bond_call_rnvs_nmix", # Call재투자지수
"bond_zero_rnvs_nmix", # Zero재투자지수
"bond_futs_thpr", # 선물이론가격
"bond_avrg_drtn_val", # 평균듀레이션
"bond_avrg_cnvx_val", # 평균컨벡서티
"bond_avrg_ytm_val", # 평균YTM
"bond_avrg_frdl_ytm_val", # 평균선도YTM
]
return msg, columns

Some files were not shown because too many files have changed in this diff Show More