5c721e6b70
- score-engine: 일간리포트 텔레그램을 notify=True(16:30 1회)로 게이팅 → 호출마다 발신되던 폭주 제거. 데이터 무결성 모니터(/data-health + 평일 매시간 경고). 정확도 검증 하베스트(/accuracy + 주간 리포트) — 추천 등급별 실측 알파/적중률. - ta-engine: job_analyze가 is_active=true 전 활성종목 시총순 커버(장중 상위500· 장마감 전종목). 기존 LIMIT 500·무필터로 LS 등 누락되던 버그 수정. - docs/ai_org.md: 데이터우선 마스터 기획(데이터→검증→지능→실행). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
981 lines
40 KiB
Python
981 lines
40 KiB
Python
"""
|
||
기술적 분석 엔진 (Technical Analysis Engine)
|
||
- 네이버 금융 차트 API (1차) / yfinance (2차 백업) OHLCV 수집
|
||
- MA5/20/60/120, RSI(14), MACD(12,26,9), 볼린저밴드(20,2), 스토캐스틱(14,3)
|
||
- 기술적 점수 (-100~100) 산출
|
||
- 매수/매도 목표가 T1/T2/T3 + 손절가 자동 계산
|
||
- vLLM AI 문장형 판단 생성
|
||
- 보유 포지션 손익 + 맞춤 전략 분석
|
||
"""
|
||
import asyncio, json, os, re, math
|
||
from datetime import datetime
|
||
from typing import Optional, List, Dict
|
||
import asyncpg, httpx, redis.asyncio as aioredis, structlog
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from fastapi import FastAPI, Query, Body
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from pydantic import BaseModel
|
||
|
||
structlog.configure(processors=[
|
||
structlog.processors.TimeStamper(fmt="iso"),
|
||
structlog.processors.add_log_level,
|
||
structlog.processors.JSONRenderer(),
|
||
])
|
||
logger = structlog.get_logger()
|
||
|
||
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
|
||
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
||
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", "")
|
||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||
|
||
pg_pool: Optional[asyncpg.Pool] = None
|
||
redis_cl: Optional[aioredis.Redis] = None
|
||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||
|
||
class Stats:
|
||
analyzed = 0; errors = 0; last_run = ""
|
||
stats = Stats()
|
||
|
||
# ── 기술적 지표 계산 ──────────────────────────────────────
|
||
|
||
def _ema_series(values: List[float], period: int) -> List[float]:
|
||
if not values or len(values) < period:
|
||
return [values[-1]] * len(values) if values else []
|
||
k = 2.0 / (period + 1)
|
||
seed = sum(values[:period]) / period
|
||
out = [seed]
|
||
for v in values[period:]:
|
||
out.append(v * k + out[-1] * (1 - k))
|
||
return [out[0]] * (period - 1) + out
|
||
|
||
def _ma(closes: List[float], n: int) -> float:
|
||
if not closes: return 0.0
|
||
data = closes[-n:] if len(closes) >= n else closes
|
||
return sum(data) / len(data)
|
||
|
||
def _rsi(closes: List[float], period: int = 14) -> float:
|
||
if len(closes) < period + 1:
|
||
return 50.0
|
||
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
||
gains = [max(d, 0.0) for d in deltas]
|
||
losses = [max(-d, 0.0) for d in deltas]
|
||
ag = sum(gains[:period]) / period
|
||
al = sum(losses[:period]) / period
|
||
for i in range(period, len(gains)):
|
||
ag = (ag * (period - 1) + gains[i]) / period
|
||
al = (al * (period - 1) + losses[i]) / period
|
||
if al == 0:
|
||
return 100.0
|
||
return round(100 - 100 / (1 + ag / al), 2)
|
||
|
||
def _macd(closes: List[float]) -> tuple:
|
||
if len(closes) < 26:
|
||
return 0.0, 0.0, 0.0
|
||
e12 = _ema_series(closes, 12)
|
||
e26 = _ema_series(closes, 26)
|
||
macd_line = [a - b for a, b in zip(e12, e26)]
|
||
signal_line = _ema_series(macd_line[-9:] if len(macd_line) >= 9 else macd_line, 9)
|
||
macd = macd_line[-1]
|
||
signal = signal_line[-1]
|
||
return round(macd, 4), round(signal, 4), round(macd - signal, 4)
|
||
|
||
def _bollinger(closes: List[float], period: int = 20) -> tuple:
|
||
if len(closes) < period:
|
||
c = closes[-1] if closes else 0
|
||
return float(c), float(c), float(c), 0.5
|
||
recent = closes[-period:]
|
||
ma = sum(recent) / period
|
||
std = math.sqrt(sum((x - ma) ** 2 for x in recent) / period)
|
||
upper = ma + 2 * std
|
||
lower = ma - 2 * std
|
||
cur = closes[-1]
|
||
pct_b = (cur - lower) / (upper - lower) if upper != lower else 0.5
|
||
return round(upper), round(ma), round(lower), round(pct_b, 3)
|
||
|
||
def _stochastic(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> tuple:
|
||
if len(closes) < period:
|
||
return 50.0, 50.0
|
||
h = max(highs[-period:])
|
||
l = min(lows[-period:])
|
||
k = ((closes[-1] - l) / (h - l) * 100) if h != l else 50.0
|
||
ks = []
|
||
for i in range(3):
|
||
idx = -(3 - i)
|
||
hh = max(highs[idx - period + 1:idx + 1] if idx != -1 else highs[-period:])
|
||
ll = min(lows[idx - period + 1:idx + 1] if idx != -1 else lows[-period:])
|
||
ks.append(((closes[idx] - ll) / (hh - ll) * 100) if hh != ll else 50.0)
|
||
d = sum(ks) / len(ks)
|
||
return round(k, 2), round(d, 2)
|
||
|
||
def _vol_ratio(volumes: List[float], period: int = 20) -> float:
|
||
if len(volumes) < period + 1:
|
||
return 1.0
|
||
avg = sum(volumes[-period - 1:-1]) / period
|
||
return round(volumes[-1] / avg, 2) if avg > 0 else 1.0
|
||
|
||
def _atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> float:
|
||
"""Average True Range — 변동성 측정"""
|
||
if len(closes) < period + 1:
|
||
return 0.0
|
||
trs = []
|
||
for i in range(1, len(closes)):
|
||
tr = max(
|
||
highs[i] - lows[i],
|
||
abs(highs[i] - closes[i-1]),
|
||
abs(lows[i] - closes[i-1]),
|
||
)
|
||
trs.append(tr)
|
||
recent = trs[-period:]
|
||
return round(sum(recent) / period, 2) if recent else 0.0
|
||
|
||
|
||
def _obv(closes: List[float], volumes: List[float]) -> tuple:
|
||
"""On-Balance Volume — 거래량 누적, 가격 상승일 +volume, 하락일 -volume"""
|
||
if len(closes) < 2: return 0, 0
|
||
obv = 0.0
|
||
obvs = [0.0]
|
||
for i in range(1, len(closes)):
|
||
if closes[i] > closes[i-1]: obv += volumes[i]
|
||
elif closes[i] < closes[i-1]: obv -= volumes[i]
|
||
obvs.append(obv)
|
||
# 최근 OBV 추세 (20일 평균 대비)
|
||
recent = obvs[-20:] if len(obvs) >= 20 else obvs
|
||
avg = sum(recent) / len(recent) if recent else 0
|
||
obv_trend = "상승" if obv > avg * 1.05 else ("하락" if obv < avg * 0.95 else "중립")
|
||
return int(obv), obv_trend
|
||
|
||
|
||
def _vwap(highs: List[float], lows: List[float], closes: List[float], volumes: List[float],
|
||
period: int = 20) -> float:
|
||
"""Volume Weighted Average Price — 거래량 가중 평균가, 기관 매매 기준선"""
|
||
if len(closes) < period: return float(closes[-1]) if closes else 0
|
||
cum_vp = sum(((highs[i] + lows[i] + closes[i]) / 3) * volumes[i]
|
||
for i in range(-period, 0))
|
||
cum_v = sum(volumes[-period:])
|
||
return round(cum_vp / cum_v, 2) if cum_v > 0 else 0
|
||
|
||
|
||
def _ichimoku(highs: List[float], lows: List[float], closes: List[float]) -> dict:
|
||
"""일목균형표 (Ichimoku Kinko Hyo) — 5개 라인"""
|
||
if len(closes) < 52: return {}
|
||
# 전환선(Tenkan-sen): (9일 고가 + 9일 저가) / 2
|
||
tenkan = (max(highs[-9:]) + min(lows[-9:])) / 2
|
||
# 기준선(Kijun-sen): (26일 고가 + 26일 저가) / 2
|
||
kijun = (max(highs[-26:]) + min(lows[-26:])) / 2
|
||
# 선행스팬1(Senkou Span A): (전환+기준)/2, 26일 후
|
||
span_a = (tenkan + kijun) / 2
|
||
# 선행스팬2(Senkou Span B): (52일 고가 + 52일 저가)/2, 26일 후
|
||
span_b = (max(highs[-52:]) + min(lows[-52:])) / 2
|
||
# 후행스팬(Chikou Span): 종가, 26일 전
|
||
chikou = closes[-26] if len(closes) > 26 else closes[-1]
|
||
cur = closes[-1]
|
||
cloud_top = max(span_a, span_b)
|
||
cloud_bot = min(span_a, span_b)
|
||
pos = "구름위" if cur > cloud_top else ("구름아래" if cur < cloud_bot else "구름속")
|
||
return {
|
||
"tenkan": int(tenkan), "kijun": int(kijun),
|
||
"span_a": int(span_a), "span_b": int(span_b), "chikou": int(chikou),
|
||
"cloud_pos": pos,
|
||
}
|
||
|
||
|
||
def calc_indicators(ohlcv: List[dict]) -> dict:
|
||
if len(ohlcv) < 5:
|
||
return {}
|
||
closes = [float(d["close"]) for d in ohlcv]
|
||
highs = [float(d["high"]) for d in ohlcv]
|
||
lows = [float(d["low"]) for d in ohlcv]
|
||
volumes = [float(d["volume"]) for d in ohlcv]
|
||
|
||
bb_upper, bb_mid, bb_lower, pct_b = _bollinger(closes, 20)
|
||
stoch_k, stoch_d = _stochastic(highs, lows, closes, 14)
|
||
macd, macd_signal, macd_hist = _macd(closes)
|
||
atr14 = _atr(highs, lows, closes, 14)
|
||
obv_val, obv_trend = _obv(closes, volumes)
|
||
vwap_val = _vwap(highs, lows, closes, volumes, 20)
|
||
ichi = _ichimoku(highs, lows, closes)
|
||
|
||
return {
|
||
"price": int(closes[-1]),
|
||
"ma5": round(_ma(closes, 5)),
|
||
"ma20": round(_ma(closes, 20)),
|
||
"ma60": round(_ma(closes, 60)),
|
||
"ma120": round(_ma(closes, 120)),
|
||
"rsi": _rsi(closes, 14),
|
||
"macd": macd,
|
||
"macd_signal": macd_signal,
|
||
"macd_hist": macd_hist,
|
||
"bb_upper": bb_upper,
|
||
"bb_mid": bb_mid,
|
||
"bb_lower": bb_lower,
|
||
"pct_b": pct_b,
|
||
"stoch_k": stoch_k,
|
||
"stoch_d": stoch_d,
|
||
"vol_ratio": _vol_ratio(volumes, 20),
|
||
"atr14": atr14,
|
||
"high_52w": int(max(highs[-min(len(highs), 252):])),
|
||
"low_52w": int(min(lows[-min(len(lows), 252):])),
|
||
"obv": obv_val,
|
||
"obv_trend": obv_trend,
|
||
"vwap20": vwap_val,
|
||
"ichimoku": ichi,
|
||
}
|
||
|
||
def calc_tech_score(ind: dict) -> tuple:
|
||
"""기술적 점수 (-100~100)와 근거 신호 목록 반환"""
|
||
if not ind:
|
||
return 0.0, []
|
||
|
||
price = ind["price"]
|
||
score = 0.0
|
||
signals: List[str] = []
|
||
|
||
# ── 이동평균 (±40) ──────────────────────────
|
||
if ind["ma5"] > ind["ma20"]:
|
||
score += 10; signals.append("MA5>MA20 단기상승")
|
||
else:
|
||
score -= 10; signals.append("MA5<MA20 단기하락")
|
||
|
||
if ind["ma20"] > ind["ma60"]:
|
||
score += 8; signals.append("MA20>MA60 중기상승")
|
||
else:
|
||
score -= 8
|
||
|
||
if ind["ma60"] > ind["ma120"]:
|
||
score += 7
|
||
else:
|
||
score -= 7
|
||
|
||
if price > ind["ma20"]:
|
||
score += 8; signals.append("현재가 MA20 위")
|
||
elif price < ind["ma60"]:
|
||
score -= 8; signals.append("현재가 MA60 아래")
|
||
|
||
# 정배열/역배열
|
||
if ind["ma5"] > ind["ma20"] > ind["ma60"] > ind["ma120"]:
|
||
score += 7; signals.append("정배열")
|
||
elif ind["ma5"] < ind["ma20"] < ind["ma60"] < ind["ma120"]:
|
||
score -= 7; signals.append("역배열")
|
||
|
||
# ── RSI (±25) ───────────────────────────────
|
||
rsi = ind["rsi"]
|
||
if rsi <= 30:
|
||
score += 25; signals.append(f"RSI 과매도({rsi:.0f})")
|
||
elif rsi <= 40:
|
||
score += 15; signals.append(f"RSI 저점({rsi:.0f})")
|
||
elif rsi <= 60:
|
||
score += 5
|
||
elif rsi <= 70:
|
||
score -= 5
|
||
else:
|
||
score -= 20; signals.append(f"RSI 과매수({rsi:.0f})")
|
||
|
||
# ── MACD (±20) ──────────────────────────────
|
||
if ind["macd_hist"] > 0 and ind["macd"] > ind["macd_signal"]:
|
||
score += 20; signals.append("MACD 골든크로스")
|
||
elif ind["macd_hist"] > 0:
|
||
score += 8
|
||
elif ind["macd_hist"] < 0 and ind["macd"] < ind["macd_signal"]:
|
||
score -= 20; signals.append("MACD 데드크로스")
|
||
else:
|
||
score -= 5
|
||
|
||
# ── 볼린저밴드 (±15) ────────────────────────
|
||
pb = ind["pct_b"]
|
||
if pb < 0.1:
|
||
score += 15; signals.append("볼밴 하단(과매도)")
|
||
elif pb < 0.3:
|
||
score += 8
|
||
elif pb > 0.9:
|
||
score -= 15; signals.append("볼밴 상단(과매수)")
|
||
elif pb > 0.7:
|
||
score -= 5
|
||
|
||
# ── 스토캐스틱 (±10) ────────────────────────
|
||
sk, sd = ind["stoch_k"], ind["stoch_d"]
|
||
if sk < 20 and sk > sd:
|
||
score += 10; signals.append("스토캐스틱 바닥반등")
|
||
elif sk > 80 and sk < sd:
|
||
score -= 10; signals.append("스토캐스틱 고점하락")
|
||
|
||
# ── 거래량 보너스 (±5) ──────────────────────
|
||
if ind["vol_ratio"] > 1.5:
|
||
if score > 0:
|
||
score += 5; signals.append("거래량 급증(매수세)")
|
||
else:
|
||
score -= 5; signals.append("거래량 급증(매도세)")
|
||
|
||
return round(max(-100.0, min(100.0, score)), 1), signals
|
||
|
||
def calc_price_targets(price: int, ind: dict, sig: str) -> dict:
|
||
"""매수/매도 목표가(T1/T2/T3) + 손절가 계산 (10원 단위 반올림)"""
|
||
if not ind or price <= 0:
|
||
return {}
|
||
|
||
def r10(p): return int(round(p / 10) * 10)
|
||
|
||
h52 = ind.get("high_52w", price * 1.3)
|
||
l52 = ind.get("low_52w", price * 0.7)
|
||
bb_up = ind.get("bb_upper", price * 1.05)
|
||
bb_dn = ind.get("bb_lower", price * 0.95)
|
||
ma20 = ind.get("ma20", price)
|
||
ma60 = ind.get("ma60", price)
|
||
|
||
if sig == "매수":
|
||
# 진입: 현재가 기준 -2% (기술지표 확인 후 매수)
|
||
entry = r10(price * 0.98)
|
||
# T1: +7% (단기), T2: +14% (중기), T3: min(+22%, 52주고가 -3%)
|
||
t1 = r10(price * 1.07)
|
||
t2 = r10(price * 1.14)
|
||
t3 = r10(min(price * 1.22, h52 * 0.97))
|
||
t3 = t3 if t3 > t2 else r10(price * 1.22)
|
||
# 손절: max(-8%, MA60 -5%) — 최대 -10% 이내 제한
|
||
raw_stop = max(price * 0.92, ma60 * 0.95)
|
||
stop = r10(max(raw_stop, price * 0.90)) # 최소 -10%
|
||
er1 = round((t1 - price) / price * 100, 1)
|
||
sl_r = round(abs(stop - price) / price * 100, 1)
|
||
# M3: ATR 기반 trailing stop (현재가 기준 2 ATR 아래)
|
||
atr = ind.get("atr14", 0)
|
||
atr_trailing = r10(price - 2 * atr) if atr > 0 else stop
|
||
return {
|
||
"entry_price": entry,
|
||
"t1": t1, "t1_pct": er1, "t1_sell_pct": 50,
|
||
"t2": t2, "t2_pct": round((t2 - price) / price * 100, 1), "t2_sell_pct": 30,
|
||
"t3": t3, "t3_pct": round((t3 - price) / price * 100, 1), "t3_sell_pct": 20,
|
||
"stop_loss": stop, "stop_pct": -sl_r,
|
||
"atr14": atr,
|
||
"trailing_stop": atr_trailing,
|
||
"risk_reward": round(er1 / sl_r, 2) if sl_r > 0 else 0,
|
||
"exit_strategy": "T1 50% + T2 30% + T3 20% 분할매도, 손절 또는 trailing(ATR×2) 도달시 전량",
|
||
}
|
||
else: # 매도 / 관망(음수)
|
||
entry = r10(price * 1.02)
|
||
t1 = r10(price * 0.93)
|
||
t2 = r10(price * 0.86)
|
||
t3 = r10(max(price * 0.78, l52 * 1.03))
|
||
t3 = t3 if t3 < t2 else r10(price * 0.78)
|
||
raw_stop = min(price * 1.08, ma20 * 1.05)
|
||
stop = r10(min(raw_stop, price * 1.10))
|
||
er1 = round((price - t1) / price * 100, 1)
|
||
sl_r = round(abs(stop - price) / price * 100, 1)
|
||
return {
|
||
"entry_price": entry,
|
||
"t1": t1, "t1_pct": -er1,
|
||
"t2": t2, "t2_pct": -round((price - t2) / price * 100, 1),
|
||
"t3": t3, "t3_pct": -round((price - t3) / price * 100, 1),
|
||
"stop_loss": stop, "stop_pct": sl_r,
|
||
"risk_reward": round(er1 / sl_r, 2) if sl_r > 0 else 0,
|
||
}
|
||
|
||
# ── OHLCV 수집 (네이버 차트 → yfinance 백업) ─────────────
|
||
|
||
async def get_ohlcv_naver_chart(client: httpx.AsyncClient, code: str, count: int = 120) -> List[dict]:
|
||
"""네이버 차트 API (fchart)"""
|
||
try:
|
||
r = await client.get(
|
||
f"https://fchart.stock.naver.com/sise.nhn?symbol={code}&timeframe=day&count={count}&requestType=0",
|
||
headers=HEADERS, timeout=10)
|
||
items = re.findall(r'data="([^"]+)"', r.text)
|
||
data = []
|
||
for item in items:
|
||
p = item.split("|")
|
||
if len(p) >= 6 and all(x.strip() for x in p[:5]):
|
||
try:
|
||
data.append({
|
||
"date": p[0], "open": int(p[1]), "high": int(p[2]),
|
||
"low": int(p[3]), "close": int(p[4]),
|
||
"volume": int(p[5]) if p[5].strip() else 0,
|
||
})
|
||
except ValueError:
|
||
pass
|
||
return data
|
||
except Exception:
|
||
return []
|
||
|
||
async def get_ohlcv_naver_sise(client: httpx.AsyncClient, code: str, pages: int = 7) -> List[dict]:
|
||
"""네이버 일별시세 페이지 (차트 API 실패 시 2차)"""
|
||
data = []
|
||
try:
|
||
for page in range(1, pages + 1):
|
||
r = await client.get(
|
||
f"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}",
|
||
headers=HEADERS, timeout=12)
|
||
r.encoding = "euc-kr"
|
||
# 날짜+종가+전일비+시가+고가+저가+거래량 패턴
|
||
rows = re.findall(
|
||
r'(\d{4}\.\d{2}\.\d{2})[^<]*</span>.*?'
|
||
r'<span[^>]*>([\d,]+)</span>.*?' # 종가
|
||
r'(?:.*?){3}'
|
||
r'<span[^>]*>([\d,]+)</span>.*?' # 시가
|
||
r'<span[^>]*>([\d,]+)</span>.*?' # 고가
|
||
r'<span[^>]*>([\d,]+)</span>.*?' # 저가
|
||
r'<span[^>]*>([\d,]+)</span>', # 거래량
|
||
r.text, re.DOTALL)
|
||
if not rows:
|
||
# 단순 종가만 추출 (더 넓은 패턴)
|
||
simple = re.findall(
|
||
r'class="tah p10 gray03">(\d{4}\.\d{2}\.\d{2})<.*?'
|
||
r'class="tah p11">([\d,]+)<',
|
||
r.text, re.DOTALL)
|
||
for date_str, close_str in simple:
|
||
close = int(close_str.replace(",", ""))
|
||
if close > 0:
|
||
data.append({"date": date_str.replace(".", ""),
|
||
"open": close, "high": close,
|
||
"low": close, "close": close, "volume": 0})
|
||
else:
|
||
for m in rows:
|
||
date_str = m[0].replace(".", "")
|
||
close = int(m[1].replace(",", ""))
|
||
open_ = int(m[2].replace(",", "")) if m[2] else close
|
||
high = int(m[3].replace(",", "")) if m[3] else close
|
||
low = int(m[4].replace(",", "")) if m[4] else close
|
||
vol = int(m[5].replace(",", "")) if m[5] else 0
|
||
if close > 0:
|
||
data.append({"date": date_str, "open": open_,
|
||
"high": high, "low": low,
|
||
"close": close, "volume": vol})
|
||
if len(data) >= 120:
|
||
break
|
||
await asyncio.sleep(0.15)
|
||
except Exception as e:
|
||
logger.warning("ohlcv.sise.err", code=code, error=str(e))
|
||
return data[:120]
|
||
|
||
async def get_ohlcv_yfinance(code: str) -> List[dict]:
|
||
"""yfinance 최종 백업"""
|
||
try:
|
||
import yfinance as yf
|
||
loop = asyncio.get_event_loop()
|
||
def _fetch():
|
||
t = yf.Ticker(f"{code}.KS")
|
||
return t.history(period="1y")
|
||
h = await loop.run_in_executor(None, _fetch)
|
||
if h.empty:
|
||
return []
|
||
return [{"date": idx.strftime("%Y%m%d"),
|
||
"open": int(row["Open"] or 0), "high": int(row["High"] or 0),
|
||
"low": int(row["Low"] or 0), "close": int(row["Close"] or 0),
|
||
"volume": int(row["Volume"] or 0)}
|
||
for idx, row in h.iterrows() if row["Close"] and row["High"]]
|
||
except Exception as e:
|
||
logger.warning("ohlcv.yfinance.err", code=code, error=str(e))
|
||
return []
|
||
|
||
async def get_ohlcv(client: httpx.AsyncClient, code: str, count: int = 120) -> List[dict]:
|
||
data = await get_ohlcv_naver_chart(client, code, count)
|
||
if len(data) < 20:
|
||
data = await get_ohlcv_naver_sise(client, code)
|
||
if len(data) < 20:
|
||
logger.info("ohlcv.fallback.yfinance", code=code)
|
||
await asyncio.sleep(0.5) # rate limit 방지
|
||
data = await get_ohlcv_yfinance(code)
|
||
return data
|
||
|
||
# ── vLLM AI 판단문 생성 ───────────────────────────────────
|
||
|
||
async def generate_ai_opinion(client: httpx.AsyncClient, code: str, name: str,
|
||
ind: dict, tech_score: float, signals: List[str],
|
||
targets: dict, news_score: float = 0) -> str:
|
||
"""vLLM으로 문장형 투자 판단 생성"""
|
||
sig = "매수" if tech_score >= 30 else ("매도" if tech_score <= -30 else "관망")
|
||
price = ind.get("price", 0)
|
||
rsi = ind.get("rsi", 50)
|
||
h52 = ind.get("high_52w", price)
|
||
l52 = ind.get("low_52w", price)
|
||
pos52 = int((price - l52) / (h52 - l52) * 100) if h52 != l52 else 50
|
||
|
||
prompt = f"""다음 주식 데이터를 바탕으로 투자자에게 명확한 매매 판단을 3~5문장으로 설명하세요.
|
||
한국어로, 구체적인 가격과 수치를 포함해서 작성하세요.
|
||
|
||
종목: {name}({code})
|
||
현재가: {price:,}원
|
||
기술점수: {tech_score}점 / 신호: {sig}
|
||
이동평균: MA5={ind.get('ma5',0):,} MA20={ind.get('ma20',0):,} MA60={ind.get('ma60',0):,}
|
||
RSI: {rsi} / MACD히스토그램: {'양수(골든)' if ind.get('macd_hist',0)>0 else '음수(데드)'}
|
||
볼린저%B: {ind.get('pct_b',0.5)*100:.0f}%
|
||
52주위치: 하단에서 {pos52}%
|
||
뉴스감성점수: {news_score:.0f}
|
||
기술신호: {', '.join(signals[:4])}
|
||
{f"1차목표가: {targets.get('t1',0):,}원 / 손절가: {targets.get('stop_loss',0):,}원" if targets else ""}
|
||
|
||
투자 판단 (3~5문장):"""
|
||
|
||
try:
|
||
r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
||
"model": "exaone3.5:7.8b",
|
||
"messages": [
|
||
{"role": "system", "content": "당신은 한국 주식 전문 애널리스트입니다. 기술적 분석 데이터를 바탕으로 명확하고 실용적인 투자 의견을 제시합니다."},
|
||
{"role": "user", "content": prompt}
|
||
],
|
||
"max_tokens": 300, "temperature": 0.2
|
||
}, timeout=60)
|
||
return r.json()["choices"][0]["message"]["content"].strip()
|
||
except Exception as e:
|
||
logger.warning("ai_opinion.err", code=code, error=str(e))
|
||
return ""
|
||
|
||
# ── 포지션 손익 분석 ──────────────────────────────────────
|
||
|
||
class PositionRequest(BaseModel):
|
||
code: str
|
||
name: str = ""
|
||
buy_price: int
|
||
qty: int
|
||
|
||
def analyze_position(price: int, buy_price: int, qty: int,
|
||
ind: dict, tech_score: float) -> dict:
|
||
"""보유 포지션 기반 맞춤 전략 계산"""
|
||
pnl = (price - buy_price) * qty
|
||
pnl_pct = (price - buy_price) / buy_price * 100
|
||
total_buy = buy_price * qty
|
||
h52 = ind.get("high_52w", price * 1.3)
|
||
l52 = ind.get("low_52w", price * 0.7)
|
||
ma20 = ind.get("ma20", price)
|
||
ma60 = ind.get("ma60", price)
|
||
bbu = ind.get("bb_upper", price * 1.05)
|
||
|
||
def r10(p): return int(round(p / 10) * 10)
|
||
|
||
# 손절선: 매입가 -8% 또는 MA60 -3% 중 높은 것
|
||
stop = max(r10(buy_price * 0.92), r10(ma60 * 0.97))
|
||
|
||
# 목표가
|
||
t1 = r10(max(buy_price * 1.08, bbu * 0.97)) # 본전+8% 또는 볼밴 상단
|
||
t2 = r10(max((price + h52) / 2, buy_price * 1.15))
|
||
t3 = r10(max(h52 * 0.97, buy_price * 1.25))
|
||
|
||
# 추가매수 구간 (물타기) - 현재가 -5%, -10%
|
||
avg_down1_price = r10(price * 0.95)
|
||
avg_down1_qty = max(1, qty // 3)
|
||
avg_down1_avg = (total_buy + avg_down1_price * avg_down1_qty) / (qty + avg_down1_qty)
|
||
|
||
avg_down2_price = r10(price * 0.90)
|
||
avg_down2_qty = max(1, qty // 2)
|
||
avg_down2_avg = (total_buy + avg_down2_price * avg_down2_qty) / (qty + avg_down2_qty)
|
||
|
||
return {
|
||
"pnl": pnl,
|
||
"pnl_pct": round(pnl_pct, 2),
|
||
"total_buy": total_buy,
|
||
"current_value": price * qty,
|
||
"stop_loss": stop,
|
||
"stop_pnl": (stop - buy_price) * qty,
|
||
"t1": t1, "t1_pnl": (t1 - buy_price) * qty,
|
||
"t2": t2, "t2_pnl": (t2 - buy_price) * qty,
|
||
"t3": t3, "t3_pnl": (t3 - buy_price) * qty,
|
||
"avg_down": [
|
||
{"price": avg_down1_price, "add_qty": avg_down1_qty,
|
||
"new_avg": round(avg_down1_avg), "add_cost": avg_down1_price * avg_down1_qty},
|
||
{"price": avg_down2_price, "add_qty": avg_down2_qty,
|
||
"new_avg": round(avg_down2_avg), "add_cost": avg_down2_price * avg_down2_qty},
|
||
],
|
||
"action": (
|
||
"손절 고려" if price <= stop else
|
||
"추가매수 검토" if pnl_pct < -5 and tech_score >= 0 else
|
||
"홀드" if -5 <= pnl_pct < 5 else
|
||
"1차 익절 고려" if pnl_pct >= 10 else "홀드"
|
||
),
|
||
}
|
||
|
||
# ── 단일 종목 분석 ────────────────────────────────────────
|
||
|
||
EXCLUDE_KEYWORDS = (
|
||
"기업인수목적", "선박투자회사", "부동산투자회사", "특별자산", "인프라투자",
|
||
"사모투자", "맥쿼리", "리츠", "REITs",
|
||
)
|
||
|
||
async def analyze_stock(client: httpx.AsyncClient, code: str, name: str = "",
|
||
with_ai: bool = False, news_score: float = 0) -> Optional[dict]:
|
||
# 이름이 없으면 DB에서 조회
|
||
if not name and pg_pool:
|
||
try:
|
||
async with pg_pool.acquire() as conn:
|
||
name = await conn.fetchval(
|
||
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) or ""
|
||
except: pass
|
||
|
||
# SPAC·REITs·선박펀드 등 제외
|
||
if any(kw in name for kw in EXCLUDE_KEYWORDS):
|
||
return None
|
||
|
||
ohlcv = await get_ohlcv(client, code, 120)
|
||
if len(ohlcv) < 20:
|
||
return None
|
||
|
||
ind = calc_indicators(ohlcv)
|
||
if not ind:
|
||
return None
|
||
|
||
# 주가 500원 미만 penny stock 제외
|
||
if ind.get("price", 0) < 500:
|
||
return None
|
||
|
||
tech_score, signals = calc_tech_score(ind)
|
||
sig = "매수" if tech_score >= 30 else ("매도" if tech_score <= -30 else "관망")
|
||
# 관망도 목표가 계산 (기술점수 양수면 매수 기준, 음수면 매도 기준)
|
||
tgt_sig = "매수" if tech_score >= 0 else "매도"
|
||
targets = calc_price_targets(ind["price"], ind, tgt_sig)
|
||
|
||
ai_opinion = ""
|
||
if with_ai and sig != "관망":
|
||
ai_opinion = await generate_ai_opinion(
|
||
client, code, name, ind, tech_score, signals, targets, news_score)
|
||
|
||
result = {
|
||
"code": code, "name": name,
|
||
"tech_score": tech_score, "signal": sig,
|
||
"signals": signals, "indicators": ind, "targets": targets,
|
||
"ai_opinion": ai_opinion,
|
||
"analyzed_at": datetime.now().isoformat(),
|
||
}
|
||
|
||
if redis_cl:
|
||
try:
|
||
await redis_cl.set(f"ta:{code}", json.dumps(result, ensure_ascii=False), ex=1800)
|
||
except: pass
|
||
|
||
if pg_pool:
|
||
try:
|
||
async with pg_pool.acquire() as conn:
|
||
await conn.execute("""
|
||
INSERT INTO stock_technical (
|
||
stock_code, stock_name, price,
|
||
ma5, ma20, ma60, ma120,
|
||
rsi, macd, macd_signal, macd_hist,
|
||
bb_upper, bb_mid, bb_lower, pct_b,
|
||
stoch_k, stoch_d, vol_ratio,
|
||
tech_score, signal, signals, targets, analyzed_at
|
||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
|
||
ON CONFLICT (stock_code) DO UPDATE SET
|
||
stock_name=$2, price=$3,
|
||
ma5=$4, ma20=$5, ma60=$6, ma120=$7,
|
||
rsi=$8, macd=$9, macd_signal=$10, macd_hist=$11,
|
||
bb_upper=$12, bb_mid=$13, bb_lower=$14, pct_b=$15,
|
||
stoch_k=$16, stoch_d=$17, vol_ratio=$18,
|
||
tech_score=$19, signal=$20, signals=$21, targets=$22, analyzed_at=$23
|
||
""",
|
||
code, name, ind["price"],
|
||
ind["ma5"], ind["ma20"], ind["ma60"], ind["ma120"],
|
||
ind["rsi"], ind["macd"], ind["macd_signal"], ind["macd_hist"],
|
||
ind["bb_upper"], ind["bb_mid"], ind["bb_lower"], ind["pct_b"],
|
||
ind["stoch_k"], ind["stoch_d"], ind["vol_ratio"],
|
||
tech_score, sig,
|
||
json.dumps(signals, ensure_ascii=False),
|
||
json.dumps(targets, ensure_ascii=False),
|
||
datetime.now())
|
||
except Exception as e:
|
||
logger.warning("ta.db.err", code=code, error=str(e))
|
||
|
||
return result
|
||
|
||
# ── DB 초기화 ─────────────────────────────────────────────
|
||
|
||
async def init_db():
|
||
async with pg_pool.acquire() as conn:
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS stock_technical (
|
||
id SERIAL PRIMARY KEY,
|
||
stock_code VARCHAR(10) UNIQUE NOT NULL,
|
||
stock_name VARCHAR(100) DEFAULT '',
|
||
price INTEGER DEFAULT 0,
|
||
ma5 FLOAT DEFAULT 0,
|
||
ma20 FLOAT DEFAULT 0,
|
||
ma60 FLOAT DEFAULT 0,
|
||
ma120 FLOAT DEFAULT 0,
|
||
rsi FLOAT DEFAULT 50,
|
||
macd FLOAT DEFAULT 0,
|
||
macd_signal FLOAT DEFAULT 0,
|
||
macd_hist FLOAT DEFAULT 0,
|
||
bb_upper FLOAT DEFAULT 0,
|
||
bb_mid FLOAT DEFAULT 0,
|
||
bb_lower FLOAT DEFAULT 0,
|
||
pct_b FLOAT DEFAULT 0.5,
|
||
stoch_k FLOAT DEFAULT 50,
|
||
stoch_d FLOAT DEFAULT 50,
|
||
vol_ratio FLOAT DEFAULT 1,
|
||
tech_score FLOAT DEFAULT 0,
|
||
signal VARCHAR(10) DEFAULT '관망',
|
||
signals JSONB DEFAULT '[]'::jsonb,
|
||
targets JSONB DEFAULT '{}'::jsonb,
|
||
analyzed_at TIMESTAMP DEFAULT NOW()
|
||
)
|
||
""")
|
||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_ta_score ON stock_technical(tech_score DESC)")
|
||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_ta_signal ON stock_technical(signal)")
|
||
logger.info("ta.db.initialized")
|
||
|
||
# ── 전체 분석 작업 ────────────────────────────────────────
|
||
|
||
async def job_analyze(limit: int = 500):
|
||
"""limit>0: 시총 상위 N개(장중 경량). limit=0: 전 활성종목(장마감 풀커버).
|
||
is_active=true 필터 필수 — 상장폐지 제외 + LS 등 누락 방지."""
|
||
logger.info("ta.job.start", limit=limit)
|
||
async with httpx.AsyncClient() as client:
|
||
codes: List[tuple] = []
|
||
|
||
if pg_pool:
|
||
try:
|
||
q = """
|
||
SELECT c.stock_code, c.corp_name
|
||
FROM dart_corps c
|
||
LEFT JOIN (
|
||
SELECT DISTINCT ON (stock_code) stock_code, market_cap
|
||
FROM stock_prices ORDER BY stock_code, collected_at DESC
|
||
) p ON p.stock_code = c.stock_code
|
||
WHERE c.is_active = true
|
||
ORDER BY COALESCE(p.market_cap, 0) DESC
|
||
"""
|
||
if limit and limit > 0:
|
||
q += f" LIMIT {int(limit)}"
|
||
rows = await pg_pool.fetch(q)
|
||
codes = [(r["stock_code"], r["corp_name"] or "") for r in rows if r["stock_code"]]
|
||
except Exception as e:
|
||
logger.warning("ta.codes.err", error=str(e))
|
||
|
||
if not codes:
|
||
for sosok in [0, 1]:
|
||
for page in range(1, 30):
|
||
try:
|
||
r = await client.get(
|
||
f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}",
|
||
headers=HEADERS, timeout=15)
|
||
r.encoding = "euc-kr"
|
||
found = re.findall(r'main\.naver\?code=(\d{6})[^>]*>([^<]+)</a>', r.text)
|
||
if not found: break
|
||
codes.extend([(c.strip(), n.strip()) for c, n in found])
|
||
await asyncio.sleep(0.2)
|
||
except: break
|
||
if len(codes) >= 500: break
|
||
|
||
ok = 0
|
||
for code, name in codes:
|
||
if not code or len(code) != 6: continue
|
||
try:
|
||
result = await analyze_stock(client, code, name)
|
||
if result: ok += 1
|
||
except Exception as e:
|
||
stats.errors += 1
|
||
logger.warning("ta.analyze.err", code=code, error=str(e))
|
||
await asyncio.sleep(0.2)
|
||
|
||
stats.analyzed += ok
|
||
stats.last_run = datetime.now().isoformat()
|
||
logger.info("ta.job.done", analyzed=ok, requested=len(codes))
|
||
|
||
# ── FastAPI ────────────────────────────────────────────────
|
||
|
||
app = FastAPI(title="기술적 분석 엔진")
|
||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||
|
||
@app.on_event("startup")
|
||
async def startup():
|
||
global pg_pool, redis_cl
|
||
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=5, decode_responses=True)
|
||
await init_db()
|
||
scheduler.add_job(lambda: job_analyze(limit=500), "cron", day_of_week="mon-fri",
|
||
hour="9-16", minute="*/30", id="ta_30m", replace_existing=True)
|
||
scheduler.add_job(lambda: job_analyze(limit=0), "cron", day_of_week="mon-fri",
|
||
hour=16, minute=15, id="ta_close", replace_existing=True)
|
||
scheduler.start()
|
||
logger.info("ta-engine.started")
|
||
|
||
@app.on_event("shutdown")
|
||
async def shutdown():
|
||
scheduler.shutdown()
|
||
if pg_pool: await pg_pool.close()
|
||
if redis_cl: await redis_cl.aclose()
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"status": "ok", "analyzed": stats.analyzed,
|
||
"errors": stats.errors, "last_run": stats.last_run}
|
||
|
||
@app.get("/technical/{code}")
|
||
async def technical(code: str):
|
||
if redis_cl:
|
||
cached = await redis_cl.get(f"ta:{code}")
|
||
if cached:
|
||
return JSONResponse(content=json.loads(cached))
|
||
if pg_pool:
|
||
async with pg_pool.acquire() as conn:
|
||
row = await conn.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[k], str):
|
||
d[k] = json.loads(d[k])
|
||
return JSONResponse(content=d)
|
||
# 실시간 분석
|
||
async with httpx.AsyncClient() as client:
|
||
result = await analyze_stock(client, code)
|
||
if result:
|
||
return JSONResponse(content=result)
|
||
return JSONResponse(content={"error": "not found"}, status_code=404)
|
||
|
||
@app.get("/ranking")
|
||
async def ranking(limit: int = Query(default=30), signal: str = Query(default="")):
|
||
async with pg_pool.acquire() as conn:
|
||
if signal:
|
||
rows = await conn.fetch(
|
||
"SELECT * FROM stock_technical WHERE signal=$1 ORDER BY tech_score DESC LIMIT $2",
|
||
signal, limit)
|
||
else:
|
||
rows = await conn.fetch(
|
||
"SELECT * FROM stock_technical ORDER BY tech_score DESC LIMIT $1", limit)
|
||
result = []
|
||
for row in rows:
|
||
d = dict(row)
|
||
d["analyzed_at"] = str(d["analyzed_at"])
|
||
for k in ("signals", "targets"):
|
||
if isinstance(d[k], str):
|
||
d[k] = json.loads(d[k])
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.get("/buy-candidates")
|
||
async def buy_candidates(limit: int = Query(default=20)):
|
||
"""기술적 매수 후보 (점수 30 이상) + 펀더멘탈 점수 합산"""
|
||
async with pg_pool.acquire() as conn:
|
||
rows = await conn.fetch("""
|
||
SELECT t.*,
|
||
s.news_score, s.dart_score, s.recommendation AS fundamental_rec,
|
||
s.total_score AS fundamental_total
|
||
FROM stock_technical t
|
||
LEFT JOIN stock_scores s
|
||
ON t.stock_code = s.stock_code
|
||
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||
WHERE t.signal = '매수' AND t.tech_score >= 30
|
||
ORDER BY (t.tech_score + COALESCE(s.total_score, 0)) DESC
|
||
LIMIT $1
|
||
""", limit)
|
||
result = []
|
||
for row in rows:
|
||
d = dict(row)
|
||
d["analyzed_at"] = str(d["analyzed_at"])
|
||
for k in ("signals", "targets"):
|
||
if isinstance(d.get(k), str):
|
||
d[k] = json.loads(d[k])
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.post("/analyze/all")
|
||
async def analyze_all(limit: int = 0):
|
||
asyncio.create_task(job_analyze(limit=limit))
|
||
return {"status": "started"}
|
||
|
||
@app.post("/analyze/{code}")
|
||
async def analyze_single(code: str, ai: bool = False):
|
||
async with httpx.AsyncClient() as client:
|
||
result = await analyze_stock(client, code, with_ai=ai)
|
||
if result:
|
||
return JSONResponse(content=result)
|
||
return JSONResponse(content={"error": "analysis failed"}, status_code=500)
|
||
|
||
# ── 보유 포지션 맞춤 분석 ────────────────────────────────
|
||
|
||
@app.post("/position")
|
||
async def position_analysis(req: PositionRequest, ai: bool = False):
|
||
"""보유 종목 매입가/수량 기반 맞춤 손익 + 전략 분석"""
|
||
code = req.code
|
||
|
||
# 캐시 확인
|
||
result = None
|
||
if redis_cl:
|
||
try:
|
||
cached = await redis_cl.get(f"ta:{code}")
|
||
if cached:
|
||
result = json.loads(cached)
|
||
except: pass
|
||
|
||
if not result:
|
||
async with httpx.AsyncClient() as client:
|
||
result = await analyze_stock(client, code, req.name, with_ai=False)
|
||
|
||
if not result:
|
||
return JSONResponse(content={"error": "종목 분석 실패"}, status_code=500)
|
||
|
||
ind = result.get("indicators", {})
|
||
tech_score = result.get("tech_score", 0)
|
||
price = ind.get("price", 0)
|
||
|
||
pos = analyze_position(price, req.buy_price, req.qty, ind, tech_score)
|
||
|
||
# AI 판단문 (요청 시)
|
||
ai_opinion = result.get("ai_opinion", "")
|
||
if ai and not ai_opinion:
|
||
async with httpx.AsyncClient() as client:
|
||
ai_opinion = await generate_ai_opinion(
|
||
client, code, req.name or result.get("name", code),
|
||
ind, tech_score, result.get("signals", []),
|
||
result.get("targets", {}))
|
||
|
||
return {
|
||
"code": code,
|
||
"name": req.name or result.get("name", code),
|
||
"buy_price": req.buy_price,
|
||
"qty": req.qty,
|
||
"current_price": price,
|
||
"tech_score": tech_score,
|
||
"signal": result.get("signal"),
|
||
"signals": result.get("signals", []),
|
||
"indicators": ind,
|
||
"position": pos,
|
||
"targets": result.get("targets", {}),
|
||
"ai_opinion": ai_opinion,
|
||
"analyzed_at": result.get("analyzed_at"),
|
||
}
|
||
|
||
# ── 종목 전체 리포트 (AI 판단문 포함) ────────────────────
|
||
|
||
@app.get("/report/{code}")
|
||
async def full_report(code: str):
|
||
"""기술적 분석 + AI 판단문 + 뉴스감성 통합 리포트"""
|
||
news_score = 0.0
|
||
if pg_pool:
|
||
try:
|
||
async with pg_pool.acquire() as conn:
|
||
row = await conn.fetchrow(
|
||
"SELECT news_score FROM stock_scores WHERE stock_code=$1 "
|
||
"ORDER BY score_date DESC LIMIT 1", code)
|
||
if row:
|
||
news_score = float(row["news_score"] or 0)
|
||
except: pass
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
result = await analyze_stock(client, code, with_ai=True, news_score=news_score)
|
||
|
||
if not result:
|
||
return JSONResponse(content={"error": "분석 실패"}, status_code=500)
|
||
|
||
# DB에서 추가 정보
|
||
extra = {}
|
||
if pg_pool:
|
||
try:
|
||
async with pg_pool.acquire() as conn:
|
||
score_row = await conn.fetchrow(
|
||
"SELECT * FROM stock_scores WHERE stock_code=$1 "
|
||
"ORDER BY score_date DESC LIMIT 1", code)
|
||
news_rows = await conn.fetch(
|
||
"SELECT title, sentiment, intensity, reason "
|
||
"FROM news_analysis WHERE primary_stock=$1 "
|
||
"ORDER BY analyzed_at DESC LIMIT 5", code)
|
||
if score_row:
|
||
extra["score"] = dict(score_row)
|
||
extra["score"]["score_date"] = str(extra["score"]["score_date"])
|
||
extra["recent_news"] = [dict(r) for r in news_rows]
|
||
except: pass
|
||
|
||
return {**result, **extra, "news_score": news_score}
|