initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
239
App.tsx
Normal 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
26
Dockerfile
Normal 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
20
README.md
Normal 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
15
backend/ReadMe.md
Normal 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
91
backend/api.md
Normal 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
126
backend/models.md
Normal 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
71
backend/tables.md
Normal 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
103
components/CommonUI.tsx
Normal 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>
|
||||
);
|
||||
21
components/InsightBubble.tsx
Normal file
21
components/InsightBubble.tsx
Normal 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>
|
||||
);
|
||||
183
components/StockDetailModal.tsx
Normal file
183
components/StockDetailModal.tsx
Normal 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
90
components/StockRow.tsx
Normal 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
510
components/TradeModal.tsx
Normal 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
49
constants.tsx
Normal 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
41
index.html
Normal 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
16
index.tsx
Normal 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
5
metadata.json
Normal 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
2783
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
308
pages/AiInsights.tsx
Normal 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
289
pages/AutoTrading.tsx
Normal 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
195
pages/Dashboard.tsx
Normal 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
242
pages/Discovery.tsx
Normal 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
81
pages/History.tsx
Normal 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
186
pages/News.tsx
Normal 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
264
pages/Settings.tsx
Normal 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
93
pages/Stocks.tsx
Normal 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
89
pages/Trading.tsx
Normal 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
|
||||
216
pages/WatchlistManagement.tsx
Normal file
216
pages/WatchlistManagement.tsx
Normal 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
65
services/aiService.ts
Normal 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
184
services/dbService.ts
Normal 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
33
services/geminiService.ts
Normal 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
61
services/kisService.ts
Normal 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
38
services/naverService.ts
Normal 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()
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
49
services/telegramService.ts
Normal file
49
services/telegramService.ts
Normal 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
29
tsconfig.json
Normal 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
131
types.ts
Normal 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
23
vite.config.ts
Normal 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
167
한국투자증권(API)/.gitignore
vendored
Normal 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/
|
||||
77
한국투자증권(API)/MCP/Kis Trading MCP/.dockerignore
Normal file
77
한국투자증권(API)/MCP/Kis Trading MCP/.dockerignore
Normal 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
|
||||
4
한국투자증권(API)/MCP/Kis Trading MCP/.env.live
Normal file
4
한국투자증권(API)/MCP/Kis Trading MCP/.env.live
Normal file
@@ -0,0 +1,4 @@
|
||||
MCP_TYPE=sse
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_PORT=3000
|
||||
MCP_PATH=/sse
|
||||
23
한국투자증권(API)/MCP/Kis Trading MCP/.gitignore
vendored
Normal file
23
한국투자증권(API)/MCP/Kis Trading MCP/.gitignore
vendored
Normal 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
|
||||
1
한국투자증권(API)/MCP/Kis Trading MCP/.python-version
Normal file
1
한국투자증권(API)/MCP/Kis Trading MCP/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
63
한국투자증권(API)/MCP/Kis Trading MCP/Dockerfile
Normal file
63
한국투자증권(API)/MCP/Kis Trading MCP/Dockerfile
Normal 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"]
|
||||
391
한국투자증권(API)/MCP/Kis Trading MCP/Readme.md
Normal file
391
한국투자증권(API)/MCP/Kis Trading MCP/Readme.md
Normal 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를 통해 제공되는 정보의 정확성은 한국투자증권에 의존하며, 투자 전 반드시 정보를 검증하시기 바랍니다
|
||||
- 🧠 **신중한 판단**: 충분한 조사와 신중한 판단 없이 투자하지 마시기 바랍니다
|
||||
- 🎯 **모의투자 권장**: 실전 투자 전 반드시 모의투자를 통해 충분히 연습하시기 바랍니다
|
||||
|
||||
**투자는 본인의 판단과 책임 하에 이루어져야 하며, 본 도구 사용으로 인한 어떠한 손실에 대해서도 개발자는 책임지지 않습니다.**
|
||||
105
한국투자증권(API)/MCP/Kis Trading MCP/configs/auth.json
Normal file
105
한국투자증권(API)/MCP/Kis Trading MCP/configs/auth.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1022
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_bond.json
Normal file
1022
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_bond.json
Normal file
File diff suppressed because it is too large
Load Diff
1311
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_futureoption.json
Normal file
1311
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_futureoption.json
Normal file
File diff suppressed because it is too large
Load Diff
4881
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_stock.json
Normal file
4881
한국투자증권(API)/MCP/Kis Trading MCP/configs/domestic_stock.json
Normal file
File diff suppressed because it is too large
Load Diff
172
한국투자증권(API)/MCP/Kis Trading MCP/configs/elw.json
Normal file
172
한국투자증권(API)/MCP/Kis Trading MCP/configs/elw.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
한국투자증권(API)/MCP/Kis Trading MCP/configs/etfetn.json
Normal file
70
한국투자증권(API)/MCP/Kis Trading MCP/configs/etfetn.json
Normal 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": "[필수] 입력 종목코드"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1670
한국투자증권(API)/MCP/Kis Trading MCP/configs/overseas_futureoption.json
Normal file
1670
한국투자증권(API)/MCP/Kis Trading MCP/configs/overseas_futureoption.json
Normal file
File diff suppressed because it is too large
Load Diff
2914
한국투자증권(API)/MCP/Kis Trading MCP/configs/overseas_stock.json
Normal file
2914
한국투자증권(API)/MCP/Kis Trading MCP/configs/overseas_stock.json
Normal file
File diff suppressed because it is too large
Load Diff
29
한국투자증권(API)/MCP/Kis Trading MCP/model/__init__.py
Normal file
29
한국투자증권(API)/MCP/Kis Trading MCP/model/__init__.py
Normal 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
|
||||
]
|
||||
11
한국투자증권(API)/MCP/Kis Trading MCP/model/auth.py
Normal file
11
한국투자증권(API)/MCP/Kis Trading MCP/model/auth.py
Normal 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) # 종목코드
|
||||
5
한국투자증권(API)/MCP/Kis Trading MCP/model/base.py
Normal file
5
한국투자증권(API)/MCP/Kis Trading MCP/model/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
# SQLAlchemy Base 클래스
|
||||
Base = declarative_base()
|
||||
|
||||
12
한국투자증권(API)/MCP/Kis Trading MCP/model/domestic_bond.py
Normal file
12
한국투자증권(API)/MCP/Kis Trading MCP/model/domestic_bond.py
Normal 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) # 거래소 코드
|
||||
@@ -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) # 거래소 코드
|
||||
12
한국투자증권(API)/MCP/Kis Trading MCP/model/domestic_stock.py
Normal file
12
한국투자증권(API)/MCP/Kis Trading MCP/model/domestic_stock.py
Normal 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) # 거래소 코드
|
||||
12
한국투자증권(API)/MCP/Kis Trading MCP/model/elw.py
Normal file
12
한국투자증권(API)/MCP/Kis Trading MCP/model/elw.py
Normal 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) # 거래소 코드
|
||||
12
한국투자증권(API)/MCP/Kis Trading MCP/model/etfetn.py
Normal file
12
한국투자증권(API)/MCP/Kis Trading MCP/model/etfetn.py
Normal 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) # 거래소 코드
|
||||
@@ -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) # 거래소 코드
|
||||
12
한국투자증권(API)/MCP/Kis Trading MCP/model/overseas_stock.py
Normal file
12
한국투자증권(API)/MCP/Kis Trading MCP/model/overseas_stock.py
Normal 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) # 거래소 코드
|
||||
15
한국투자증권(API)/MCP/Kis Trading MCP/model/updated.py
Normal file
15
한국투자증권(API)/MCP/Kis Trading MCP/model/updated.py
Normal 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}')>"
|
||||
|
||||
3
한국투자증권(API)/MCP/Kis Trading MCP/module/__init__.py
Normal file
3
한국투자증권(API)/MCP/Kis Trading MCP/module/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .decorator import singleton
|
||||
from .plugin import setup_environment, EnvironmentConfig, setup_kis_config, MasterFileManager
|
||||
from .middleware import EnvironmentMiddleware
|
||||
32
한국투자증권(API)/MCP/Kis Trading MCP/module/decorator.py
Normal file
32
한국투자증권(API)/MCP/Kis Trading MCP/module/decorator.py
Normal 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
|
||||
6
한국투자증권(API)/MCP/Kis Trading MCP/module/factory.py
Normal file
6
한국투자증권(API)/MCP/Kis Trading MCP/module/factory.py
Normal 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"
|
||||
46
한국투자증권(API)/MCP/Kis Trading MCP/module/middleware.py
Normal file
46
한국투자증권(API)/MCP/Kis Trading MCP/module/middleware.py
Normal 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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
540
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/database.py
Normal file
540
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/database.py
Normal 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()
|
||||
43
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/environment.py
Normal file
43
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/environment.py
Normal 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")
|
||||
)
|
||||
163
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/kis.py
Normal file
163
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/kis.py
Normal 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
|
||||
1593
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/master_file.py
Normal file
1593
한국투자증권(API)/MCP/Kis Trading MCP/module/plugin/master_file.py
Normal file
File diff suppressed because it is too large
Load Diff
17
한국투자증권(API)/MCP/Kis Trading MCP/pyproject.toml
Normal file
17
한국투자증권(API)/MCP/Kis Trading MCP/pyproject.toml
Normal 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",
|
||||
]
|
||||
105
한국투자증권(API)/MCP/Kis Trading MCP/server.py
Normal file
105
한국투자증권(API)/MCP/Kis Trading MCP/server.py
Normal 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)")
|
||||
8
한국투자증권(API)/MCP/Kis Trading MCP/tools/__init__.py
Normal file
8
한국투자증권(API)/MCP/Kis Trading MCP/tools/__init__.py
Normal 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
|
||||
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/auth.py
Normal file
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/auth.py
Normal 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"
|
||||
818
한국투자증권(API)/MCP/Kis Trading MCP/tools/base.py
Normal file
818
한국투자증권(API)/MCP/Kis Trading MCP/tools/base.py
Normal 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)}
|
||||
10
한국투자증권(API)/MCP/Kis Trading MCP/tools/domestic_bond.py
Normal file
10
한국투자증권(API)/MCP/Kis Trading MCP/tools/domestic_bond.py
Normal 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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/domestic_stock.py
Normal file
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/domestic_stock.py
Normal 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"
|
||||
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/elw.py
Normal file
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/elw.py
Normal 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"
|
||||
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/etfetn.py
Normal file
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/etfetn.py
Normal 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"
|
||||
@@ -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"
|
||||
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/overseas_stock.py
Normal file
9
한국투자증권(API)/MCP/Kis Trading MCP/tools/overseas_stock.py
Normal 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"
|
||||
383
한국투자증권(API)/MCP/MCP AI 도구 연결 방법.md
Normal file
383
한국투자증권(API)/MCP/MCP AI 도구 연결 방법.md
Normal 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가 든든한 파트너가 되겠습니다.
|
||||
78
한국투자증권(API)/MCP/README.MD
Normal file
78
한국투자증권(API)/MCP/README.MD
Normal 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가 담당합니다. 더 스마트한 투자를 위한 새로운 도구를 경험해 보세요.*
|
||||
|
||||
---
|
||||
314
한국투자증권(API)/README.md
Normal file
314
한국투자증권(API)/README.md
Normal 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)에 언제든 궁금한 점을 물어보세요.
|
||||
112
한국투자증권(API)/docs/convention.md
Normal file
112
한국투자증권(API)/docs/convention.md
Normal 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” 을 사용하여 기록 할 것
|
||||
120
한국투자증권(API)/examples_llm/auth/auth_token/auth_token.py
Normal file
120
한국투자증권(API)/examples_llm/auth/auth_token/auth_token.py
Normal 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()
|
||||
104
한국투자증권(API)/examples_llm/auth/auth_token/chk_auth_token.py
Normal file
104
한국투자증권(API)/examples_llm/auth/auth_token/chk_auth_token.py
Normal 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()
|
||||
127
한국투자증권(API)/examples_llm/auth/auth_ws_token/auth_ws_token.py
Normal file
127
한국투자증권(API)/examples_llm/auth/auth_ws_token/auth_ws_token.py
Normal 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()
|
||||
104
한국투자증권(API)/examples_llm/auth/auth_ws_token/chk_auth_ws_token.py
Normal file
104
한국투자증권(API)/examples_llm/auth/auth_ws_token/chk_auth_ws_token.py
Normal 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()
|
||||
171
한국투자증권(API)/examples_llm/domestic_bond/avg_unit/avg_unit.py
Normal file
171
한국투자증권(API)/examples_llm/domestic_bond/avg_unit/avg_unit.py
Normal 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()
|
||||
184
한국투자증권(API)/examples_llm/domestic_bond/avg_unit/chk_avg_unit.py
Normal file
184
한국투자증권(API)/examples_llm/domestic_bond/avg_unit/chk_avg_unit.py
Normal 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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user