c5ef30b2be
- score-engine: TRADE_SETTINGS.auto_execute(기본 True) — auto_trade_scan_job이 제안한 pending 주문을 즉시 자동 체결(모의). 체결로직을 _fill_order로 추출(수동버튼/자동 공용). /trade/history: 체결이력+일별 실현/평가손익+보유 평가손익. (전부 DB 시뮬레이션, 실제 브로커 전송 없음) - dashboard-api: /api/trade-history 프록시. - 가드 그대로: 종목당10%·일일손실-3% halt·강력매수+두LLM일치+확신도4·손절-8%·익절+15%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2180 lines
108 KiB
Python
2180 lines
108 KiB
Python
"""대시보드 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")
|
||
|
||
|
||
# ── AI 투자조직 패널 (데이터건강·정확도·핫검증·결정안) ──────────────
|
||
@app.get("/api/data-health")
|
||
async def proxy_data_health():
|
||
"""데이터 무결성 (score-engine /data-health)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/data-health")
|
||
|
||
@app.get("/api/accuracy")
|
||
async def proxy_accuracy(days: int = Query(default=90, ge=7, le=365)):
|
||
"""추천 등급별 정확도 (score-engine /accuracy)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/accuracy?days={days}")
|
||
|
||
@app.get("/api/hot-validate")
|
||
async def proxy_hot_validate():
|
||
"""핫종목 검증 (score-engine /hot/validate)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/hot/validate", timeout=20.0)
|
||
|
||
@app.get("/api/decisions")
|
||
async def proxy_decisions():
|
||
"""오늘의 결정안 (score-engine /decisions)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions")
|
||
|
||
@app.get("/api/decisions-accuracy")
|
||
async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)):
|
||
"""결정안 실측 성과 (score-engine /decisions/accuracy)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}")
|
||
|
||
@app.get("/api/trade-history")
|
||
async def proxy_trade_history(days: int = Query(default=30, ge=1, le=365)):
|
||
"""모의매매 이력 + 손익 (score-engine /trade/history)"""
|
||
return await _proxy_get(f"{SCORE_ENGINE_URL}/trade/history?days={days}")
|
||
|
||
|
||
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")
|