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>
This commit is contained in:
+76
-12
@@ -317,7 +317,9 @@ async def recent(limit: int = Query(default=40), only_stock: bool = Query(defaul
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch(f"""
|
||||
SELECT title, sentiment, intensity, primary_stock, reason,
|
||||
investment_action, source, analyzed_at, url, catalyst, stock_codes
|
||||
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)
|
||||
@@ -473,7 +475,7 @@ async def buy_candidates(limit: int = Query(default=20)):
|
||||
ON t.stock_code = s.stock_code
|
||||
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
SELECT stock_code, roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
operating_profit, revenue
|
||||
FROM dart_financials
|
||||
WHERE stock_code = t.stock_code
|
||||
@@ -507,9 +509,11 @@ async def alerts():
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, primary_stock,
|
||||
reason, investment_action, source, analyzed_at
|
||||
reason, investment_action, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at
|
||||
FROM news_analysis
|
||||
WHERE intensity >= 3 AND analyzed_at >= NOW() - INTERVAL '24 hours'
|
||||
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]
|
||||
@@ -520,12 +524,12 @@ async def alerts():
|
||||
async def timeline(hours: int = Query(default=24)):
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT date_trunc('hour', analyzed_at) AS hour,
|
||||
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 analyzed_at >= NOW() - INTERVAL '%s hours'
|
||||
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"],
|
||||
@@ -537,7 +541,8 @@ async def timeline(hours: int = Query(default=24)):
|
||||
async def stock(code: str):
|
||||
async with pg_pool.acquire() as c:
|
||||
news = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, reason, source, analyzed_at
|
||||
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)
|
||||
@@ -766,11 +771,23 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
except: pass
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT stock_code, total_score, recommendation, news_score, technical_score
|
||||
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:
|
||||
@@ -786,6 +803,7 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
"signal": ta.get("signal") or "관망",
|
||||
"ai_score": sc.get("total_score"),
|
||||
"recommendation": sc.get("recommendation"),
|
||||
"sector": sc.get("sector") or "기타",
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1487,6 +1505,17 @@ async def volume_surge():
|
||||
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):
|
||||
@@ -2042,12 +2071,39 @@ async def proxy_backtest(days: int = Query(default=180, ge=30, le=720)):
|
||||
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 목록"""
|
||||
@@ -2056,14 +2112,22 @@ async def proxy_us_etfs():
|
||||
|
||||
@app.get("/api/usmarket/pairs")
|
||||
async def proxy_us_pairs():
|
||||
"""KR↔US 페어 + 60일 회귀 베타"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/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():
|
||||
"""미증시 동조 시그널 (전체)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
"""미증시 동조 시그널 (전체) + 한국 종목명"""
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user