Files
trading/dashboard-api/main.py
T
kyu 8b200e6cc6 feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:22:23 +09:00

2148 lines
107 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""대시보드 API - 프론트엔드에서 호출"""
import asyncio, os, json, re
from datetime import datetime, timedelta, timezone
import asyncpg, httpx, redis.asyncio as aioredis
from fastapi import FastAPI, Query, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, EmailStr, Field
from auth import hash_password, verify_password, create_token, current_user, dummy_verify
TA_ENGINE_URL = os.getenv("TA_ENGINE_URL", "http://ta-engine:8484")
KIS_API_URL = os.getenv("KIS_API_URL", "http://kis-api:8585")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686")
US_MARKET_URL = os.getenv("US_MARKET_URL", "http://us-market:8383")
AUX_SIGNAL_URL = os.getenv("AUX_SIGNAL_URL", "http://aux-signal:8282")
CHAT_MODEL = os.getenv("CHAT_MODEL", "exaone3.5:7.8b")
class PositionReq(BaseModel):
code: str
name: str = ""
buy_price: int
qty: int
PG_HOST = os.getenv("POSTGRES_HOST", "postgres")
PG_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
PG_DB = os.getenv("POSTGRES_DB", "trading_ai")
PG_USER = os.getenv("POSTGRES_USER", "kyu")
PG_PASS = os.getenv("POSTGRES_PASSWORD", "")
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
pg_pool = None
redis_cl = None
ADMIN_EMAILS = {
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
}
TRUSTED_ORIGINS = [
o.strip() for o in os.getenv("TRUSTED_ORIGINS", "").split(",") if o.strip()
] or ["http://localhost:8989"]
# 레이트 리밋: IP당 시도 수 (Redis db=2 사용)
RL_LOGIN_MAX = 10 # 15분 내 10회
RL_LOGIN_WINDOW = 900
RL_REGISTER_MAX = 5 # 1시간 내 5회
RL_REGISTER_WINDOW = 3600
LOCK_THRESHOLD = 5 # 실패 5회 시 잠금
LOCK_DURATION = 900 # 15분
app = FastAPI(title="Dashboard API")
app.add_middleware(
CORSMiddleware,
allow_origins=TRUSTED_ORIGINS,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()"
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# 인증용 별도 Redis (db=2)
auth_redis = None
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
async def _rate_limit(key: str, limit: int, window: int):
if not auth_redis:
return
try:
cnt = await auth_redis.incr(key)
if cnt == 1:
await auth_redis.expire(key, window)
if cnt > limit:
ttl = await auth_redis.ttl(key)
raise HTTPException(
status_code=429,
detail=f"요청이 너무 많습니다. {max(ttl,1)}초 후 다시 시도하세요")
except HTTPException:
raise
except Exception:
pass # Redis 장애 시 게이트만 우회 (보안 vs 가용성 트레이드오프)
@app.on_event("startup")
async def startup():
global pg_pool, redis_cl, auth_redis
pg_pool = await asyncpg.create_pool(
host=PG_HOST, port=PG_PORT, database=PG_DB,
user=PG_USER, password=PG_PASS, min_size=2, max_size=5)
redis_cl = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True)
auth_redis = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=2, decode_responses=True)
# 워치리스트 + 알림 테이블 초기화
async with pg_pool.acquire() as c:
await c.execute("""
CREATE TABLE IF NOT EXISTS user_watchlist (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
stock_code VARCHAR(10) NOT NULL,
memo VARCHAR(200) DEFAULT '',
added_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, stock_code)
)
""")
await c.execute(
"CREATE INDEX IF NOT EXISTS idx_watchlist_user ON user_watchlist(user_id)")
await c.execute("""
CREATE TABLE IF NOT EXISTS user_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
stock_code VARCHAR(10) NOT NULL,
alert_type VARCHAR(30) NOT NULL,
threshold FLOAT NOT NULL,
active BOOLEAN DEFAULT TRUE,
last_triggered TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
)
""")
await c.execute(
"CREATE INDEX IF NOT EXISTS idx_alerts_active ON user_alerts(active) WHERE active=true")
# 알림 cron — 5분마다 활성 알림 체크
if not hasattr(app.state, 'alert_scheduler'):
from apscheduler.schedulers.asyncio import AsyncIOScheduler
app.state.alert_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
app.state.alert_scheduler.add_job(
check_alerts, "cron", day_of_week="mon-fri",
hour="9-15", minute="*/5", id="alerts_check", replace_existing=True)
app.state.alert_scheduler.start()
@app.on_event("shutdown")
async def shutdown():
if pg_pool: await pg_pool.close()
if redis_cl: await redis_cl.aclose()
if auth_redis: await auth_redis.aclose()
# ── 응답 정리 helper (소수점·중복·잘림) ─────────────────────
def _r1(v):
"""소수 1자리 반올림 (None 안전)"""
if v is None: return 0.0
try: return round(float(v), 1)
except: return 0.0
def _clean_reasons(raw: str, max_items: int = 4, max_len: int = 60) -> list:
"""
top_reasons " | " 분리 → 중복 제거(앞 30자 기준) + 잘림 명시
"""
if not raw:
return []
parts = [p.strip() for p in raw.split("|") if p.strip()]
seen = set()
out = []
for p in parts:
key = p[:30].strip()
if key in seen: continue
seen.add(key)
if len(p) > max_len:
p = p[:max_len].rstrip() + ""
out.append(p)
if len(out) >= max_items: break
return out
async def _enrich_rec(c, redis_cl, code: str, base: dict) -> dict:
"""
추천 종목 dict에 매매가/포지션/재무/현재가/수급 통합 채움
"""
# 1) Redis 현재가
price = ch = mc = per = pbr = 0
try:
raw = await redis_cl.get(f"price:{code}")
if raw:
p = json.loads(raw)
price = int(p.get("price") or 0)
ch = float(p.get("change_pct") or 0)
mc = int(p.get("market_cap") or 0)
per = float(p.get("per") or 0)
pbr = float(p.get("pbr") or 0)
except: pass
# 2) stock_scores 최신 (DCF/추세/포지션/안전마진/시장레짐/섹터)
sc_row = await c.fetchrow("""
SELECT trend_score, intrinsic_value, margin_of_safety, earnings_quality,
position_size_pct, volatility_60d, market_regime_adj, sector,
foreign_score, short_score, foreign_ratio, short_weight
FROM stock_scores WHERE stock_code=$1
ORDER BY score_date DESC LIMIT 1
""", code)
# 3) ta-engine 결과 (목표가/손절/ATR trailing)
targets = {}
ta_row = await c.fetchrow("""
SELECT targets, signal AS ta_signal, tech_score
FROM stock_technical WHERE stock_code=$1
""", code)
if ta_row and ta_row["targets"]:
try:
t = ta_row["targets"]
targets = json.loads(t) if isinstance(t, str) else dict(t)
except: targets = {}
# 4) 재무 (최신 사업보고서)
fin_row = await c.fetchrow("""
SELECT roe, operating_margin, debt_ratio, fcf_ratio,
revenue_growth, bsns_year
FROM dart_financials WHERE stock_code=$1 AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 1
""", code)
base["score"] = _r1(base.get("total_score"))
base["news_score"] = _r1(base.get("news_score"))
base["technical_score"] = _r1(base.get("technical_score"))
base["dart_score"] = _r1(base.get("dart_score"))
base["price"] = price
base["change_pct"] = _r1(ch)
base["market_cap_eok"] = round(mc / 100_000_000) if mc else 0
base["per"] = _r1(per)
base["pbr"] = round(pbr, 2) if pbr else 0
base["reasons"] = _clean_reasons(base.pop("top_reasons", "") or "")
base["entry_price"] = targets.get("entry_price", 0)
base["t1"] = targets.get("t1", 0)
base["t2"] = targets.get("t2", 0)
base["t3"] = targets.get("t3", 0)
base["t1_pct"] = _r1(targets.get("t1_pct"))
base["t2_pct"] = _r1(targets.get("t2_pct"))
base["t3_pct"] = _r1(targets.get("t3_pct"))
base["stop_loss"] = targets.get("stop_loss", 0)
base["trailing_stop"] = targets.get("trailing_stop", 0)
base["atr14"] = targets.get("atr14", 0)
base["exit_strategy"] = targets.get("exit_strategy", "")
if sc_row:
base["trend_score"] = _r1(sc_row["trend_score"])
base["intrinsic_value"] = sc_row["intrinsic_value"] or 0
base["margin_of_safety"] = _r1(sc_row["margin_of_safety"])
base["earnings_quality"] = _r1(sc_row["earnings_quality"])
base["position_size_pct"] = _r1(sc_row["position_size_pct"])
base["volatility_60d"] = _r1(sc_row["volatility_60d"])
base["market_regime_adj"] = _r1(sc_row["market_regime_adj"])
base["sector"] = sc_row["sector"] or "(미분류)"
base["foreign_score"] = _r1(sc_row["foreign_score"])
base["short_score"] = _r1(sc_row["short_score"])
base["foreign_ratio_pct"] = _r1(sc_row["foreign_ratio"])
base["short_weight_pct"] = _r1(sc_row["short_weight"])
if fin_row:
base["roe"] = _r1(fin_row["roe"])
base["operating_margin"] = _r1(fin_row["operating_margin"])
base["debt_ratio"] = _r1(fin_row["debt_ratio"])
base["fcf_ratio"] = _r1(fin_row["fcf_ratio"])
base["revenue_growth"] = _r1(fin_row["revenue_growth"])
base["bsns_year"] = fin_row["bsns_year"]
if "total_score" in base: del base["total_score"]
if "price_score" in base: del base["price_score"]
if "recommended_at" in base:
base["recommended_at"] = str(base["recommended_at"])
return base
# ── 요약 ────────────────────────────────────────────────────
@app.get("/api/summary")
async def summary():
async with pg_pool.acquire() as c:
total = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE analyzed_at>=CURRENT_DATE-7")
pos = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE sentiment='호재' AND analyzed_at>=CURRENT_DATE-7")
neg = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE sentiment='악재' AND analyzed_at>=CURRENT_DATE-7")
dart = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE source='DART공시' AND analyzed_at>=CURRENT_DATE-7")
stocks = await c.fetchval("SELECT COUNT(DISTINCT primary_stock) FROM news_analysis WHERE primary_stock!='' AND analyzed_at>=CURRENT_DATE-7")
signals = await c.fetchval("SELECT COUNT(*) FROM trade_signals WHERE created_at>=CURRENT_DATE-1")
regime = await c.fetchrow("SELECT regime, regime_adj FROM market_regime ORDER BY dt DESC LIMIT 1")
rec_count = await c.fetchrow("""
SELECT COUNT(*) FILTER (WHERE recommendation='강력매수') AS strong_buy,
COUNT(*) FILTER (WHERE recommendation='매수관심') AS interest_buy,
COUNT(*) FILTER (WHERE recommendation='매도관심') AS interest_sell,
COUNT(*) FILTER (WHERE recommendation='강력매도') AS strong_sell
FROM stock_scores WHERE score_date=CURRENT_DATE
""")
return {
"total": total, "positive": pos, "negative": neg,
"dart": dart, "stocks_analyzed": stocks,
"signals_today": signals,
"sentiment_ratio": round(pos / (pos + neg) * 100 if pos + neg > 0 else 50, 1),
"market_regime": regime["regime"] if regime else "데이터부족",
"market_regime_adj": _r1(regime["regime_adj"]) if regime else 0,
"strong_buy": rec_count["strong_buy"] if rec_count else 0,
"interest_buy": rec_count["interest_buy"] if rec_count else 0,
"interest_sell": rec_count["interest_sell"] if rec_count else 0,
"strong_sell": rec_count["strong_sell"] if rec_count else 0,
}
# ── 최근 뉴스 ────────────────────────────────────────────────
@app.get("/api/recent")
async def recent(limit: int = Query(default=40), only_stock: bool = Query(default=True)):
"""only_stock=true(기본): 종목/시장 매칭된 뉴스만. false: 전체."""
where = ""
if only_stock:
where = """WHERE (primary_stock IS NOT NULL AND primary_stock <> '')
OR (stock_codes IS NOT NULL AND jsonb_array_length(stock_codes) > 0)
OR (catalyst IS NOT NULL AND catalyst <> '')"""
async with pg_pool.acquire() as c:
rows = await c.fetch(f"""
SELECT title, sentiment, intensity, primary_stock, reason,
investment_action, source,
COALESCE(published_at, analyzed_at) AS analyzed_at,
url, catalyst, stock_codes
FROM news_analysis {where}
ORDER BY analyzed_at DESC LIMIT $1
""", limit)
return [dict(r) for r in rows]
# ── 종목 랭킹 ─────────────────────────────────────────────────
@app.get("/api/ranking")
async def ranking():
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation,
s.news_score, s.dart_score, s.price_score,
COALESCE(s.technical_score, 0) AS technical_score,
s.news_total, s.news_positive, s.news_negative, s.top_reasons,
COALESCE(s.foreign_score, 0) AS foreign_score,
COALESCE(s.short_score, 0) AS short_score,
COALESCE(s.foreign_ratio, 0) AS foreign_ratio,
COALESCE(s.short_weight, 0) AS short_weight
FROM stock_scores s
JOIN dart_corps d ON s.stock_code = d.stock_code AND d.is_active = true
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
ORDER BY s.total_score DESC LIMIT 30
""")
return [dict(r) for r in rows]
# ── 추천 종목 ─────────────────────────────────────────────────
@app.get("/api/recommendations")
async def recommendations(days: int = Query(default=7), limit: int = Query(default=50)):
"""매수 추천 — 매매가/포지션/재무/현재가 모두 통합된 종목 카드 응답"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT DISTINCT ON (r.stock_code) r.stock_code, r.stock_name, r.recommendation,
r.total_score, r.news_score, r.dart_score, r.price_score, r.technical_score,
r.top_reasons, r.recommended_at
FROM stock_recommendations r
JOIN dart_corps d ON r.stock_code = d.stock_code AND d.is_active = true
WHERE r.recommended_at >= NOW() - INTERVAL '%s days'
AND r.recommendation IN ('강력매수', '매수관심')
ORDER BY r.stock_code, r.total_score DESC, r.recommended_at DESC
""" % days)
rows_sorted = sorted(rows, key=lambda r: r["total_score"], reverse=True)[:limit]
out = []
for r in rows_sorted:
d = dict(r)
await _enrich_rec(c, redis_cl, d["stock_code"], d)
out.append(d)
return out
@app.get("/api/avoid")
async def avoid(days: int = Query(default=7), limit: int = Query(default=30)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT DISTINCT ON (r.stock_code) r.stock_code, r.stock_name, r.recommendation,
r.total_score, r.news_score, r.dart_score, r.price_score, r.technical_score,
r.top_reasons, r.recommended_at
FROM stock_recommendations r
JOIN dart_corps d ON r.stock_code = d.stock_code AND d.is_active = true
WHERE r.recommended_at >= NOW() - INTERVAL '%s days'
AND r.recommendation IN ('매도관심', '강력매도')
ORDER BY r.stock_code, r.total_score ASC, r.recommended_at DESC
""" % days)
rows_sorted = sorted(rows, key=lambda r: r["total_score"])[:limit]
out = []
for r in rows_sorted:
d = dict(r)
await _enrich_rec(c, redis_cl, d["stock_code"], d)
out.append(d)
return out
# ── 매매 시그널 ──────────────────────────────────────────────
@app.get("/api/signals")
async def signals(days: int = Query(default=7), limit: int = Query(default=100),
signal_type: str = Query(default="")):
async with pg_pool.acquire() as c:
if signal_type:
rows = await c.fetch("""
SELECT ts.*, t.tech_score, t.signals AS ta_signals, t.targets
FROM trade_signals ts
JOIN dart_corps d ON ts.stock_code = d.stock_code AND d.is_active = true
LEFT JOIN stock_technical t ON ts.stock_code = t.stock_code
WHERE ts.created_at >= NOW() - INTERVAL '%s days'
AND ts.signal_type = $1
ORDER BY ts.confidence DESC LIMIT $2
""" % days, signal_type, limit)
else:
rows = await c.fetch("""
SELECT ts.*, t.tech_score, t.signals AS ta_signals, t.targets
FROM trade_signals ts
JOIN dart_corps d ON ts.stock_code = d.stock_code AND d.is_active = true
LEFT JOIN stock_technical t ON ts.stock_code = t.stock_code
WHERE ts.created_at >= NOW() - INTERVAL '%s days'
ORDER BY ts.confidence DESC LIMIT $1
""" % days, limit)
result = []
for row in rows:
d = dict(row)
d["created_at"] = str(d["created_at"])
for k in ("ta_signals", "targets"):
if isinstance(d.get(k), str):
try: d[k] = json.loads(d[k])
except: d[k] = []
result.append(d)
return result
# ── 기술적 분석 ──────────────────────────────────────────────
@app.get("/api/technical/{code}")
async def technical(code: str):
# Redis db=5 (ta-engine)
ta_redis = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True)
try:
cached = await ta_redis.get(f"ta:{code}")
if cached:
return JSONResponse(content=json.loads(cached))
finally:
await ta_redis.aclose()
async with pg_pool.acquire() as c:
row = await c.fetchrow("SELECT * FROM stock_technical WHERE stock_code=$1", code)
if row:
d = dict(row)
d["analyzed_at"] = str(d["analyzed_at"])
for k in ("signals", "targets"):
if isinstance(d.get(k), str):
try: d[k] = json.loads(d[k])
except: pass
return JSONResponse(content=d)
return JSONResponse(content={"error": "not found"}, status_code=404)
@app.get("/api/buy-candidates")
async def buy_candidates(limit: int = Query(default=20)):
"""기술적+펀더멘탈 통합 매수 후보 (버핏 가치 필터 포함)"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT t.stock_code,
COALESCE(NULLIF(t.stock_name,''), d.corp_name, t.stock_code) AS stock_name,
t.price, t.tech_score, t.signal, t.signals, t.targets,
t.rsi, t.macd_hist, t.ma5, t.ma20, t.ma60,
t.bb_upper, t.bb_lower, t.pct_b, t.vol_ratio,
s.news_score, s.dart_score, s.recommendation,
COALESCE(s.total_score, 0) AS score,
(t.tech_score * 0.4 + COALESCE(s.total_score, 0) * 0.6) AS combined_score,
COALESCE(s.foreign_score, 0) AS foreign_score,
COALESCE(s.short_weight, 0) AS short_weight,
f.roe, f.operating_margin, f.debt_ratio, f.revenue_growth, f.net_margin
FROM stock_technical t
JOIN dart_corps d ON t.stock_code = d.stock_code AND d.is_active = true
LEFT JOIN stock_scores s
ON t.stock_code = s.stock_code
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
LEFT JOIN LATERAL (
SELECT stock_code, roe, operating_margin, debt_ratio, revenue_growth, net_margin,
operating_profit, revenue
FROM dart_financials
WHERE stock_code = t.stock_code
ORDER BY bsns_year DESC, reprt_code DESC
LIMIT 1
) f ON true
WHERE t.signal = '매수' AND t.tech_score >= 30
AND t.price >= 1000
AND t.stock_name NOT LIKE '%기업인수목적%'
AND t.stock_name NOT LIKE '%선박투자%'
AND t.stock_name NOT LIKE '%부동산투자회사%'
AND t.stock_name NOT LIKE '%특별자산%'
AND (f.stock_code IS NULL OR f.operating_profit > 0)
ORDER BY combined_score DESC
LIMIT $1
""", limit)
result = []
for row in rows:
d = dict(row)
for k in ("signals", "targets"):
if isinstance(d.get(k), str):
try: d[k] = json.loads(d[k])
except: d[k] = []
result.append(d)
return result
# ── 알림 ─────────────────────────────────────────────────────
@app.get("/api/alerts")
async def alerts():
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT title, sentiment, intensity, primary_stock,
reason, investment_action, source,
COALESCE(published_at, analyzed_at) AS analyzed_at
FROM news_analysis
WHERE intensity >= 3
AND COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '24 hours'
ORDER BY intensity DESC, analyzed_at DESC LIMIT 20
""")
return [dict(r) for r in rows]
# ── 타임라인 ─────────────────────────────────────────────────
@app.get("/api/timeline")
async def timeline(hours: int = Query(default=24)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT date_trunc('hour', COALESCE(published_at, analyzed_at)) AS hour,
SUM(CASE WHEN sentiment='호재' THEN 1 ELSE 0 END) AS pos,
SUM(CASE WHEN sentiment='악재' THEN 1 ELSE 0 END) AS neg,
COUNT(*) AS total
FROM news_analysis
WHERE COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '%s hours'
GROUP BY hour ORDER BY hour
""" % hours)
return [{"hour": str(r["hour"]), "positive": r["pos"],
"negative": r["neg"], "total": r["total"]} for r in rows]
# ── 종목 상세 ─────────────────────────────────────────────────
@app.get("/api/stock/{code}")
async def stock(code: str):
async with pg_pool.acquire() as c:
news = await c.fetch("""
SELECT title, sentiment, intensity, reason, source,
COALESCE(published_at, analyzed_at) AS analyzed_at
FROM news_analysis WHERE primary_stock=$1
ORDER BY analyzed_at DESC LIMIT 20
""", code)
scores = await c.fetch(
"SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 30", code)
fin = await c.fetch(
"SELECT * FROM dart_financials WHERE stock_code=$1 ORDER BY bsns_year DESC", code)
sigs = await c.fetch(
"SELECT * FROM trade_signals WHERE stock_code=$1 ORDER BY created_at DESC LIMIT 5", code)
price = None
ta = None
foreign = None
short = None
ohlcv = None
if redis_cl:
try:
p = await redis_cl.get(f"price:{code}")
if p: price = json.loads(p)
except: pass
try:
f = await redis_cl.get(f"foreign:{code}")
if f: foreign = json.loads(f)[:10]
except: pass
try:
s = await redis_cl.get(f"short:{code}")
if s: short = json.loads(s)[:10]
except: pass
try:
o = await redis_cl.get(f"ohlcv:{code}")
if o: ohlcv = json.loads(o)[:30]
except: pass
ta_redis = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True)
try:
t = await ta_redis.get(f"ta:{code}")
if t: ta = json.loads(t)
except: pass
finally:
await ta_redis.aclose()
def _serial(rows):
result = []
for row in rows:
d = dict(row)
for k, v in d.items():
if hasattr(v, 'isoformat'): d[k] = str(v)
result.append(d)
return result
return {
"code": code, "price": price, "technical": ta,
"news": _serial(news), "scores": _serial(scores),
"financials": _serial(fin), "signals": _serial(sigs),
"foreign": foreign, "short": short, "ohlcv": ohlcv,
}
# ── 검색 ──────────────────────────────────────────────────────
@app.get("/api/search")
async def search(q: str = Query(default=""), days: int = Query(default=30)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT n.title, n.sentiment, n.intensity, n.primary_stock,
n.reason, n.investment_action, n.source, n.analyzed_at,
s.total_score, s.recommendation,
s.news_score, s.dart_score, s.price_score,
COALESCE(s.technical_score, 0) AS technical_score
FROM news_analysis n
LEFT JOIN stock_scores s
ON n.primary_stock = s.stock_code
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
WHERE (n.primary_stock ILIKE $1 OR n.title ILIKE $1)
AND n.analyzed_at >= NOW() - INTERVAL '%s days'
ORDER BY n.analyzed_at DESC LIMIT 50
""" % days, f"%{q}%")
return [dict(r) for r in rows]
# ── 포지션 분석 (ta-engine 프록시) ───────────────────────────
@app.post("/api/position")
async def position(req: PositionReq, ai: bool = False):
"""보유 종목 매입가/수량 입력 → 손익 + 맞춤 전략 반환"""
async with httpx.AsyncClient() as c:
r = await c.post(
f"{TA_ENGINE_URL}/position?ai={str(ai).lower()}",
json=req.dict(), timeout=90)
return JSONResponse(content=r.json())
@app.get("/api/report/{code}")
async def report(code: str):
"""종목 전체 리포트 (기술적 분석 + AI 판단문 + 뉴스)"""
async with httpx.AsyncClient() as c:
r = await c.get(f"{TA_ENGINE_URL}/report/{code}", timeout=90)
return JSONResponse(content=r.json())
# ── 펀더멘털 분석 (버핏 가치투자 뷰) ────────────────────────
@app.get("/api/fundamentals")
async def fundamentals(limit: int = Query(default=30)):
"""버핏 가치 기준 상위 종목 - ROE, 영업이익률, 부채비율 기반"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT DISTINCT ON (f.stock_code)
f.stock_code, d.corp_name AS stock_name,
f.bsns_year, f.reprt_name,
f.roe, f.operating_margin, f.net_margin,
f.debt_ratio, f.revenue_growth, f.fcf_ratio,
f.revenue, f.operating_profit, f.net_income,
f.total_equity, f.total_liabilities,
s.total_score, s.recommendation
FROM dart_financials f
JOIN dart_corps d ON f.stock_code = d.stock_code AND d.is_active = true
LEFT JOIN stock_scores s
ON f.stock_code = s.stock_code
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
WHERE f.operating_profit > 0
AND f.revenue > 0
AND f.debt_ratio BETWEEN 0 AND 80
AND f.roe > 5
ORDER BY f.stock_code, f.bsns_year DESC, f.reprt_code DESC
LIMIT $1
""", limit * 3)
# ROE 기준 재정렬
sorted_rows = sorted(rows, key=lambda r: (r["roe"] or 0), reverse=True)
return [dict(r) for r in sorted_rows[:limit]]
@app.get("/api/fundamentals/{code}")
async def fundamentals_detail(code: str):
"""특정 종목 재무 이력"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT bsns_year, reprt_name, revenue, operating_profit, net_income,
total_assets, total_liabilities, total_equity, operating_cashflow,
roe, operating_margin, net_margin, debt_ratio, revenue_growth, fcf_ratio
FROM dart_financials
WHERE stock_code = $1
ORDER BY bsns_year DESC, reprt_code DESC
LIMIT 8
""", code)
return [dict(r) for r in rows]
# ── 종목명 조회 ──────────────────────────────────────────────
@app.get("/api/name/{code}")
async def stock_name(code: str):
async with pg_pool.acquire() as c:
name = await c.fetchval("SELECT corp_name FROM dart_corps WHERE stock_code=$1", code)
if not name:
name = await c.fetchval(
"SELECT stock_name FROM stock_scores WHERE stock_code=$1 LIMIT 1", code)
return {"code": code, "name": name or ""}
@app.get("/api/performance")
async def performance():
"""추천 성과 통계 (score-engine DB 직접 조회)"""
async with pg_pool.acquire() as c:
stats = await c.fetchrow("""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE return_7d IS NOT NULL) AS measured_7d,
ROUND(AVG(return_7d) FILTER (WHERE return_7d IS NOT NULL)::numeric, 2) AS avg_return_7d,
COUNT(*) FILTER (WHERE return_7d > 0) AS wins_7d,
ROUND(AVG(return_30d) FILTER (WHERE return_30d IS NOT NULL)::numeric, 2) AS avg_return_30d,
COUNT(*) FILTER (WHERE return_30d > 0) AS wins_30d,
COUNT(*) FILTER (WHERE return_30d IS NOT NULL) AS measured_30d,
ROUND(AVG(return_7d) FILTER (WHERE recommendation='강력매수' AND return_7d IS NOT NULL)::numeric, 2) AS strong_buy_avg_7d
FROM recommendation_performance
WHERE rec_date >= CURRENT_DATE - 90
""")
recent = await c.fetch("""
SELECT stock_code, stock_name, recommendation, entry_price,
price_7d, return_7d, price_30d, return_30d, rec_date
FROM recommendation_performance
WHERE return_7d IS NOT NULL OR return_30d IS NOT NULL
ORDER BY rec_date DESC LIMIT 30
""")
def s(r): return {**dict(r), "rec_date": str(r["rec_date"])}
return {"summary": dict(stats) if stats else {}, "recent": [s(r) for r in recent]}
@app.get("/api/sector-ranking")
async def sector_ranking():
"""섹터별 평균 AI 스코어 랭킹"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT
COALESCE(NULLIF(d.sector_name,''), '기타') AS sector,
COUNT(DISTINCT s.stock_code) AS stock_count,
ROUND(AVG(s.total_score)::numeric, 1) AS avg_score,
ROUND(AVG(s.news_score)::numeric, 1) AS avg_news,
ROUND(AVG(s.technical_score)::numeric, 1) AS avg_tech,
COUNT(*) FILTER (WHERE s.recommendation IN ('강력매수','매수관심')) AS buy_count,
COUNT(*) FILTER (WHERE s.recommendation IN ('강력매도','매도관심')) AS sell_count
FROM stock_scores s
JOIN dart_corps d ON s.stock_code = d.stock_code AND d.is_active = true
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
GROUP BY d.sector_name
HAVING COUNT(DISTINCT s.stock_code) >= 3
ORDER BY AVG(s.total_score) DESC
""")
return [dict(r) for r in rows]
@app.get("/api/portfolio/prices")
async def portfolio_prices(codes: str = Query(default="")):
"""보유 종목 현재가 + 기술점수 + AI점수 일괄 조회"""
code_list = [c.strip() for c in codes.split(",") if c.strip() and len(c.strip()) == 6]
if not code_list:
return []
ta_redis = aioredis.Redis(host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True)
ta_map = {}
try:
vals = await ta_redis.mget(*[f"ta:{c}" for c in code_list])
for code, v in zip(code_list, vals):
if v:
ta_map[code] = json.loads(v)
except: pass
finally:
await ta_redis.aclose()
price_map = {}
if redis_cl:
try:
vals = await redis_cl.mget(*[f"price:{c}" for c in code_list])
for code, v in zip(code_list, vals):
if v:
price_map[code] = json.loads(v)
except: pass
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT stock_code, total_score, recommendation, news_score, technical_score, sector
FROM stock_scores
WHERE stock_code = ANY($1)
AND score_date = (SELECT MAX(score_date) FROM stock_scores)
""", code_list)
# Redis 가격캐시 미스 시 stock_prices 테이블에서 폴백 (캐시 비어도 손익 계산되게)
missing = [x for x in code_list if not price_map.get(x)]
if missing:
prows = await c.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, price, change_pct
FROM stock_prices
WHERE stock_code = ANY($1) AND price > 0
ORDER BY stock_code, collected_at DESC
""", missing)
for pr in prows:
price_map[pr["stock_code"]] = {
"price": pr["price"], "change_pct": float(pr["change_pct"] or 0)}
score_map = {r["stock_code"]: dict(r) for r in rows}
result = []
for code in code_list:
ta = ta_map.get(code, {})
pr = price_map.get(code, {})
sc = score_map.get(code, {})
price = pr.get("price") or ta.get("price") or 0
result.append({
"code": code,
"price": int(price) if price else 0,
"change_pct": float(pr.get("change_pct") or 0),
"tech_score": float(ta.get("tech_score") or 0),
"signal": ta.get("signal") or "관망",
"ai_score": sc.get("total_score"),
"recommendation": sc.get("recommendation"),
"sector": sc.get("sector") or "기타",
})
return result
# ── 외국인·공매도·OHLCV ───────────────────────────────────────
@app.get("/api/foreign/{code}")
async def foreign_flow(code: str):
"""외국인 수급 데이터 (Redis + DB 폴백)"""
if redis_cl:
c = await redis_cl.get(f"foreign:{code}")
if c:
return JSONResponse(content={"code": code, "data": json.loads(c)})
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT dt, close_price, change_qty, hold_qty, hold_ratio, limit_ratio
FROM stock_foreign_flow WHERE stock_code=$1
ORDER BY dt DESC LIMIT 30
""", code)
result = []
for row in rows:
d = dict(row)
d["dt"] = str(d["dt"]).replace("-", "")
result.append(d)
return JSONResponse(content={"code": code, "data": result})
@app.get("/api/short/{code}")
async def short_sale(code: str):
"""공매도 데이터 (Redis + DB 폴백)"""
if redis_cl:
c = await redis_cl.get(f"short:{code}")
if c:
return JSONResponse(content={"code": code, "data": json.loads(c)})
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT dt, close_price, short_qty, short_balance_qty, trade_weight, short_avg_price
FROM stock_short_sale WHERE stock_code=$1
ORDER BY dt DESC LIMIT 30
""", code)
result = []
for row in rows:
d = dict(row)
d["dt"] = str(d["dt"]).replace("-", "")
result.append(d)
return JSONResponse(content={"code": code, "data": result})
@app.get("/api/ohlcv/{code}")
async def ohlcv(code: str, days: int = Query(default=60)):
"""일봉 OHLCV + 외국인·기관 순매수 (Redis + DB 폴백)"""
if redis_cl:
c = await redis_cl.get(f"ohlcv:{code}")
if c:
data = json.loads(c)
return JSONResponse(content={"code": code, "data": data[:days]})
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT dt, open_price, high_price, low_price, close_price,
volume, trade_amount, foreign_ratio, foreign_net, institution_net
FROM stock_ohlcv WHERE stock_code=$1
ORDER BY dt DESC LIMIT $2
""", code, days)
result = []
for row in rows:
d = dict(row)
d["dt"] = str(d["dt"]).replace("-", "")
result.append(d)
return JSONResponse(content={"code": code, "data": result})
@app.get("/api/supply-demand")
async def supply_demand(limit: int = Query(default=20)):
"""외국인 순매수 상위 종목 (최근 5일 누적)"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT ff.stock_code,
COALESCE(d.corp_name, ff.stock_code) AS stock_name,
SUM(ff.change_qty) AS net_5d,
AVG(ff.hold_ratio) AS avg_ratio,
MAX(ff.hold_ratio) - MIN(ff.hold_ratio) AS ratio_delta,
MAX(ff.close_price) AS price
FROM stock_foreign_flow ff
JOIN dart_corps d ON ff.stock_code = d.stock_code AND d.is_active = true
WHERE ff.dt >= CURRENT_DATE - 5
GROUP BY ff.stock_code, d.corp_name
HAVING SUM(ff.change_qty) != 0
ORDER BY SUM(ff.change_qty) DESC
LIMIT $1
""", limit)
return [dict(r) for r in rows]
@app.get("/api/short-ranking")
async def short_ranking(limit: int = Query(default=20)):
"""공매도 비중 상위 종목 (최근일 기준)"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT DISTINCT ON (ss.stock_code)
ss.stock_code,
COALESCE(d.corp_name, ss.stock_code) AS stock_name,
ss.trade_weight, ss.short_qty, ss.short_balance_qty,
ss.short_avg_price, ss.close_price, ss.dt
FROM stock_short_sale ss
JOIN dart_corps d ON ss.stock_code = d.stock_code AND d.is_active = true
WHERE ss.dt >= CURRENT_DATE - 3
ORDER BY ss.stock_code, ss.dt DESC
""")
sorted_rows = sorted(rows, key=lambda r: r["trade_weight"] or 0, reverse=True)
return [dict(r) for r in sorted_rows[:limit]]
# ── 챗봇 ─────────────────────────────────────────────────────
class ChatReq(BaseModel):
message: str
history: list = []
async def _build_context() -> str:
try:
async with pg_pool.acquire() as c:
# 상위 매수 추천 + 기술적 데이터 합산
recs = await c.fetch("""
SELECT s.stock_code, d.corp_name, s.total_score, s.recommendation,
s.news_score, s.technical_score, s.dart_score,
s.foreign_score, s.short_score,
t.price, t.rsi, t.tech_score AS ta_score, t.signal AS ta_signal,
t.vol_ratio, t.ma5, t.ma20, t.ma60,
COALESCE(t.signals, '[]') AS ta_signals
FROM stock_scores s
JOIN dart_corps d ON d.stock_code = s.stock_code AND d.is_active = true
LEFT JOIN stock_technical t ON t.stock_code = s.stock_code
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
AND s.total_score >= 30
ORDER BY s.total_score DESC LIMIT 15
""")
# 매도 주의 종목
sells = await c.fetch("""
SELECT s.stock_code, d.corp_name, s.total_score, s.recommendation,
t.price, t.rsi, t.signal AS ta_signal
FROM stock_scores s
JOIN dart_corps d ON d.stock_code = s.stock_code AND d.is_active = true
LEFT JOIN stock_technical t ON t.stock_code = s.stock_code
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
AND s.total_score <= -30
ORDER BY s.total_score ASC LIMIT 5
""")
# 오늘 매매시그널
sigs = await c.fetch("""
SELECT ts.stock_code, ts.stock_name, ts.signal_type,
ts.current_price, ts.target_price, ts.stop_loss,
ts.confidence, ts.expected_return_pct, ts.reason,
ts.news_score, ts.dart_score, ts.price_momentum,
ts.foreign_net_5d, ts.short_weight
FROM trade_signals ts
WHERE ts.created_at::date = CURRENT_DATE
ORDER BY ts.confidence DESC LIMIT 10
""")
# 최근 24시간 주요 뉴스
news = await c.fetch("""
SELECT title, sentiment, intensity, primary_stock,
COALESCE(stock_names::text,'[]') AS stock_names, reason, catalyst
FROM news_analysis
WHERE analyzed_at >= NOW() - INTERVAL '24 hours'
AND intensity >= 3
ORDER BY intensity DESC, analyzed_at DESC LIMIT 10
""")
# 시장 전체 통계
stats = await c.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE total_score>=60) AS strong_buy,
COUNT(*) FILTER (WHERE total_score>=30 AND total_score<60) AS buy,
COUNT(*) FILTER (WHERE total_score<=-30) AS sell,
ROUND(AVG(total_score)::numeric,1) AS avg_score,
MAX(score_date) AS score_date
FROM stock_scores s JOIN dart_corps d ON d.stock_code=s.stock_code
WHERE d.is_active=true
AND s.score_date=(SELECT MAX(score_date) FROM stock_scores)
""")
today = stats['score_date'].strftime('%Y-%m-%d') if stats['score_date'] else '오늘'
ctx = f"=== 시장 개요 ({today}) ===\n"
ctx += f"강력매수: {stats['strong_buy']}종목 / 매수관심: {stats['buy']}종목 / 매도관심: {stats['sell']}종목 / 평균점수: {stats['avg_score']}\n\n"
ctx += "=== 매수 추천 종목 (점수·가격·기술지표) ===\n"
for r in recs:
price_str = f"{r['price']:,}" if r['price'] else "가격미수집"
rsi_str = f"RSI{r['rsi']:.0f}" if r['rsi'] else ""
ma_str = ""
if r['ma5'] and r['ma20']:
ma_str = "▲정배열" if r['ma5'] > r['ma20'] else "▽역배열"
vol_str = f"거래량{r['vol_ratio']:.1f}x" if r['vol_ratio'] else ""
sigs_list = json.loads(r['ta_signals']) if r['ta_signals'] else []
sigs_str = " | ".join(sigs_list[:3]) if sigs_list else ""
ctx += (f"- **{r['corp_name']}({r['stock_code']})** {price_str} "
f"종합{r['total_score']:.0f}점[{r['recommendation']}] "
f"뉴스{r['news_score']:.0f}/기술{r['technical_score']:.0f}/공시{r['dart_score']:.0f} "
f"{rsi_str} {ma_str} {vol_str}\n")
if sigs_str:
ctx += f" 기술신호: {sigs_str}\n"
if sells:
ctx += "\n=== 주의/회피 종목 ===\n"
for r in sells:
price_str = f"{r['price']:,}" if r['price'] else "가격미수집"
rsi_str = f"RSI{r['rsi']:.0f}" if r['rsi'] else "RSI:N/A"
ctx += f"- {r['corp_name']}({r['stock_code']}) {price_str} {r['total_score']:.0f}점[{r['recommendation']}] {rsi_str}\n"
if sigs:
ctx += "\n=== 오늘 매매시그널 ===\n"
for s in sigs:
ret_str = f"기대수익{s['expected_return_pct']:.1f}%" if s['expected_return_pct'] else ""
ctx += (f"- **{s['stock_name']}({s['stock_code']})** {s['signal_type']} "
f"현재가{s['current_price']:,}원 → 목표{s['target_price']:,}"
f"손절{s['stop_loss']:,}원 신뢰도{s['confidence']:.0f}% {ret_str}\n"
f" 근거: {(s['reason'] or '')[:100]}\n")
if news:
ctx += "\n=== 최근 24시간 주요 뉴스 ===\n"
for n in news:
names = n['stock_names'] if n['stock_names'] != '[]' else ''
ctx += f"- [{n['sentiment']}·강도{n['intensity']}·{n['catalyst']}] {n['title'][:70]}\n"
ctx += f"{(n['reason'] or '')[:90]}\n"
return ctx
except Exception as e:
return f"(컨텍스트 로드 실패: {e})"
async def _calc_valuation(code: str, eps: float, sector: str, current_price: int) -> dict:
"""
종목별 적정가 추정 (3가지 방식 통합)
A. DCF 내재가치 — stock_scores.intrinsic_value (5년 영업현금흐름 + Gordon 영구가치)
B. 섹터 평균 PER × EPS
C. 시나리오: 보수(P25) / 중립(P50) / 낙관(P75) PER × EPS
"""
out: dict = {}
try:
async with pg_pool.acquire() as c:
# A. DCF
sc = await c.fetchrow("""
SELECT intrinsic_value, margin_of_safety FROM stock_scores
WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1
""", code)
if sc and sc['intrinsic_value']:
iv = int(sc['intrinsic_value'])
if iv > 0 and current_price > 0:
# 시총 → 주당가치 환산: intrinsic_value는 기업가치(원) → 발행주식수 필요
# stock_scores.intrinsic_value는 시총 단위로 저장되어 있어 시총/현재가 비율로 적정 주가 환산
async with pg_pool.acquire() as c2:
mc_row = await c2.fetchrow("""
SELECT market_cap FROM stock_prices WHERE stock_code=$1
ORDER BY collected_at DESC LIMIT 1
""", code)
if mc_row and mc_row['market_cap'] and mc_row['market_cap'] > 0:
ratio = iv / float(mc_row['market_cap'])
out['dcf_fair_price'] = int(current_price * ratio)
out['dcf_safety_pct'] = round((out['dcf_fair_price'] - current_price)
/ current_price * 100, 1)
# B + C. 섹터 PER 분포
if sector and sector != '기타' and eps and eps > 0:
rows = await c.fetch("""
SELECT p.per FROM stock_prices p
JOIN dart_corps d ON d.stock_code=p.stock_code
WHERE d.sector=$1 AND p.per > 0 AND p.per < 100
AND p.collected_at >= NOW()-INTERVAL '7 days'
""", sector)
pers = sorted(float(r['per']) for r in rows)
if len(pers) >= 5:
p25, p50, p75 = pers[len(pers)//4], pers[len(pers)//2], pers[3*len(pers)//4]
out['sector_per_p25'] = round(p25, 1)
out['sector_per_p50'] = round(p50, 1)
out['sector_per_p75'] = round(p75, 1)
out['sector_n'] = len(pers)
out['fair_p25'] = int(p25 * eps)
out['fair_p50'] = int(p50 * eps)
out['fair_p75'] = int(p75 * eps)
if current_price > 0:
out['upside_p50'] = round((p50*eps - current_price)/current_price*100, 1)
except Exception as e:
out['err'] = str(e)
return out
async def _fetch_naver_live_price(code: str) -> dict:
"""네이버 모바일 종목 API에서 실시간 가격·PER·PBR·시총·배당 즉시 fetch"""
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
f"https://m.stock.naver.com/api/stock/{code}/integration",
headers={"User-Agent": "Mozilla/5.0"})
if r.status_code != 200:
return {}
d = r.json()
info = {ti.get("key"): ti.get("value") for ti in (d.get("totalInfos") or [])}
# basic API에서 closePrice / fluctuationsRatio 별도 fetch
r2 = await client.get(
f"https://m.stock.naver.com/api/stock/{code}/basic",
headers={"User-Agent": "Mozilla/5.0"})
basic = r2.json() if r2.status_code == 200 else {}
return {
"name": d.get("stockName") or basic.get("stockName") or "",
"close": basic.get("closePrice") or info.get("전일") or "",
"change_pct": basic.get("fluctuationsRatio") or 0,
"compare": basic.get("compareToPreviousClosePrice") or 0,
"open": info.get("시가"), "high": info.get("고가"), "low": info.get("저가"),
"volume": info.get("거래량"), "market_cap": info.get("시총"),
"foreign_ratio": info.get("외인소진율"),
"high_52w": info.get("52주 최고"), "low_52w": info.get("52주 최저"),
"per": info.get("PER"), "eps": info.get("EPS"),
"per_est": info.get("추정PER"), "eps_est": info.get("추정EPS"),
"pbr": info.get("PBR"), "bps": info.get("BPS"),
"div_yield": info.get("배당수익률"), "dps": info.get("주당배당금"),
}
except Exception:
return {}
async def _get_stock_detail(code: str) -> str:
"""특정 종목 상세 데이터 조회 (Redis 실시간 → DB → 네이버 API 폴백)"""
try:
# Redis db=3에서 실시간 가격 우선 조회
redis_price = None
price_fresh = False
try:
p = await redis_cl.get(f"price:{code}")
if p:
redis_price = json.loads(p)
price_fresh = True
except:
pass
# Redis에 없으면 네이버 모바일 API에서 즉시 fetch (일요일/장외 시간에도 종가 제공)
naver_live = {}
if not redis_price:
naver_live = await _fetch_naver_live_price(code)
async with pg_pool.acquire() as c:
tech = await c.fetchrow(
"SELECT * FROM stock_technical WHERE stock_code=$1", code)
score = await c.fetchrow("""
SELECT s.*, d.corp_name FROM stock_scores s
JOIN dart_corps d ON d.stock_code=s.stock_code
WHERE s.stock_code=$1
ORDER BY s.score_date DESC LIMIT 1
""", code)
fin = await c.fetchrow("""
SELECT * FROM dart_financials WHERE stock_code=$1
ORDER BY bsns_year DESC, reprt_code DESC LIMIT 1
""", code)
recent_news = await c.fetch("""
SELECT title, sentiment, intensity, reason, analyzed_at
FROM news_analysis
WHERE primary_stock=$1 OR stock_codes::text LIKE $2
ORDER BY analyzed_at DESC LIMIT 5
""", code, f'%{code}%')
sig = await c.fetchrow(
"SELECT * FROM trade_signals WHERE stock_code=$1 AND created_at::date=CURRENT_DATE", code)
parts = []
name = score['corp_name'] if score else code
# 현재가 결정: Redis 실시간 > stock_technical (스냅샷)
current_price = None
price_note = ""
if redis_price and redis_price.get("price"):
current_price = int(redis_price["price"])
chg = redis_price.get("change_pct", 0)
price_note = f"(실시간) 등락:{chg:+.2f}%"
elif naver_live.get("close"):
try:
current_price = int(str(naver_live["close"]).replace(",", ""))
chg = float(naver_live.get("change_pct") or 0)
price_note = f"(네이버 종가) 등락:{chg:+.2f}%"
except: pass
elif tech and tech.get("price"):
current_price = int(tech["price"])
analyzed = tech["analyzed_at"]
if analyzed:
age = datetime.now().replace(tzinfo=None) - analyzed.replace(tzinfo=None)
hours = int(age.total_seconds() / 3600)
price_note = f"(스냅샷, {hours}시간 전 데이터)"
else:
price_note = "(스냅샷)"
# 네이버 라이브 시세 정보 명시적으로 LLM에게 전달 (PER/PBR/시총 등)
if naver_live.get("close"):
parts.append(
f"[{name} 시세 (네이버 종가 기준, {datetime.now().strftime('%Y-%m-%d %H:%M')})]\n"
f"종가:{naver_live.get('close')}원 등락:{naver_live.get('change_pct',0)}% "
f"(전일대비 {naver_live.get('compare')}원)\n"
f"시가:{naver_live.get('open')} 고가:{naver_live.get('high')} 저가:{naver_live.get('low')} "
f"거래량:{naver_live.get('volume')}\n"
f"시총:{naver_live.get('market_cap')} 외인:{naver_live.get('foreign_ratio')}\n"
f"52주 최고/최저:{naver_live.get('high_52w')} / {naver_live.get('low_52w')}\n"
f"PER:{naver_live.get('per')} (추정 {naver_live.get('per_est')}) | "
f"PBR:{naver_live.get('pbr')} | 배당수익률:{naver_live.get('div_yield')} "
f"(주당배당금 {naver_live.get('dps')})\n"
f"EPS:{naver_live.get('eps')} (추정 {naver_live.get('eps_est')}) | BPS:{naver_live.get('bps')}\n"
)
# 적정가 분석 (DCF + 섹터 PER + 시나리오)
try:
eps_val = float(str(naver_live.get('eps') or '0').replace(',', '').replace('', ''))
sector = (score['sector'] if score and 'sector' in score.keys() else '') or ''
if not sector:
async with pg_pool.acquire() as c:
sec_row = await c.fetchrow("SELECT sector FROM dart_corps WHERE stock_code=$1", code)
sector = (sec_row['sector'] if sec_row else '') or '기타'
val = await _calc_valuation(code, eps_val, sector, current_price or 0)
if val:
lines = [f"[{name} 적정가 분석]"]
if val.get('dcf_fair_price'):
sign = "+" if val['dcf_safety_pct'] > 0 else ""
lines.append(f"DCF 내재가치: {val['dcf_fair_price']:,}원 (안전마진 {sign}{val['dcf_safety_pct']}%)")
if val.get('fair_p50'):
lines.append(f"섹터 PER 기반 적정가 ({sector}, n={val['sector_n']}):")
lines.append(f" 보수(P25, PER {val['sector_per_p25']}배): {val['fair_p25']:,}")
lines.append(f" 중립(P50, PER {val['sector_per_p50']}배): {val['fair_p50']:,}"
f"(상승여력 {val.get('upside_p50',0):+.1f}%)")
lines.append(f" 낙관(P75, PER {val['sector_per_p75']}배): {val['fair_p75']:,}")
if len(lines) > 1:
parts.append("\n".join(lines) + "\n")
except Exception:
pass
if tech:
signals = json.loads(tech['signals']) if tech['signals'] else []
targets = json.loads(tech['targets']) if tech['targets'] else {}
display_price = current_price or tech['price']
parts.append(
f"[{name} 기술적 분석]\n"
f"현재가: {display_price:,}{price_note} | RSI: {tech['rsi']:.1f} | 기술점수: {tech['tech_score']:.0f}\n"
f"MA5:{tech['ma5']:,} MA20:{tech['ma20']:,} MA60:{tech['ma60']:,}\n"
f"볼밴상단:{tech['bb_upper']:,} 볼밴하단:{tech['bb_lower']:,} 볼밴위치:%B{tech['pct_b']:.2f}\n"
f"MACD:{tech['macd']:.1f} 시그널:{tech['macd_signal']:.1f} 히스토그램:{tech['macd_hist']:.1f}\n"
f"거래량비율:{tech['vol_ratio']:.2f}x | 스토캐스틱K:{tech['stoch_k']:.1f} D:{tech['stoch_d']:.1f}\n"
f"기술신호: {' | '.join(signals)}\n"
+ (f"목표가 T1:{targets.get('t1',0):,}원({(targets['t1']/display_price-1)*100:.1f}%) "
f"T2:{targets.get('t2',0):,}원({(targets['t2']/display_price-1)*100:.1f}%) "
f"T3:{targets.get('t3',0):,}원({(targets['t3']/display_price-1)*100:.1f}%) "
f"손절:{targets.get('stop_loss',0):,}원({(targets['stop_loss']/display_price-1)*100:.1f}%)\n"
if targets and display_price else "")
)
# 매매 금액 가이드
if current_price and targets:
sl = targets.get('stop_loss', 0)
t1 = targets.get('t1', 0)
if sl and sl < current_price:
risk_per_share = current_price - sl
# 투자금 100만원 기준, 리스크 2% = 20,000원 허용
budget_100w = 1_000_000
max_risk = budget_100w * 0.02
safe_shares = int(max_risk / risk_per_share) if risk_per_share > 0 else 0
safe_amount = safe_shares * current_price
parts.append(
f"[매매 금액 가이드 (100만원 기준, 리스크2% 원칙)]\n"
f"추천매수가: {current_price:,}원 | 손절가: {sl:,}원 (하락여지 {(sl/current_price-1)*100:.1f}%)\n"
f"안전매수 수량: {safe_shares}주 (약 {safe_amount:,}원)\n"
f"T1 도달시 수익: +{safe_shares*(t1-current_price):,}원 ({(t1/current_price-1)*100:.1f}%)\n"
f"※ 실제 투자금에 비례해 수량 조정. 1종목 최대 10~15% 권장\n"
)
elif current_price:
parts.append(f"[{name}] 현재가: {current_price:,}{price_note}\n")
if score:
parts.append(
f"[{name} 종합점수]\n"
f"총점: {score['total_score']:.1f} [{score['recommendation']}]\n"
f"뉴스:{score['news_score']:.0f} | 기술:{score['technical_score']:.0f} | 공시:{score['dart_score']:.0f} | 외국인:{score['foreign_score']:.0f} | 공매도:{score['short_score']:.0f}\n"
f"외국인보유:{score['foreign_ratio']:.2f}% | 공매도비중:{score['short_weight']:.2f}%\n"
f"호재:{score['news_positive']} 악재:{score['news_negative']} 중립:{score['news_neutral']}\n"
+ (f"주요근거: {score['top_reasons'][:150]}\n" if score['top_reasons'] else "")
)
if fin:
parts.append(
f"[{name} 재무({fin['bsns_year']}년)]\n"
f"매출:{fin['revenue']:,}억 | 영업이익:{fin['operating_profit']:,}억 | 순이익:{fin['net_income']:,}\n"
f"ROE:{fin['roe']:.1f}% | 영업이익률:{fin['operating_margin']:.1f}% | 부채비율:{fin['debt_ratio']:.1f}%\n"
f"FCF비율:{fin['fcf_ratio']:.1f}% | 매출성장:{fin['revenue_growth']:.1f}%\n"
)
if sig:
parts.append(
f"[오늘 매매시그널] {sig['signal_type']} | 신뢰도:{sig['confidence']:.0f}%\n"
f"진입가:{sig['current_price']:,}원 → T1목표:{sig['target_price']:,}원 손절:{sig['stop_loss']:,}\n"
f"기대수익:{sig['expected_return_pct']:.1f}% | 외국인5일순매수:{sig['foreign_net_5d']:,}\n"
f"근거: {(sig['reason'] or '')[:120]}\n"
)
if recent_news:
news_text = "\n".join(
f" [{n['sentiment']}·강도{n['intensity']}] {n['title'][:60]}{(n['reason'] or '')[:70]}"
for n in recent_news
)
parts.append(f"[최근 뉴스]\n{news_text}\n")
return "\n".join(parts) if parts else f"{code} 데이터 없음"
except Exception as e:
return f"({code} 조회 실패: {e})"
async def _detect_stocks(message: str) -> list[str]:
"""메시지에서 종목명/코드 감지 → 코드 목록 반환
우선순위:
1. 한글 종목명 양방향 LIKE 매칭 (HD현대중공업 ↔ 현대중공업 등)
2. 6자리 종목코드 직접 매칭
종목명 우선 — 사용자가 잘못된 코드 적어도 종목명으로 정정"""
try:
found: list[str] = []
# 1. 한글 단어(2자 이상) 추출 후 LIKE 검색
words = [w for w in re.findall(r'[가-힣A-Za-z]{2,}', message) if len(w) >= 2]
if words:
async with pg_pool.acquire() as c:
for word in words:
if len(found) >= 3: break
rows = await c.fetch("""
SELECT stock_code, corp_name FROM dart_corps
WHERE is_active=true
AND (corp_name LIKE $1 OR corp_name LIKE $2)
ORDER BY LENGTH(corp_name) ASC LIMIT 3
""", f'%{word}%', f'%{word.replace(" ", "")}%')
for r in rows:
if r['stock_code'] not in found:
found.append(r['stock_code'])
if len(found) >= 3: break
# 2. 6자리 코드 매칭 (종목명 매칭이 없을 때만)
if not found:
codes = re.findall(r'\b(\d{6})\b', message)
if codes:
async with pg_pool.acquire() as c:
rows = await c.fetch(
"SELECT stock_code FROM dart_corps WHERE stock_code = ANY($1) AND is_active=true",
codes)
found = [r['stock_code'] for r in rows][:3]
return found
except Exception:
return []
@app.post("/api/chat")
async def chat(req: ChatReq):
context, stock_codes = await asyncio.gather(
_build_context(),
_detect_stocks(req.message)
)
stock_ctx = ""
if stock_codes:
details = await asyncio.gather(*[_get_stock_detail(c) for c in stock_codes])
stock_ctx = "\n\n=== 질문 관련 종목 상세 데이터 ===\n" + "\n".join(details)
system = (
"당신은 워렌 버핏 스타일의 한국 주식 투자 전문 AI 애널리스트입니다.\n"
"가치투자 관점(ROE·영업이익률·부채비율·FCF)을 최우선으로 판단합니다.\n"
"아래 실시간 시스템 데이터를 기반으로 구체적인 수치를 인용하며 답변하세요.\n"
"답변은 핵심 위주로, 한국어로 작성하세요. 마크다운 사용 가능.\n\n"
"【중요】 특정 종목 가격·투자 여부 질문 시 반드시:\n"
"1. 현재가(실시간 또는 스냅샷 여부 명시)\n"
"2. 진입가(추천 매수가) / T1·T2·T3 목표가 / 손절가 (%, 원 단위)\n"
"3. 100만원 투자 기준 매수 수량과 리스크 금액 예시\n"
"4. 데이터가 스냅샷이면 '주가는 장중 변동이 있을 수 있음' 명시\n"
"가격 데이터가 없으면 '현재 가격 데이터를 조회할 수 없습니다' 라고 명시할 것.\n"
"절대 임의의 가격을 만들어내지 말 것.\n\n"
f"{context}{stock_ctx}"
)
messages = [{"role": "system", "content": system}]
for h in req.history[-10:]:
messages.append(h)
messages.append({"role": "user", "content": req.message})
async def stream():
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream("POST", f"{OLLAMA_URL}/v1/chat/completions", json={
"model": CHAT_MODEL, "messages": messages,
"stream": True, "temperature": 0.3, "max_tokens": 1024
}) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
yield "data: [DONE]\n\n"
break
try:
delta = json.loads(payload)["choices"][0]["delta"].get("content", "")
if delta:
yield f"data: {json.dumps({'content': delta}, ensure_ascii=False)}\n\n"
except:
pass
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# ── KOSPI 지수 ───────────────────────────────────────────────
@app.get("/api/kospi")
async def kospi(days: int = Query(default=30)):
"""네이버 금융에서 KOSPI 최근 N일 데이터 조회"""
try:
async with httpx.AsyncClient(timeout=10) as c:
today = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d")
r = await c.get(
"https://m.stock.naver.com/api/index/KOSPI/price",
params={"startTime": start, "endTime": today, "timeframe": "day"},
headers={"User-Agent": "Mozilla/5.0"}
)
data = r.json()
# 최근 N일만
def nf(v): return float(str(v).replace(",", "") or 0)
items = data if isinstance(data, list) else []
items = sorted(items, key=lambda x: x.get("localTradedAt", ""))[-days:]
result = []
for x in items:
is_fall = x.get("compareToPreviousPrice", {}).get("code") in ("5", "4")
sign = -1 if is_fall else 1
result.append({"date": x.get("localTradedAt", "")[:10],
"close": nf(x.get("closePrice", 0)),
"change": sign * nf(x.get("compareToPreviousClosePrice", 0)),
"change_pct": sign * nf(x.get("fluctuationsRatio", 0))})
current = result[-1] if result else {}
return {"data": result, "current": current}
except Exception as e:
return {"data": [], "current": {}, "error": str(e)}
# ── 오늘의 투자 팁 ────────────────────────────────────────────
INVEST_TIPS = [
{"title": "워렌 버핏의 첫 번째 규칙", "body": "절대 돈을 잃지 마라. 두 번째 규칙은 첫 번째 규칙을 절대 잊지 마라.", "tag": "버핏 철학"},
{"title": "PER이란?", "body": "주가수익비율(PER) = 주가 ÷ EPS. 낮을수록 저평가 가능성이 높지만, 산업 평균과 비교해야 합니다. PER 10 미만이면 일반적으로 저평가 신호.", "tag": "기초 지표"},
{"title": "ROE 15% 이상을 주목하라", "body": "자기자본이익률(ROE)은 기업이 자기 돈으로 얼마나 잘 버는지를 나타냅니다. 버핏은 ROE 15% 이상을 지속하는 기업을 선호합니다.", "tag": "재무 분석"},
{"title": "분산 투자의 함정", "body": "피터 린치는 '덩어리 분산'을 경계했습니다. 20개 종목에 나눠도 같은 섹터면 위험이 분산되지 않습니다. 섹터와 성격이 다른 종목을 섞으세요.", "tag": "포트폴리오"},
{"title": "거래량은 주가보다 먼저 움직인다", "body": "주가 상승 전에 거래량이 먼저 늘어나는 경우가 많습니다. 평소 거래량의 2배 이상이면 주목할 신호.", "tag": "기술적 분석"},
{"title": "RSI 과매수·과매도 활용법", "body": "RSI 70 이상은 과매수(단기 조정 가능성), 30 이하는 과매도(반등 가능성). 단, 강한 추세장에서는 70 이상을 오래 유지하기도 합니다.", "tag": "기술적 분석"},
{"title": "부채비율 200% 법칙", "body": "총부채 ÷ 자기자본. 200% 이하가 안정권이며, 업종 특성에 따라 다릅니다. 금융주·건설주는 높아도 정상, 제조업은 100% 이하가 이상적.", "tag": "재무 안전성"},
{"title": "52주 신고가 전략", "body": "52주 신고가를 돌파하는 종목은 저항선이 없어 추가 상승 여력이 큽니다. 단, 거래량 수반 여부를 반드시 확인하세요.", "tag": "모멘텀"},
{"title": "MACD 골든크로스", "body": "MACD선이 시그널선을 아래에서 위로 돌파할 때 매수 신호. 반대는 데드크로스(매도 신호). 다른 지표와 함께 쓸 때 신뢰도가 높아집니다.", "tag": "기술적 분석"},
{"title": "배당수익률의 함정", "body": "배당수익률 = 주당배당금 ÷ 주가. 주가가 폭락해서 수익률이 높아 보이는 경우가 있습니다. 배당 지속성과 이익 대비 배당성향(60% 이하)을 함께 봐야 합니다.", "tag": "배당 투자"},
{"title": "볼린저 밴드 활용", "body": "가격이 밴드 하단에 닿으면 과매도, 상단에 닿으면 과매수 신호. 밴드가 좁아질수록 변동성 폭발이 임박했다는 신호입니다.", "tag": "기술적 분석"},
{"title": "외국인 수급을 따라가라", "body": "외국인이 5일 이상 연속 순매수하는 종목은 중기 상승 가능성이 높습니다. 단, 환율 변동에 민감하게 반응하므로 원달러 환율도 함께 확인하세요.", "tag": "수급 분석"},
{"title": "공매도 잔고 주의", "body": "공매도 거래 비중이 5% 이상이면 기관의 하락 베팅이 강하다는 신호입니다. 반면 공매도 과다 종목이 호재를 만나면 숏스퀴즈로 급등하기도 합니다.", "tag": "수급 분석"},
{"title": "이동평균선의 배열", "body": "MA5 > MA20 > MA60 > MA120 순서로 배열되면 '정배열'로 강한 상승추세. 반대는 '역배열'로 하락 추세를 의미합니다.", "tag": "기술적 분석"},
{"title": "현금은 포지션이다", "body": "버핏은 적절한 투자 기회가 없을 때 현금을 60% 이상 보유했습니다. 현금은 수익률이 낮지만 다음 기회를 잡기 위한 최고의 무기입니다.", "tag": "버핏 철학"},
{"title": "EPS 성장률을 보라", "body": "주당순이익(EPS)이 매년 10% 이상 성장하는 기업은 장기 투자 대상으로 적합합니다. 최근 3년간의 EPS 성장 추세를 확인하세요.", "tag": "성장 투자"},
{"title": "매수는 분할로, 매도는 한 번에", "body": "매수 시 3번 나눠 분할 매수하면 평균 단가를 낮출 수 있습니다. 반대로 매도는 손절 라인에 닿으면 신속하게 결정하는 것이 좋습니다.", "tag": "매매 전략"},
{"title": "실적발표 전후 전략", "body": "어닝 서프라이즈(예상 상회) 종목은 발표 후 3일~1주일 추가 상승하는 경향이 있습니다. 단, 호실적에도 주가가 빠지는 '소문에 사고 뉴스에 팔아라' 패턴도 주의하세요.", "tag": "이벤트 전략"},
{"title": "손절은 원칙이다", "body": "매수가 대비 -7~8% 하락 시 무조건 손절하는 것이 큰 손실을 막는 가장 확실한 방법입니다. 한 종목에서 -30% 손실을 회복하려면 +43% 수익이 필요합니다.", "tag": "리스크 관리"},
{"title": "시장 주도 섹터를 파악하라", "body": "AI/반도체, 바이오, 2차전지 등 시장을 이끄는 테마 섹터를 파악하고, 그 섹터 내에서 1~3위 기업에 집중하는 것이 개별 종목 리스크를 줄이는 방법입니다.", "tag": "섹터 분석"},
{"title": "KOSPI와 개별 주식의 관계", "body": "코스피가 상승할 때 대형주가 먼저 움직이고, 이후 중소형주로 자금이 흘러가는 경향이 있습니다. 코스피 레벨을 보면 중소형주 진입 타이밍을 가늠할 수 있습니다.", "tag": "시장 분석"},
{"title": "PBR 1배 미만 = 청산가치 이하", "body": "주가순자산비율(PBR)이 1 미만이면 이론상 회사를 청산했을 때 받을 금액보다 시가총액이 낮습니다. 구조조정 리스크가 없다면 안전마진이 있는 저평가 종목입니다.", "tag": "가치 투자"},
{"title": "영업현금흐름을 믿어라", "body": "순이익보다 영업현금흐름(OCF)이 더 중요합니다. OCF가 순이익보다 크면 이익의 질이 높은 기업입니다. 반대면 분식회계 가능성도 점검해 보세요.", "tag": "재무 분석"},
{"title": "코스피 2500 이하는 역사적 저점대", "body": "코스피 2500은 과거 통계상 12개월 내 반등 확률이 높은 구간입니다. 단, 경제 위기나 금리 급등 시에는 예외가 있으므로 매크로 환경을 함께 살피세요.", "tag": "시장 분석"},
{"title": "분기 실적의 계절성", "body": "1분기(1~3월)는 대부분 기업의 비수기입니다. 4분기 실적을 확인하고 1분기 초에 진입, 2~3분기 성수기를 노리는 계절적 전략이 유효합니다.", "tag": "계절성"},
{"title": "신규 상장주 주의", "body": "IPO 첫날 급등 후 3~6개월 보호예수 해제 시 대주주 물량이 쏟아져 주가 하락하는 경우가 많습니다. 상장 6개월 이후 기본기가 확인된 종목을 노리는 것이 안전합니다.", "tag": "리스크 관리"},
{"title": "스토캐스틱 활용법", "body": "스토캐스틱 %K가 20 이하면 과매도, 80 이상이면 과매수 구간. %K가 %D를 아래에서 돌파하면 매수, 위에서 하향 돌파하면 매도 신호입니다.", "tag": "기술적 분석"},
{"title": "인플레이션과 주식", "body": "인플레이션이 높을 때는 가격 전가력이 있는 기업(필수소비재, 에너지, 원자재)이 유리합니다. 금리 인하 사이클에 진입하면 성장주·배당주로 자금이 이동하는 경향이 있습니다.", "tag": "매크로"},
{"title": "환율과 수출주", "body": "원달러 환율이 오르면(원화 약세) 삼성전자·현대차 등 수출 비중이 높은 기업의 이익이 증가합니다. 1원 상승 시 삼성전자 영업이익은 약 500억 원 증가하는 것으로 알려져 있습니다.", "tag": "매크로"},
{"title": "기관 투자자 동향", "body": "기관이 3일 이상 연속 순매수하는 종목은 중기 상승 압력이 있습니다. 기관의 연기금(국민연금 등)은 코스피 2500 이하에서 주식 비중을 높이는 경향이 있습니다.", "tag": "수급 분석"},
{"title": "밸류에이션 상대 비교", "body": "같은 섹터 내에서 PER, PBR을 비교하는 것이 절대 수치보다 유용합니다. 섹터 평균 PER 대비 20% 이상 저평가된 기업을 찾아보세요.", "tag": "가치 투자"},
]
@app.get("/api/daily-tip")
async def daily_tip():
"""날짜 기반 오늘의 투자 팁 (매일 자동 변경)"""
day_of_year = datetime.now().timetuple().tm_yday
tip = INVEST_TIPS[day_of_year % len(INVEST_TIPS)]
return tip
# ── 정적 파일 / 루트 ─────────────────────────────────────────
# ── Kiwoom 실시간 프록시 ───────────────────────────────────
@app.get("/api/minute/{code}")
async def minute(code: str, scope: str = Query(default="5")):
"""분봉 차트 (kis-api 프록시)"""
async with httpx.AsyncClient() as c:
try:
r = await c.get(f"{KIS_API_URL}/minute/{code}?scope={scope}", timeout=20)
return JSONResponse(content=r.json())
except Exception as e:
return JSONResponse(content={"code": code, "data": [], "error": str(e)})
@app.get("/api/orderbook/{code}")
async def orderbook(code: str):
"""호가 (kis-api 프록시)"""
async with httpx.AsyncClient() as c:
try:
r = await c.get(f"{KIS_API_URL}/orderbook/{code}", timeout=10)
return JSONResponse(content=r.json())
except Exception as e:
return JSONResponse(content={"code": code, "ask": [], "bid": [], "error": str(e)})
@app.get("/api/volume-surge")
async def volume_surge():
"""거래량 급증 종목 TOP (kis-api 프록시)"""
async with httpx.AsyncClient() as c:
try:
r = await c.get(f"{KIS_API_URL}/volume-surge", timeout=15)
return JSONResponse(content=r.json())
except Exception as e:
return JSONResponse(content={"data": [], "error": str(e)})
@app.get("/api/hot")
async def hot():
"""지금 뜨는 종목 — 뉴스+거래량 모멘텀 (score-engine /hot 프록시)"""
async with httpx.AsyncClient() as c:
try:
r = await c.get(f"{SCORE_ENGINE_URL}/hot?limit=20", timeout=15)
return JSONResponse(content=r.json())
except Exception as e:
return JSONResponse(content=[], status_code=200)
# ── 사용자 인증 + 포트폴리오 ────────────────────────────────
class RegisterReq(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=100)
class LoginReq(BaseModel):
email: EmailStr
password: str
class PortfolioItemReq(BaseModel):
stock_code: str = Field(min_length=6, max_length=6)
stock_name: str = ""
buy_price: int = Field(gt=0)
qty: int = Field(gt=0)
memo: str = ""
async def _require_admin(user: dict = Depends(current_user)) -> dict:
async with pg_pool.acquire() as c:
role = await c.fetchval("SELECT role FROM users WHERE id=$1", user["id"])
if role != "admin":
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다")
return user
@app.post("/api/auth/register")
async def auth_register(req: RegisterReq, request: Request):
ip = _client_ip(request)
await _rate_limit(f"auth:rl:reg:{ip}", RL_REGISTER_MAX, RL_REGISTER_WINDOW)
is_admin = req.email.lower() in ADMIN_EMAILS
role = "admin" if is_admin else "user"
is_approved = is_admin # 관리자만 즉시 승인
async with pg_pool.acquire() as c:
existing = await c.fetchval("SELECT id FROM users WHERE email=$1", req.email)
if existing:
# 이메일 존재 노출 방지: 가입 처리된 것처럼 보이는 일반 메시지
raise HTTPException(status_code=409, detail="가입할 수 없는 이메일입니다")
row = await c.fetchrow("""
INSERT INTO users(email, password_hash, role, is_approved)
VALUES($1,$2,$3,$4)
RETURNING id, email, role, is_approved
""", req.email, hash_password(req.password), role, is_approved)
if not is_approved:
return {
"pending": True,
"user": {"id": row["id"], "email": row["email"]},
"message": "회원가입이 접수되었습니다. 관리자 승인 후 로그인 가능합니다."
}
token = create_token(row["id"], row["email"])
return {
"access_token": token,
"user": {"id": row["id"], "email": row["email"], "role": row["role"]},
}
@app.post("/api/auth/login")
async def auth_login(req: LoginReq, request: Request):
ip = _client_ip(request)
await _rate_limit(f"auth:rl:login:{ip}", RL_LOGIN_MAX, RL_LOGIN_WINDOW)
async with pg_pool.acquire() as c:
row = await c.fetchrow("""
SELECT id, email, password_hash, role, is_approved,
failed_login_count, locked_until
FROM users WHERE email=$1
""", req.email)
# 사용자 없음 → 더미 verify로 응답시간 균일화
if not row:
dummy_verify()
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다")
# 잠금 상태 체크
if row["locked_until"]:
now = datetime.now(timezone.utc)
if row["locked_until"] > now:
remaining = int((row["locked_until"] - now).total_seconds())
raise HTTPException(
status_code=423,
detail=f"로그인 시도가 너무 많습니다. {remaining // 60 + 1}분 후 다시 시도하세요")
# 비밀번호 검증
if not verify_password(req.password, row["password_hash"]):
new_count = row["failed_login_count"] + 1
lock_until = None
if new_count >= LOCK_THRESHOLD:
lock_until = datetime.now(timezone.utc) + timedelta(seconds=LOCK_DURATION)
await c.execute("""
UPDATE users SET failed_login_count=$1, locked_until=$2 WHERE id=$3
""", new_count, lock_until, row["id"])
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다")
# 승인 게이트
if not row["is_approved"]:
raise HTTPException(status_code=403, detail="관리자 승인 대기 중입니다")
# 성공 → 카운터 리셋
await c.execute("""
UPDATE users SET failed_login_count=0, locked_until=NULL, last_login_at=NOW()
WHERE id=$1
""", row["id"])
token = create_token(row["id"], row["email"])
return {
"access_token": token,
"user": {"id": row["id"], "email": row["email"], "role": row["role"]},
}
@app.get("/api/auth/me")
async def auth_me(user: dict = Depends(current_user)):
async with pg_pool.acquire() as c:
row = await c.fetchrow("""
SELECT id, email, role, is_approved, created_at, last_login_at
FROM users WHERE id=$1
""", user["id"])
if not row:
raise HTTPException(status_code=404, detail="user not found")
if not row["is_approved"]:
raise HTTPException(status_code=403, detail="승인되지 않은 계정")
return {
"id": row["id"], "email": row["email"], "role": row["role"],
"is_approved": row["is_approved"],
"created_at": str(row["created_at"]),
"last_login_at": str(row["last_login_at"]) if row["last_login_at"] else None,
}
# ── 관리자 전용: 회원 목록/승인/삭제 ───────────────────────
@app.get("/api/admin/users")
async def admin_list_users(_: dict = Depends(_require_admin)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT id, email, role, is_approved, created_at, last_login_at,
failed_login_count, locked_until
FROM users ORDER BY is_approved ASC, created_at DESC
""")
return [
{
"id": r["id"], "email": r["email"], "role": r["role"],
"is_approved": r["is_approved"],
"created_at": str(r["created_at"]),
"last_login_at": str(r["last_login_at"]) if r["last_login_at"] else None,
"failed_login_count": r["failed_login_count"],
"locked_until": str(r["locked_until"]) if r["locked_until"] else None,
}
for r in rows
]
@app.post("/api/admin/users/{user_id}/approve")
async def admin_approve_user(user_id: int, _: dict = Depends(_require_admin)):
async with pg_pool.acquire() as c:
result = await c.execute(
"UPDATE users SET is_approved=true WHERE id=$1", user_id)
if result.endswith(" 0"):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"approved": user_id}
@app.post("/api/admin/users/{user_id}/unlock")
async def admin_unlock_user(user_id: int, _: dict = Depends(_require_admin)):
async with pg_pool.acquire() as c:
result = await c.execute("""
UPDATE users SET failed_login_count=0, locked_until=NULL WHERE id=$1
""", user_id)
if result.endswith(" 0"):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"unlocked": user_id}
@app.delete("/api/admin/users/{user_id}")
async def admin_delete_user(user_id: int, admin: dict = Depends(_require_admin)):
if user_id == admin["id"]:
raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다")
async with pg_pool.acquire() as c:
result = await c.execute("DELETE FROM users WHERE id=$1", user_id)
if result.endswith(" 0"):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"deleted": user_id}
@app.get("/api/portfolio")
async def portfolio_list(user: dict = Depends(current_user)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT id, stock_code, stock_name, buy_price, qty, memo,
created_at, updated_at
FROM user_portfolio WHERE user_id=$1
ORDER BY created_at DESC
""", user["id"])
return [
{**dict(r), "created_at": str(r["created_at"]), "updated_at": str(r["updated_at"])}
for r in rows
]
@app.post("/api/portfolio")
async def portfolio_add(req: PortfolioItemReq, user: dict = Depends(current_user)):
async with pg_pool.acquire() as c:
row = await c.fetchrow("""
INSERT INTO user_portfolio(user_id, stock_code, stock_name, buy_price, qty, memo)
VALUES($1,$2,$3,$4,$5,$6)
RETURNING id, stock_code, stock_name, buy_price, qty, memo, created_at, updated_at
""", user["id"], req.stock_code, req.stock_name, req.buy_price, req.qty, req.memo)
return {**dict(row), "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"])}
@app.put("/api/portfolio/{item_id}")
async def portfolio_update(item_id: int, req: PortfolioItemReq, user: dict = Depends(current_user)):
async with pg_pool.acquire() as c:
row = await c.fetchrow("""
UPDATE user_portfolio
SET stock_code=$1, stock_name=$2, buy_price=$3, qty=$4, memo=$5, updated_at=NOW()
WHERE id=$6 AND user_id=$7
RETURNING id, stock_code, stock_name, buy_price, qty, memo, created_at, updated_at
""", req.stock_code, req.stock_name, req.buy_price, req.qty, req.memo,
item_id, user["id"])
if not row:
raise HTTPException(status_code=404, detail="포지션을 찾을 수 없습니다")
return {**dict(row), "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"])}
@app.delete("/api/portfolio/{item_id}")
async def portfolio_delete(item_id: int, user: dict = Depends(current_user)):
async with pg_pool.acquire() as c:
result = await c.execute(
"DELETE FROM user_portfolio WHERE id=$1 AND user_id=$2",
item_id, user["id"])
if result.endswith(" 0"):
raise HTTPException(status_code=404, detail="포지션을 찾을 수 없습니다")
return {"deleted": item_id}
@app.post("/api/portfolio/import")
async def portfolio_import(items: list[PortfolioItemReq], user: dict = Depends(current_user)):
"""localStorage → 서버 1회 마이그레이션. 동일 stock_code 중복 시 skip."""
inserted = 0
skipped = 0
async with pg_pool.acquire() as c:
existing = await c.fetch(
"SELECT stock_code FROM user_portfolio WHERE user_id=$1", user["id"])
existing_codes = {r["stock_code"] for r in existing}
for it in items:
if it.stock_code in existing_codes:
skipped += 1
continue
await c.execute("""
INSERT INTO user_portfolio(user_id, stock_code, stock_name, buy_price, qty, memo)
VALUES($1,$2,$3,$4,$5,$6)
""", user["id"], it.stock_code, it.stock_name, it.buy_price, it.qty, it.memo)
inserted += 1
return {"inserted": inserted, "skipped": skipped}
# ═══════════════════════════════════════════════════════════
# 증권사 리서치 데스크 도구 (거시·비교·워치리스트·캘린더)
# ═══════════════════════════════════════════════════════════
MACRO_TARGETS = {
"KOSPI": "^KS11", "KOSDAQ": "^KQ11",
"S&P500": "^GSPC", "NASDAQ": "^IXIC", "DOW": "^DJI",
"NIKKEI": "^N225", "HSI": "^HSI",
"VIX": "^VIX",
"USD/KRW": "KRW=X", "JPY/KRW": "KRWJPY=X",
"GOLD": "GC=F", "WTI": "CL=F",
"US10Y": "^TNX",
}
async def _fetch_macro_one(client: httpx.AsyncClient, symbol: str) -> dict:
try:
r = await client.get(
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d",
headers={"User-Agent": "Mozilla/5.0"}, timeout=6)
if r.status_code != 200: return {}
meta = r.json()["chart"]["result"][0]["meta"]
cur = meta.get("regularMarketPrice")
prev = meta.get("chartPreviousClose")
chg_pct = (cur - prev) / prev * 100 if cur and prev else 0
return {"price": cur, "prev": prev, "change_pct": round(chg_pct, 2)}
except Exception:
return {}
@app.get("/api/macro")
async def macro():
"""글로벌 거시지표 일괄 fetch (Yahoo Finance + 60초 Redis 캐시)"""
cache_key = "macro:dashboard"
if redis_cl:
try:
cached = await redis_cl.get(cache_key)
if cached: return json.loads(cached)
except: pass
async with httpx.AsyncClient() as c:
results = await asyncio.gather(*[
_fetch_macro_one(c, sym) for sym in MACRO_TARGETS.values()
], return_exceptions=True)
out = {}
for (name, _), res in zip(MACRO_TARGETS.items(), results):
if isinstance(res, dict) and res.get("price") is not None:
out[name] = res
out["_ts"] = datetime.now().isoformat()
if redis_cl:
try: await redis_cl.setex(cache_key, 60, json.dumps(out))
except: pass
return out
@app.get("/api/compare")
async def compare(codes: str = Query(..., description="콤마로 구분된 종목코드")):
"""N개 종목 동시 비교 + 동종업계 평균"""
code_list = [c.strip() for c in codes.split(",") if c.strip()][:10]
if not code_list: return {"error": "codes 필수"}
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation,
s.magic_score, s.f_score, s.altman_z, s.peg, s.momentum_pct,
s.beneish_score, s.buy_votes, s.sell_votes, s.sector,
f.roe, f.operating_margin, f.debt_ratio, f.fcf_ratio,
f.revenue_growth, f.bsns_year,
p.price, p.per, p.pbr, p.market_cap, p.change_pct, p.collected_at
FROM dart_corps d
LEFT JOIN stock_scores s
ON s.stock_code=d.stock_code AND s.score_date=(SELECT MAX(score_date) FROM stock_scores)
LEFT JOIN dart_financials f
ON f.stock_code=d.stock_code AND f.reprt_code='11011'
AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2
WHERE f2.stock_code=d.stock_code AND f2.reprt_code='11011')
LEFT JOIN LATERAL (
SELECT price, per, pbr, market_cap, change_pct, collected_at
FROM stock_prices WHERE stock_code=d.stock_code
ORDER BY collected_at DESC LIMIT 1
) p ON true
WHERE d.stock_code = ANY($1) AND d.is_active=true
""", code_list)
items = [dict(r) for r in rows]
sector_summary = {}
for sec in {i["sector"] for i in items if i.get("sector")}:
async with pg_pool.acquire() as c:
avg = await c.fetchrow("""
SELECT AVG(NULLIF(p.per,0)) AS avg_per, AVG(NULLIF(p.pbr,0)) AS avg_pbr,
AVG(NULLIF(f.roe,0)) AS avg_roe, AVG(NULLIF(f.debt_ratio,0)) AS avg_debt,
COUNT(*) AS n
FROM dart_corps d
LEFT JOIN dart_financials f ON f.stock_code=d.stock_code AND f.reprt_code='11011'
LEFT JOIN LATERAL (SELECT per, pbr FROM stock_prices WHERE stock_code=d.stock_code
ORDER BY collected_at DESC LIMIT 1) p ON true
WHERE d.sector=$1 AND d.is_active=true AND p.per > 0 AND p.per < 100
""", sec)
if avg:
sector_summary[sec] = {
"avg_per": round(float(avg["avg_per"] or 0), 1),
"avg_pbr": round(float(avg["avg_pbr"] or 0), 2),
"avg_roe": round(float(avg["avg_roe"] or 0), 1),
"avg_debt_ratio": round(float(avg["avg_debt"] or 0), 1),
"n_peers": int(avg["n"] or 0),
}
return {"stocks": items, "sectors": sector_summary, "n": len(code_list)}
@app.get("/api/watchlist")
async def watchlist_get(user_id: int = Query(...)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT w.stock_code, w.memo, w.added_at, d.corp_name, d.sector,
s.total_score, s.recommendation, s.buy_votes, s.sell_votes,
p.price, p.change_pct, p.per, p.pbr
FROM user_watchlist w
LEFT JOIN dart_corps d ON d.stock_code=w.stock_code
LEFT JOIN stock_scores s ON s.stock_code=w.stock_code
AND s.score_date=(SELECT MAX(score_date) FROM stock_scores)
LEFT JOIN LATERAL (SELECT price, change_pct, per, pbr FROM stock_prices
WHERE stock_code=w.stock_code ORDER BY collected_at DESC LIMIT 1) p ON true
WHERE w.user_id=$1 ORDER BY w.added_at DESC
""", user_id)
return [dict(r) for r in rows]
@app.post("/api/watchlist")
async def watchlist_add(req: dict):
user_id = req.get("user_id"); code = req.get("stock_code"); memo = req.get("memo", "")
if not user_id or not code: return {"error": "user_id, stock_code 필수"}
async with pg_pool.acquire() as c:
await c.execute("""
INSERT INTO user_watchlist (user_id, stock_code, memo)
VALUES ($1, $2, $3) ON CONFLICT (user_id, stock_code) DO UPDATE SET memo=$3
""", user_id, code, memo)
return {"status": "added", "stock_code": code}
@app.delete("/api/watchlist/{code}")
async def watchlist_remove(code: str, user_id: int = Query(...)):
async with pg_pool.acquire() as c:
await c.execute("DELETE FROM user_watchlist WHERE user_id=$1 AND stock_code=$2", user_id, code)
return {"status": "removed", "stock_code": code}
async def _send_telegram(msg: str):
"""텔레그램 알림 발송 (TG 토큰/채팅ID 환경변수 사용)"""
token = os.getenv("TELEGRAM_BOT_TOKEN", "")
chat_id = os.getenv("TELEGRAM_CHAT_ID", "")
if not token or not chat_id: return
try:
async with httpx.AsyncClient() as c:
await c.post(f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": chat_id, "text": msg, "parse_mode": "HTML"},
timeout=10)
except: pass
async def check_alerts():
"""활성 알림 조건 체크 → 발화 시 텔레그램 + last_triggered 업데이트"""
async with pg_pool.acquire() as c:
alerts = await c.fetch("""
SELECT a.id, a.stock_code, a.alert_type, a.threshold, a.user_id,
d.corp_name
FROM user_alerts a
LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
WHERE a.active=true
AND (a.last_triggered IS NULL OR a.last_triggered < NOW()-INTERVAL '6 hours')
""")
for a in alerts:
try:
code = a["stock_code"]; t = a["alert_type"]; thr = float(a["threshold"])
cur = None; ind = None
# Redis price → fallback DB
if redis_cl:
p = await redis_cl.get(f"price:{code}")
if p: cur = float(json.loads(p).get("price") or 0)
if not cur:
async with pg_pool.acquire() as c:
pr = await c.fetchrow("SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", code)
if pr: cur = float(pr["price"] or 0)
# 기술 지표 (MA·RSI 알림용)
async with pg_pool.acquire() as c:
tech = await c.fetchrow("SELECT ma20, ma60, rsi FROM stock_technical WHERE stock_code=$1", code)
score_row = await c.fetchrow("SELECT total_score FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1", code)
triggered = False; msg = ""
name = a["corp_name"] or code
if t == "price_above" and cur and cur > thr:
triggered = True; msg = f"💹 <b>{name}</b> ({code}) 가격 도달\n현재가 {cur:,.0f} > 목표 {thr:,.0f}"
elif t == "price_below" and cur and cur < thr:
triggered = True; msg = f"📉 <b>{name}</b> ({code}) 가격 하락\n현재가 {cur:,.0f} < 손절 {thr:,.0f}"
elif t == "rsi_above" and tech and tech["rsi"] and tech["rsi"] > thr:
triggered = True; msg = f"⚠️ <b>{name}</b> RSI {tech['rsi']:.1f} > {thr} (과매수)"
elif t == "rsi_below" and tech and tech["rsi"] and tech["rsi"] < thr:
triggered = True; msg = f"📊 <b>{name}</b> RSI {tech['rsi']:.1f} < {thr} (과매도)"
elif t == "ma20_break_up" and cur and tech and tech["ma20"] and cur > tech["ma20"] * (1 + thr/100):
triggered = True; msg = f"📈 <b>{name}</b> MA20 돌파 +{thr}% ({cur:,.0f})"
elif t == "ma20_break_dn" and cur and tech and tech["ma20"] and cur < tech["ma20"] * (1 - thr/100):
triggered = True; msg = f"📉 <b>{name}</b> MA20 이탈 -{thr}% ({cur:,.0f})"
elif t == "score_above" and score_row and score_row["total_score"] > thr:
triggered = True; msg = f"⭐ <b>{name}</b> 종합점수 {score_row['total_score']:.1f} > {thr}"
elif t == "score_below" and score_row and score_row["total_score"] < thr:
triggered = True; msg = f"⚠️ <b>{name}</b> 종합점수 {score_row['total_score']:.1f} < {thr}"
if triggered:
await _send_telegram(msg)
async with pg_pool.acquire() as c:
await c.execute("UPDATE user_alerts SET last_triggered=NOW() WHERE id=$1", a["id"])
except Exception:
pass
@app.get("/api/alerts/list")
async def alerts_list(user_id: int = Query(...)):
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT a.*, d.corp_name FROM user_alerts a
LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
WHERE a.user_id=$1 ORDER BY a.created_at DESC
""", user_id)
return [dict(r) for r in rows]
@app.post("/api/alerts/create")
async def alerts_create(req: dict):
user_id = req.get("user_id"); code = req.get("stock_code")
t = req.get("alert_type"); thr = req.get("threshold")
valid = ("price_above","price_below","rsi_above","rsi_below",
"ma20_break_up","ma20_break_dn","score_above","score_below")
if not all([user_id, code, t]) or t not in valid:
return {"error": f"alert_type ∈ {valid}"}
async with pg_pool.acquire() as c:
await c.execute("""
INSERT INTO user_alerts (user_id, stock_code, alert_type, threshold)
VALUES ($1,$2,$3,$4)
""", user_id, code, t, float(thr))
return {"status": "created"}
@app.delete("/api/alerts/{alert_id}")
async def alerts_delete(alert_id: int, user_id: int = Query(...)):
async with pg_pool.acquire() as c:
await c.execute("DELETE FROM user_alerts WHERE id=$1 AND user_id=$2", alert_id, user_id)
return {"status": "deleted"}
@app.get("/api/calendar")
async def calendar_api(days: int = Query(default=30, ge=1, le=180)):
"""이벤트 캘린더 — 향후 N일 실적·배당·매크로(FOMC/BOK)"""
today = date.today()
until = today + timedelta(days=days)
async with pg_pool.acquire() as c:
recent = await c.fetch("""
SELECT primary_stock AS code, title, sentiment, intensity,
published_at::date AS dt, catalyst, source
FROM news_analysis
WHERE source='DART공시' AND published_at::date >= $1
ORDER BY published_at DESC LIMIT 50
""", today - timedelta(days=7))
macro_events = [
{"date": "2026-06-12", "event": "FOMC 회의", "impact": "글로벌 금리"},
{"date": "2026-06-13", "event": "한국은행 금통위", "impact": "원화·금리"},
{"date": "2026-07-10", "event": "FOMC 회의", "impact": "글로벌 금리"},
{"date": "2026-08-22", "event": "Jackson Hole", "impact": "글로벌 자산"},
]
macro_events = [e for e in macro_events
if today <= datetime.strptime(e["date"], "%Y-%m-%d").date() <= until]
return {"period": {"from": str(today), "to": str(until)},
"macro_events": macro_events,
"recent_dart": [dict(r) for r in recent][:30]}
async def _proxy_get(url: str, timeout: float = 10.0):
"""외부 서비스 GET 프록시 (실패 시 빈 응답)"""
try:
async with httpx.AsyncClient(timeout=timeout) as c:
r = await c.get(url)
r.raise_for_status()
return r.json()
except Exception as e:
return {"error": str(e), "source": url}
@app.get("/api/formulas/matrix")
async def formulas_matrix(limit: int = Query(default=50, ge=5, le=200)):
"""종목 × 10공식 신호 매트릭스 (오늘 스코어 기준 상위 N)"""
async with pg_pool.acquire() as c:
rows = await c.fetch("""
SELECT s.stock_code, COALESCE(d.corp_name, s.stock_name) AS stock_name,
s.total_score, s.recommendation, s.buy_votes, s.sell_votes,
s.magic_score, s.f_score, s.altman_z, s.peg, s.momentum_pct,
s.beneish_score, s.gpa_pct, s.g_score, s.amihud_illiq, s.market_beta,
s.signals, s.sector,
s.news_score, s.sentiment_momentum, s.sentiment_alpha,
s.attention_score, s.news_surge_ratio
FROM stock_scores s
LEFT JOIN dart_corps d ON d.stock_code = s.stock_code
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
AND COALESCE(d.is_active, true) = true
ORDER BY s.total_score DESC
LIMIT $1
""", limit)
return [dict(r) for r in rows]
@app.get("/api/weights")
async def proxy_weights():
"""공식별 학습 가중치 (score-engine /learn-weights)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/learn-weights")
@app.get("/api/backtest")
async def proxy_backtest(days: int = Query(default=180, ge=30, le=720)):
"""추천 백테스트 (score-engine /backtest)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/backtest?days={days}", timeout=30.0)
@app.get("/api/portfolio/recommended")
async def proxy_portfolio_recommended(amount: int = Query(default=0, ge=0)):
"""AI 추천 포트폴리오 구성 (score-engine /portfolio/recommended)"""
return await _proxy_get(
f"{SCORE_ENGINE_URL}/portfolio/recommended?amount={amount}", timeout=20.0)
@app.get("/api/sector-concentration")
async def proxy_sector_concentration():
"""섹터 집중도 + 30% 초과 경고 (score-engine /sector/concentration)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration")
async def _enrich_kr_names(rows):
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
if not isinstance(rows, list) or not rows:
return rows
codes = list({(r.get("kr_code") or r.get("stock_code") or "") for r in rows if isinstance(r, dict)})
codes = [c for c in codes if c]
if not codes:
return rows
async with pg_pool.acquire() as c:
name_rows = await c.fetch(
"SELECT stock_code, corp_name FROM dart_corps WHERE stock_code = ANY($1::text[])", codes)
name_map = {r["stock_code"]: r["corp_name"] for r in name_rows}
for r in rows:
if not isinstance(r, dict): continue
code = r.get("kr_code") or r.get("stock_code")
if code and name_map.get(code):
r["kr_name"] = name_map[code]
return rows
@app.get("/api/usmarket/etfs")
async def proxy_us_etfs():
"""미국 섹터 ETF 목록"""
return await _proxy_get(f"{US_MARKET_URL}/etfs")
@app.get("/api/usmarket/pairs")
async def proxy_us_pairs():
"""KR↔US 페어 + 60일 회귀 베타 + 한국 종목명"""
rows = await _proxy_get(f"{US_MARKET_URL}/pairs")
return await _enrich_kr_names(rows)
@app.get("/api/usmarket/signals")
async def proxy_us_signals():
"""미증시 동조 시그널 (전체) + 한국 종목명"""
rows = await _proxy_get(f"{US_MARKET_URL}/signal/latest")
return await _enrich_kr_names(rows)
@app.get("/api/usmarket/briefing")
async def proxy_us_briefing():
"""미증시 새벽 핫/저조 종목 + 관련 KOSPI 추천 (us-market /overnight-briefing)"""
return await _proxy_get(f"{US_MARKET_URL}/overnight-briefing")
@app.get("/api/macro/ecos")
async def proxy_macro_ecos():
"""한국 매크로 (aux-signal /macro/latest: USD/KRW, 국고채 10년, KOSPI)"""
return await _proxy_get(f"{AUX_SIGNAL_URL}/macro/latest")
@app.get("/")
async def index():
return FileResponse("/app/index.html")
@app.get("/cards")
async def cards_page():
"""종목 카드형 대시보드 (신규 직관 UI)"""
return FileResponse("/app/cards.html")