..
This commit is contained in:
38
App.tsx
38
App.tsx
@@ -15,7 +15,9 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Compass
|
Compass,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ApiSettings, StockItem, OrderType, MarketType, TradeOrder, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from './types';
|
import { ApiSettings, StockItem, OrderType, MarketType, TradeOrder, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from './types';
|
||||||
import { MOCK_STOCKS } from './constants';
|
import { MOCK_STOCKS } from './constants';
|
||||||
@@ -38,14 +40,39 @@ interface LogEntry {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopNavItem: React.FC<{ to: string, icon: React.ReactNode, label: string, active: boolean }> = ({ to, icon, label, active }) => (
|
const TopNavItem: React.FC<{ to: string, icon: React.ReactNode, label: string, active: boolean, className?: string }> = ({ to, icon, label, active, className = "" }) => (
|
||||||
<Link to={to} className={`flex items-center gap-2 px-3 py-2 rounded-xl font-black text-[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'}`}>
|
<Link to={to} className={`flex items-center gap-2 px-3 py-2 rounded-xl font-black text-[12px] uppercase tracking-wider transition-all ${active ? 'bg-blue-600 text-white shadow-md' : 'text-slate-400 hover:text-blue-600 hover:bg-blue-50'} ${className}`}>
|
||||||
{/* 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}
|
{React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement<any>, { size: 16 }) : icon}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const IndexTicker = () => {
|
||||||
|
const indices = [
|
||||||
|
{ name: '코스피', value: '2,561.32', change: '+12.45', percent: '0.49%', isUp: true },
|
||||||
|
{ name: '코스닥', value: '842.11', change: '-3.21', percent: '0.38%', isUp: false },
|
||||||
|
{ name: '나스닥', value: '15,628.95', change: '+215.12', percent: '1.40%', isUp: true },
|
||||||
|
{ name: 'S&P 500', value: '4,850.12', change: '+45.23', percent: '0.94%', isUp: true },
|
||||||
|
{ name: 'USD/KRW', value: '1,324.50', change: '+2.10', percent: '0.16%', isUp: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 bg-white border-b border-slate-100 flex items-center px-6 gap-8 overflow-x-auto scrollbar-hide z-40">
|
||||||
|
{indices.map((idx, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 group cursor-pointer whitespace-nowrap">
|
||||||
|
<span className="text-[11px] font-black text-slate-400 uppercase tracking-tighter">{idx.name}</span>
|
||||||
|
<span className="text-[13px] font-black text-slate-900 font-mono">{idx.value}</span>
|
||||||
|
<div className={`flex items-center gap-0.5 text-[11px] font-bold ${idx.isUp ? 'text-rose-500' : 'text-blue-600'}`}>
|
||||||
|
{idx.isUp ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||||
|
<span>{idx.percent}</span>
|
||||||
|
</div>
|
||||||
|
{i < indices.length - 1 && <div className="w-[1px] h-3 bg-slate-100 ml-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const [settings, setSettings] = useState<ApiSettings>(() => {
|
const [settings, setSettings] = useState<ApiSettings>(() => {
|
||||||
const saved = localStorage.getItem('trader_settings');
|
const saved = localStorage.getItem('trader_settings');
|
||||||
@@ -173,13 +200,13 @@ const AppContent: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-0.5">
|
<nav className="flex items-center gap-0.5">
|
||||||
<TopNavItem to="/" icon={<LayoutDashboard />} label="대시보드" active={isActive('/')} />
|
<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="/stocks" icon={<LayoutGrid />} label="종목" active={isActive('/stocks')} />
|
||||||
<TopNavItem to="/auto" icon={<Cpu />} label="자동매매" active={isActive('/auto')} />
|
<TopNavItem to="/auto" icon={<Cpu />} label="자동매매" active={isActive('/auto')} />
|
||||||
<TopNavItem to="/watchlist" icon={<Star />} label="관심종목" active={isActive('/watchlist')} />
|
<TopNavItem to="/watchlist" icon={<Star />} label="관심종목" active={isActive('/watchlist')} />
|
||||||
<TopNavItem to="/news" icon={<Newspaper />} label="뉴스" active={isActive('/news')} />
|
<TopNavItem to="/news" icon={<Newspaper />} label="뉴스" active={isActive('/news')} />
|
||||||
<TopNavItem to="/history" icon={<History />} label="기록" active={isActive('/history')} />
|
<TopNavItem to="/history" icon={<History />} label="기록" active={isActive('/history')} />
|
||||||
<TopNavItem to="/settings" icon={<SettingsIcon />} label="설정" active={isActive('/settings')} />
|
<TopNavItem to="/settings" icon={<SettingsIcon />} label="설정" active={isActive('/settings')} />
|
||||||
|
<TopNavItem to="/discovery" icon={<Compass />} label="종목발굴" active={isActive('/discovery')} className="hidden xl:flex" />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -191,6 +218,7 @@ const AppContent: React.FC = () => {
|
|||||||
<div className="w-8 h-8 rounded-xl bg-slate-900 flex items-center justify-center text-white shadow-md"><ShieldCheck size={16} /></div>
|
<div className="w-8 h-8 rounded-xl bg-slate-900 flex items-center justify-center text-white shadow-md"><ShieldCheck size={16} /></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<IndexTicker />
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-6 custom-scrollbar relative">
|
<main className="flex-1 overflow-y-auto p-6 custom-scrollbar relative">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
### 1.1 Headless Execution Engine
|
### 1.1 Headless Execution Engine
|
||||||
1. **Batch Engine**: 매 1분마다 `auto_trade_configs`를 스캔하여 예약된 시각에 주문 실행.
|
1. **Batch Engine**: 매 1분마다 `auto_trade_configs`를 스캔하여 예약된 시각에 주문 실행.
|
||||||
2. **Monitoring Engine**: WebSocket 시세를 수신하여 `reserved_orders` 조건 감시 및 자동 매매.
|
2. **Monitoring Engine**: WebSocket 시세를 수신하여 `reserved_orders` 조건 감시 및 자동 매매.
|
||||||
3. **AI Proxy**: API 보안을 위해 AI 분석 및 뉴스 요청 중계.
|
3. **Market Index Collector**:
|
||||||
|
- **조회**: 매 5분마다 주요 시장 지수(KOSPI, KOSDAQ, NASDAQ, S&P500, USD/KRW) 수신.
|
||||||
|
- **기록**: 1시간마다 해당 시점의 최종 데이터를 `market_index_history` 테이블에 기록(Upsert).
|
||||||
|
- **프론트 연동**: 프론트엔드는 DB에 저장된 최신 데이터를 5분 단위로 폴링하여 대시보드 업데이트.
|
||||||
|
4. **AI Proxy**: API 보안을 위해 AI 분석 및 뉴스 요청 중계.
|
||||||
|
|
||||||
## 2. 상세 명세 가이드
|
## 2. 상세 명세 가이드
|
||||||
- **DB 스키마**: [tables.md](./tables.md) 참조.
|
- **DB 스키마**: [tables.md](./tables.md) 참조.
|
||||||
|
|||||||
@@ -69,3 +69,11 @@
|
|||||||
- `foreignNetBuy`: INTEGER
|
- `foreignNetBuy`: INTEGER
|
||||||
- `institutionalNetBuy`: INTEGER
|
- `institutionalNetBuy`: INTEGER
|
||||||
- **용도**: `StockItem` 테이블을 직접 확장하거나 별도 통계 테이블로 관리하여 발굴 데이터 조회 성능 최적화.
|
- **용도**: `StockItem` 테이블을 직접 확장하거나 별도 통계 테이블로 관리하여 발굴 데이터 조회 성능 최적화.
|
||||||
|
## 9. market_index_history (시장 지수 이력)
|
||||||
|
- `index_id`: TEXT (PK - KOSPI, KOSDAQ, NASDAQ, SP500, USDKRW 등)
|
||||||
|
- `timestamp`: DATETIME (PK - 1시간 단위 정규화된 시각)
|
||||||
|
- `value`: REAL
|
||||||
|
- `change`: REAL
|
||||||
|
- `change_percent`: REAL
|
||||||
|
- `updated_at`: DATETIME (실제 마지막 갱신 시각)
|
||||||
|
- **용도**: 트렌드 분석 및 대시보드 인덱스 카드 표시용. 백엔드가 5분 단위로 조회하되, DB에는 1시간 단위로 마지막 데이터를 Upsert 하여 누적.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const StockRow: React.FC<StockRowProps> = ({
|
|||||||
className="group cursor-pointer transition-colors hover:bg-slate-50/70"
|
className="group cursor-pointer transition-colors hover:bg-slate-50/70"
|
||||||
>
|
>
|
||||||
{showRank && (
|
{showRank && (
|
||||||
<td className="pl-4 py-2 font-mono font-black text-slate-400 group-hover:text-blue-600 transition-colors text-[12px]">
|
<td className="pl-4 py-2 font-mono font-black text-slate-400 group-hover:text-blue-600 transition-colors text-[13px]">
|
||||||
{rank}
|
{rank}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -39,26 +39,26 @@ export const StockRow: React.FC<StockRowProps> = ({
|
|||||||
<Star size={16} fill={isWatchlisted ? "currentColor" : "none"} className={isWatchlisted ? "text-amber-500" : ""} />
|
<Star size={16} fill={isWatchlisted ? "currentColor" : "none"} className={isWatchlisted ? "text-amber-500" : ""} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-white text-[10px] font-black shadow-sm overflow-hidden">
|
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-white text-[11px] font-black shadow-sm overflow-hidden">
|
||||||
{stock.name[0]}
|
{stock.name[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-black text-slate-900 text-[12.5px] tracking-tight group-hover:text-blue-600 leading-tight">{stock.name}</span>
|
<span className="font-black text-slate-900 text-[13.5px] tracking-tight group-hover:text-blue-600 leading-tight">{stock.name}</span>
|
||||||
<span className="text-[9px] text-slate-400 font-mono font-bold">{stock.code}</span>
|
<span className="text-[10px] text-slate-400 font-mono font-bold">{stock.code}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right font-mono font-black text-slate-800 text-[12.5px]">
|
<td className="px-3 py-2 text-right font-mono font-black text-slate-800 text-[13.5px]">
|
||||||
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() + '원' : '$' + stock.price}
|
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() + '원' : '$' + stock.price}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{showPL ? (
|
{showPL ? (
|
||||||
<div className={showPL.pl >= 0 ? 'text-rose-500' : 'text-blue-600'}>
|
<div className={showPL.pl >= 0 ? 'text-rose-500' : 'text-blue-600'}>
|
||||||
<p className="font-black text-[12.5px] leading-tight">{showPL.pl.toLocaleString()}</p>
|
<p className="font-black text-[13.5px] leading-tight">{showPL.pl.toLocaleString()}</p>
|
||||||
<p className="text-[9px] font-bold opacity-80 mt-0.5">({showPL.percent.toFixed(2)}%)</p>
|
<p className="text-[10px] font-bold opacity-80 mt-0.5">({showPL.percent.toFixed(2)}%)</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={`font-black text-[12.5px] ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
|
<span className={`font-black text-[13.5px] ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
|
||||||
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent}%
|
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -70,7 +70,7 @@ export const StockRow: React.FC<StockRowProps> = ({
|
|||||||
<div className="h-full bg-rose-400" style={{ width: `${stock.buyRatio || 50}%` }} />
|
<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 className="h-full bg-blue-400" style={{ width: `${stock.sellRatio || 50}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full text-[7.5px] font-black font-mono opacity-60">
|
<div className="flex justify-between w-full text-[8.5px] font-black font-mono opacity-60">
|
||||||
<span className="text-rose-500">{stock.buyRatio || 50}</span>
|
<span className="text-rose-500">{stock.buyRatio || 50}</span>
|
||||||
<span className="text-blue-500">{stock.sellRatio || 50}</span>
|
<span className="text-blue-500">{stock.sellRatio || 50}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,85 +63,85 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
const filteredGroups = groups.filter(g => g.codes.some(code => stocks.find(s => s.code === code)?.market === marketMode));
|
const filteredGroups = groups.filter(g => g.codes.some(code => stocks.find(s => s.code === code)?.market === marketMode));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 animate-in slide-in-from-bottom-6 duration-500 pb-24">
|
<div className="space-y-6 animate-in slide-in-from-bottom-6 duration-500 pb-20 pr-4">
|
||||||
<div className="flex justify-between items-center bg-white p-12 rounded-[4rem] shadow-sm border border-slate-100">
|
<div className="flex justify-between items-center bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-5">
|
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
|
||||||
<Cpu className="text-emerald-500" size={36} /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 매매 엔진
|
<Cpu className="text-emerald-500" size={24} /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 매매 엔진
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base font-bold text-slate-400 uppercase tracking-widest mt-3 flex items-center gap-3">
|
<p className="text-[12px] font-black text-slate-400 uppercase tracking-widest mt-1 flex items-center gap-2">
|
||||||
<span className="relative flex h-3 w-3">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-400' : 'bg-slate-300'}`}></span>
|
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-400' : 'bg-slate-300'}`}></span>
|
||||||
<span className={`relative inline-flex rounded-full h-3 w-3 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-500' : 'bg-slate-400'}`}></span>
|
<span className={`relative inline-flex rounded-full h-2 w-2 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-500' : 'bg-slate-400'}`}></span>
|
||||||
</span>
|
</span>
|
||||||
현재 {configs.filter(c => c.active && c.market === marketMode).length}개의 로봇 에이전트 활성화됨
|
{configs.filter(c => c.active && c.market === marketMode).length}개의 로봇 활성화됨
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddModal(true); setTargetType('SINGLE'); }}
|
onClick={() => { setShowAddModal(true); setTargetType('SINGLE'); }}
|
||||||
className="bg-slate-900 text-white px-12 py-6 rounded-[2.5rem] font-black text-base uppercase tracking-widest flex items-center gap-4 hover:bg-slate-800 transition-all shadow-2xl shadow-slate-300 active:scale-95"
|
className="bg-slate-900 text-white px-6 py-2.5 rounded-xl font-black text-[12px] uppercase tracking-widest flex items-center gap-2 hover:bg-slate-800 transition-all shadow-md active:scale-95"
|
||||||
>
|
>
|
||||||
<Plus size={24} /> 새 매매 전략 배포
|
<Plus size={18} /> 새 전략 배포
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{configs.filter(c => c.market === marketMode).map(config => (
|
{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 key={config.id} className={`bg-white p-5 rounded-2xl shadow-sm border transition-all relative overflow-hidden group hover:shadow-md ${config.active ? 'border-emerald-200' : 'border-slate-100 grayscale-[0.5]'}`}>
|
||||||
<div className={`absolute top-0 left-0 w-full h-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={`absolute top-0 left-0 w-full h-1.5 transition-colors ${config.active ? (config.groupId ? 'bg-indigo-500' : (config.type === 'ACCUMULATION' ? 'bg-blue-500' : 'bg-orange-500')) : 'bg-slate-300'}`}></div>
|
||||||
|
|
||||||
<div className="flex justify-between items-start mb-10">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<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'}`}>
|
<div className={`p-3 rounded-xl shadow-sm transition-colors ${config.active ? (config.groupId ? 'bg-indigo-50 text-indigo-600' : (config.type === 'ACCUMULATION' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600')) : 'bg-slate-100 text-slate-400'}`}>
|
||||||
{config.groupId ? <Layers size={32} /> : <Activity size={32} />}
|
{config.groupId ? <Layers size={20} /> : <Activity size={20} />}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<h4 className={`font-black text-[16px] leading-tight mb-0.5 transition-colors ${config.active ? 'text-slate-900' : 'text-slate-400'}`}>{config.stockName}</h4>
|
||||||
<p className="text-[12px] text-slate-400 font-mono font-bold tracking-widest uppercase">{config.groupId ? 'GROUP AGENT' : config.stockCode}</p>
|
<p className="text-[10px] text-slate-400 font-mono font-bold tracking-widest uppercase">{config.groupId ? 'GROUP AGENT' : config.stockCode}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 활성화 토글 스위치 */}
|
{/* 활성화 토글 스위치 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleConfig(config.id)}
|
onClick={() => onToggleConfig(config.id)}
|
||||||
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all focus:outline-none ${config.active ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-all focus:outline-none ${config.active ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${config.active ? 'translate-x-7' : 'translate-x-2'}`} />
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm transition-transform ${config.active ? 'translate-x-4.5' : 'translate-x-1'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<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={`p-4 rounded-xl space-y-3 border transition-colors ${config.active ? 'bg-slate-50/80 border-slate-100' : 'bg-slate-50/30 border-transparent'}`}>
|
||||||
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
|
<div className="flex justify-between items-center text-[11px] font-black uppercase tracking-widest">
|
||||||
<span className="text-slate-400">오퍼레이션</span>
|
<span className="text-slate-400">오퍼레이션</span>
|
||||||
<span className={config.active ? (config.groupId ? 'text-indigo-600' : 'text-slate-600') : 'text-slate-300'}>{config.groupId ? '그룹 일괄' : '개별 자산'}</span>
|
<span className={config.active ? (config.groupId ? 'text-indigo-600' : 'text-slate-600') : 'text-slate-300'}>{config.groupId ? '그룹 일괄' : '개별 자산'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
|
<div className="flex justify-between items-center text-[11px] font-black uppercase tracking-widest">
|
||||||
<span className="text-slate-400">코어 전략</span>
|
<span className="text-slate-400">코어 전략</span>
|
||||||
<span className={`px-3 py-1 rounded-lg transition-colors ${config.active ? (config.type === 'ACCUMULATION' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600') : 'bg-slate-100 text-slate-300'}`}>
|
<span className={`px-2 py-0.5 rounded-lg transition-colors ${config.active ? (config.type === 'ACCUMULATION' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600') : 'bg-slate-100 text-slate-300'}`}>
|
||||||
{config.type === 'ACCUMULATION' ? '적립식 매수' : 'TS 자동매매'}
|
{config.type === 'ACCUMULATION' ? '적립식 매수' : 'TS 자동매매'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-base font-bold">
|
<div className="flex justify-between items-center text-[12px] font-bold">
|
||||||
<span className="text-slate-400 uppercase tracking-widest text-[12px]">스케줄링</span>
|
<span className="text-slate-400 uppercase tracking-widest text-[11px]">스케줄링</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>
|
<span className={`flex items-center gap-2 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Calendar size={14} className="text-slate-400" /> {getDayLabel(config)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-base font-bold">
|
<div className="flex justify-between items-center text-[12px] font-bold">
|
||||||
<span className="text-slate-400 uppercase tracking-widest text-[12px]">실행정보</span>
|
<span className="text-slate-400 uppercase tracking-widest text-[11px]">실행정보</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>
|
<span className={`flex items-center gap-2 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Clock size={14} className="text-slate-400" /> {config.executionTime} / {config.quantity}주</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-8 border-t border-slate-100 flex justify-between items-center">
|
<div className="pt-4 border-t border-slate-100 flex justify-between items-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-3 h-3 rounded-full transition-colors ${config.active ? 'bg-emerald-500' : 'bg-slate-300'}`}></span>
|
<span className={`w-2 h-2 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>
|
<span className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{config.active ? '에이전트 운용 중' : '일시 중단됨'}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteConfig(config.id)}
|
onClick={() => onDeleteConfig(config.id)}
|
||||||
className="p-4 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-[1.5rem] transition-all"
|
className="p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Trash2 size={24} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,44 +149,43 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 전략 추가 모달 (기존 동일) */}
|
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6">
|
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6">
|
||||||
<div className="bg-white w-full max-w-2xl rounded-[4rem] p-16 shadow-2xl animate-in zoom-in-95 duration-300 border border-slate-100 overflow-hidden">
|
<div className="bg-white w-full max-w-xl rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-300 border border-slate-100 overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-12">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h3 className="text-4xl font-black text-slate-900 uppercase tracking-tight flex items-center gap-5">
|
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter flex items-center gap-3">
|
||||||
<Zap className="text-yellow-400 fill-yellow-400" /> 로봇 전략 설계
|
<Zap className="text-yellow-400 fill-yellow-400" size={24} /> 로봇 전략 설계
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowAddModal(false)} className="p-4 hover:bg-slate-100 rounded-full transition-colors"><X size={32} className="text-slate-400" /></button>
|
<button onClick={() => setShowAddModal(false)} className="p-2 hover:bg-slate-100 rounded-lg transition-colors"><X size={20} className="text-slate-400" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-6">
|
||||||
<div className="space-y-5">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">타겟 유형</label>
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">타겟 유형</label>
|
||||||
<div className="flex bg-slate-100 p-2.5 rounded-[2.5rem] shadow-inner">
|
<div className="flex bg-slate-100 p-1.5 rounded-xl shadow-inner">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTargetType('SINGLE')}
|
onClick={() => setTargetType('SINGLE')}
|
||||||
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'SINGLE' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
|
className={`flex-1 py-2.5 rounded-lg text-[12px] font-black transition-all ${targetType === 'SINGLE' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
|
||||||
>
|
>
|
||||||
개별 자산
|
개별 자산
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTargetType('GROUP')}
|
onClick={() => setTargetType('GROUP')}
|
||||||
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'GROUP' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
|
className={`flex-1 py-2.5 rounded-lg text-[12px] font-black transition-all ${targetType === 'GROUP' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
|
||||||
>
|
>
|
||||||
자산 그룹
|
자산 그룹
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
|
||||||
{targetType === 'SINGLE' ? '자산 선택' : '그룹 선택'}
|
{targetType === 'SINGLE' ? '자산 선택' : '그룹 선택'}
|
||||||
</label>
|
</label>
|
||||||
{targetType === 'SINGLE' ? (
|
{targetType === 'SINGLE' ? (
|
||||||
<select
|
<select
|
||||||
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
|
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
|
||||||
onChange={(e) => setNewConfig({...newConfig, stockCode: e.target.value})}
|
onChange={(e) => setNewConfig({...newConfig, stockCode: e.target.value})}
|
||||||
value={newConfig.stockCode || ''}
|
value={newConfig.stockCode || ''}
|
||||||
>
|
>
|
||||||
@@ -195,7 +194,7 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
|
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
|
||||||
onChange={(e) => setNewConfig({...newConfig, groupId: e.target.value})}
|
onChange={(e) => setNewConfig({...newConfig, groupId: e.target.value})}
|
||||||
value={newConfig.groupId || ''}
|
value={newConfig.groupId || ''}
|
||||||
>
|
>
|
||||||
@@ -204,20 +203,20 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">단위 수량</label>
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">단위 수량</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-black text-slate-800 text-center text-2xl shadow-sm"
|
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-black text-slate-800 text-center text-[18px] shadow-sm"
|
||||||
value={newConfig.quantity}
|
value={newConfig.quantity}
|
||||||
onChange={(e) => setNewConfig({...newConfig, quantity: parseInt(e.target.value)})}
|
onChange={(e) => setNewConfig({...newConfig, quantity: parseInt(e.target.value)})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">실행 주파수</label>
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">실행 주파수</label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ val: 'DAILY', label: '매일' },
|
{ val: 'DAILY', label: '매일' },
|
||||||
{ val: 'WEEKLY', label: '매주' },
|
{ val: 'WEEKLY', label: '매주' },
|
||||||
@@ -226,7 +225,7 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
<button
|
<button
|
||||||
key={freq.val}
|
key={freq.val}
|
||||||
onClick={() => setNewConfig({...newConfig, frequency: freq.val as any, specificDay: freq.val === 'DAILY' ? undefined : 1})}
|
onClick={() => setNewConfig({...newConfig, frequency: freq.val as any, specificDay: freq.val === 'DAILY' ? undefined : 1})}
|
||||||
className={`py-5 rounded-[2rem] text-[12px] font-black transition-all border-2 ${newConfig.frequency === freq.val ? 'bg-slate-900 text-white border-slate-900 shadow-2xl' : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'}`}
|
className={`py-3 rounded-xl text-[12px] font-black transition-all border-2 ${newConfig.frequency === freq.val ? 'bg-slate-900 text-white border-slate-900 shadow-md' : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'}`}
|
||||||
>
|
>
|
||||||
{freq.label}
|
{freq.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -234,14 +233,14 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
{newConfig.frequency !== 'DAILY' && (
|
{newConfig.frequency !== 'DAILY' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
|
||||||
{newConfig.frequency === 'WEEKLY' ? '요일' : '날짜'}
|
{newConfig.frequency === 'WEEKLY' ? '요일' : '날짜'}
|
||||||
</label>
|
</label>
|
||||||
<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 shadow-sm"
|
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
|
||||||
value={newConfig.specificDay}
|
value={newConfig.specificDay}
|
||||||
onChange={(e) => setNewConfig({...newConfig, specificDay: parseInt(e.target.value)})}
|
onChange={(e) => setNewConfig({...newConfig, specificDay: parseInt(e.target.value)})}
|
||||||
>
|
>
|
||||||
@@ -253,27 +252,27 @@ const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs,
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">시퀀스 타임</label>
|
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">시퀀스 타임</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 shadow-sm"
|
className="w-full p-3 bg-slate-50 rounded-xl border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-[14px] shadow-sm"
|
||||||
value={newConfig.executionTime}
|
value={newConfig.executionTime}
|
||||||
onChange={(e) => setNewConfig({...newConfig, executionTime: e.target.value})}
|
onChange={(e) => setNewConfig({...newConfig, executionTime: e.target.value})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-8 pt-10">
|
<div className="flex gap-4 pt-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
className="flex-1 py-6 bg-slate-100 text-slate-400 rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-slate-200 transition-all"
|
className="flex-1 py-3 bg-slate-100 text-slate-400 rounded-xl font-black uppercase text-[12px] tracking-widest hover:bg-slate-200 transition-all"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="flex-1 py-6 bg-blue-600 text-white rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-2xl shadow-blue-100"
|
className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-md"
|
||||||
>
|
>
|
||||||
전략 추가
|
전략 추가
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,40 +23,6 @@ interface DashboardProps {
|
|||||||
onRefreshHoldings: () => void;
|
onRefreshHoldings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IndexBar = () => {
|
|
||||||
const indices = [
|
|
||||||
{ name: '코스피', value: '2,561.32', change: '+12.45', percent: '0.49%', isUp: true },
|
|
||||||
{ name: '코스닥', value: '842.11', change: '-3.21', percent: '0.38%', isUp: false },
|
|
||||||
{ name: '나스닥', value: '15,628.95', change: '+215.12', percent: '1.40%', isUp: true },
|
|
||||||
{ name: 'S&P 500', value: '4,850.12', change: '+45.23', percent: '0.94%', isUp: true },
|
|
||||||
{ name: 'USD/KRW', value: '1,324.50', change: '+2.10', percent: '0.16%', isUp: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide -mx-2 px-2 items-center">
|
|
||||||
{indices.map((idx, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3 bg-white px-4 py-2 rounded-xl border border-slate-100 shadow-sm shrink-0 hover:border-blue-200 transition-colors cursor-pointer">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{idx.name}</span>
|
|
||||||
<span className="text-[14px] font-black text-slate-900 font-mono tracking-tighter">{idx.value}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`flex flex-col items-end ${idx.isUp ? 'text-rose-500' : 'text-blue-600'}`}>
|
|
||||||
<span className="text-[10px] font-bold flex items-center gap-0.5">
|
|
||||||
{idx.isUp ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
|
||||||
{idx.percent}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] font-bold opacity-70">{idx.change}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button className="flex items-center gap-1 text-slate-400 hover:text-blue-500 transition-colors shrink-0 px-2">
|
|
||||||
<span className="text-[11px] font-black">더보기</span>
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Dashboard: React.FC<DashboardProps> = ({
|
const Dashboard: React.FC<DashboardProps> = ({
|
||||||
marketMode, watchlistGroups, stocks, reservedOrders, onAddReservedOrder, onDeleteReservedOrder, onRefreshHoldings, orders
|
marketMode, watchlistGroups, stocks, reservedOrders, onAddReservedOrder, onDeleteReservedOrder, onRefreshHoldings, orders
|
||||||
}) => {
|
}) => {
|
||||||
@@ -121,19 +87,16 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
|
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
|
||||||
{/* 1. 지수 및 환율 바 */}
|
|
||||||
<IndexBar />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] lg:col-span-1">
|
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] lg:col-span-1">
|
||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
<h3 className="text-[15px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tighter">
|
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tighter">
|
||||||
<PieChart size={20} className="text-blue-600" /> 관심 그룹
|
<PieChart size={20} className="text-blue-600" /> 관심 그룹
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2 scrollbar-hide">
|
<div className="flex gap-2 mb-6 overflow-x-auto pb-2 scrollbar-hide">
|
||||||
{activeMarketGroups.map(group => (
|
{activeMarketGroups.map(group => (
|
||||||
<button key={group.id} onClick={() => setActiveGroupId(group.id)} className={`relative px-4 py-2 rounded-xl font-black text-[11px] transition-all border-2 whitespace-nowrap ${activeGroupId === group.id ? 'bg-white border-blue-500 text-blue-600 shadow-sm' : 'bg-transparent border-slate-50 text-slate-400 hover:border-slate-200'}`}>
|
<button key={group.id} onClick={() => setActiveGroupId(group.id)} className={`relative px-4 py-2 rounded-xl font-black text-[12px] transition-all border-2 whitespace-nowrap ${activeGroupId === group.id ? 'bg-white border-blue-500 text-blue-600 shadow-sm' : 'bg-transparent border-slate-50 text-slate-400 hover:border-slate-200'}`}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -161,14 +124,14 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[350px]">
|
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[350px]">
|
||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
<h3 className="text-[15px] font-black text-slate-800 flex items-center gap-2 tracking-tighter">
|
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 tracking-tighter">
|
||||||
<Database size={20} className="text-emerald-600" /> 보유 포트폴리오
|
<Database size={20} className="text-emerald-600" /> 보유 포트폴리오
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto flex-1 scrollbar-hide">
|
<div className="overflow-x-auto flex-1 scrollbar-hide">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
|
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
|
||||||
<th className="pb-3 px-3">종목</th>
|
<th className="pb-3 px-3">종목</th>
|
||||||
<th className="pb-3 px-3 text-right">현재가</th>
|
<th className="pb-3 px-3 text-right">현재가</th>
|
||||||
<th className="pb-3 px-3 text-right">수익금 (%)</th>
|
<th className="pb-3 px-3 text-right">수익금 (%)</th>
|
||||||
@@ -196,7 +159,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[274px] overflow-hidden">
|
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[274px] overflow-hidden">
|
||||||
<h3 className="text-[15px] font-black text-slate-800 flex items-center gap-2 mb-5">
|
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 mb-5">
|
||||||
<Timer size={20} className="text-blue-600" /> 실시간 감시 목록
|
<Timer size={20} className="text-blue-600" /> 실시간 감시 목록
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-hide">
|
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-hide">
|
||||||
@@ -204,7 +167,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
<div key={order.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex justify-between items-center group">
|
<div key={order.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex justify-between items-center group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={14} fill="currentColor" /></div>
|
<div className={`p-2 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={14} fill="currentColor" /></div>
|
||||||
<div><p className="font-black text-[13px] text-slate-800">{order.stockName}</p></div>
|
<div><p className="font-black text-[14px] text-slate-800">{order.stockName}</p></div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-2 bg-white hover:bg-rose-50 rounded-lg text-slate-300 hover:text-rose-500 transition-all shadow-sm">
|
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-2 bg-white hover:bg-rose-50 rounded-lg text-slate-300 hover:text-rose-500 transition-all shadow-sm">
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ interface DiscoveryProps {
|
|||||||
const DISCOVERY_CATEGORIES = [
|
const DISCOVERY_CATEGORIES = [
|
||||||
{ id: 'trading_value', name: '거래대금 상위', icon: <Flame size={16} /> },
|
{ id: 'trading_value', name: '거래대금 상위', icon: <Flame size={16} /> },
|
||||||
{ id: 'gainers', name: '급상승 종목', icon: <Zap size={16} /> },
|
{ id: 'gainers', name: '급상승 종목', icon: <Zap size={16} /> },
|
||||||
{ id: 'continuous_rise', name: '연속 상승세', icon: <Trophy size={16} />, badge: '인기' },
|
{ id: 'continuous_rise', name: '연속 상승세', icon: <Trophy size={16} />, isPending: true },
|
||||||
{ id: 'undervalued_growth', name: '저평가 성장주', icon: <Sparkles size={16} />, badge: '인기' },
|
{ id: 'undervalued_growth', name: '저평가 성장주', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'cheap_value', name: '아직 저렴한 가치주', icon: <Sparkles size={16} /> },
|
{ id: 'cheap_value', name: '아직 저렴한 가치주', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'stable_dividends', name: '꾸준한 배당주', icon: <Sparkles size={16} />, badge: '인기' },
|
{ id: 'stable_dividends', name: '꾸준한 배당주', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'profitable_companies', name: '돈 잘버는 회사 찾기', icon: <Sparkles size={16} /> },
|
{ id: 'profitable_companies', name: '돈 잘버는 회사 찾기', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'undervalued_recovery', name: '저평가 탈출', icon: <Sparkles size={16} /> },
|
{ id: 'undervalued_recovery', name: '저평가 탈출', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'future_dividend_kings', name: '미래의 배당왕 찾기', icon: <Sparkles size={16} /> },
|
{ id: 'future_dividend_kings', name: '미래의 배당왕 찾기', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'growth_prospects', name: '성장 기대주', icon: <Sparkles size={16} /> },
|
{ id: 'growth_prospects', name: '성장 기대주', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'buy_at_cheap', name: '싼값에 매수', icon: <Sparkles size={16} /> },
|
{ id: 'buy_at_cheap', name: '싼값에 매수', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'high_yield_undervalued', name: '고수익 저평가', icon: <Sparkles size={16} /> },
|
{ id: 'high_yield_undervalued', name: '고수익 저평가', icon: <Sparkles size={16} />, isPending: true },
|
||||||
{ id: 'popular_growth', name: '인기 성장주', icon: <Sparkles size={16} /> },
|
{ id: 'popular_growth', name: '인기 성장주', icon: <Sparkles size={16} />, isPending: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
|
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
|
||||||
@@ -122,34 +122,37 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
{/* 1. 좌측 사이드바 메뉴 */}
|
{/* 1. 좌측 사이드바 메뉴 */}
|
||||||
<div className="w-full lg:w-[240px] shrink-0 space-y-8">
|
<div className="w-full lg:w-[240px] shrink-0 space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] font-black text-slate-400 uppercase tracking-widest px-4 mb-4">주식 골라보기 목록</h3>
|
<h3 className="text-[12px] font-black text-slate-400 uppercase tracking-widest px-4 mb-4">주식 골라보기 목록</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-[10px] font-black text-slate-300 px-4 mb-2 uppercase italic">내가 만든</p>
|
<p className="text-[11px] font-black text-slate-300 px-4 mb-2 uppercase italic">내가 만든</p>
|
||||||
<button className="w-full flex items-center gap-3 px-4 py-2 text-blue-600 font-bold text-[13px] hover:bg-blue-50/50 rounded-xl transition-all">
|
<button disabled className="w-full flex items-center gap-3 px-4 py-2 text-slate-300 font-bold text-[14px] cursor-not-allowed rounded-xl transition-all">
|
||||||
<div className="w-6 h-6 rounded-lg bg-blue-50 flex items-center justify-center">+</div>
|
<div className="w-6 h-6 rounded-lg bg-slate-50 flex items-center justify-center">+</div>
|
||||||
직접 만들기
|
직접 만들기 (대기)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black text-slate-300 px-4 mb-3 uppercase italic">토스증권이 만든</p>
|
<p className="text-[11px] font-black text-slate-300 px-4 mb-3 uppercase italic">토스증권이 만든</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{DISCOVERY_CATEGORIES.map(cat => (
|
{DISCOVERY_CATEGORIES.map(cat => (
|
||||||
<button
|
<button
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onClick={() => setActiveCategoryId(cat.id)}
|
disabled={cat.isPending}
|
||||||
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-xl transition-all group ${activeCategoryId === cat.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-50'}`}
|
onClick={() => !cat.isPending && setActiveCategoryId(cat.id)}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-xl transition-all group ${activeCategoryId === cat.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-50'} ${cat.isPending ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`${activeCategoryId === cat.id ? 'text-blue-400' : 'text-slate-400 group-hover:text-slate-900'}`}>{cat.icon}</span>
|
<span className={`${activeCategoryId === cat.id ? 'text-blue-400' : 'text-slate-400 group-hover:text-slate-900'}`}>{cat.icon}</span>
|
||||||
<span className="text-[13px] font-black tracking-tight">{cat.name}</span>
|
<span className="text-[14px] font-black tracking-tight">{cat.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{cat.badge && (
|
{cat.isPending ? (
|
||||||
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-black italic ${activeCategoryId === cat.id ? 'bg-blue-500 text-white' : 'bg-blue-50 text-blue-500'}`}>
|
<span className="text-[9px] px-1.5 py-0.5 rounded-md font-black bg-slate-100 text-slate-400 uppercase">대기</span>
|
||||||
|
) : cat.badge ? (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-black italic ${activeCategoryId === cat.id ? 'bg-blue-500 text-white' : 'bg-blue-50 text-blue-500'}`}>
|
||||||
{cat.badge}
|
{cat.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +164,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-black text-slate-900 italic tracking-tighter">{activeCategory.name}</h2>
|
<h2 className="text-2xl font-black text-slate-900 italic tracking-tighter">{activeCategory.name}</h2>
|
||||||
<p className="text-[11px] font-bold text-slate-400 mt-1 uppercase tracking-tight">수천 개의 주식 중 조건에 맞는 종목을 선별했습니다.</p>
|
<p className="text-[12px] font-bold text-slate-400 mt-1 uppercase tracking-tight">수천 개의 주식 중 조건에 맞는 종목을 선별했습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
|
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
|
||||||
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
|
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
|
||||||
@@ -173,7 +176,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] font-black text-slate-400 uppercase tracking-widest border-b bg-slate-50/50">
|
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b bg-slate-50/50">
|
||||||
<th className="pl-6 py-4 w-12 text-center">순위</th>
|
<th className="pl-6 py-4 w-12 text-center">순위</th>
|
||||||
<th className="px-4 py-4">종목</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>
|
||||||
@@ -206,7 +209,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
<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 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 className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight truncate" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
|
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight truncate" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
|
||||||
<p className="text-[11px] font-black text-slate-400">{selectedStock.code}</p>
|
<p className="text-[12px] font-black text-slate-400">{selectedStock.code}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all shrink-0 ${selectedStock.isHidden ? 'bg-rose-50 text-rose-500' : 'bg-slate-50 text-slate-400 hover:text-rose-500'}`}>
|
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all shrink-0 ${selectedStock.isHidden ? 'bg-rose-50 text-rose-500' : 'bg-slate-50 text-slate-400 hover:text-rose-500'}`}>
|
||||||
@@ -221,13 +224,13 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
|
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
|
||||||
<StickyNote size={14} className="text-amber-500" /> 종목 메모
|
<StickyNote size={14} className="text-amber-500" /> 종목 메모
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleSaveMemo} className="text-[10px] font-black text-blue-600 hover:underline">저장</button>
|
<button onClick={handleSaveMemo} className="text-[11px] font-black text-blue-600 hover:underline">저장</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-20 p-4 bg-slate-50 border border-slate-100 rounded-2xl text-[12px] text-slate-600 font-medium resize-none focus:bg-white outline-none transition-all"
|
className="w-full h-20 p-4 bg-slate-50 border border-slate-100 rounded-2xl text-[13px] text-slate-600 font-medium resize-none focus:bg-white outline-none transition-all"
|
||||||
placeholder="메모를 입력하세요..."
|
placeholder="메모를 입력하세요..."
|
||||||
value={memo}
|
value={memo}
|
||||||
onChange={(e) => setMemo(e.target.value)}
|
onChange={(e) => setMemo(e.target.value)}
|
||||||
@@ -235,7 +238,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 pt-4 border-t border-slate-50">
|
<div className="space-y-3 pt-4 border-t border-slate-50">
|
||||||
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
|
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
|
||||||
<HistoryIcon size={14} className="text-blue-500" /> 거래 기록
|
<HistoryIcon size={14} className="text-blue-500" /> 거래 기록
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -243,26 +246,26 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
stockOrders.slice(0, 2).map((order) => (
|
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 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="flex items-center gap-2">
|
||||||
<div className={`p-1.5 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-500'}`}>
|
<div className={`p-1.5 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}>
|
||||||
{order.type === OrderType.BUY ? <ArrowDownRight size={12} /> : <ArrowUpRight size={12} />}
|
{order.type === OrderType.BUY ? <ArrowDownRight size={12} /> : <ArrowUpRight size={12} />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-black text-slate-800">{order.type === OrderType.BUY ? '매수' : '매도'} {order.quantity}주</p>
|
<p className="text-[12px] font-black text-slate-800">{order.type === OrderType.BUY ? '매수' : '매도'} {order.quantity}주</p>
|
||||||
<p className="text-[9px] text-slate-400 font-bold">{new Date(order.timestamp).toLocaleDateString()}</p>
|
<p className="text-[10px] text-slate-400 font-bold">{new Date(order.timestamp).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] font-black text-slate-900">{order.price.toLocaleString()}원</p>
|
<p className="text-[12px] font-black text-slate-900">{order.price.toLocaleString()}원</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[10px] font-black text-slate-300 text-center py-4 italic">기록 없음</p>
|
<p className="text-[11px] font-black text-slate-300 text-center py-4 italic">기록 없음</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 pt-4 border-t border-slate-50">
|
<div className="space-y-3 pt-4 border-t border-slate-50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
|
<div className="flex items-center gap-1.5 text-[14px] font-black text-slate-800">
|
||||||
<Sparkles size={14} className="text-purple-500" /> AI 분석
|
<Sparkles size={14} className="text-purple-500" /> AI 분석
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg shrink-0">
|
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg shrink-0">
|
||||||
@@ -270,12 +273,12 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedStock.aiAnalysis ? (
|
{selectedStock.aiAnalysis ? (
|
||||||
<div className="bg-slate-900 p-4 rounded-2xl text-slate-100 text-[11px] leading-relaxed font-medium italic">
|
<div className="bg-slate-900 p-4 rounded-2xl text-slate-100 text-[12px] leading-relaxed font-medium italic">
|
||||||
{selectedStock.aiAnalysis}
|
{selectedStock.aiAnalysis}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-6 border border-dashed border-slate-100 rounded-2xl flex flex-col items-center gap-2 opacity-30 text-center">
|
<div className="p-6 border border-dashed border-slate-100 rounded-2xl flex flex-col items-center gap-2 opacity-30 text-center">
|
||||||
<p className="text-[10px] font-black uppercase">분석 데이터 없음</p>
|
<p className="text-[11px] font-black uppercase">분석 데이터 없음</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
121
pages/News.tsx
121
pages/News.tsx
@@ -71,8 +71,34 @@ const News: React.FC<NewsProps> = ({ settings }) => {
|
|||||||
const headlines = news.slice(0, 10).map(n => n.title);
|
const headlines = news.slice(0, 10).map(n => n.title);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await AiService.analyzeNewsSentiment(config, headlines);
|
const fullResult = await AiService.analyzeNewsSentiment(config, headlines);
|
||||||
setAnalysisResult(result);
|
const [report, metadataStr] = fullResult.split('---METADATA---');
|
||||||
|
|
||||||
|
setAnalysisResult(report.trim());
|
||||||
|
|
||||||
|
if (metadataStr) {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataStr.trim());
|
||||||
|
if (Array.isArray(metadata)) {
|
||||||
|
setNews(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
metadata.forEach(item => {
|
||||||
|
if (updated[item.index]) {
|
||||||
|
updated[item.index] = {
|
||||||
|
...updated[item.index],
|
||||||
|
relatedThemes: item.themes,
|
||||||
|
relatedStocks: item.stocks,
|
||||||
|
sentiment: item.sentiment
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("메타데이터 파싱 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setAnalysisResult("AI 분석 중 오류가 발생했습니다. 설정을 확인해 주세요.");
|
setAnalysisResult("AI 분석 중 오류가 발생했습니다. 설정을 확인해 주세요.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,30 +112,30 @@ const News: React.FC<NewsProps> = ({ settings }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 max-w-6xl mx-auto animate-in slide-in-from-right-4 duration-500 pb-20">
|
<div className="space-y-6 w-full animate-in slide-in-from-right-4 duration-500 pb-20 pr-4">
|
||||||
|
|
||||||
{/* 뉴스 스크랩 비활성 알림 */}
|
{/* 뉴스 스크랩 비활성 알림 */}
|
||||||
{!settings.useNaverNews && (
|
{!settings.useNaverNews && (
|
||||||
<div className="bg-amber-50 border border-amber-100 p-8 rounded-[2.5rem] flex items-start gap-5 shadow-sm">
|
<div className="bg-amber-50 border border-amber-100 p-5 rounded-2xl flex items-start gap-3 shadow-sm">
|
||||||
<AlertCircle className="text-amber-500 shrink-0" size={28} />
|
<AlertCircle className="text-amber-500 shrink-0" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-bold text-amber-900 mb-2 text-lg">뉴스 스크랩 비활성</h5>
|
<h5 className="font-black text-amber-900 mb-1 text-sm uppercase tracking-tight">뉴스 스크랩 비활성</h5>
|
||||||
<p className="text-base text-amber-700">네이버 뉴스 연동이 꺼져 있습니다. 설정 메뉴에서 Naver Client ID를 입력하세요.</p>
|
<p className="text-[14px] text-amber-700 font-medium">네이버 뉴스 연동이 꺼져 있습니다. 설정 메뉴에서 Naver Client ID를 입력하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 분석 결과 리포트 */}
|
{/* 분석 결과 리포트 */}
|
||||||
{analysisResult && (
|
{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">
|
<div className="bg-white p-6 rounded-2xl shadow-lg border-l-4 border-l-blue-600 animate-in fade-in zoom-in duration-300 relative group">
|
||||||
<button onClick={() => setAnalysisResult(null)} className="absolute top-6 right-6 p-2 hover:bg-slate-50 rounded-full text-slate-300 hover:text-slate-600 transition-all"><X size={20}/></button>
|
<button onClick={() => setAnalysisResult(null)} className="absolute top-4 right-4 p-1.5 hover:bg-slate-50 rounded-lg text-slate-300 hover:text-slate-600 transition-all"><X size={16}/></button>
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-4">
|
||||||
<div className="p-4 bg-blue-50 text-blue-600 rounded-2xl">
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-xl">
|
||||||
<MessageSquareQuote size={28} />
|
<MessageSquareQuote size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 pr-10">
|
<div className="space-y-2 pr-6">
|
||||||
<h4 className="text-[11px] font-black text-blue-600 uppercase tracking-[0.2em]">AI Intelligence Report</h4>
|
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em]">AI Intelligence Report</h4>
|
||||||
<div className="text-slate-800 text-lg font-bold leading-relaxed whitespace-pre-wrap">
|
<div className="text-slate-800 text-[15px] font-bold leading-relaxed whitespace-pre-wrap">
|
||||||
{analysisResult}
|
{analysisResult}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,61 +144,78 @@ const News: React.FC<NewsProps> = ({ settings }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 툴바: 검색 및 실행 버튼 */}
|
{/* 툴바: 검색 및 실행 버튼 */}
|
||||||
<div className="flex flex-col md:flex-row gap-8 items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||||
<div className="relative w-full md:flex-1">
|
<div className="relative w-full md:flex-1">
|
||||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" size={24} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="뉴스 검색..."
|
placeholder="뉴스 검색..."
|
||||||
className="w-full pl-16 pr-6 py-5 bg-white border border-slate-200 rounded-[2rem] shadow-sm focus:ring-4 focus:ring-blue-50 outline-none text-base font-bold text-slate-800"
|
className="w-full pl-12 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-100 outline-none text-[14px] font-black text-slate-800"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={isAnalyzing || news.length === 0}
|
disabled={isAnalyzing || news.length === 0}
|
||||||
className={`flex-1 md:shrink-0 px-8 py-5 bg-blue-600 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-blue-700 flex items-center justify-center gap-4 transition-all active:scale-95 disabled:opacity-50`}
|
className={`flex-1 md:shrink-0 px-5 py-2.5 bg-blue-600 text-white rounded-xl shadow-md font-black text-[12px] uppercase tracking-wider hover:bg-blue-700 flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
<Sparkles size={22} className={isAnalyzing ? 'animate-pulse' : ''} />
|
<Sparkles size={16} className={isAnalyzing ? 'animate-pulse' : ''} />
|
||||||
{isAnalyzing ? 'AI 분석 중...' : 'AI 브리핑'}
|
{isAnalyzing ? '분석 중...' : 'AI 브리핑'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`flex-1 md:shrink-0 px-8 py-5 bg-slate-900 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-slate-800 flex items-center justify-center gap-4 transition-all active:scale-95 ${loading ? 'opacity-50' : ''}`}
|
className={`flex-1 md:shrink-0 px-5 py-2.5 bg-slate-900 text-white rounded-xl shadow-md font-black text-[12px] uppercase tracking-wider hover:bg-slate-800 flex items-center justify-center gap-2 transition-all active:scale-95 ${loading ? 'opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
뉴스 새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 뉴스 리스트 */}
|
{/* 뉴스 리스트 */}
|
||||||
<div className="space-y-8">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
{filteredNews.map((item, idx) => (
|
{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">
|
<article key={idx} className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex items-start gap-4 hover:border-blue-200 hover:shadow-md transition-all group">
|
||||||
<div className="w-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">
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
<Newspaper size={40} />
|
item.sentiment === 'POSITIVE' ? 'bg-rose-50 text-rose-500' :
|
||||||
|
item.sentiment === 'NEGATIVE' ? 'bg-blue-50 text-blue-500' :
|
||||||
|
'bg-slate-50 text-slate-400 group-hover:bg-slate-900 group-hover:text-white'
|
||||||
|
}`}>
|
||||||
|
<Newspaper size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<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-[10px] font-black text-blue-500 uppercase tracking-widest">Financial Market</span>
|
||||||
<span className="text-sm text-slate-400 font-bold">{new Date(item.pubDate).toLocaleDateString('ko-KR')}</span>
|
<span className="text-[11px] text-slate-400 font-bold">{new Date(item.pubDate).toLocaleDateString('ko-KR')}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors">
|
<h3 className="text-[16px] font-black text-slate-900 leading-tight mb-1 group-hover:text-blue-600 transition-colors truncate">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-500 text-base font-medium leading-relaxed line-clamp-2 opacity-80">
|
<p className="text-slate-500 text-[13px] font-medium leading-normal line-clamp-1 opacity-70">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</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">
|
{/* AI 분석 인사이트 태그 */}
|
||||||
상세보기 <ExternalLink size={18} />
|
{(item.relatedThemes || item.relatedStocks) && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{item.relatedThemes?.map(theme => (
|
||||||
|
<span key={theme} className="text-[10px] font-black text-blue-600 bg-blue-50/50 px-1.5 py-0.5 rounded-md leading-none border border-blue-100/50">#{theme}</span>
|
||||||
|
))}
|
||||||
|
{item.relatedStocks?.map(stock => (
|
||||||
|
<span key={stock} className="text-[10px] font-black text-slate-500 bg-slate-50 px-1.5 py-0.5 rounded-md leading-none border border-slate-200/50">{stock}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
<a href={item.link} target="_blank" rel="noopener noreferrer" className="text-[11px] font-black uppercase text-blue-600 hover:underline flex items-center gap-1.5 tracking-tight transition-colors">
|
||||||
|
기사보기 <ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
<button className="text-slate-300 hover:text-amber-500 transition-colors">
|
<button className="text-slate-300 hover:text-amber-500 transition-colors">
|
||||||
<Bookmark size={22} />
|
<Bookmark size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,19 @@ export class AiService {
|
|||||||
* 뉴스 기사들을 바탕으로 시장 심리 및 인사이트 분석
|
* 뉴스 기사들을 바탕으로 시장 심리 및 인사이트 분석
|
||||||
*/
|
*/
|
||||||
static async analyzeNewsSentiment(config: AiConfig, newsHeadlines: string[]): Promise<string> {
|
static async analyzeNewsSentiment(config: AiConfig, newsHeadlines: string[]): Promise<string> {
|
||||||
const prompt = `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인들을 분석하여 시장의 심리(상승/하락/중립)와 투자자가 주목해야 할 핵심 포인트 3가지를 한국어로 요약해 주세요.
|
const isBatch = newsHeadlines.length > 1;
|
||||||
|
const prompt = isBatch
|
||||||
|
? `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인들을 분석하여 시장의 전반적인 심리와 투자 핵심 포인트를 한국어로 리포트 형식으로 작성해 주세요.
|
||||||
|
|
||||||
뉴스 헤드라인:
|
또한, 반드시 리포트 내용이 끝난 뒤에 "---METADATA---" 라는 구분선을 넣고, 그 바로 뒤에 각 뉴스 인덱스별로 연관된 테마와 종목 정보를 JSON 배열 형식으로 포함해 주세요.
|
||||||
|
JSON 형식 예시: [{"index": 0, "themes": ["반도체", "AI"], "stocks": ["삼성전자"], "sentiment": "POSITIVE"}, ...]
|
||||||
|
|
||||||
|
분석할 뉴스 헤드라인:
|
||||||
|
${newsHeadlines.map((h, i) => `${i}. ${h}`).join('\n')}
|
||||||
|
`
|
||||||
|
: `당신은 전문 주식 분석가입니다. 다음 뉴스 헤드라인을 분석하여 시장의 심리(상승/하락/중립)와 투자자가 주목해야 할 핵심 포인트를 한국어로 요약해 주세요.
|
||||||
|
|
||||||
|
분석할 뉴스 헤드라인:
|
||||||
${newsHeadlines.join('\n')}
|
${newsHeadlines.join('\n')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user