Files
KisStock/App.tsx
2026-01-31 22:34:57 +09:00

240 lines
13 KiB
TypeScript

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;