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>
This commit is contained in:
kyu
2026-05-20 21:33:56 +09:00
commit 6d3b0bacc0
60 changed files with 17818 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install fastapi uvicorn asyncpg redis orjson httpx pydantic \
bcrypt "python-jose[cryptography]" email-validator apscheduler
COPY . .
EXPOSE 8989
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8989", "--workers", "1"]
+59
View File
@@ -0,0 +1,59 @@
"""인증 유틸 - bcrypt 해시 + JWT 토큰 (이메일/비밀번호)"""
import os
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import jwt, JWTError
from fastapi import Header, HTTPException, status
JWT_SECRET = os.getenv("JWT_SECRET", "")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_DAYS = int(os.getenv("JWT_EXPIRE_DAYS", "7"))
if not JWT_SECRET:
raise RuntimeError("JWT_SECRET 환경변수가 설정되지 않았습니다")
def hash_password(plain: str) -> str:
# bcrypt는 72바이트 제한 — 안전을 위해 잘라서 해시
pw_bytes = plain.encode("utf-8")[:72]
return bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
pw_bytes = plain.encode("utf-8")[:72]
return bcrypt.checkpw(pw_bytes, hashed.encode("utf-8"))
except Exception:
return False
def create_token(user_id: int, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"email": email,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=JWT_EXPIRE_DAYS)).timestamp()),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except JWTError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"invalid token: {e}")
async def current_user(authorization: str = Header(default="")) -> dict:
"""Authorization: Bearer <token> → {"id": int, "email": str}"""
if not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
token = authorization.split(" ", 1)[1].strip()
payload = decode_token(token)
try:
return {"id": int(payload["sub"]), "email": payload.get("email", "")}
except (KeyError, ValueError):
raise HTTPException(status_code=401, detail="malformed token payload")
# 타이밍 공격 방지용 더미 해시 (이메일이 없을 때도 verify를 호출해 응답시간 균일화)
_DUMMY_HASH = bcrypt.hashpw(b"dummy_password_for_timing", bcrypt.gensalt()).decode("utf-8")
def dummy_verify():
"""존재하지 않는 사용자에 대해서도 bcrypt 비용을 동일하게 지불"""
bcrypt.checkpw(b"dummy_password_for_timing", _DUMMY_HASH.encode("utf-8"))
+292
View File
@@ -0,0 +1,292 @@
<!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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
-- 사용자 인증 + 포트폴리오 (5~20명 지인 대상, JWT 기반)
-- idempotent: 여러 번 실행해도 안전
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_portfolio (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) NOT NULL DEFAULT '',
buy_price INTEGER NOT NULL,
qty INTEGER NOT NULL,
memo TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_portfolio_user ON user_portfolio(user_id);
CREATE INDEX IF NOT EXISTS idx_user_portfolio_user_code ON user_portfolio(user_id, stock_code);
@@ -0,0 +1,9 @@
-- 회원가입 관리자 승인 + 보안 (계정 잠금)
-- ADMIN_EMAILS 환경변수에 매칭되는 이메일은 register 시 자동 승인+admin
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_users_pending ON users(is_approved) WHERE is_approved = false;