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:
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user