Files
trading/trading-dashboard.jsx
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

269 lines
15 KiB
React

import { useState, useEffect, useCallback } from "react";
const API = "http://192.168.0.60:8989/api";
const SENTIMENT_MAP = { "호재": { color: "#00E676", icon: "▲", bg: "rgba(0,230,118,0.08)" }, "악재": { color: "#FF5252", icon: "▼", bg: "rgba(255,82,82,0.08)" }, "중립": { color: "#78909C", icon: "●", bg: "rgba(120,144,156,0.06)" } };
const REC_MAP = { "강력매수": { color: "#00E676", weight: 900 }, "매수관심": { color: "#69F0AE", weight: 700 }, "관망": { color: "#78909C", weight: 500 }, "매도관심": { color: "#FF8A80", weight: 700 }, "강력매도": { color: "#FF5252", weight: 900 } };
function useFetch(endpoint, interval = 60000) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
fetch(`${API}${endpoint}`).then(r => r.json()).then(d => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, [endpoint]);
useEffect(() => { load(); const t = setInterval(load, interval); return () => clearInterval(t); }, [load, interval]);
return { data, loading, reload: load };
}
function formatTime(ts) {
if (!ts) return "-";
const d = new Date(ts);
const now = new Date();
const diff = (now - d) / 1000 / 60;
if (diff < 60) return `${Math.floor(diff)}분 전`;
if (diff < 1440) return `${Math.floor(diff / 60)}시간 전`;
return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
}
function ScoreBar({ value, max = 100 }) {
const pct = Math.min(100, Math.max(0, ((value + max) / (2 * max)) * 100));
const color = value >= 30 ? "#00E676" : value >= -30 ? "#78909C" : "#FF5252";
return (
<div style={{ width: "100%", height: 6, background: "rgba(255,255,255,0.05)", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: color, borderRadius: 3, transition: "width 0.8s ease" }} />
</div>
);
}
function SummaryCard({ icon, label, value, sub, color }) {
return (
<div style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)", borderRadius: 16, padding: "24px 20px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 13, color: "#90A4AE", letterSpacing: 1 }}>{icon} {label}</div>
<div style={{ fontSize: 36, fontWeight: 800, color: color || "#E0E0E0", fontFamily: "'JetBrains Mono', monospace", lineHeight: 1 }}>{value}</div>
{sub && <div style={{ fontSize: 12, color: "#607D8B" }}>{sub}</div>}
</div>
);
}
function Header({ onRefresh }) {
const [time, setTime] = useState(new Date());
useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []);
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "20px 0", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 800, color: "#E0E0E0", letterSpacing: -0.5 }}>
<span style={{ color: "#00E676" }}></span> Trading AI Dashboard
</h1>
<div style={{ fontSize: 12, color: "#546E7A", marginTop: 4 }}>뉴스 + DART 공시 기반 종목 분석 시스템</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ fontSize: 13, color: "#607D8B", fontFamily: "'JetBrains Mono', monospace" }}>
{time.toLocaleString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
<button onClick={onRefresh} style={{ background: "rgba(0,230,118,0.1)", border: "1px solid rgba(0,230,118,0.2)", borderRadius: 8, padding: "8px 16px", color: "#00E676", fontSize: 12, cursor: "pointer", fontWeight: 600 }}>
새로고침
</button>
</div>
</div>
);
}
function RecommendationCard({ item }) {
const rec = REC_MAP[item.recommendation] || REC_MAP["관망"];
return (
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 12, padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "#E0E0E0" }}>{item.stock_name}</div>
<div style={{ fontSize: 11, color: "#546E7A", fontFamily: "'JetBrains Mono', monospace" }}>{item.stock_code}</div>
</div>
<div style={{ background: `${rec.color}15`, border: `1px solid ${rec.color}30`, borderRadius: 6, padding: "4px 10px", fontSize: 12, fontWeight: rec.weight, color: rec.color }}>
{item.recommendation}
</div>
</div>
<div style={{ display: "flex", gap: 12, fontSize: 11, color: "#78909C" }}>
<span>종합 <b style={{ color: "#E0E0E0", fontSize: 14 }}>{item.total_score?.toFixed(1)}</b></span>
<span>뉴스 <b style={{ color: "#69F0AE" }}>{item.news_score?.toFixed(0)}</b></span>
<span>공시 <b style={{ color: "#40C4FF" }}>{item.dart_score?.toFixed(0)}</b></span>
<span>가격 <b style={{ color: "#FFD740" }}>{item.price_score?.toFixed(0)}</b></span>
</div>
<ScoreBar value={item.total_score} />
{item.top_reasons && (
<div style={{ fontSize: 11, color: "#607D8B", lineHeight: 1.5, borderTop: "1px solid rgba(255,255,255,0.04)", paddingTop: 8 }}>
{item.top_reasons.split("|")[0]?.trim().substring(0, 120)}...
</div>
)}
</div>
);
}
function NewsItem({ item }) {
const s = SENTIMENT_MAP[item.sentiment] || SENTIMENT_MAP["중립"];
return (
<div style={{ display: "flex", gap: 12, padding: "12px 0", borderBottom: "1px solid rgba(255,255,255,0.03)" }}>
<div style={{ minWidth: 40, textAlign: "center" }}>
<div style={{ fontSize: 18, color: s.color }}>{s.icon}</div>
<div style={{ fontSize: 10, color: s.color, fontWeight: 700 }}>{item.intensity || 0}</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#CFD8DC", lineHeight: 1.4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{item.title}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 4, fontSize: 11 }}>
{item.primary_stock && <span style={{ color: "#40C4FF" }}>{item.primary_stock}</span>}
<span style={{ color: "#546E7A" }}>{item.source}</span>
<span style={{ color: "#455A64" }}>{formatTime(item.analyzed_at)}</span>
</div>
{item.reason && <div style={{ fontSize: 11, color: "#607D8B", marginTop: 4, lineHeight: 1.4 }}>{item.reason.substring(0, 100)}</div>}
</div>
<div style={{ background: s.bg, borderRadius: 6, padding: "4px 8px", fontSize: 11, color: s.color, fontWeight: 600, height: "fit-content", whiteSpace: "nowrap" }}>
{item.investment_action || "관망"}
</div>
</div>
);
}
function AlertItem({ item }) {
const s = SENTIMENT_MAP[item.sentiment] || SENTIMENT_MAP["중립"];
return (
<div style={{ background: `${s.color}08`, border: `1px solid ${s.color}20`, borderRadius: 10, padding: 12, marginBottom: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: s.color }}>
{s.icon} {item.sentiment} (강도 {item.intensity})
</div>
<div style={{ fontSize: 10, color: "#546E7A" }}>{formatTime(item.analyzed_at)}</div>
</div>
<div style={{ fontSize: 12, color: "#CFD8DC", marginTop: 6, lineHeight: 1.4 }}>{item.title}</div>
<div style={{ fontSize: 11, color: "#78909C", marginTop: 4 }}>{item.reason?.substring(0, 100)}</div>
</div>
);
}
function Tab({ active, label, onClick }) {
return (
<button onClick={onClick} style={{
background: active ? "rgba(0,230,118,0.1)" : "transparent",
border: active ? "1px solid rgba(0,230,118,0.2)" : "1px solid transparent",
borderRadius: 8, padding: "8px 16px", color: active ? "#00E676" : "#607D8B",
fontSize: 13, fontWeight: active ? 700 : 500, cursor: "pointer", transition: "all 0.2s"
}}>{label}</button>
);
}
export default function Dashboard() {
const [tab, setTab] = useState("overview");
const { data: summary, reload: r1 } = useFetch("/summary", 30000);
const { data: ranking } = useFetch("/ranking", 60000);
const { data: recs, reload: r2 } = useFetch("/recommendations", 60000);
const { data: recent } = useFetch("/recent?limit=30", 30000);
const { data: alerts } = useFetch("/alerts", 30000);
const { data: timeline } = useFetch("/timeline?hours=48", 60000);
const refresh = () => { r1(); r2(); };
return (
<div style={{ minHeight: "100vh", background: "#0D1117", color: "#E0E0E0", fontFamily: "'Noto Sans KR', -apple-system, sans-serif", padding: "0 24px 40px" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
* { box-sizing: border-box; margin: 0; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
body { background: #0D1117 !important; }
`}</style>
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
<Header onRefresh={refresh} />
{/* Tabs */}
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
<Tab active={tab === "overview"} label="📊 종합" onClick={() => setTab("overview")} />
<Tab active={tab === "recommend"} label="🏆 추천종목" onClick={() => setTab("recommend")} />
<Tab active={tab === "news"} label="📰 뉴스피드" onClick={() => setTab("news")} />
<Tab active={tab === "alerts"} label="🚨 알림" onClick={() => setTab("alerts")} />
</div>
{/* Overview */}
{tab === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{/* Summary Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<SummaryCard icon="📰" label="분석 뉴스" value={summary?.total || 0} sub="최근 7일" />
<SummaryCard icon="▲" label="호재" value={summary?.positive || 0} color="#00E676" sub={`${summary?.sentiment_ratio || 50}%`} />
<SummaryCard icon="▼" label="악재" value={summary?.negative || 0} color="#FF5252" />
<SummaryCard icon="📋" label="DART 공시" value={summary?.dart || 0} />
<SummaryCard icon="🏢" label="분석 종목" value={summary?.stocks_analyzed || 0} />
</div>
{/* Two column: Ranking + Recent */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
{/* Ranking */}
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: "#90A4AE", marginBottom: 16 }}>🏆 종목 랭킹 (최신)</h3>
{ranking && ranking.length > 0 ? ranking.slice(0, 10).map((item, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: "1px solid rgba(255,255,255,0.03)" }}>
<div style={{ width: 24, height: 24, borderRadius: "50%", background: i < 3 ? "rgba(0,230,118,0.15)" : "rgba(255,255,255,0.04)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, color: i < 3 ? "#00E676" : "#546E7A" }}>{i + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#CFD8DC" }}>{item.stock_name}</div>
<div style={{ fontSize: 10, color: "#546E7A" }}>{item.stock_code}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 14, fontWeight: 700, color: (REC_MAP[item.recommendation] || {}).color || "#78909C" }}>{item.total_score?.toFixed(1)}</div>
<div style={{ fontSize: 10, color: (REC_MAP[item.recommendation] || {}).color || "#78909C" }}>{item.recommendation}</div>
</div>
</div>
)) : <div style={{ color: "#546E7A", fontSize: 13, padding: 20, textAlign: "center" }}>데이터 수집 ...</div>}
</div>
{/* Recent News */}
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: 20, maxHeight: 500, overflow: "auto" }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: "#90A4AE", marginBottom: 16 }}>📰 최근 분석</h3>
{recent && recent.length > 0 ? recent.slice(0, 15).map((item, i) => (
<NewsItem key={i} item={item} />
)) : <div style={{ color: "#546E7A", fontSize: 13, padding: 20, textAlign: "center" }}>데이터 수집 ...</div>}
</div>
</div>
</div>
)}
{/* Recommendations */}
{tab === "recommend" && (
<div>
<div style={{ fontSize: 12, color: "#546E7A", marginBottom: 16 }}>뉴스 감성 + DART 공시 + 가격 모멘텀 기반 종합 점수</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", gap: 12 }}>
{recs && recs.length > 0 ? recs.map((item, i) => (
<RecommendationCard key={i} item={item} />
)) : <div style={{ color: "#546E7A", fontSize: 13, padding: 40, textAlign: "center", gridColumn: "1/-1" }}>추천 데이터가 아직 없습니다. 16:30 자동 산출 업데이트됩니다.</div>}
</div>
</div>
)}
{/* News Feed */}
{tab === "news" && (
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: 20 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: "#90A4AE", marginBottom: 16 }}>전체 뉴스 피드</h3>
{recent && recent.length > 0 ? recent.map((item, i) => (
<NewsItem key={i} item={item} />
)) : <div style={{ color: "#546E7A", fontSize: 13, padding: 40, textAlign: "center" }}>뉴스 데이터 수집 ...</div>}
</div>
)}
{/* Alerts */}
{tab === "alerts" && (
<div>
<div style={{ fontSize: 12, color: "#546E7A", marginBottom: 16 }}>강도 3 이상 뉴스/공시 (최근 24시간)</div>
{alerts && alerts.length > 0 ? alerts.map((item, i) => (
<AlertItem key={i} item={item} />
)) : <div style={{ background: "rgba(255,255,255,0.02)", borderRadius: 16, padding: 40, textAlign: "center", color: "#546E7A", fontSize: 13 }}>현재 긴급 알림이 없습니다.</div>}
</div>
)}
{/* Footer */}
<div style={{ marginTop: 40, padding: "16px 0", borderTop: "1px solid rgba(255,255,255,0.04)", textAlign: "center", fontSize: 11, color: "#37474F" }}>
Trading AI System 뉴스/공시 기반 자동 분석 투자 판단의 참고 자료로만 활용하세요
</div>
</div>
</div>
);
}