6d3b0bacc0
- 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>
293 lines
11 KiB
HTML
293 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Trading AI · 종목 카드</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
|
||
background: #0d1117; color: #e6edf3; line-height: 1.5;
|
||
padding: 16px; max-width: 1400px; margin: 0 auto;
|
||
}
|
||
header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #30363d;
|
||
}
|
||
h1 { font-size: 20px; font-weight: 600; }
|
||
.regime {
|
||
padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 500;
|
||
}
|
||
.regime.강세 { background: #1f3d1f; color: #4ade80; }
|
||
.regime.약세 { background: #3d1f1f; color: #f87171; }
|
||
.regime.중립 { background: #2d2d3a; color: #a5a5b8; }
|
||
.summary-bar {
|
||
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
|
||
}
|
||
.summary-pill {
|
||
background: #161b22; border: 1px solid #30363d; padding: 8px 14px;
|
||
border-radius: 6px; font-size: 13px;
|
||
}
|
||
.summary-pill .v { color: #58a6ff; font-weight: 600; margin-left: 6px; }
|
||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||
.tab {
|
||
padding: 8px 18px; background: #161b22; border: 1px solid #30363d;
|
||
border-radius: 6px; cursor: pointer; font-size: 14px;
|
||
}
|
||
.tab.active { background: #1f6feb; border-color: #1f6feb; color: white; }
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
|
||
.card {
|
||
background: #161b22; border: 1px solid #30363d; border-radius: 10px;
|
||
padding: 16px; transition: transform .15s, border-color .15s;
|
||
}
|
||
.card:hover { transform: translateY(-2px); border-color: #58a6ff; }
|
||
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||
.name { font-size: 17px; font-weight: 600; color: #e6edf3; }
|
||
.code { font-size: 12px; color: #7d8590; margin-top: 2px; }
|
||
.score-badge {
|
||
text-align: right;
|
||
}
|
||
.score {
|
||
font-size: 28px; font-weight: 700; line-height: 1;
|
||
}
|
||
.score.강력매수 { color: #4ade80; }
|
||
.score.매수관심 { color: #58a6ff; }
|
||
.score.관망 { color: #a5a5b8; }
|
||
.score.매도관심, .score.강력매도 { color: #f87171; }
|
||
.rec {
|
||
font-size: 11px; padding: 2px 8px; border-radius: 4px;
|
||
margin-top: 4px; display: inline-block; font-weight: 500;
|
||
}
|
||
.rec.강력매수 { background: #1f3d1f; color: #4ade80; }
|
||
.rec.매수관심 { background: #1f2d3d; color: #58a6ff; }
|
||
.rec.매도관심, .rec.강력매도 { background: #3d1f1f; color: #f87171; }
|
||
.price-line {
|
||
display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px;
|
||
}
|
||
.price { font-size: 20px; font-weight: 600; }
|
||
.change { font-size: 14px; font-weight: 500; }
|
||
.change.up { color: #4ade80; }
|
||
.change.down { color: #f87171; }
|
||
.change.flat { color: #7d8590; }
|
||
.sector-tag {
|
||
font-size: 11px; padding: 2px 8px; background: #21262d;
|
||
border-radius: 4px; color: #a5a5b8;
|
||
}
|
||
.target-grid {
|
||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
|
||
background: #0d1117; padding: 10px; border-radius: 6px; margin-bottom: 12px;
|
||
}
|
||
.target-cell { font-size: 12px; }
|
||
.target-cell .label { color: #7d8590; margin-bottom: 2px; }
|
||
.target-cell .v { font-weight: 600; }
|
||
.target-cell .pct { color: #7d8590; margin-left: 4px; font-size: 11px; }
|
||
.target-cell .v.t { color: #4ade80; }
|
||
.target-cell .v.s { color: #f87171; }
|
||
.metrics {
|
||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||
font-size: 12px; padding: 10px 0; border-top: 1px solid #21262d; border-bottom: 1px solid #21262d;
|
||
margin-bottom: 10px;
|
||
}
|
||
.m-cell .label { color: #7d8590; font-size: 10px; margin-bottom: 2px; }
|
||
.m-cell .v { font-weight: 500; }
|
||
.m-cell .v.good { color: #4ade80; }
|
||
.m-cell .v.bad { color: #f87171; }
|
||
.position-line {
|
||
display: flex; justify-content: space-between; padding: 8px 10px;
|
||
background: #1c2839; border-radius: 6px; margin-bottom: 10px; font-size: 12px;
|
||
}
|
||
.pos-label { color: #7d8590; }
|
||
.pos-val { color: #58a6ff; font-weight: 600; }
|
||
.reasons { font-size: 12px; color: #a5a5b8; line-height: 1.6; }
|
||
.reason-item {
|
||
padding: 4px 0; padding-left: 14px; position: relative;
|
||
}
|
||
.reason-item:before { content: "→"; position: absolute; left: 0; color: #58a6ff; }
|
||
.empty { text-align: center; color: #7d8590; padding: 60px 20px; font-size: 14px; }
|
||
.loader { text-align: center; padding: 40px; color: #7d8590; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>📊 Trading AI · 종목 카드</h1>
|
||
<div class="regime" id="regime">로딩…</div>
|
||
</header>
|
||
|
||
<div class="summary-bar" id="summary"></div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" data-kind="recommendations">🟢 매수 추천</div>
|
||
<div class="tab" data-kind="avoid">🔴 회피 종목</div>
|
||
</div>
|
||
|
||
<div id="cards-container">
|
||
<div class="loader">불러오는 중…</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = location.origin;
|
||
|
||
function fmt(n) {
|
||
if (n === null || n === undefined) return "-";
|
||
if (typeof n !== "number") return String(n);
|
||
return n.toLocaleString("ko-KR");
|
||
}
|
||
function pctClass(v) { return v > 0 ? "up" : v < 0 ? "down" : "flat"; }
|
||
function pctSign(v) { return (v > 0 ? "+" : "") + v + "%"; }
|
||
function metricCls(v, good) { return v >= good ? "good" : v < 0 ? "bad" : ""; }
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
const r = await fetch(`${API}/api/summary`);
|
||
const d = await r.json();
|
||
document.getElementById("regime").textContent =
|
||
`시장 ${d.market_regime} (${d.market_regime_adj > 0 ? "+" : ""}${d.market_regime_adj})`;
|
||
document.getElementById("regime").className = "regime " + (d.market_regime || "중립");
|
||
|
||
document.getElementById("summary").innerHTML = `
|
||
<div class="summary-pill">강력매수<span class="v">${d.strong_buy || 0}</span></div>
|
||
<div class="summary-pill">매수관심<span class="v">${d.interest_buy || 0}</span></div>
|
||
<div class="summary-pill">매도관심<span class="v">${d.interest_sell || 0}</span></div>
|
||
<div class="summary-pill">강력매도<span class="v">${d.strong_sell || 0}</span></div>
|
||
<div class="summary-pill">7일 호재 비율<span class="v">${d.sentiment_ratio || 0}%</span></div>
|
||
<div class="summary-pill">분석 종목<span class="v">${d.stocks_analyzed || 0}</span></div>
|
||
`;
|
||
} catch (e) { console.error(e); }
|
||
}
|
||
|
||
function renderCard(s) {
|
||
const recCls = s.recommendation || "관망";
|
||
const ch = s.change_pct || 0;
|
||
const mosCls = s.margin_of_safety > 25 ? "good" : s.margin_of_safety < -25 ? "bad" : "";
|
||
const eqCls = metricCls(s.earnings_quality || 0, 5);
|
||
const reasons = (s.reasons || []).map(r =>
|
||
`<div class="reason-item">${r}</div>`).join("");
|
||
return `
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="name">${s.stock_name}</div>
|
||
<div class="code">${s.stock_code} · <span class="sector-tag">${s.sector || "기타"}</span></div>
|
||
</div>
|
||
<div class="score-badge">
|
||
<div class="score ${recCls}">${s.score}</div>
|
||
<div class="rec ${recCls}">${s.recommendation}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="price-line">
|
||
<div class="price">${fmt(s.price)}원</div>
|
||
<div class="change ${pctClass(ch)}">${pctSign(ch)}</div>
|
||
${s.market_cap_eok ? `<div class="sector-tag">시총 ${fmt(s.market_cap_eok)}억</div>` : ""}
|
||
</div>
|
||
|
||
${s.t1 ? `
|
||
<div class="target-grid">
|
||
<div class="target-cell">
|
||
<div class="label">진입가</div>
|
||
<div class="v">${fmt(s.entry_price)}원</div>
|
||
</div>
|
||
<div class="target-cell">
|
||
<div class="label">손절</div>
|
||
<div class="v s">${fmt(s.stop_loss)}원</div>
|
||
</div>
|
||
<div class="target-cell">
|
||
<div class="label">T1 (50% 매도)</div>
|
||
<div class="v t">${fmt(s.t1)}<span class="pct">${pctSign(s.t1_pct)}</span></div>
|
||
</div>
|
||
<div class="target-cell">
|
||
<div class="label">Trailing (ATR×2)</div>
|
||
<div class="v">${fmt(s.trailing_stop)}원</div>
|
||
</div>
|
||
<div class="target-cell">
|
||
<div class="label">T2 (30%)</div>
|
||
<div class="v t">${fmt(s.t2)}<span class="pct">${pctSign(s.t2_pct)}</span></div>
|
||
</div>
|
||
<div class="target-cell">
|
||
<div class="label">T3 (20%)</div>
|
||
<div class="v t">${fmt(s.t3)}<span class="pct">${pctSign(s.t3_pct)}</span></div>
|
||
</div>
|
||
</div>` : `<div class="loader" style="padding:8px;font-size:11px;">목표가 데이터 없음 (다음 기술분석 cron에서 갱신)</div>`}
|
||
|
||
${s.position_size_pct ? `
|
||
<div class="position-line">
|
||
<span class="pos-label">추천 매수 비중</span>
|
||
<span class="pos-val">${s.position_size_pct}% · 변동성 ${s.volatility_60d || "-"}%</span>
|
||
</div>` : ""}
|
||
|
||
<div class="metrics">
|
||
<div class="m-cell">
|
||
<div class="label">ROE</div>
|
||
<div class="v ${metricCls(s.roe || 0, 10)}">${s.roe ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">영업이익률</div>
|
||
<div class="v ${metricCls(s.operating_margin || 0, 10)}">${s.operating_margin ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">부채비율</div>
|
||
<div class="v ${(s.debt_ratio||100) <= 50 ? "good" : (s.debt_ratio||0) > 80 ? "bad" : ""}">${s.debt_ratio ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">PER</div>
|
||
<div class="v">${s.per ?? "-"}</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">안전마진</div>
|
||
<div class="v ${mosCls}">${s.margin_of_safety ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">이익품질</div>
|
||
<div class="v ${eqCls}">${s.earnings_quality ?? "-"}</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">외국인지분</div>
|
||
<div class="v">${s.foreign_ratio_pct ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">공매도비중</div>
|
||
<div class="v ${(s.short_weight_pct||0) > 5 ? "bad" : ""}">${s.short_weight_pct ?? "-"}%</div>
|
||
</div>
|
||
<div class="m-cell">
|
||
<div class="label">FCF</div>
|
||
<div class="v ${metricCls(s.fcf_ratio || 0, 5)}">${s.fcf_ratio ?? "-"}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
${reasons ? `<div class="reasons">${reasons}</div>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadCards(kind) {
|
||
const c = document.getElementById("cards-container");
|
||
c.innerHTML = '<div class="loader">불러오는 중…</div>';
|
||
try {
|
||
const r = await fetch(`${API}/api/${kind}?days=1&limit=30`);
|
||
const data = await r.json();
|
||
if (!data.length) {
|
||
c.innerHTML = '<div class="empty">해당 카테고리에 종목이 없습니다.</div>';
|
||
return;
|
||
}
|
||
c.innerHTML = `<div class="cards">${data.map(renderCard).join("")}</div>`;
|
||
} catch (e) {
|
||
c.innerHTML = `<div class="empty">에러: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll(".tab").forEach(t => {
|
||
t.addEventListener("click", () => {
|
||
document.querySelectorAll(".tab").forEach(x => x.classList.remove("active"));
|
||
t.classList.add("active");
|
||
loadCards(t.dataset.kind);
|
||
});
|
||
});
|
||
|
||
loadSummary();
|
||
loadCards("recommendations");
|
||
setInterval(loadSummary, 60000);
|
||
</script>
|
||
</body>
|
||
</html>
|