240 lines
13 KiB
TypeScript
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;
|